我非常高兴向您介绍由 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 个操作(如果省略则为默认值)
| | └──┴─────── 每 60 秒 30 个操作
| └───────────── 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 trait(将 trait 视为大多数语言中的接口),我们可以保证 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 方面给予我的一些指导。