我到现在还记得那次半夜的告警订单服务的缓存 Redis 主节点一下子没了心跳从节点顶上之后紧接着数据库连接池被打满。并不是主从切换失败而是切换前积攒的主库写流量在几十秒内全部打到了数据库上。那种经历让我彻底意识到光做主从复制、加哨兵还不够单点故障的根子不在于“有没有备份”而在于“主节点单点”这个架构问题本身。后来我把核心缓存整体迁移到了 Redis Cluster配合 Spring Boot 应用做了完整的接入改造并把运维侧的故障演练常态化才有了这篇文章。这篇内容适合三种朋友一是正在用 Redis 但对 Cluster 模式停留在“知道名字、没动手搭过”阶段的开发者二是准备把 Redis 从单机/主从/哨兵升级为集群但搞不清楚原理和坑的运维或全栈工程师三是面试前想彻底弄懂哈希槽、Gossip、故障转移这些硬核概念的求职者。下面我从原理讲到 Spring Boot 落地最后把线上最容易踩的坑一次说透。1. 从单机到集群把单点故障“拆”成多主架构的演进逻辑1.1 主从复制 哨兵解决了什么留下了什么单机 Redis 最大的问题不是慢而是“一个人扛所有事”。容量受限于单机内存写 QPS 受限于单线程模型更致命的是宕机即全量缓存失效打穿缓存后数据库直接被雪崩流量压垮。于是最基础的方案出现了主从复制。主节点负责写从节点负责读数据通过复制流同步。主节点挂了人工把从节点提升为主节点这在业务低峰期勉强可行但半夜发生故障时人的反应速度远跟不上告警轰炸。更麻烦的是Redis 的复制默认是异步的主节点写入成功后还没来得及把命令传给从节点就宕机这部分数据就丢了。你说加 AOF 和 RDB它们解决的是重启后的数据恢复问题解决不了“复制链路中间断掉”的丢数据问题。哨兵模式的出现就是为了补齐“自动故障转移”这块拼图。Sentinel 集群通过 PING 监控主从节点的健康状态主观下线后再由多个哨兵协商确认最后执行 failover把某个从节点晋升为新的主节点并且通知客户端切换连接。这套机制确实让 Redis 的高可用往前迈了一大步但它有一个绕不过去的天花板哨兵模式本质上还是“一主多从”的架构所有节点都保存全量数据单节点的内存上限就是整个缓存的容量上限写能力也始终被主节点单实例锁死。就算你加了再多的从节点也只能扩展读扩展不了写和容量。1.2 Redis Cluster 的多主分片到底“拆”了什么Redis Cluster 的核心思路和哨兵模式完全不同它把数据按哈希槽拆成了 16384 个分片每个主节点只负责其中一部分槽。假设你部署三个主节点那么槽位大概分成三段每个节点只存全量数据的三分之一。这样一来容量可以水平扩展写 QPS 也可以水平扩展——因为多个主节点同时在干活。而且集群的高可用逻辑内置在每个节点里不再需要独立的 Sentinel 进程。每个主节点都可以挂一个或多个从节点当主节点被判定故障后它的从节点会自动发起选举并晋升为新的主节点整个过程由集群节点间相互协商完成外部客户端只需要知道任意一个节点的地址。所以你要理解Redis Cluster 解决的不只是“单个主节点挂掉”的问题它同时解决了容量、吞吐、高可用三个维度的瓶颈。这也是为什么我把标题写成“彻底告别单点故障”——不是玄学而是架构上把单点变成了多点把全量数据变成了分片数据任何一个节点挂掉其余节点仍然承担自己的那部分槽位整个集群对外继续可用。2. 哈希槽、Gossip 与选举集群运行前必须吃透的三个底层机制2.1 哈希槽是如何决定一个 key 去向的Redis Cluster 没有使用一致性哈希比如哈希环那种而是引入了固定数量的哈希槽。整个 keyspace 被划分为 16384 个槽每个 key 具体落到哪个槽由下面这个公式决定slot CRC16(key) % 16384比如你执行SET user:1001:name zhangsanRedis 会先计算user:1001:name这个 key 的 CRC16 校验和然后对 16384 取模得到一个 0 到 16383 之间的整数这个整数对应的槽位由某个主节点负责。客户端连接集群里的任意一个节点都可以拿到当前的槽位分布信息从而直接找到应该访问的目标节点。你可能会问为什么是 16384 而不是 65536我在面试中也被问过好几次。原因有几个第一集群节点数量通常不会超过 1000 个16384 个槽位已经足够细粒度地做数据分布和迁移第二节点之间需要通过位图传播槽位信息16384 个槽的位图只有 2KB65536 就是 8KB网络开销会明显增加第三槽位小一点reshard 时的数据迁移单元也更紧凑减少迁移对业务的影响。当然如果你只有一个只有几个 key 的小业务这些差异感知不强但大规模集群下这些都是实打实的性能成本。另外如果你希望某些 key 一定落在同一个槽里可以用 hashtag 语法。Redis 在计算槽位时只对{}内的字符串做 CRC16比如{user:1001}:name和{user:1001}:age这两个 key花括号里都是user:1001所以它们会落到同一个槽。这一点在后面的批量操作坑里会非常有用。2.2 节点与节点之间怎么发现故障集群里的每个节点都通过一个额外的 TCP 端口与其他节点通信这个端口是服务端口加 10000。比如你 Redis 服务端口是 6381那么集群总线端口就是 16381防火墙必须放通这个端口否则节点之间会互相把对方标记为故障。节点间通信使用 Gossip 协议简单说就是“八卦传播”。每个节点会定期向其他节点发送 PING、PONG、MEET 消息带上自己掌握的节点状态和槽位信息。通过这种八卦式的传播整个集群的节点状态在很短时间内就能达成一致。当一个节点在cluster-node-timeout默认 15000 毫秒内没有收到某个节点的 PING 响应它会把这个节点标记为 PFAIL疑似下线然后通过 Gossip 消息把这个怀疑广播给其他节点。当超过一半的主节点都认为这个节点 PFAIL 时它就会被升级为 FAIL客观下线此时才会触发故障转移流程。这套机制纯粹是“去中心化”的没有独立的监控角色所以比哨兵更省心但同时也意味着节点之间需要保持足够稳定的网络。如果网络抖动频繁节点之间的 Gossip 会收不到响应集群就可能误判故障。2.3 从节点晋升主节点的完整过程当某个主节点被标记为 FAIL 后它的从节点们会开始竞选。竞选流程有点类似 Raft但做了简化每个从节点会检查自己与故障主节点的数据复制偏移量偏移量越接近主节点说明数据越完整它就越有资格发起竞选。竞选成功后这个从节点会广播一条消息让其他节点把它当做新的主节点并把故障主节点的槽位重新分配给它。整个选举不是瞬间完成的。正常情况下需要在 cluster-node-timeout 基础上再叠加选举时间所以你在故障演练时可能会看到十几秒甚至二十秒的不可用窗口。这也是为什么我在文中反复强调“集群不是魔法”它解决单点故障但做不到零切换时间。如果你的业务对缓存连续性要求极高还得在客户端做本地缓存兜底或者通过 Redisson 这类库提供更优雅的重连策略。3. 本地环境下从零搭建三主三从 Redis Cluster 的全过程3.1 六份配置文件的差异化设置搭建集群不需要很强大的机器六节点完全可以跑在同一台机器上每个节点一个端口。这里我以 6381 到 6386 六个端口为例演示生产环境当然要分散到不同物理机。先创建目录mkdir -p /etc/redis-cluster/6381 /etc/redis-cluster/6382 /etc/redis-cluster/6383 /etc/redis-cluster/6384 /etc/redis-cluster/6385 /etc/redis-cluster/6386每个端口都创建一份独立的 redis.conf以 6381 为例# /etc/redis-cluster/6381/redis.conf port 6381 daemonize yes protected-mode no bind 127.0.0.1 dir /etc/redis-cluster/6381/ pidfile /etc/redis-cluster/6381/redis-6381.pid logfile /etc/redis-cluster/6381/redis-6381.log appendonly yes appendfilename appendonly.aof cluster-enabled yes cluster-config-file nodes-6381.conf cluster-node-timeout 15000关键点有三个一是cluster-enabled yes少了它节点根本不会进入集群模式二是cluster-config-file必须是每个节点独立的名字这个文件会保存节点 ID、槽位分配、主从关系等元数据多个节点共用同一个文件会直接启动失败三是dir和日志路径也要独立不然 aof 文件和日志会互相覆盖。剩下的 6382 到 6386 配置文件只需要把端口、目录、pid、log、cluster-config-file 里的数字替换掉。需要特别提醒的是集群模式下不要像主从模式那样在配置文件里写replicaof集群的主从关系由集群协议动态决定初始化的时候会自动分配。3.2 初始化集群并验证槽位分布六个配置都准备好之后逐个启动for port in 6381 6382 6383 6384 6385 6386 do redis-server /etc/redis-cluster/${port}/redis.conf done全部启动后用ps -ef | grep redis检查进程是否都活着。然后执行集群创建命令redis-cli --cluster create 127.0.0.1:6381 127.0.0.1:6382 127.0.0.1:6383 127.0.0.1:6384 127.0.0.1:6385 127.0.0.1:6386 --cluster-replicas 1--cluster-replicas 1的意思是每个主节点配一个从节点。命令执行后 Redis 会自动把 6381、6382、6383 设为主节点6384、6385、6386 设为对应的从节点并弹出槽位分配方案让你输入 yes 确认。确认后16384 个槽位会平均分配给三个主节点。我现在这台测试机上执行完成后cluster info结果类似redis-cli -p 6381 cluster info输出关键行cluster_state:ok cluster_slots_assigned:16384 cluster_slots_ok:16384 cluster_known_nodes:6 cluster_size:3cluster_state:ok表示整个集群健康。再用cluster nodes查看拓扑redis-cli -p 6381 cluster nodes你会看到六行节点信息每个节点都有一个 40 位的十六进制 ID后面的master或slave表明角色还有0-5460这样的槽位区间。主从节点的对应关系也能在这里看到从节点行末尾会带上主节点的 ID。最后做一个最简单的读写验证redis-cli -c -p 6381 set foo bar redis-cli -c -p 6381 get foo这里-c是 cluster 模式客户端会自动跟随 MOVED 重定向所以我连接 6381 写一个可能属于 6382 槽位的 key命令也能正常完成。如果不加-c写一个不属于 6381 槽位的 key你会看到MOVED slot ip:port的报错信息这就是集群协议在做槽位重定向。3.3 故障转移实验直接干掉一个主节点搭建完集群后我强烈建议你立刻做一次故障转移实验否则你永远不知道集群真实切换一次要多久。比如我实验时先看看 6384 是谁的从节点然后直接停掉它的主节点redis-cli -p 6383 shutdown nosave停掉后从节点需要先等 cluster-node-timeout15 秒超时再走选举流程。我实测大概 20 到 25 秒左右6384 会从 slave 变成 master。验证方法redis-cli -p 6384 cluster info如果集群重新变成cluster_state:ok并且cluster nodes里 6384 标记为 master就说明故障转移成功。注意我刚才停掉的 6383 重启后不会自动恢复成主节点它会以从节点的身份加入集群挂到新的主节点 6384 下面。这种“旧主复活变从”的行为是集群的正常收敛不用手动干预。4. Spring Boot 接入集群配置、序列化与客户端路由4.1 依赖与配置文件Spring Boot 2.x 和 3.x 的差异Spring Boot 接入 Redis 集群最常用的还是spring-boot-starter-data-redis。这个包默认带的是 Lettuce 客户端它对集群协议的支持相当完善包括槽位缓存、MOVED/ASK 自动处理、连接复用所以我不建议你轻易换成 Jedis除非你有强理由。pom 依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency配置方面有个容易踩的坑Spring Boot 2.x 和 3.x 的前缀不一样。2.x 用spring.redis3.x 改成了spring.data.redis。如果你照着一篇旧文章抄配置项目直接启动就报无法解析属性。Spring Boot 3.x 的集群配置如下spring: data: redis: cluster: nodes: - 127.0.0.1:6381 - 127.0.0.1:6382 - 127.0.0.1:6383 max-redirects: 3 timeout: 5000max-redirects是客户端在遇到 MOVED 重定向时最大跟随次数通常给 3 就够因为槽位迁移很快客户端一般重试第二次就能命中正确节点。如果你还在用 Spring Boot 2.x把前缀换成spring: redis: cluster: nodes: - 127.0.0.1:6381 - 127.0.0.1:6382 - 127.0.0.1:63834.2 RedisTemplate 序列化与集群连接的关系默认的 RedisTemplate 用的是 JdkSerializationRedisSerializerkey 和 value 都会被序列化成一串以\xAC\xED开头的二进制字节用 redis-cli 看就是乱码排障极不方便。我每一次接手项目都会先改序列化配置这几乎算是 Redis 接入的潜规则。在集群模式下序列化配置和单机模式完全一样不会因为加了集群就自动变好。你可以写一个配置类Configuration public class RedisClusterConfig { Bean public RedisTemplateString, Object redisTemplate(RedisConnectionFactory factory) { RedisTemplateString, Object template new RedisTemplate(); template.setConnectionFactory(factory); StringRedisSerializer keySerializer new StringRedisSerializer(); GenericJackson2JsonRedisSerializer valueSerializer new GenericJackson2JsonRedisSerializer(); template.setKeySerializer(keySerializer); template.setHashKeySerializer(keySerializer); template.setValueSerializer(valueSerializer); template.setHashValueSerializer(valueSerializer); template.afterPropertiesSet(); return template; } }这里 key 用 StringRedisSerializervalue 用 GenericJackson2JsonRedisSerializer因为 value 序列化成 JSON 字符串后方便排查内存占用也比 JDK 序列化小得多。需要注意一点GenericJackson2JsonRedisSerializer 会在 JSON 里写入class字段来记录类型信息反序列化时才能还原对象代价是体积变大。如果你明确知道对象的类型用 Jackson2JsonRedisSerializer 并指定类型会更省空间缺点是不够通用。还有一个集群特有的限制Redis Cluster 只支持 db0不支持 SELECT 切换数据库。你如果在单机环境配了spring.redis.database1切到集群后会报错或者 DATABASE 相关异常。所以上集群前业务代码里绝对不能出现select(index)这种操作。4.3 客户端自动处理 MOVED/ASK 的实际表现Lettuce 在 Cluster 连接模式下会自己维护一份槽位与节点的路由表。第一次请求时如果恰好发到了错误的节点服务端会返回 MOVEDLettuce 收到 MOVED 后会更新本地路由表并重新发送请求。这个过程对业务代码完全透明。我把同一个 key 连续读了几次抓包能看到每次请求最后都会到达正确的节点这就是客户端路由兜底的价值。但如果你自己写 Redis 客户端、绕过 Lettuce 和 Jedis就必须自己实现槽位计算和 MOVED 重定向代价极高。所以老老实实用 Spring Data Redis 封装好的模板类是正道。给一个最简单的 Service 示例Service public class CacheService { private final RedisTemplateString, Object redisTemplate; public CacheService(RedisTemplateString, Object redisTemplate) { this.redisTemplate redisTemplate; } public void put(String key, Object value, long timeout, TimeUnit unit) { redisTemplate.opsForValue().set(key, value, timeout, unit); } public Object get(String key) { return redisTemplate.opsForValue().get(key); } }这段代码在单机、哨兵、集群三种模式下都能跑Spring Data Redis 的 ConnectionFactory 会根据配置自动适配底层访问逻辑。这就是 Spring Boot 集成的最大价值你不需要为 Redis Cluster 单独定制一套缓存代码只要在配置层做切换。5. 集群模式下最容易踩的四个坑批量操作、锁、扩容、分区5.1 跨 Slot 批量操作与 hashtag 的使用集群模式下Redis 要求单条命令涉及的 key 必须在同一个哈希槽。以 MGET 为例如果你传入了三个 key这三个 key 的哈希槽不一样Redis 会直接返回 CROSSSLOT 错误。这不是客户端问题是服务器端在拒绝跨槽操作。最简单的解法就是 hashtag。把需要一起操作的 key 设计成包含相同花括号内容的格式例如USER:LOGIN:{1001}:token USER:LOGIN:{1001}:lastTime USER:LOGIN:{1001}:expireAt因为花括号里的内容都是1001这三个 key 会命中同一个哈希槽。这样你就能放心使用 MGET、DEL 或者 pipeline 对它们做批量操作。但注意hashtag 用多了会导致数据在槽位上分布不均因为相同前缀的 key 全挤到一个槽里。如果存在热点用户甚至会把某个主节点的 CPU 打高。我见过有人把所有 key 都用{common}做前缀结果集群退化成了伪分片三个主节点只有第一个在干活。所以 hashtag 只应该在确实需要事务或批量操作的业务范围内使用不能全局滥用。5.2 主从切换与分布式锁的冲突Redis 分布式锁最简单可靠的写法是SET key value NX PX 30000单机环境下没问题但集群模式下有一个隐性问题如果线程 A 在主节点上成功加锁主节点还没把这条写命令同步给从节点就宕机了从节点晋升为新的主节点它内存里根本没有这把锁。此时线程 B 再来加锁同样能成功于是两个线程同时进入了临界区。针对这个问题业界有 Redlock 方案通过向多个独立 Redis 节点加锁来降低失败概率但这个方案本身也有争议尤其在运维复杂度和时钟问题上容易翻车。我的工程经验是普通业务场景继续用 SETNX 加锁同时配合 Redisson 这类客户端会更省心。Redisson 的 RLock 在集群模式下通过 Lua 脚本实现加锁和看门狗续期持有锁期间会自动延长过期时间避免业务没执行完锁就过期的问题而且对 Cluster 路由做了适配。如果你要的是可靠互斥Redisson 比手写 SETNX 稳得多如果你要的是极限性能且能容忍极端情况下的重复执行最简单的 SETNX 也够用。5.3 扩容缩容时的数据迁移注意事项集群扩容不是把新节点加进集群就完事槽位不会自动迁移必须显式执行 reshard。举个例子加入一个新节点之后先把它加入集群redis-cli --cluster add-node 127.0.0.1:6387 127.0.0.1:6381然后执行 reshard把一部分槽位从旧节点迁移到新节点redis-cli --cluster reshard 127.0.0.1:6381 --cluster-from old-node-id --cluster-to new-node-id --cluster-slots 1000 --cluster-yes--cluster-from和--cluster-to都填节点 ID可以从cluster nodes里拿到。迁移是按 key 逐个进行的每个 key 的迁移过程短暂阻塞该 key 的读写但不会阻塞整个集群。不过如果一次迁移大量槽位还是会占用大量网络带宽和 CPU我建议安排在业务低峰期并且一次别迁太多分批次观察集群状态。缩容更危险。删除节点前要先把这个节点的槽位迁走否则节点删除后它负责的槽位数据就找不回来了。正确流程是先 reshard 把它的槽位清零再执行redis-cli --cluster del-node 127.0.0.1:6387 new-node-id5.4 网络分区导致的双主与脑裂处理Redis Cluster 在遭遇网络分区时会面临一个两难选择。配置项cluster-require-full-coverage默认是 yes意思是一旦任何一部分哈希槽不可达整个集群会拒绝所有请求保证的是强一致性。如果改成 no集群在缺失部分槽位的情况下仍然对外提供读写但分区两侧可能同时出现两个节点都认为自己还是某个槽的 owner造成短暂的双主。我的建议是如果是内部系统对一致性要求高保留默认 yes哪怕短暂不可用也不能错。如果是面向用户的互联网业务一般把cluster-require-full-coverage设为 no优先保证缓存可用性但在代码里必须做好读取失败的兜底不能让缓存异常直接穿透到数据库。无论怎么选故障演练都不可少。我每次改集群配置都会故意杀掉一个节点验证行为否则根本不知道线上真实切换时会发生什么。6. 哨兵模式还是集群模式一张表帮你做选型6.1 哨兵与集群的功能对比很多人在面试里被问“哨兵模式和集群模式的区别”其实是希望你从数据分片、扩展性、高可用三个层面去区分。用一张表总结对比维度哨兵模式Sentinel集群模式Cluster数据存储所有节点存全量数据数据按 16384 槽分片写扩展性受单主节点限制可通过增加主节点水平扩展读扩展性可增加从节点可增加从节点也可增加主节点分片高可用机制Sentinel 监控 自动 failover集群节点通过 Gossip 投票自行 failover数据容量受单机内存限制可多机累计容量客户端复杂度简单哨兵对客户端透明客户端需支持 Cluster 协议运维复杂度需要额外部署 Sentinel节点自身管理集群状态适用规模中小规模QPS 中等大规模容量和写 QPS 都有扩展需求还有一个很容易混淆的点哨兵模式下从节点默认可以分担读流量但主节点切换后客户端需要感知新地址集群模式下客户端需要知道槽位分布才能做路由虽然 Lettuce 把这些封装好了运维排查时仍必须理解 MOVED 和 ASK 两种重定向的区别。6.2 我的选型经验和实操建议如果业务规模不大比如缓存数据量不超过单机内存的 60%整体 QPS 也就几万那我建议直接用主从复制加哨兵就好。没必要上集群因为集群模式会引入跨槽操作受限、客户端适配、拓扑变更等一系列额外复杂度对一个小缓存系统来说是过度设计。但如果你的数据量已经逼近单机内存上限或者写 QPS 已经让单实例 CPU 接近 100%或者你有明显的水平扩展规划那就该上集群。不要等到缓存容量告急才仓促迁移提前做好槽位设计和客户端适配会从容得多。还有一个容易被忽略的点无论哪种模式持久化和备份都不能省。集群模式不会自动帮你多备份一份数据AOF 和 RDB 仍然要按原策略配置。节点挂了可以从节点顶上但如果不小心执行了FLUSHALL从节点也会同步执行这时候你就知道备份有多值钱了。最后分享我自己的一个小习惯每次做了集群扩容或者配置变更都会用redis-cli --cluster check全面扫一遍集群健康度再写个脚本定时执行cluster info抓取cluster_state状态。如果你连集群当前是 ok 还是 fail 都不知道那“彻底告别单点故障”就只是一句口号而不是可以验证的事实。