Redis 通常不会被用作主要的数据存储,但它在存储和访问可容忍丢失的临时数据(如度量指标、会话状态、缓存)方面却独有长处,并且速度非常快,不仅提供了最佳性能,还内置了一组非常有用的数据结构。它是现代技术栈中最常见的主要部件之一。
Stripe(一家做支付的硅谷创业公司)的速率限定器就是基于 Redis 构建的,这些限速器运行在一个 Redis 实例上。Redis 主服务器有一些用于失效备援的追随者,不过在任何时候,都只有一个节点在处理读写操作。
各种消息来源声称,一个 Redis 节点每秒可以处理百万次操作。尽管我们的操作没有那么多,但也不会很少。每个速率限定器都需要运行多个 Redis 命令,而每个 API 请求都要通过很多个速率限定器。所以,每个节点每秒钟需要处理数万次到数十万次的操作。
如果节点出现饱和,就会不断出现故障。我们的服务可以容忍 Redis 的不可用,因此大多数情况下是没有问题的,但在某些情况下,问题的严重程度会升级。我们最后通过迁移到包含 10 节点的 Redis 集群来解决这个问题。对性能的影响可以忽略不计,重要的是现在我们可以实现水平可伸缩。
改造前后的错误率比较:
使用 Redis 集群后错误率明显降低
在更换系统之前,应该先了解导致原始故障的原因。
虽说 Redis 使用了单线程模型,但也并非那么严格,因为后台还是使用了其他线程来处理一些操作,比如删除对象,不过所有正在执行的操作还是会阻塞在单个控制点上。
要理解这些并非难事——Redis 操作(无论是单一命令、MULTI 还是 EXEC)的原子性保证源于它一次只执行一个操作。即便如此,Redis 还是有可能会采用并行机制,FAQ 中的一些内容表明,5.0 之后的版本有可能考虑采用多线程设计。
单线程模型确实是我们的瓶颈所在,在登录到原始节点时可以看到,单核的使用率达到了 100%。
我们发现,即使开启了最大容量,Redis 也会自动优雅地降级。主要表现是,与 Redis 发生交互的节点的基线连接性错误率在增加——为了容忍发生故障的 Redis,它们受到连接和读取超时(约 0.1 秒)方面的限制,并且无法在给定时间内建立用于执行操作的连接。
这种情况在大多数时候是没有问题的。只有当合法用户成功通过身份验证并在底层数据库上进行昂贵的操作时(也就是说,数量级超过允许的范围),它才会成为问题。这种昂贵的操作是相对而言的——从列表中返回一组对象比用 401 错误来拒绝请求或用 429 错误来告知超制都要昂贵得多。这些昂贵的操作通常都是因为用户运行高并发程序而导致的。
这些流量高峰会导致错误率成比例增加,并且很多流量将被允许通过限速器,因为在发生错误时,限速器默认允许请求通过。这会给后端数据库带来更大的压力,而且这种压力所带来的故障不会像 Redis 的过载故障那么优雅。我们可以看到,分区几乎完全不可操作,并且大量请求出现超时。
Redis 集群的分片模型
Redis 的核心价值是速度,而 Redis 集群的分布式结构不会对此产生任何影响。与其他分布式模型不同的是,Redis 集群的操作不需要通过多个节点的确认,它看起来更像是一组独立的 Redis 实例在分担工作负载。这就是通过牺牲可用性来换取速度——与 Redis 独立实例相比,Redis 群集操作的额外开销可以忽略不计。
键空间总共被分为 16384 个槽,槽是通过稳定的散列函数计算出来的,所有客户端都知道该如何使用这个散列函数:
- HASH_SLOT = CRC16(key) mod 16384
例如,如果我们想执行 GET foo,会得到 foo 的槽号:
- HASH_SLOT = CRC16("foo") mod 16384 = 12182
集群中的每个节点将处理 16384 个槽中的一部分,具体取决于节点数量。节点间通过彼此交互来调节槽的数量、进行可用性转移和再均衡。
分布在集群各个节点上的槽
客户端使用 CLUSTER 系列命令来查询集群的状态。CLUSTER NODES 是一个常见的操作,用于获取槽到节点的映射,其结果通常缓存在本地。
- 127.0.0.1:30002 master - 0 1426238316232 2 connected 5461-10922
- 127.0.0.1:30003 master - 0 1426238318243 3 connected 10923-16383
- 127.0.0.1:30001 myself,master - 0 0 1 connected 0-5460
上面的输出经过了简化,最重要的部分是第一列的主机地址和最后一列的数字。5461-10922 表示该节点处理从 5461 到 10922 的槽。
MOVED重定向
如果 Redis 群集中的某个节点接收到一个无法处理的命令,并不会尝试将该命令转发给其他节点。相反,客户会被告知向其他节点尝试发送该命令。这是通过 MOVED 响应来实现的,MOVED 响应消息包含了新的目标地址:
- GET foo
- -MOVED 3999 127.0.0.1:6381
在集群进行再均衡期间,槽从一个节点迁移到另一个节点,而 MOVED 是服务器用于告诉客户端,槽到节点的映射已经发生了变化。
一个槽从一个节点迁移到另一个节点
每个节点都知道当前的映射关系,理论上,当一个节点在接收到无法处理的操作时,可以向正确的节点请求结果,并将结果转发回客户端,但 MOVED 其实是一种有意的设计。它通过将一些额外的复杂性交给客户端去实现,以便换取更快的速度。只要客户端的映射是最新的,请求操作总能在一个 hop 之内完成。由于再均衡相对较少出现,因此在群集的使用期间,花在协调上的开销可以忽略不计。
除了 MOVED 之外,Redis 集群还有其他一些特定的机制,但为了简洁起见,我将跳过它们。完整的规范(https://redis.io/topics/cluster-spec)是深入了解 Redis 集群工作原理的重要资源。
客户端如何发送请求
Redis 客户端需要一些额外的功能来支持 Redis 群集,其中最重要的是要支持键的散列算法和用于维护槽到节点映射的方案,这样它们就知道往哪里发送命令。
一般来说,客户端会这样操作:
- 在启动时,连接到一个节点并获得一个 CLUSTER NODES 的映射表。
- 正常执行命令,根据槽和槽映射定位服务器。
- 如果收到 MOVED,返回到第一步。
我们可以在客户端使用多线程进行优化,在收到 MOVED 时将映射表标记为过时,一些线程向新的服务器发送命令,同时让后台线程异步刷新映射表。实际上,即使发生了再均衡,大多数槽也不需要移动,因此该模型允许大多数命令在没有额外开销的情况下继续执行。
使用散列标签本地化多键操作
在 Redis 中,通过 EVAL 命令和自定义 Lua 脚本来运行多键操作是很常见的。这是实现速率限定器的一个特别重要的特性,因为通过单个 EVAL 命令分派的操作是原子性的。我们因此能够正确计算剩余配额,即使存在可能会发生冲突的并发操作。
分布式模型会让这种多键操作变得十分困难。由于每个键对应的槽都是通过散列来计算的,因此不能保证相关键都会被映射到同一个槽。比如,user123.first_name 和 user123.last_name 显然应该是要放在一起的,但最终可能会分布在两个完全不同的节点上。
举例来说,我们有一个 EVAL 操作,将姓和名连接起来组合成一个人的全名:
- # Gets the full name of a user
- EVAL "return redis.call('GET', KEYS[1]) .. ' ' .. redis.call('GET', KEYS[2])"
- 2 "user123.first_name" "user123.last_name"
调用示例:
- > SET "user123.first_name" William
- > SET "user123.last_name" Adama
- > EVAL "..." 2 "user123.first_name" "user123.last_name"
- "William Adama"
如果 Redis 集群没有提供这种方式,该脚本将无法正常运行。幸运的是,我们通过使用哈希标签来运行脚本。
对于需要跨节点操作的 EVAL,Redis 集群会禁止它们(这样做也是出于速度方面的考虑)。所以,用户需要确保 EVAL 中的键属于相同的槽,可以通过散列标签来获得键的散列值。散列标签就是键名字中的花括号,表示只有花括号部分用于散列。
我们对键进行重新定义,只对 user123 进行散列处理:
- > EVAL "..." 2 "{user123}.first_name" "{user123}.last_name"
计算其中一个槽:
- HASH_SLOT = CRC16("{user123}.first_name") mod 16384
- = CRC16("user123") mod 16384
- = 13438
.first_name 和{user123}.last_name 现在映射到了相同的槽,那么就可以执行 EVAL 操作了。这是一个简单的例子,不过相同的概念可被用于实现复杂的速率限定器。
迁移到 Redis 集群非常顺利,最困难的部分是如何构建一个可用于生产环境的 Redis 集群客户端。即使到了今天,Redis 客户端的质量也是参差不齐,可能是因为 Redis 速度足够快,以至于大多数人直接使用单个实例。
从设计方面看,Redis 集群的设计有很多值得一提的地方——简单但功能强大。特别是当涉及到分布式系统时,许多实现过程非常复杂,而在生产环境中遇到极端错误时,复杂程度可能是灾难性的。Redis 集群具备了可伸缩性,却没有那么多令人难以理解的组件,即使像我这样的门外汉也能明白它的原理。它的设计文档也很好理解,很接地气。
在搭建集群之后的几个月,尽管每时每刻都有相当大的负载,我也没有再去碰过它。如此高质量的集群实属罕见。我们需要更多像 Redis 这样的构建块,让它们做它们该做的事,无需我们多作操心。