当前位置: 首页 > 科技观察

一个10节点Redis集群的实际例子

时间:2023-03-13 15:43:52 科技观察

Redis通常不被用作主数据存储,但它在存储和访问容忍丢失的临时数据(如指标、会话状态、缓存)方面具有独特的优势,以及速度非常快,不仅提供了最好的性能,而且内置了一组非常有用的数据结构。它是现代技术栈中最常见的主要组件之一。Stripe的速率限制器(硅谷一家从事支付业务的初创公司)构建在Redis之上,并且这些速率限制器在Redis实例上运行。Redismaster有一些follower用于故障转移,但在任何时候,只有一个节点在处理读写。各种消息来源声称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集群的Sharding模型Redis的核心价值是速度,Redis集群的分布式结构不会对此产生任何影响。与其他分布式模型不同,Redis集群的运行不需要多个节点的确认。它看起来更像是一组独立的Redis实例共享工作负载。这是用可用性换取速度——与Redis独立实例相比,Redis集群操作的额外开销可以忽略不计。key空间一共分为16384个slots,slots由一个稳定的hash函数计算,所有clients都知道如何使用这个hash函数:HASH_SLOT=CRC16(key)mod16384比如我们要执行GETfoo,will获取foo的槽号:HASH_SLOT=CRC16("foo")mod16384=12182集群中的每个节点将处理16384个槽中的一部分,具体取决于节点的数量。节点相互交互以调整槽数、传输可用性和再平衡。分布在集群各个节点上的Slot客户端使用CLUSTER系列命令来查询集群的状态。CLUSTERNODES是获取槽到节点映射的常见操作,其结果通常缓存在本地。127.0.0.1:30002master-014262383162322connected5461-10922127.0.0.1:30003master-014262383182433connected10923-16383127.0.0.1:30001myself,master-001connected0-5460上面的输出经过了简化,最重要的部分是第一列的主机地址和最后一列的数字.5461-10922表示该节点处理从5461到10922的槽。MOVED重定向如果Redis集群中的某个节点收到一个它无法处理的命令,它不会尝试将该命令转发给其他节点。相反,客户端被告知尝试将命令发送到其他节点。这是通过MOVED响应完成的,其中包含新的目标地址:GETfoo-MOVED3999127.0.0.1:6381在集群重新平衡期间,槽从一个节点迁移到另一个节点,服务器使用MOVED来告诉客户端插槽到节点的映射已更改。槽从一个节点迁移到另一个节点。每个节点都知道当前的映射关系。理论上,当节点收到无法处理的操作时,它可以向正确的节点请求结果并将结果转发回客户端,但MOVED其实是有意设计的。它通过将一些额外的复杂性卸载给客户端来换取更快的速度来实现这一点。只要客户端的映射是最新的,请求的操作总是可以在一跳内完成。由于重新平衡发生的频率相对较低,因此在集群的生命周期中花费在协调上的开销可以忽略不计。除了MOVED,还有一些特定于RedisCluster的其他机制,但为了简洁起见,我将跳过它们。完整规范(https://redis.io/topics/cluster-spec)是深入了解Redis集群工作原理的重要资源。客户端如何发送请求Redis客户端需要一些额外的特性来支持Redis集群,其中最重要的是支持密钥散列和维护槽到节点映射的方案,以便它们知道将命令发送到哪里。一般来说,客户端会这样操作:启动时,连接到一个节点,得到一个CLUSTERNODES的映射表。正常执行命令,根据槽位和槽位映射定位服务器。如果收到MOVED,则返回步骤1。我们可以在客户端使用多线程进行优化,当收到MOVED时将地图标记为过时,部分线程向新服务器发送命令,让后台线程异步刷新地图。事实上,即使发生再平衡,大多数槽也不需要移动,因此该模型允许大多数命令继续执行而无需额外开销。使用哈希标签本地化多键操作在Redis中,通常通过EVAL命令和自定义Lua脚本运行多键操作。这是实现速率限制器的一个特别重要的特性,因为通过单个EVAL命令分派的操作是原子的。因此,即使存在可能冲突的并发操作,我们也能够正确计算剩余配额。分布式模型可以使这种多键操作变得非常困难。由于每个键的槽都是散列的,因此不能保证相关键都映射到同一个槽。例如,user123.first_name和user123.last_name明明应该放在一起,但最终可能分布在两个完全不同的节点上。例如,我们有一个EVAL操作,将名字和姓氏连接起来形成一个人的全名:#GetsthefullnameofauserEVAL"returnredis.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""WilliamAdama"如果RedisCluster不提供这种方式,脚本将无法正常运行。幸运的是,我们可以使用主题标签运行脚本。对于需要跨节点操作的EVAL,RedisCluster会禁止它们(这样做也是出于速度原因)。因此,用户需要保证EVAL中的key属于同一个slot,key的哈希值可以通过标签的哈希得到。hash标签是键名中的花括号,表示只用花括号进行哈希。我们重新定义key只散列user123:>EVAL"..."2"{user123}.first_name""{user123}.last_name"计算其中一个slot:HASH_SLOT=CRC16("{user123}.first_name")mod16384=CRC16("user123")mod16384=13438.first_name和{user123}.last_name现在映射到同一个槽,因此可以执行EVAL操作。这是一个简单的示例,但可以使用相同的概念来实现复杂的速率限制器。迁移到RedisCluster非常顺利,最难的部分是如何构建一个生产就绪的RedisCluster客户端。即使在今天,Redis客户端的质量也参差不齐,这可能是因为Redis足够快,以至于大多数人直接使用单个实例。在设计方面,RedisCluster的设计有很多值得喜欢的地方——简单但功能强大。尤其是涉及到分布式系统时,许多实现都很复杂,当在生产中遇到极端错误时,复杂程度可能是灾难性的。Redis集群具有可扩展性,没有那么多令人困惑的组件,即使是像我这样的外行也能理解它是如何工作的。它的设计文档也通俗易懂,非常接地气。在设置集群后的几个月里,尽管一直有相当大的负载,但我没有再碰它。这种质量的集群是罕见的。我们需要更多像Redis这样的构建块来完成它们应该做的事情,而无需我们担心。