熟悉Redis的朋友应该都有同感:单机版用起来确实爽,性能高、API简单,但流量一上来、数据一多,单机的那点内存和单线程吞吐就成了墙。Redis集群模式正是用来撞开这堵墙的官方方案,它不仅解决了容量和吞吐的横向扩展问题,还顺手把高可用做了进去。这篇就把集群模式从原理到搭建到踩坑完整捋一遍,适合正准备把Redis从主从升级到集群的团队,也适合那些想把集群原理理清楚再去应付面试的人。
1. 先理清问题:集群模式到底在解决什么
1.1 单机Redis的容量与吞吐天花板
很多人刚接触Redis时会被它的高性能震撼,单实例轻松跑到十万级QPS,读写延迟基本在亚毫秒级别。但这里有个容易被忽略的现实:再强的单机也是有上限的。内存方面,一台物理机不可能无限插内存,就算你用大内存机器,RDB全量持久化时fork子进程的开销、内存碎片率、网络带宽占用都会成倍放大。CPU方面,Redis 6.x之前的单线程模型让单个实例只能用一个核心,就算后面的多线程IO把网络读写分摊出去,真正的命令执行依然是串行的。
我见过不少团队踩过类似的坑:业务增长后Redis内存占用直逼30GB,RDB持久化一次要好几秒,期间主线程阻塞导致业务超时;或者QPS冲到一定程度后,单个实例的CPU率先满了,连扩容都不知道怎么扩,只能把热点key拆到多个独立Redis里,然后自己维护一堆路由规则,痛苦得很。
所以,当你发现单机Redis快撑不住的时候,本质上是在面对三个问题:数据容量不够、吞吐能力不够、某个节点挂了影响面太大。集群模式就是冲着这三个问题去的。
1.2 主从复制和哨兵模式的"不可扩展"之困
在聊集群之前,得先说说为什么很多团队明明已经用了主从复制和哨兵模式,最后还是得迁移到集群。
主从复制解决了数据备份和读扩展的问题,主节点负责写,从节点分担读流量。但写入能力始终只有一个主节点在扛,内存总量也还是那一份。你加再多的从节点,也只是把读分散了,写瓶颈和数据容量瓶颈没有丝毫缓解。
哨兵模式则是在主从复制基础之上加了监控和自动故障转移。它解决了高可用问题:主节点挂了,哨兵可以自动把从节点晋升为主节点。可它还是一种“一个主节点对外服务”的架构,同样存在写入能力单点的问题。
这两种模式还有个共同的麻烦:当数据量超过单机内存,你是没有办法自动把数据分到多台机器上的。Redis Cluster用一句话概括就是:数据自动分片,每个节点只存一部分数据,同时内置了类似哨兵的高可用能力。不是主从和哨兵不好,而是当数据规模和写入规模上来之后,它们不具备平滑扩展的基因。
1.3 集群模式的核心承诺:分片、高可用、去中心化
集群模式的设计目标很明确,官方叫法Redis Cluster,核心就三件事。
第一,数据按槽位自动分片。整个集群把 key 空间分成 16384 个槽,每个节点负责一部分槽,写入哪个 key,就根据 key 算出一个槽,然后路由到对应节点。你不需要像以前那样自己维护key和节点的映射关系,加节点、减节点时,集群会把槽连同数据自动迁移一部分过去。
第二,内置高可用。每个分片可以配一个或多个从节点,主节点挂了,从节点自动升级。整个集群不需要哨兵在外面盯着,节点之间自己通过内部通信协商出谁来接管。
第三,去中心化。集群里的每个节点都保存了全集群的槽位分布信息和节点状态信息,客户端连上任意一个节点都能拿到完整路由表,不存在一个“总指挥”节点。这个设计带来的最大好处就是没有单点,坏处则是节点间的状态同步得靠一套内部通信机制来保证,也就是后面要讲的Gossip协议。
2. 集群模式核心机制拆解
2.1 16384个槽位:为什么是这个数
Redis Cluster把key空间分成16384个槽,每个key通过CRC16算法算出一个0到16383之间的数字,这个数字就是它归属的槽。
CRC16是一个16位的校验算法,理论上能产生65536个不同的值,但Redis只用了其中16384个。为什么不是65536?原因有两方面:一方面,16384个槽对大多数规模集群已经足够精细,比如你有100个主节点,均摊下来每个节点负责约164个槽;另一方面,集群节点之间通信时,每个节点需要把槽位信息广播给其他节点,如果槽位数太多,心跳包就要携带更大的位图,网络开销会明显增加。16384个槽用2KB的位图就能表示,而65536个槽需要8KB,集群规模越大,这个差距在频繁心跳通信时就越明显。
槽位分配是一个只有运维人员能控制的事情。初始创建集群时,可以平均分配,也可以手动指定某个节点多分一些槽,后者适合节点硬件配置不均的场景。不过大多数情况下,直接用自动分配就够了。
关于“为什么有的key在同一个集群里却没法在同一个事务里操作”,关键也在这个槽机制上。后面第5章会详细展开。
2.2 Gossip协议:节点之间怎么“通风报信”
集群里的节点没有中央协调者,那它们怎么知道谁在线、谁挂了、槽位在哪呢?靠的是节点之间持续的Gossip通信。
Gossip协议你可以把它理解成办公室里的八卦传播:一个人知道的消息会传给身边的几个人,这几个人再分别传给他们的朋友,很快全公司都知道了。Redis集群里,每个节点每隔一段固定时间(默认是每隔100ms)就会随机挑几个其他节点发PING消息,对方收到后回PONG消息。PING/PONG消息里不光有发送节点的状态,还会捎带一部分它从别人那里听来的节点信息。
这种传播方式的优点是状态信息最终会收敛到全集群一致,而且不需要专门维护一个中心注册表;缺点是消息传播有延迟,某个节点的状态变化不会瞬间被所有人知道。所以Redis集群在故障检测上用了两阶段的判定机制:先标记疑似下线(PFAIL),再经过一段时间和一番确认之后,才把节点标记为真正下线(FAIL),并且这个判定会通过Gossip传遍集群。
值得一提的还有集群总线(Cluster Bus):节点之间除了对外服务的16379端口(端口上是6379+10000),还会额外占用一个端口专门跑Gossip等内部通信。日常运维时如果发现Redis端口不通,记得检查这个内部端口有没有被防火墙拦掉。
2.3 故障转移:从PFAIL到FAIL,再到从节点上位
故障转移的过程分三步:疑似下线、确认下线、从节点竞选。
当节点A发现节点B连续一段时间(默认node-timeout,通常是15秒)联系不上,它不会立刻认定B挂了,而是先把B标记为PFAIL。随后通过Gossip把这个信息传播出去,其他节点也会对B做同样的探测。如果集群里超过一半的主节点都认为B是PFAIL,B就会被确认为FAIL状态,这个信息会被广播到整个集群。
确认FAIL之后,B的从节点就开始争取上位。这里有个微妙的设计:不是所有从节点都能马上升级,而是只有一个从节点能胜出。选举机制用的是类似RAFT的过半原则,从节点会向所有主节点发起投票请求,主节点在一个配置纪元内只能投一票,得票超过一半的从节点胜出。
胜出后,这个从节点会把自己的配置纪元加一,然后开始“篡位”:把持有的槽位信息接管到自己名下,并广播给全集群。客户端后续访问这些槽的请求就会被路由到新主节点上。
整个过程里最幸运的是,数据在从节点上本来就是热备份,切换时不需要像主从复制那样全量同步,所以故障转移时间很大程度上取决于选举和心跳探测的速度,通常几秒到十几秒级别。
2.4 哨兵模式 vs 集群模式:一张表看清区别
很多人会把哨兵和集群搞混,其实它们解决的是不同维度的问题。我常用Excel打过一个比方:哨兵更像是给某个业务配了备用人员,人挂了能替补,但活儿还是那一个人干;集群则是把一个业务拆给多个小组干,每组还有自己的备用人员。
| 对比维度 | 哨兵模式 | 集群模式 |
|---|---|---|
| 数据存储 | 全部数据在每个主节点各存一份 | 数据按槽分散在不同节点 |
| 写入扩展 | 不可扩展,始终只有主节点可写 | 可扩展,整体写入能力随节点增加 |
| 自动分片 | 无,需要应用自己处理 | 内置,key哈希到槽自动路由 |
| 高可用 | 哨兵本身也是一组独立进程负责监控与切换 | 节点间自行检测与选举,不依赖外置组件 |
| 多key操作 | 不受限于slot,同一节点可直接操作多个key | 只有带相同Hash Tag的key才能保证在同一个槽 |
| 运维复杂度 | 相对简单,适合中小规模 | 相对高,需要关注槽迁移与集群网络 |
| 适用场景 | 读多写少、数据总量可控 | 数据规模大、写入量大、需要水平扩展 |
一句话总结:如果你的Redis总内存不超过单机范围,优先用主从加哨兵;一旦数据量和写入量单机扛不住,集群模式几乎是唯一平滑的官方路线。
3. 从零搭建一套三主三从集群(实操篇)
3.1 准备6个Redis实例:每个节点该配什么
这里我用Docker来演示,因为Docker方式最省事,生产环境如果用物理机或云主机,配置思路完全相同,只是少了容器这层包装。
先拉镜像,这里用Redis 7.x作为示例:
docker pull redis:7然后创建集群专用的Docker网络,方便容器间按主机名互通:
docker network create redis-cluster-net接着准备6个节点,我规划了三主三从。每个节点用一个独立的容器,端口从7000映射到7005。核心配置项有这几个:cluster-enabled yes、cluster-config-file、appendonly yes。cluster-enabled一定要开,否则节点之间根本不参与集群通信。
以节点7000为例,一条命令搞定:
docker run -d --name redis-7000 \ --network redis-cluster-net \ -p 7000:6379 \ -v /data/redis-cluster/7000:/data \ redis:7 \ redis-server --port 6379 \ --cluster-enabled yes \ --cluster-config-file nodes-7000.conf \ --cluster-node-timeout 5000 \ --appendonly yes这里注意几件事:
- cluster-node-timeout设成5000,也就是5秒,测试环境方便观察故障转移,生产环境建议10秒以上,太短的超时容易在网络抖动时误判节点下线。
- appendonly yes开启AOF持久化。集群模式虽然内置复制,但持久化依然要配置,不然节点重启后数据全丢,从节点复制也会出问题。
- cluster-config-file是节点自己维护的集群状态文件,里面记录了节点的ID、槽位归属等信息,由Redis自动读写,千万别手动改。
6个节点创建完成后,逐一启动,看到日志里输出“Cluster state changed”之类的不重要,现在它们还只是6个独立的“孤岛”,还没组成集群。
3.2 用redis-cli --cluster create快速完成集群初始化
Redis 5.0以后,官方推荐使用redis-cli自带的集群管理命令,不再需要单独的redis-trib.rb脚本,这是个很大的进步,因为之前那套Ruby脚本还得单独装Ruby环境,很多团队就是卡在这一步放弃了集群。
在任意一个Redis容器里执行创建命令:
docker exec -it redis-7000 redis-cli --cluster create \ 172.20.0.2:6379 172.20.0.3:6379 172.20.0.4:6379 \ 172.20.0.5:6379 172.20.0.6:6379 172.20.0.7:6379 \ --cluster-replicas 1命令后面的6个IP:PORT就是所有节点地址。--cluster-replicas 1表示每个主节点配1个从节点。Redis会自动把这6个节点分成三主三从,并打印出一段分配方案让你确认,大概长这样:
>>> Performing hash slots allocation on 6 nodes... Master[0] -> Slots 0 - 5460 Master[1] -> Slots 5461 - 10922 Master[2] -> Slots 10923 - 16383 Adding replica 172.20.0.5:6379 to 172.20.0.2:6379 ...看到这段信息后输入yes确认,它会自动把槽位分好、把主从关系建立好,最后输出集群已经创建成功。这里有个实践心得:IP地址在生产环境最好用固定的内网IP或域名,千万不要用容器的动态IP,一旦容器重建IP变了,整个集群配置就得重新折腾,非常痛苦。
用redis-cli --cluster check检查一下:
redis-cli --cluster check 172.20.0.2:6379正常情况下会输出每个节点的角色、槽位范围、从节点列表,最后一行显示All 16384 slots covered,这表示所有槽都已分配完毕,集群可用。
3.3 验证集群读写:数据到底落在了哪个节点
集群创建好后,连上任意节点读写试试。这里要注意,直接执行redis-cli不带-c参数是连不上集群的,因为数据可能落在了其他节点上。
redis-cli -c -p 7000-c是cluster模式,客户端会自动处理节点的重定向逻辑。写入一个key看看:
127.0.0.1:7000> set user:1001 zhangsan -> Redirected to slot [5474] located at 172.20.0.4:6397 OK这个输出就很有意思了。user:1001这个key通过CRC16算出来的槽是5474,不属于7000这个节点,于是客户端被重定向到了负责5474槽的节点。redis-cli加的-c参数让它自动跟随重定向,所以看起来像是没感知。但如果我不加-c直接set,Redis会返回一个MOVED错误,告诉你该去哪个节点访问。
这一步其实就是集群工作的最直观展示:数据不在某个固定的主节点上,而是按照槽位散落在各个节点。实际业务里,客户端SDK会在本地缓存槽位和节点的映射关系,直接计算出某个key该去哪个节点,不会每次都靠重定向。
3.4 模拟主节点宕机:故障转移怎么发生
集群高可用最精彩的演示就是杀掉一个主节点。假设我们先把集群信息捋清楚,找到某个主节点,比方说172.20.0.3这个节点的ID。直接在容器层面把它停掉:
docker stop redis-7001停掉之后,你观察一下日志或者等十几秒再查集群状态。执行:
redis-cli --cluster check 172.20.0.2:6379你会发现原来属于172.20.0.3的槽位,现在挂到了它的从节点名下,并且从节点的角色已经变成了master。整个过程没有人工介入,全是靠Gossip探测和选举完成的。
这里我想强调一个生产上的细节:故障转移期间,原本路由到宕机节点的请求会短暂失败,返回CLUSTERDOWN或者连接错误,持续时间大概在node-timeout加上选举时间这么长。所以上游业务一定要配好重试机制,不然一次主节点切换就会造成一批报错,这在线上会非常明显。
4. 集群模式最容易踩的坑与排查记录
4.1 MOVED和ASK:客户端为什么老报错
“MOVED”和“ASK”是集群模式下最常见的两条报错信息,很多第一次接触集群的人看到后第一反应是“是不是集群坏了”,其实都是正常的路由逻辑。
MOVED表示这个key对应的槽位已经固定由某个节点负责,客户端你找错门了,应该转到指定节点访问。这种情况一般发生在客户端初始化的时候,或者槽位分配有变化但客户端缓存了旧路由。遇到MOVED,正确的处理是更新本地缓存并重发请求。
ASK和MOVED长得像,但含义完全不同。ASK发生在槽位迁移过程中,这个槽正在从节点A迁往节点B,key可能还在A上,也可能已经被搬到B上了。当客户端访问一个迁移中的槽时,节点如果认为key已经不在自己这里,就会返回ASK错误,并给出目标节点地址。客户端需要先发一个ASKING命令,然后再发真正的请求。
你现在可以把MOVED理解成“这户人家永久搬家了,你记一下新地址”,把ASK理解成“这户人家正在搬,东西一部分在这边一部分在那边,你去那边先亮个通行证”。生产环境的客户端SDK基本都把这些逻辑封装好了,但我们自己写脚本用裸redis-cli时,还是要靠-c参数或者手动处理。
4.2 扩容、缩容时的槽位迁移不能乱来
集群一个非常实用的特性是动态扩容。想把新节点加进去,分两步:第一步,把新节点加入集群;第二步,把一部分槽分配给它。但很多人会忽略第二步,结果新节点一直是空转的,数据没有分布过去,就以为扩容成功了。
正确的流程是这样的,先启动一个新Redis实例,然后用ADD-NODE把它加进集群:
redis-cli --cluster add-node 新节点IP:PORT 任意已有节点IP:PORT这时候新节点状态是handshake continue到最后,它还没有任何槽位。接下来要把一些槽迁移过去,可以用:
redis-cli --cluster reshard 任意已有节点IP:PORT它会问你打算迁移多少个槽、迁移到什么节点ID、从哪些源节点迁出。按照提示填完,数据就开始搬了。
整个迁移过程是逐槽进行的,每个槽的迁移会把槽内所有key逐个挪过去,期间这个槽对外表现为迁移中状态,客户端处理ASK逻辑就行。生产上我一般选择凌晨流量低峰期操作,并且一次迁移不要太多槽,控制每个槽的key总量,否则很容易出现超时。
缩容也容易踩坑。直接从一个节点上remove-node是行不通的,如果它上面还有槽位,集群会拒绝移除。必须先把他负责的槽迁移到其他节点,然后再把节点摘除。另外,如果这个节点还挂着从节点,也要先处理从节点关系,或者把从节点一并删掉。
4.3 网络分区与脑裂:集群也有脆弱时刻
很多人觉得集群有了自动故障转移就万事大吉,这是不对的。集群在网络分区时同样会出现脑裂风险。
举个典型场景:某个主节点和它的从节点分别处于两个网络分区里,主节点所在的区域和集群其他主节点通信中断,但和它自己的从节点可能还能通信。如果大多数主节点都认为这个主节点挂了,那么它的从节点成功上位成为新主。可如果此时旧主节点还没死透,网络又恢复了,两个节点都认为自己是主,就出现了脑裂。Redis靠两个方面来控制这个问题:一是选举时要求从节点得到超过半数主节点的投票,确保只有“大多数人认可”的从节点才能上位;二是客户端写入时,如果主节点发现自己已经联系不上集群里的大多数节点,它会在一定时间内拒绝写入,防止数据在分区期间被写入到即将被废弃的旧主上。
即便如此,脑裂依然会带来两种可能的后果:一是有段时间数据不一致,两个节点都接受了写入;二是当旧主恢复并发现自己的槽位归属已被撤销后,它会把自己切换成新主的从节点,然后清空与主节点不一致的数据,也就是强制丢弃脑裂期间写入的那些数据。这种后果是设计使然,所以对于要求不丢数据的业务,需要在上层做补偿或校验。
4.4 持久化、缓存穿透和性能取舍一起捋清楚
集群模式本身不会自动帮你做好持久化策略,每个节点依然要单独配置RDB和AOF。我的建议是AOF至少开启everysec,这是性能和安全的折中;RDB可以按天做一次全量备份,用来冷备和快速重建节点。
集群下缓存穿透的问题会更麻烦一点。因为数据分散了,一次穿透查询会打到某一个分片节点上,如果某个热点key被恶意构造,它只会打爆一个节点,而其他节点正常,表现就是集群里某台Redis CPU飙高,其他节点没事。我的处理思路是:布隆过滤器前置拦截,加上对单个key的短时间空值缓存。缓存击穿也一样,热点key过期的一瞬间大量请求打到数据库,集群模式下热点key还是要靠单节点的逻辑解决,比如用互斥锁重建缓存。
性能取舍上,集群不等于无限扩展。槽位迁移期间会占带宽和CPU;多个key的批量操作受到槽位限制;全局扫描类命令(比如KEYS、SCAN)虽然可以用SCAN慢慢遍历,但跨节点汇总数据要依赖客户端自己做。所以设计之初就要接受这些约束,不要用集群思维解决所有缓存问题。
5. 从使用到落地:客户端选型与key设计约束
5.1 Smart Client和Proxy:两种接入方式的体验差异
集群模式下的客户端主要分两类。一类是Smart Client,比如Jedis(配ShardedJedisPool或JedisCluster)、Lettuce、Redisson。这类客户端会在本地维护槽位和节点的映射表,直接计算出key应该打到哪台节点上,性能最好,因为没有任何中间层。缺点是客户端要处理节点变更时的路由表更新,好在主流SDK都做好了。
另一类是Proxy模式,比如Codis和Redis Cluster自带的集群代理。客户端只连一个代理地址,由代理去做路由转发。它对业务的侵入最小,但多了一层网络转发,延迟略高,而且代理本身要保证高可用。
我个人在项目里优先选择Lettuce或Redisson这类Smart Client。原因很简单:性能损耗最小,而且它们对集群模式的支持非常成熟。需要说明的是,不同语言的SDK对槽位缓存的更新时机不一样,有些SDK在遇到MOVED错误时才刷新路由表,有些会额外监听集群配置变更,选型时要确认这一点,不然节点变更后可能有一段时间报错。
5.2 多key操作限制与Hash Tag的正确用法
集群模式一个无法回避的限制是:多个key只有在属于同一个槽时,才能放进同一个事务、同一个Lua脚本,或者用MSET、MGET一次操作。如果两个key不在同一个槽,这些操作会直接报CROSSSLOT错误。
怎么让多个key落在同一个槽?答案是Hash Tag。Redis计算key槽位的时候,如果key里包含花括号{},那么只对花括号内部的内容计算CRC16。例如user:{1001}:profile和user:{1001}:orders,它们的Hash Tag都是1001,所以会落入同一个槽。
使用Hash Tag的核心原则是:只有确实需要一起操作的key才加相同的Tag,不要为了让所有key都聚集强行设计成同一个Tag,那样会严重破坏数据分布的均衡性。我看过有团队为了让一段Lua脚本跑通,把所有key的Tag都写成同一个固定字符串,结果整个集群的写入全部集中在一两个节点上,其他节点全是空的,集群直接被写成了“单机”。
合理的设计思路是:业务维度的聚合要尽量小,比如订单详情、订单列表、订单状态这些强相关的key用同一个订单号做Tag;跨订单的统计类操作,直接放弃集群内的原子性,通过业务补偿或其他存储解决。
5.3 分布式锁在集群模式下的正确打开方式
单机Redis实现分布式锁很简单,SET NX EX一条命令就搞定了,比如set lock:order:1001 1 nx ex 30。但在集群模式下,经典的单key分布式锁会暴露出一个问题:如果锁所在的节点发生故障转移,锁数据可能已经没了,但业务还在跑。
Redis官方给出的一个标准答案是RedLock算法:在主节点上获取锁时,同时向大多数Redis节点尝试加锁,只有当超过半数的节点加锁成功并且消耗时间在锁有效期内,才认定加锁成功;释放时则向所有节点广播释放。
现实中很多团队直接用Redisson,它内置了RedLock的分布式锁实现,对使用者透明。我自己的实践是:普通业务用单节点锁加一个业务自身唯一ID判断就够了,只有对绝对安全性要求极高的跨节点事务才考虑RedLock,因为RedLock本身的复杂度、时钟依赖和性能开销都不小。另外要注意,不管用哪种锁,加锁时必须绑定一个唯一的请求标识,这样释放锁的时候才能确认是自己加的锁,避免误删别人的锁。
集群模式还有一类常见问题:一个大事务想拆成多个小事务,或者某个key的value特别大导致槽位迁移很慢,这些都属于使用层面需要提前设计的约束,别等上了集群再被各种报错追着跑。先把这些边界想清楚,集群其实没有想象中那么难伺候。
我自己搭过几次Redis集群,最深刻的体会就是:cluster相关的坑,大部分不是原理有多深,而是很多人没认真看日志、没搞懂MOVED和ASK的重定向、没提前设计好Hash Tag。你把这些基础机制吃透了,再上手集群会非常顺。日常维护时,建议多利用redis-cli --cluster check做巡检,关注槽位迁移是否完成、主从节点数量是否正常,这些东西在异常出现前就会给你信号。