Redis集群竟然越扩越慢?这种匪夷所思的现象竟然真的出现了!下面就来一探究竟。
1. 遇到了什么问题
618期间,为了应对压测流量,我们扩容Redis集群到40分片,但业务方反馈999线相比扩容前有明显劣化(扩容前60ms,扩容后100ms+):
扩容后的99线反而不如扩容前的
比如5.31扩容了cl8个分片,扩容前999线在4ms左右:
![]()
扩容后就到了8ms,然而无论是整体cl集群的QPS等各项指标都没有明显变化:
![]()
问题是扩容前后整体Redis指标没有太大变化,QPS也没有明显波动,但999线就是比扩容前高,这是为什么呢?
2. 原理
Cluster Nodes慢查询?
初步排查发现有大量的Cluster Nodes慢查询(是Redis自己记录的执行时间超过10ms的命令)
而且还存在低峰期时CPU使用率偏高的问题
选取cl集群的一台8C16G的Master节点在上午5点45左右的CPU使用率和QPS,可以看到QPS不到1k,但是Redis进程使用单核CPU可以达到50%左右。
![]()
这些空闲时间的CPU在做什么?使用perf采样redis进程CPU使用率最高的函数:
perf record -F 99 $(pgrep redis)
特别明显的是处于第一位的函数超过50%,通过查看源码,发现这个函数就是cluster nodes命令会调用的函数!
所以我们怀疑此次扩容出现性能变差的原因很可能来自cluster nodes,这又和cluster nodes出现大量慢查询关联起来。
难道是这些慢查询引发的吗?首先我们要搞清楚cluster nodes是什么,为什么需要执行它,真的是这些慢查询影响到集群性能了吗?
整理了一份核心面试笔记包括了:Java面试、Spring、JVM、MyBatis、Redis、MySQL、并发编程、微服务、Linux、Springboot、SpringCloud、MQ、Kafka 面试专题
需要全套面试笔记的【点击此处即可】即可免费获取
Cluster Nodes是什么以及它的作用
我们知道Redis集群的数据是分片的,所有key都分布到16384个slot里,每个Master都负责一部分slot,如果想访问一个key,但是访问的Redis节点不包含这次命令执行的slot,会返回MOVED ip port, 告诉客户端正确的Redis节点的地址,然后客户端再发送命令到正确的Redis节点。
可以看到这样做存在两次命令交互,为了提升性能,一般的客户端都会缓存一个视图保存slot到Redis实例的映射关系。
Spring Redis中不同的底层客户端实现:Jedis和Lettuce 有不一样的策略,Jedis本身没有拓扑缓存,Spring在Jedis之外实现了拓扑缓存,而由于Lettuce具备拓扑缓存功能,就直接使用其本身的实现。
这个视图可以用cluster nodes 或者 cluster slots获取(在Redis7中新增了一个cluster shards命令,cluster slots被考虑废弃),lettuce使用cluster nodes获取,其输出大致如下:
arduino
代码解读
复制代码
ca2dabcdeeeb2d7dececeaf18133c11bc2ada69e 10.3.14.155:6379@16379 slave 875a64d4afd9901ea7bbd8512c4d413a0cc6e313 0 1718349194000 22930 connected 2b81a3db103fb523ffd08968300003527c1359d8 10.3.10.66:6379@16379 master - 0 1718349193000 18909 connected 1765-1819 2244-2348 2731-2970 3097-3105 a3a1d93d882bd7a7f4d3a094632bd90db3c9bf49 10.3.28.148:6379@16379 slave 552a9698a2a1878de0f3372a776998c8fdb310aa 0 1718349192000 18908 connected d7603324abb7792c454079a0349ae4715d388d76 10.20.22.224:6379@16379 slave 5edc58c17900ea236a9ba8a665b2c9d5b021ba01 0 1718349193000 21766 connected 69e0048f9504f98800a2f3b3db21af5b0a99a193 10.20.22.40:6379@16379 slave 53cd8c03e28ff74e17f35997c7c4391a6f411d2e 0 1718349194000 21714 connected 773486cd616c232f4b87ac69b9151453ca4bcabc 10.20.21.51:6379@16379 master - 0 1718349193819 21823 connected 12782-12857 14101-14344 14687-14703 14794-14860 15302-15306
从中我们可以获取到redis节点和slots之间的对应关系。
但是又引申出来一个问题,当集群扩缩容 或 Failover造成集群拓扑的变更,这时就需要通过cluster nodes命令获取最新的集群拓扑,下面就来介绍lettuce具体是如何刷新拓扑的。
when-视图何时刷新?
Lettuce提供了两种刷新模式:
1、被动刷新
2、定期刷新
被动刷新是指当发生了以下几种情况会调用cluster nodes获取最新拓扑:
拓扑刷新机制 | 触发时机 | 约束条件 |
---|---|---|
onAskRedirection | 执行 Redis 命令时候,Key 所在 SLOT 正在迁移数据 | 默认 30 秒内已经刷新过,则跳过刷新 |
onReconnectAttempt | ConnectionWatchdog 监听到 channelInactive,立即触发延迟重连,默认最多重连 5 次延迟提交机制 | |
onUncoveredSlot | 客户端创建 Redis 连接时,找不到 SLOT 或者 Host/Port 对应的 partition 信息 |
除此之外Lettuce还提供定时刷新机制,Renault SDK默认60s刷新一次。同样定时刷新也适用于上述30s内刷新一次就不会再刷新的限制,防止过多刷新。
为什么除了被动刷新之外还需要定时刷新?
考虑Master宕机/进程hang住的场景,按理说应该会触发ConnectionWatchdog的自动重连,然而并不会!
经过实际测试,此时不会触发重连(原因是:ConnectionWatchdog此时并不会触发channelInactive事件,channelInactive只有自己主动关闭或者对端关闭才会触发,宕机或者对端不响应不包含在内),而宕机并没有Slot的移动,并且在此时客户端根据存量的拓扑缓存是能够给所有Slot都找到对应的Redis节点,而且Redis节点都是已知的。
因此以上被动刷新的条件都不满足,所以需要定时刷新
How-视图如何刷新?
前面讲了视图刷新的时机,现在分析下Lettuce具体刷新视图的方式。
Lettuce通过向一批Redis节点发送Cluster Nodes命令获取Slot和Redis节点之间的对应关系,之后根据策略决定使用哪一个Redis节点返回的拓扑。
这里涉及到两个问题:
1、Lettuce如何选择要发送的Redis节点
2、Lettuce如何选择哪一个Redis实例返回的拓扑
Lettuce如何选择要发送的Redis节点
默认Lettuce开启了dynamicRefreshSources开关,也就是说会选择集群内的所有节点。
另一方面可以通过关闭这个开关,Lettuce只会从seed节点获取拓扑。
Lettuce如何选择哪一个Redis实例返回的拓扑
Lettuce提供了两种策略,分别在第一次获取拓扑和后续获取时使用:
1、选择包含最多健康节点的拓扑
2、选择包含当前已知节点最多的拓扑
小结
理解了拓扑缓存是什么,如何获取,以及拓扑缓存刷新的两种方式,这下应该可以推断出来为什么有那么多cluster nodes慢查了,因为这些慢查即使没有给Redis做运维依然存在,所以可以确定的是由于定时刷新才造成了慢查,那么为什么会造成慢查?这些慢查对Redis性能的影响到底有多大?接下来继续分析。
Cluster Nodes内部实现
在Redis源码中是这样实现的: 处理cluster nodes命令过程中会遍历每个Node——调用clusterGenNodeDescription函数,生成每个Cluster节点的信息,包括其负责的Slot:
c
代码解读
复制代码
sds clusterGenNodesDescription(int filter) { sds ci = sdsempty(), ni; dictIterator *di; dictEntry *de; di = dictGetSafeIterator(server.cluster->nodes); while((de = dictNext(di)) != NULL) { // ... ni = clusterGenNodeDescription(node); } // ... }
clusterGenNodeDescription函数的逻辑是遍历所有Slot,确定属于当前Node的Slot范围。
c
代码解读
复制代码
sds clusterGenNodeDescription(clusterNode *node) { int j, start; sds ci; //...省略 /* Slots served by this instance */ start = -1; for (j = 0; j < CLUSTER_SLOTS; j++) { int bit; if ((bit = clusterNodeGetSlotBit(node,j)) != 0) { if (start == -1) start = j; } if (start != -1 && (!bit || j == CLUSTER_SLOTS-1)) { if (bit && j == CLUSTER_SLOTS-1) j++; if (start == j-1) { ci = sdscatprintf(ci," %d",start); } else { ci = sdscatprintf(ci," %d-%d",start,j-1); } start = -1; } } // ...省略 return ci; }
为什么会变慢呢?
通过分析可以得知上面clusterGenNodesDescription函数处理过程的时间复杂度是O(M*N), 其中M是集群节点数量,N是Slot数量为16384。
在压测期间,使用perf分析CPU,50%水位:
100%水位
可以看到在CPU满载的情况下,cluster nodes命令足足占用了至少18%的CPU!而且根据上述时间复杂度分析随着集群规模的增长这个值也会线性增长!
然后我们发现了另外一个问题,Redis集群的客户端数量非常多:
每个Master足有4K+的client!。
可以看到随着集群节点数量变多,Cluster nodes的处理时间会随之线性增长,而且如果客户端数量较多(比如我们的集群客户端有4K+,每秒约有66个cluster nodes处理)进一步造成cluster nodes命令占用CPU。
总结
通过源码分析我们知道了Redis5中cluster nodes的实现,以及它的时间复杂度会随着集群规模增长而增长。在高峰期占用了接近20%的CPU,可以基本确定cluster nodes慢查询就是Redis性能劣化的原因,下面我们来看如何进行优化解决,需要解决的问题是:
- cluster nodes本身的性能问题
- 客户端规模大造成cluster nodes调用过于频繁的问题
3. 优化方案
下面通过几个方面考虑优化:
- cluster nodes本身性能的优化 => 解决cluster nodes本身的性能问题
- 定时刷新的优化 => 客户端规模大造成cluster nodes调用过于频繁的问题
Redis6的优化
原理
可以看到在Redis5中每个Node都遍历了每个Slot,造成大量的重复计算,是否可以将这部分重复计算提取出来呢?答案是可以的,Redis6就是这么优化的:
c
代码解读
复制代码
sds clusterGenNodesDescription(int filter, int use_pport) { sds ci = sdsempty(), ni; dictIterator *di; dictEntry *de; /* 提前生成Node拥有的Slot. */ clusterGenNodesSlotsInfo(filter); di = dictGetSafeIterator(server.cluster->nodes); while((de = dictNext(di)) != NULL) { clusterNode *node = dictGetVal(de); if (node->flags & filter) continue; ni = clusterGenNodeDescription(node, use_pport); // ... } }
Redis6在遍历每个Node之前,先使用clusterGenNodesSlotsInfo生成每个Node拥有的Slot,准确来说是Slot区间列表:
c
代码解读
复制代码
void clusterGenNodesSlotsInfo(int filter) { clusterNode *n = NULL; int start = -1; for (int i = 0; i <= CLUSTER_SLOTS; i++) { /* Find start node and slot id. */ if (n == NULL) { if (i == CLUSTER_SLOTS) break; n = server.cluster->slots[i]; start = i; continue; } /* Generate slots info when occur different node with start * or end of slot. */ if (i == CLUSTER_SLOTS || n != server.cluster->slots[i]) { if (!(n->flags & filter)) { if (!n->slot_info_pairs) { n->slot_info_pairs = zmalloc(2 * n->numslots * sizeof(uint16_t)); } serverAssert((n->slot_info_pairs_count + 1) < (2 * n->numslots)); n->slot_info_pairs[n->slot_info_pairs_count++] = start; n->slot_info_pairs[n->slot_info_pairs_count++] = i-1; } if (i == CLUSTER_SLOTS) break; n = server.cluster->slots[i]; start = i; } } }
生成所有节点的Slot拓扑,并将字符串表示形式存储在该节点上的slots_info结构体中。提高了clusterGenNodesDescription()函数的效率,因为避免了为每个节点单独生成槽信息时遍历Slot的循环:
c
代码解读
复制代码
sds clusterGenNodeDescription(clusterNode *node, int use_pport) { // ... if (node->slot_info_pairs) { ci = representSlotInfo(ci, node->slot_info_pairs, node->slot_info_pairs_count); } // ... } sds representSlotInfo(sds ci, uint16_t *slot_info_pairs, int slot_info_pairs_count) { for (int i = 0; i< slot_info_pairs_count; i+=2) { unsigned long start = slot_info_pairs[i]; unsigned long end = slot_info_pairs[i+1]; if (start == end) { ci = sdscatfmt(ci, " %i", start); } else { ci = sdscatfmt(ci, " %i-%i", start, end); } } return ci; }
性能比较
cluster nodes命令性能比较
分别在cl集群增加一个Redis5和Redis6的空Master,然后分别执行:
bash
代码解读
复制代码
./redis-benchmark -c 1 -n 10000 cluster nodes
即用一个客户端,执行10000次cluster nodes。
redis5的结果是:
bash
代码解读
复制代码
Summary: throughput summary: 458.06 requests per second latency summary (msec): avg min p50 p95 p99 max 2.160 1.808 1.887 3.575 5.375 12.455
redis6的结果是:
c
代码解读
复制代码
Summary: throughput summary: 8583.69 requests per second latency summary (msec): avg min p50 p95 p99 max 0.111 0.096 0.111 0.135 0.191 1.015
Redis6和5相比吞吐量接近20倍。
耗时方面Redis6的99线只有0.2ms,平均0.1ms。
而Redis5的99线有5.3ms,平均2.1ms。
小结
Redis6针对Redis5做的优化是通过减少重复计算达到的,经过验证耗时减少了95%,效果非常明显。
Redis主动通知客户端拓扑变化
除了优化Redis服务端以外,另一个优化方向是优化客户端刷新拓扑缓存的频率,首先我们来分析如果不使用定时刷新会存在什么问题,能不能使用定时刷新之外的方法解决,即使这个方法需要修改Redis源码。
只使用被动刷新的问题主要有两个:
- 在拓扑发生改变之前就去获取拓扑,获取的是一个过期的视图
- 机器宕机,Lettuce的连接保活机制(ConnectionWatchDog)不能识别机器宕机、进程Hang住的情况
下面依次分析这这些场景
场景一 普通扩容
节点一 迁移 5个slot 到节点二
1.节点一迁移一个slot到节点二
2.客户端感知到MOVED响应,刷新拓扑,之后30s不刷新拓扑
3.节点一继续迁移到节点二
4.30s内客户端即使发现MOVED响应,也不能做任何事,只能重定向。
原因:客户端获取的拓扑是过时的,客户端即使接收到MOVED也不会更新拓扑视图,只会在30s后刷新全量的拓扑。
场景二 进程重启
节点一为master,节点二为节点一的replica,客户端设置为优先读从节点
1.节点一进程shutdown
2.客户端感知到inactive,5次重连之后(31ms)开始刷拓扑,得到的拓扑和之前一样,30s内不再刷拓扑
3.节点二成为master,然后节点一重启完成开始同步RDB数据
4.客户端尝试重连,继续获取获取拓扑,发现节点一是slave,发送读命令到节点一,由于此时节点一还在加载RDB因此报:'-LOADING Redis is loading the dataset in memory'错误
原因:客户端无法知道failover是否完成,导致发送命令到还在加载RDB的从节点上。
场景三 Failover
节点一为master,节点二为节点一的replica
1.节点一主机故障,进程Hang住
2.客户端开始报错
3.节点二failover成为master
4.客户端依然在报错(由于没有ASK、MOVED、连接Inactive等可触发被动刷新的事件,获得不了新拓扑)
原因:客户端不知道failover已经完成,也不知道对端已经无法响应,还在发送命令到失败的节点上。
问题总结
客户端不知道Redis的集群拓扑变化:Slot迁移、failover等何时完成,导致其在下一次刷新拓扑之前缓存的拓扑视图其实是过期的。除此之外,由于Redis使用Gossip协议传播集群内部节点状态信息,Lettuce获取的视图也可能不是最新的。
另一方面,随着节点数增加,cluster nodes命令输出大小也会增加,在大多数情况下,我们只需要发生变动的那一部分信息。
总而言之,定期刷新只是一个不完美的解决方案,根本原因是我们不知道服务端的拓扑变更在什么时候完成,总是会过早/过慢的获取到拓扑。
订阅拓扑变更消息
像Sentinel一样,Sentinel通过pub/sub自动发现新的Sentinel实例以及master/slave的状态变更信息,我们可以仿照这一机制((参考自github.com/valkey-io/v…)):
- Redis内部建立一个内部channel用于发布拓扑变更消息“__cluster__:moved”。
- 客户端启动时订阅上述“__cluster__:moved”的channel。
- 当Slot添加到一个Redis Server时,构建一个消息:"slot_id ip:port"通知客户端Slot添加到自己了。
- 客户端只需要更新本地的拓扑就可以,不需要再用cluster nodes刷新所有的了。
(对于failover的场景,可以用另外一个订阅名字: "__cluster__:failover"在Slave成功promotion的时候发送“slot_range ip:port”通知客户端。)
Redis6实现了一个新的协议RESP3,相比于RESP2,其支持了PUSH消息:Server可以在任意时机向Client主动推送数据,这里也可以考虑使用RESP3的Push消息。
具体做法:
下面是valkey(一个Redis的开源替代品,基于Redis 7.2.4版本)社区里的一个相关的PR,我们可以参考一下它的实现方式:
添加Slot时的处理
初始化集群时,clusterState里添加:
- moved_slot_since_sleep:记录移动到这个Redis实例的slotId(since_sleep含义: Redis通过sleep等待可读写的socket fd,这里是在每一次等待可读写之前处理移动的slot)
- moved_slot_channel:一个内部的channel,名字是__cluster__:moved,用于发送slot移动消息给订阅的客户端。
定义常量CLUSTER_MOVED_SLOT_NONE
和CLUSTER_MOVED_SLOT_MULTIPLE
:
CLUSTER_MOVED_SLOT_NONE
:没有Slot移动
CLUSTER_MOVED_SLOT_MULTIPLE
:有多个Slot移动
添加Slot时在clusterAddSlot
函数里记录:
- 如果上次没有添加的Slot,那么将slotId记录到
moved_slot_since_sleep
- 如果
moved_slot_since_sleep
已经记录了一个slotId那么说明有多个slot移动了,就记录为CLUSTER_MOVED_SLOT_MULTIPLE
。
等待处理命令前通知客户端
实践效果
修改了源码,启动客户端订阅__cluster__:moved 这个channel:
然后在我们的Redis管理平台上进行一个slot迁移,迁移16190到10.100.140.229这个节点:
客户端成功接收到订阅的消息:
小结
这个优化解决了Redis无法主动通知拓扑变更完成的时间点,而且改动较少比较简单,但这个提交目前只能做到单个Slot移动时通知,如果sleep前有多Slot移动和failover的场景还不支持通知客户端,但是思路可以借鉴。
拓扑刷新只查询从节点
在2. 原理中了解到拓扑刷新在Lettuce的实现中默认会查询所有节点,那么我们可以将这个策略修改为只查询一部分节点,比如只查询从节点,这样会减少cluster nodes对主节点的性能影响,理论上也是可以达到目的的,现在我们来看如何拓展Lettuce的拓扑查询:
在io.lettuce.core.cluster.RedisClusterClient的getTopologyRefreshSource方法里有默认的获取拓扑刷新源的实现,如果useDynamicRefreshSources配置为true(默认是true)的话会通过RedisClusterClient的partitions字段获取,partitions字段包含了集群内所有Redis节点:
java
代码解读
复制代码
protected Iterable<RedisURI> getTopologyRefreshSource() { boolean initialSeedNodes = !useDynamicRefreshSources(); Iterable<RedisURI> seed; if (initialSeedNodes || partitions == null || partitions.isEmpty()) { seed = this.initialUris; } else { List<RedisURI> uris = new ArrayList<>(); for (RedisClusterNode partition : TopologyComparators.sortByUri(partitions)) { uris.add(partition.getUri()); } seed = uris; } return seed; }
由于这个方法是protected修饰的,因此我们可以继承RedisClusterClient这个类并且override这个方法,返回从节点的URI就可以了:
java
代码解读
复制代码
protected Iterable<RedisURI> getTopologyRefreshSource() { if (Objects.equals(getTopologyRefreshSourceFromApollo(), TopologyRefreshSource.ALL.name())) { return super.getTopologyRefreshSource(); } try { Partitions partitions = partitions(); if (partitions == null) { return super.getTopologyRefreshSource(); } List<RedisURI> uris = new ArrayList<>(); for (RedisClusterNode partition : TopologyComparators.sortByUri(partitions)) { if (partition.is(RedisClusterNode.NodeFlag.REPLICA) || partition.is(RedisClusterNode.NodeFlag.SLAVE)) { uris.add(partition.getUri()); } } if (uris.isEmpty()) { return super.getTopologyRefreshSource(); } return uris; } catch (Exception e) { PolarisTracer.logError(e); return super.getTopologyRefreshSource(); } }
如上所示,通过判断partition是否有Slave标识获取所有从节点。当然也要注意兜底逻辑,如果没有从节点的话还是走默认逻辑。
4. 总结
这篇文章中我们通过分析线上出现的慢查询,找到了造成慢查询根因:大规模集群下频繁的cluster nodes查询,并且分析cluster nodes查询和拓扑刷新的原理,最后提出了三种解决方案:
- 升级到Redis
- Redis二开实现主动通知客户端
- 优化拓扑刷新逻辑只查询从节点
最后我们选择了1和3同时进行,由于2需要Redis二开,因此作为长期方案。