我很高兴向您介绍 Brandur Leach 的这篇客座文章,他是 redis-cell 的作者,也是第一届 Redis 模块黑客马拉松 的一等奖获得者。该模块是 限速器 的高效实现,可以用于保护免受活动高峰的影响。在这篇文章中,Brandur 解释了该模块诞生的原因、工作原理以及他选择编程语言的原因(剧透:Rust)。
当我第一次注意到 Redis 模块黑客马拉松并开始集思广益项目创意时,我很快就确定了一个想法。我在行业中工作的时间足够长,已经看到 Redis 被用于各种不同的用途——实际上它是现代生产堆栈的瑞士军刀——但我特别注意到它被一遍又一遍地应用在一个地方。
暴露在网络中的 Web 服务通常需要各种保护层。最常见的保护形式当然是身份验证,它可以确保访问您资源的用户是您期望访问的用户。另一个非常常见的是控制用户允许访问这些资源的速率。这对于需要防止(故意或无意)恶意行为者的公共服务显然非常有用,但也适用于内部服务,以防止某些类型的意外使用——例如,大量用户同时醒来并争夺对同一资源(如 API)的访问权限,称为“雷鸣般的羊群”问题。即使像 Google 这样以技术能力出众而闻名的公司也承认偶尔会犯这种错误。
如果您查看几乎所有可以在线找到的常用 API,您会注意到绝大多数 API 都使用限速器来控制访问。GitHub、Spotify、Heroku 和 Uber 都是很好的例子。
一个简单的限速器实现可能只是跟踪预期时间内执行的操作数量,并在该时间段结束时过期存储桶。大多数现实世界的限速器使用一种稍微复杂一点的算法,称为“滴水桶”。这是一个简单的比喻,将限速容量建模为一个底部有一个固定尺寸孔的桶。当用户消耗操作时,水会被添加到桶中,其水位上升。如果桶满了,就不能再执行任何操作,但幸运的是,桶上的孔以恒定的速率让水流出。只要水流入和水流出的速率大致相等,系统就会保持平衡,操作就不会受到限制。
滴水桶很有用,因为它既节省计算量,也节省存储空间,而且因为它通过提供“滚动”时间段提供了良好的用户体验。即使用户意外地在一次突发中用光了他们的全部限制,他们也会几乎立即拥有更多限制,而不必等到下一个窗口开始。
redis-cell 实现了一种称为“通用单元速率算法”(GCRA) 的滴水桶变体。它在功能上是相同的,但使用了一些巧妙的逻辑,因此每个被跟踪的用户只需要一个后端键来跟踪其整个状态。
该模块公开了一个命令:CL.THROTTLE。它使用描述应执行的限速的参数调用。例如
CL.THROTTLE user123 15 30 60 1
▲ ▲ ▲ ▲ ▲
| | | | └───── 应用 1 个操作(省略时为默认值)
| | └──┴─────── 30 个操作/60 秒
| └───────────── 15 max_burst
└─────────────────── 键“user123”
它返回一个数组,其中最前面的元素表示是否应该限制请求。其他元素包含配额和时间元数据,这些元数据通常与响应一起从 HTTP 服务返回,作为信息性标头。例如
127.0.0.1:6379> CL.THROTTLE user123 15 30 60
1) (integer) 0 # 0 表示允许;1 表示拒绝
2) (integer) 16 # 总配额(`X-RateLimit-Limit` 标头)
3) (integer) 15 # 剩余配额(`X-RateLimit-Remaining`)
4) (integer) -1 # 如果拒绝,用户应重试的时间(`Retry-After`)
5) (integer) 2 # 限制重置到最大容量的时间(`X-RateLimit-Reset`)
当然,您可以自己编写限速模块,但 redis-cell 旨在提供一个通用且广泛有用的实现,可以集成到使用任何编程语言或框架构建的项目中,只要其堆栈包含 Redis。
redis-cell 的另一个值得注意的特性是它的编写语言。尽管 Redis 模块 API 最初旨在由另一个 C 程序使用(它在 redismodule.h 中作为 C 头文件公开),但该项目是用纯 Rust 实现的。这是通过使用 Rust FFI(外部函数接口)模块来实现的,该模块允许程序突破其正常的安全防护,直接调用系统级 API。这也得益于 Rust 程序仅使用一个微小的运行时进行引导,并且与 C 程序一样,没有垃圾收集器。
那么为什么要费这个事呢?好吧,虽然我可能被认为是名义上的 C 语言精通者,但我并没有足够的能力确信自己不会编写包含内存溢出或其他会导致程序崩溃的段错误的程序。正如 Heartbleed 等普遍存在的错误所证明的那样,即使是经验丰富的 C 开发人员也无法避免这种错误。
Rust 编译器保证我所有的内存访问都是安全的,并且它的强类型系统在很大程度上确保我不会意外地以可能导致运行时问题的方式错误使用代码。这对我有好处,但对项目潜在的贡献者来说更好;即使是从未编写过 Rust 的人,只要他们能够让程序编译,他们也只有极小的机会引入低级问题。
让我们来看一个安全运行的简单示例。Redis 模块 API 提供了一些函数来在 Redis 内部分配内存,在默认操作模式下,模块负责释放它们以这种方式分配的任何内存。因此,如果调用 RedisModule_CreateString 来创建一个新字符串,则预计对 RedisModule_FreeString 的调用最终会释放它。
在 Rust 中,我用一个更高级别的类型包装这些字符串,这样我就不用直接处理它们
pub struct RedisString {
ctx: *mut raw::RedisModuleCtx,
str_inner: *mut raw::RedisModuleString,
}
现在,手动内存管理的问题在于它可能很危险。假设我有一个函数分配一个字符串,用它执行一个操作,然后在返回之前释放该字符串
fn run_operation( )-> i64 {
let s = RedisString::create(…);
…
s.free();
return 0;
}
即使它一开始运行得很好,也很容易在后面的某个地方引入一个错误,而该错误是由那些不熟悉原始代码的人引入的。例如,假设在函数中间引入了一个新的返回指令
fn run_operation() -> i64 {
let s = RedisString::create(…);
…
if error_occurred {
// s 泄漏了!
return 1;
}
…
s.free();
return 0;
}
新的条件分支在离开函数之前没有释放字符串,因此程序现在存在内存泄漏。这在 C 中很容易犯错。
使用 Rust,我们可以做一些不同的事情。通过实现语言内置的 Drop 特性(可以将特性视为大多数语言中的接口),我们可以保证 RedisString 的内存安全
impl Drop for RedisString {
fn drop(&mut self) {
self.free();
}
}
Drop 类似于 C++ 中的析构函数;当类型的实例超出范围时,它会被调用。因此在这种情况下,我们确保始终调用低级 free 函数。无需担心任何问题或错误条件。
我们甚至不必手动调用 free。无论引入多少新的条件分支,内存安全都始终得到保证
fn run_operation() -> i64 {
let s = RedisString::create(…);
…
if error_occurred {
// s 在这里自动释放
return 1;
}
…
// 这里也是!
return 0;
}
首先,我要感谢 Redis 主办 Redis 模块黑客马拉松,并给了我参与这个项目的机会。 我还要感谢 Daniel Farina 在解开一些 Redis 内部机制和弄清楚其模块系统如何布局内存方面提供的帮助,以及 Itamar Haber 在正确使用一些 Redis 模块 API 方面给予我的指导。