当业务从单体走向分布式、从日常流量走向大促洪峰,Redis 的问题就不再只是“快不快”,而变成了“能不能横向扩展、能不能扛故障、能不能平滑扩容、客户端能不能自动路由”。
真正的 Redis Cluster,不是多开几个节点那么简单,而是一整套围绕分片、复制、选举、重定向、迁移和客户端路由构建出来的分布式缓存体系。
本文不只讲概念,而是按架构师和一线工程实践的视角,把 Redis Cluster 拆成四个核心问题讲透:
- 数据如何分片,为什么是 16384 个槽
- 数据如何复制,主从切换时一致性边界在哪里
- 节点如何感知故障并自动故障转移
- Smart Client 到底做了什么,为什么它是集群可用性的关键一环
同时,文章会补齐生产环境最关心的内容:
- 高并发场景下的集群设计原则
- 热点 Key、批量操作、Lua、多 Key 事务的工程边界
- Spring Boot + Lettuce 生产级代码示例
- 扩容、迁移、监控、告警、排障与演进方案
目录
- 一、为什么单机 Redis 迟早会走到天花板
- 二、Redis Cluster 的整体架构:数据平面与控制平面
- 三、数据分片机制:Hash Slot 为什么比一致性 Hash 更适合 Redis
- 四、主从复制机制:Redis Cluster 的数据冗余是怎么工作的
- 五、故障转移机制:Gossip、PFAIL、FAIL 与投票选举
- 六、Smart Client 核心原理:客户端为什么必须“聪明”
- 七、工程化设计:高并发、高可用、可扩展的 Redis Cluster 最佳实践
- 八、生产级代码实战:Spring Boot + Lettuce 构建集群访问层
- 九、真实业务案例:电商大促下的 Redis Cluster 架构落地
- 十、扩容与迁移:如何不停机完成 Slot 重分布
- 十一、监控与排障:线上最容易踩的坑与处理方法
- 十二、从单机到集群的演进路线
- 十三、总结:把 Redis Cluster 真正用对
一、为什么单机 Redis 迟早会走到天花板
很多团队第一次遇到 Redis 瓶颈,往往不是 Redis 变慢了,而是业务变复杂了。
典型信号有四类:
1. 容量瓶颈
- 单实例内存持续逼近上限,RDB/AOF 开销开始变重
- 大 Key 和热 Key 混在一起,内存碎片率持续升高
- 单机重启或全量同步时间越来越长
2. 性能瓶颈
- 单节点 QPS 已接近 CPU 上限
- 连接数过高,网络中断与排队增多
- 大量复杂命令阻塞事件循环,出现毛刺延迟
3. 可用性瓶颈
- 单点实例故障会直接影响核心链路
- 主从方案虽然解决了冗余,但主节点仍是写入单点
- 仅靠 Sentinel 能解决主从切换,但不能解决单机容量扩展
4. 架构瓶颈
- 不同业务共享一套 Redis,资源争抢严重
- 需要在线扩容,但传统客户端分片迁移成本太高
- 跨团队、多语言场景下,分片逻辑难以统一
这时候问题的本质已经不是“Redis 能否再调一调”,而是“缓存层是否需要进入分布式阶段”。
业务视角下的典型场景
场景一:电商大促缓存层
- 商品详情、库存预热、活动页推荐都依赖 Redis
- 峰值流量从日常 5 万 QPS 拉升到 50 万 QPS
- 单机 Redis 即使 CPU 还能扛,网络与连接模型也会先触顶
场景二:用户状态中心
- 分布式 Session、Token、权限快照都在 Redis 中
- 服务实例数持续扩容
- 单实例无法再承担所有用户状态读写
场景三:排行榜与计数体系
- 热门业务存在局部超高频访问
- 少量热点集合和大量冷数据并存
- 需要将读写压力分散到多个主节点
Redis Cluster 解决的不是所有问题,而是三个核心问题
- 水平扩容:通过 Slot 分片将数据分散到多个主节点。
- 高可用:每个主节点挂从节点,主故障后可自动提升从节点。
- 在线迁移:通过 Slot 迁移实现不停机扩缩容。
一句话概括:
Sentinel 解决的是“谁是主”,Redis Cluster 解决的是“数据放哪、主挂了怎么办、扩容时怎么迁”。
二、Redis Cluster 的整体架构:数据平面与控制平面
理解 Redis Cluster,最好的方法不是从命令开始,而是先从架构分层开始。
1. 数据平面:真正承载业务读写
数据平面负责三件事:
- 存储 Key-Value 数据
- 按 Slot 接收读写请求
- 与对应从节点做复制同步
可以把它理解为“真正提供缓存服务的那一层”。
2. 控制平面:维护集群状态与拓扑
控制平面负责:
- 节点发现
- 拓扑传播
- 故障检测
- 主从选举
- Slot 分配变更同步
Redis Cluster 最大的特点就是控制平面也是去中心化的,没有一个固定元数据中心。
3. 一个最小可用集群是什么样
生产中常见的基础形态是:
- 3 个主节点
- 每个主节点 1 个从节点
- 共 6 个节点
原因很简单:
- 少于 3 个主节点,选举和容错空间不足
- 只有主节点没有从节点,故障后无法自动恢复
- 3 主 3 从是最小的“可扩容 + 可容错”起点
4. Redis Cluster 内部其实有两套通信链路
普通客户端访问端口
Cluster Bus 端口
- 默认是
16379,即业务端口 +10000
- 用于 Gossip、故障传播、投票、配置更新
这是很多线上排障会忽略的一点。如果只开了业务端口,没开 Cluster Bus 端口,节点之间虽然能接业务请求,但集群状态无法正确传播。
5. 为什么说 Redis Cluster 是“去中心化”而不是“无元数据”
它不是没有元数据,而是:
- 每个节点都保存一份完整拓扑视图
- 每个节点都知道 Slot 到节点的映射
- 每个节点都通过 Gossip 渐进同步集群状态
所以 Redis Cluster 的本质不是“没有中心”,而是“没有必须存活的中心”。
三、数据分片机制:Hash Slot 为什么比一致性 Hash 更适合 Redis
Redis Cluster 没有选择客户端常见的一致性 Hash,而是使用了固定数量的 Hash Slot。
1. 基本路由公式
slot = CRC16(key) % 16384
流程如下:
Key -> CRC16 -> Slot -> Slot所属Master -> 目标节点
2. 为什么是 16384 个槽
这不是随便拍脑袋定出来的数,而是典型的工程折中。
如果槽太少
- 数据分布粒度不够细
- 扩容迁移时调度不够灵活
- 热点更容易集中
如果槽太多
- 节点间传播 Slot 位图的开销变大
- 控制消息尺寸膨胀
- 维护成本上升
16384 的好处在于:
- 足够细,能较均匀分片
- 位图大小可控
- 取模计算开销低
- 在线迁移调度粒度适中
3. Hash Slot 与一致性 Hash 的核心差异
| 维度 |
一致性 Hash |
Redis Hash Slot |
| 分布模型 |
节点映射到哈希环 |
Key 映射到固定槽 |
| 迁移粒度 |
节点区间 |
槽级别 |
| 元数据管理 |
常依赖客户端环视图 |
集群天然维护槽映射 |
| 扩容复杂度 |
客户端逻辑较重 |
通过 reshard 迁槽 |
| 运维可视性 |
相对弱 |
槽分配清晰可见 |
Redis 选择 Slot 的根本原因是:
它更适合在线迁移、集群控制与客户端重定向,不只是路由算法更简单。
4. 多 Key 操作为什么会受限
Redis Cluster 的分片单位是 Slot,因此:
- 单 Key 命令天然可路由
- 多 Key 命令只有在所有 Key 位于同一 Slot 时才能在同一节点执行
这意味着下面这种操作天然有风险:
MGET user:1 user:2 user:3
因为三个 Key 很可能分布在不同节点,Redis 无法像单机那样原子执行。
5. Hash Tag:让相关 Key 强制落在同一槽
Redis 提供了 Hash Tag 机制,即只对 {} 中间的内容做哈希。
SET {user:1001}:profile "..."
SET {user:1001}:cart "..."
SET {user:1001}:coupon "..."
这些 Key 的实际哈希输入都是 user:1001,因此会落在同一 Slot。
6. Hash Tag 的正确使用方式
适合使用 Hash Tag 的场景:
- 一个用户维度下的一组关联数据
- 一个订单维度下的多项原子操作
- Lua 脚本中需要同时操作的 Key
- Pipeline 批量发送且期望落同一节点
不适合滥用的场景:
- 把所有业务 Key 都放在同一个 Tag 下
- 用超大颗粒度 Tag 造成热点槽
- 把本该分散的数据硬塞到一个节点
工程原则只有一句:
Hash Tag 是为“局部协同”服务,不是为“全局聚合”服务。
7. 槽迁移的本质是什么
扩容时,不是直接把“一个节点的一半数据”搬走,而是把一批 Slot 的归属关系从旧主节点迁移到新主节点。
迁移期间涉及三个状态:
MIGRATING:源节点正在迁出某个 Slot
IMPORTING:目标节点正在导入某个 Slot
MOVED/ASK:客户端根据迁移状态被重定向
这正是 Redis Cluster 能不停机扩容的关键。
四、主从复制机制:Redis Cluster 的数据冗余是怎么工作的
Redis Cluster 不是“只有分片”,而是“每个分片背后通常还有复制副本”。
1. 复制架构的基本模型
一个主节点负责:
- 持有某些 Slot
- 承担这些 Slot 的读写
- 向自己的从节点传播写命令
从节点负责:
- 保存主节点数据副本
- 在主故障时参与故障转移
- 某些场景下承担只读流量
2. 复制的两个阶段
全量复制
触发场景:
- 从节点第一次接入主节点
- 断线太久,复制积压缓冲区不够
- 节点重建或切主
大致过程:
- 从节点发起同步请求
- 主节点生成 RDB 快照
- 主节点把 RDB 发给从节点
- 从节点加载快照
- 主节点再补发快照生成期间积压的写命令
增量复制
当从节点短暂断开后重连,只要主节点的 backlog 还保留着它缺失的偏移量区间,就可以只同步缺失命令,不必再做全量复制。
3. 复制 offset 是什么
可以把复制 offset 理解成主从之间的数据同步游标。
- 主节点持续增加写入偏移
- 从节点记录自己已同步到哪里
- 两者差值越大,表示复制延迟越大
这也是生产监控里非常关键的一个指标。
4. 复制 backlog 为什么重要
很多团队线上全量同步频繁,根因往往不是网络抖动,而是 backlog 太小。
如果业务写入很快,而 repl-backlog-size 很小,那么从节点断开几十秒后再重连,就可能已经追不上偏移,只能重新全量同步。
全量同步的风险很大:
- 主节点要额外生成快照
- 网络传输量骤增
- 从节点加载 RDB 期间不可服务
- 在大内存实例下恢复时间显著上升
5. 生产配置建议
# 持续复制相关
repl-backlog-size 256mb
repl-backlog-ttl 3600
repl-timeout 60
repl-disable-tcp-nodelay no
# 主节点写保护
min-replicas-to-write 1
min-replicas-max-lag 10
6. 一致性边界必须讲清楚
Redis Cluster 默认不是强一致系统,而是异步复制系统。
这意味着:
- 主节点写成功,不代表从节点已经持久化或同步完成
- 主节点故障后,最近一小段数据有可能丢失
- 脑裂期间,旧主上接受的写入在恢复后可能被覆盖
如果你把 Redis 当数据库主存储使用,这个边界一定会出事故。
正确定位应该是:
Redis Cluster 提供的是高性能、高可用、最终一致的分布式缓存/状态存储,不是严格强一致事务数据库。
7. 能否让写入更安全
可以,但都要付出性能代价。
常见手段:
- 开启 AOF
- 使用
appendfsync everysec
- 配置
min-replicas-to-write
- 将关键写入在业务侧落 DB 后再回写 Redis
- 对超关键链路用双写校验或消息补偿
对于库存、余额、订单状态这种强一致业务,Redis 更适合作为加速层,而不是最终事实源。
五、故障转移机制:Gossip、PFAIL、FAIL 与投票选举
这是 Redis Cluster 最容易被“讲浅”的一部分,但也是最体现分布式系统设计功底的部分。
1. 节点如何知道彼此还活着
Redis Cluster 节点之间会通过 Cluster Bus 周期性交换消息,核心消息包括:
PING
PONG
MEET
FAIL
UPDATE
这些消息里不仅有“我还活着”的信息,也会捎带传播集群视图,比如:
- 我认识哪些节点
- 哪些节点最近疑似故障
- 哪些 Slot 归属发生了变化
这就是典型的 Gossip 思路。
2. 为什么需要 PFAIL 和 FAIL 两级状态
Redis 不会因为一个节点“看起来像超时”就立刻宣布它死了。
PFAIL:主观下线
某个节点自己认为另一个节点超时了,但这只是它的局部观察。
FAIL:客观下线
多个主节点都认为某节点异常,形成多数意见后,才会把目标正式标记为 FAIL。
这套机制的价值是:
- 避免单点误判
- 抵御瞬时网络抖动
- 让故障判断更接近分布式共识
3. 故障判定流程
节点A发现主节点M超时
-> 节点A将M标记为PFAIL
-> 通过Gossip传播对M的怀疑
-> 其他主节点也确认M异常
-> 超过阈值后,M被标记为FAIL
-> M的从节点开始准备竞选新主
4. 从节点如何争夺成为新主
不是所有从节点都立即无脑抢主,Redis 会综合几个因素:
- 与主节点断连时间是否过长
- 复制 offset 是否足够新
replica-priority 是否更优
- 发起选举的随机延迟是否较小
可以把它理解为:
更“新”、更“近”、优先级更高的副本,更有机会晋升。
5. 选举过程
当主节点被客观判定为 FAIL 后,对应从节点会:
- 增加自己的配置纪元
- 向其他主节点请求投票
- 获取多数主节点授权
- 晋升为新的主节点
- 接管原主节点负责的 Slot
- 通过 Gossip 将新拓扑扩散给整个集群
这不是完整的 Raft,但具有明显的投票式共识特征。
6. 故障转移耗时由什么决定
线上常见切换耗时主要由三部分构成:
- 故障检测时间
- 投票与拓扑传播时间
- 客户端刷新路由并重试的时间
最关键的参数是:
cluster-node-timeout 15000
如果这个值设为 15 秒,那么大多数情况下:
- 检测故障就需要数秒到十几秒
- 客户端恢复还要再叠加一次路由收敛时间
超低时延业务通常会调小它,但也会带来更高误判风险。
7. 脑裂为什么仍然可能发生
即使有 FAILOVER,Redis Cluster 也不是绝对不会脑裂。
例如:
- 旧主节点与部分客户端仍连通
- 旧主节点与多数主节点失联
- 集群另一侧已选出新主
- 客户端仍向旧主写入
网络恢复后,旧主降级,这段时间内的写入就可能丢失。
8. 如何降低脑裂损失
min-replicas-to-write 1
min-replicas-max-lag 10
appendonly yes
appendfsync everysec
再加上业务策略:
- 核心写入先落库,再更新缓存
- 重要变更通过消息或 Binlog 做补偿
- 对临界状态设计幂等与重试
不要试图让 Redis 单独承担强一致职责。
六、Smart Client 核心原理:客户端为什么必须“聪明”
很多人第一次接 Redis Cluster,会以为“连上一个地址不就行了”。这在单机或 Proxy 模式下成立,但在 Redis Cluster 中不成立。
1. 为什么普通客户端不行
因为 Redis Cluster 的数据分布在多个主节点上:
- 不同 Key 要打到不同节点
- Slot 在迁移时目标节点会变化
- 主节点故障后 Slot 归属也会变化
如果客户端不知道这些信息,它就无法正确路由请求。
2. Smart Client 的核心职责
一个真正可用的集群客户端,至少要完成五件事:
- 获取集群拓扑
- 本地缓存 Slot 到节点的映射
- 根据 Key 计算 Slot 并路由
- 处理
MOVED 和 ASK 重定向
- 在拓扑变化时自动刷新元数据
3. 客户端初始化流程
通常只需要提供部分种子节点:
- 客户端连接任意一个种子节点
- 执行
CLUSTER SLOTS 或 CLUSTER NODES
- 获取完整的 Slot 映射关系
- 将其缓存在本地
- 后续大多数请求直接路由到目标节点
4. MOVED 与 ASK 的差异
MOVED
表示某个 Slot 已经稳定迁移到新节点。
客户端应该:
ASK
表示 Slot 正在迁移过程中,当前请求需要临时去另一个节点。
客户端应该:
- 向目标节点先发送
ASKING
- 仅对本次请求做临时跳转
- 不立即改写整张本地路由表
这是很多自研客户端出问题的根源之一。如果把 ASK 当成 MOVED 处理,扩容期就会出现大量路由抖动。
5. 为什么生产上更推荐 Lettuce
Java 生态里常见两类客户端:
生产上越来越多团队选择 Lettuce,核心原因通常是:
- 基于 Netty,异步与响应式能力更强
- 与 Spring Data Redis 集成更成熟
- 对集群拓扑刷新、连接恢复支持更完整
- 更适合高并发服务端场景
6. Smart Client 的常见误区
误区一:只配一个节点地址
如果只配一个节点,且该节点恰好不可用,客户端就无法拉取拓扑。
正确做法:
误区二:关闭拓扑刷新
这样在 failover 或 reshard 后,客户端会长期持有旧路由,导致大量重定向和抖动。
误区三:批量命令不校验同槽
多 Key 批量操作若不保证同槽,会直接报错或失败。
七、工程化设计:高并发、高可用、可扩展的 Redis Cluster 最佳实践
这一部分才是真正决定线上体验的关键。
1. 节点规划:不要只看总内存
很多团队做容量规划时,只算:
业务数据量 / 节点数 = 每节点内存
这远远不够,至少还要考虑:
- 主从副本冗余
- 预留扩容空间
- AOF/RDB 的额外开销
- 大 Key 带来的局部倾斜
- 热点业务的 CPU 与网络上限
更稳妥的规划方式:
单分片可用内存 = 实例总内存 * 0.5 ~ 0.7
不要把 Redis 撑到 90% 再想扩容。对集群来说,真正稀缺的往往不是内存,而是故障恢复时的“腾挪空间”。
2. 高并发下的 Key 设计原则
原则一:业务前缀清晰
cart:{userId}
coupon:{userId}
sku:stock:{skuId}
feed:timeline:{userId}
优点:
- 可观测性强
- 便于做容量统计与热点分析
- 迁移和治理更容易
原则二:只对需要协同的 Key 使用 Hash Tag
例如:
order:{orderId}:base
order:{orderId}:items
order:{orderId}:status
原则三:避免产生超大集合
尤其是:
- 巨大 Hash
- 超长 List
- 成员爆炸的 Set/ZSet
它们会带来:
- 单线程执行时间过长
- 网络包过大
- 迁移成本高
- 故障恢复慢
3. 热点 Key 治理
Redis Cluster 解决的是“分散数据”,不是天然解决“单 Key 热点”。
如果一个热点商品详情全量请求都打在:
product:10001
那无论集群有多少节点,这个 Key 仍只会落在一个 Slot、一个主节点上。
常见治理手段:
本地缓存二级加速
- Caffeine / Guava 本地缓存
- 适合热点读多写少场景
热点 Key 逻辑拆分
把不同维度拆为不同 Key:
热点副本化
对于允许弱一致读取的场景,可以把热点读流量导向多个副本层或本地缓存层。
请求合并
对缓存击穿类热点,用 singleflight、互斥锁、逻辑过期等模式合并回源。
4. Pipeline 的工程边界
在集群模式下,Pipeline 并不是“想批就批”。
它成立的前提是:
- 这些命令必须落在同一个节点
- 最理想是所有 Key 都在同一 Slot
如果跨节点:
- 客户端通常需要拆分 pipeline
- 无法再享受单连接批量发送的收益
所以真正高收益的 pipeline 往往来自:
5. Lua 与事务的边界
Redis Cluster 中 Lua 脚本和事务并不是不能用,而是有边界。
要求通常是:
- 脚本涉及的所有 Key 必须在同一 Slot
MULTI/EXEC 里的相关 Key 也应同槽
因此,集群下做原子操作的正确姿势往往是:
- 先设计好 Hash Tag
- 再让脚本在单节点内执行
6. 读写分离要谨慎
Redis Cluster 可以让副本承担读流量,但必须认识到:
- 主从复制是异步的
- 从节点读取可能拿到旧数据
- 切主期间副本延迟会变大
适合读副本的场景:
不适合读副本的场景:
7. 限流、降级、超时要在客户端层做
集群不是万能的,节点切换、网络抖动、拓扑刷新期间都可能出现短暂错误。
客户端访问层至少应具备:
- 连接超时和命令超时
- 重试次数上限
- 熔断/限流
- 降级回源或兜底返回
- 慢查询日志
真正稳定的 Redis 架构,从来不是只靠 Redis 配置稳定,而是“客户端 + 服务端 + 业务兜底”三层一起稳定。
八、生产级代码实战:Spring Boot + Lettuce 构建集群访问层
下面给出一套更接近生产的写法,重点不是“能跑”,而是体现集群访问层的工程组织方式。
1. Maven 依赖
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
</dependencies>
2. application.yml 配置
spring:
data:
redis:
timeout: 3000ms
connect-timeout: 2000ms
password: your_password
cluster:
nodes:
- 10.0.0.11:6379
- 10.0.0.12:6379
- 10.0.0.13:6379
max-redirects: 5
lettuce:
pool:
max-active: 200
max-idle: 50
min-idle: 10
max-wait: 3000ms
cluster:
refresh:
adaptive: true
period: 30s
3. RedisClusterConfig
package com.example.rediscluster.config;
import java.time.Duration;
import java.util.List;
import org.apache.commons.pool2.impl.GenericObjectPoolConfig;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisClusterConfiguration;
import org.springframework.data.redis.connection.RedisPassword;
import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.connection.lettuce.LettucePoolingClientConfiguration;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import io.lettuce.core.ClientOptions;
import io.lettuce.core.ReadFrom;
import io.lettuce.core.TimeoutOptions;
import io.lettuce.core.cluster.ClusterClientOptions;
import io.lettuce.core.cluster.ClusterTopologyRefreshOptions;
@Configuration
public class RedisClusterConfig {
@Value("${spring.data.redis.password:}")
private String password;
@Bean
public LettuceConnectionFactory redisConnectionFactory() {
RedisClusterConfiguration clusterConfiguration = new RedisClusterConfiguration(
List.of("10.0.0.11:6379", "10.0.0.12:6379", "10.0.0.13:6379"));
clusterConfiguration.setMaxRedirects(5);
if (password != null && !password.isBlank()) {
clusterConfiguration.setPassword(RedisPassword.of(password));
}
ClusterTopologyRefreshOptions topologyRefreshOptions = ClusterTopologyRefreshOptions.builder()
.enablePeriodicRefresh(Duration.ofSeconds(30))
.enableAllAdaptiveRefreshTriggers()
.build();
ClientOptions clientOptions = ClusterClientOptions.builder()
.topologyRefreshOptions(topologyRefreshOptions)
.timeoutOptions(TimeoutOptions.enabled(Duration.ofSeconds(3)))
.autoReconnect(true)
.build();
GenericObjectPoolConfig<?> poolConfig = new GenericObjectPoolConfig<>();
poolConfig.setMaxTotal(200);
poolConfig.setMaxIdle(50);
poolConfig.setMinIdle(10);
poolConfig.setMaxWait(Duration.ofSeconds(3));
LettuceClientConfiguration clientConfiguration = LettucePoolingClientConfiguration.builder()
.commandTimeout(Duration.ofSeconds(3))
.shutdownTimeout(Duration.ofMillis(200))
.clientOptions(clientOptions)
.readFrom(ReadFrom.UPSTREAM)
.poolConfig(poolConfig)
.build();
return new LettuceConnectionFactory(clusterConfiguration, clientConfiguration);
}
@Bean
public RedisTemplate<String, Object> redisTemplate(LettuceConnectionFactory factory) {
RedisTemplate<String, 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;
}
}
这一版配置体现了几个生产细节:
- 配多个种子节点,而不是只配一个
- 开启自适应拓扑刷新
- 显式设置超时、连接池和重定向次数
- 默认读主,避免误用副本造成陈旧读
4. 缓存访问门面:统一封装读写、降级与日志
package com.example.rediscluster.cache;
import java.time.Duration;
import java.util.Objects;
import java.util.concurrent.ThreadLocalRandom;
import java.util.function.Supplier;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
@Component
public class ClusterCacheFacade {
private static final Logger log = LoggerFactory.getLogger(ClusterCacheFacade.class);
private final RedisTemplate<String, Object> redisTemplate;
public ClusterCacheFacade(RedisTemplate<String, Object> redisTemplate) {
this.redisTemplate = redisTemplate;
}
public <T> T get(String key, Class<T> type) {
Object value = redisTemplate.opsForValue().get(key);
if (value == null) {
return null;
}
return type.cast(value);
}
public void set(String key, Object value, Duration ttl) {
redisTemplate.opsForValue().set(key, value, ttl);
}
public <T> T getOrLoad(String key, Class<T> type, Duration ttl, Supplier<T> loader) {
try {
T cached = get(key, type);
if (cached != null) {
return cached;
}
T loaded = loader.get();
if (loaded != null) {
Duration randomizedTtl = ttl.plusSeconds(ThreadLocalRandom.current().nextInt(30, 180));
set(key, loaded, randomizedTtl);
}
return loaded;
} catch (Exception ex) {
log.error("redis cluster access failed, key={}", key, ex);
return loader.get();
}
}
public boolean delete(String key) {
Boolean deleted = redisTemplate.delete(key);
return Objects.equals(Boolean.TRUE, deleted);
}
}
这个访问层做了三件生产上非常重要的事情:
- 统一异常处理与兜底
- 统一 TTL 随机化,降低缓存雪崩风险
- 让业务层不直接散落 Redis 操作细节
5. 同槽 Key 设计:用户购物车示例
package com.example.rediscluster.key;
public final class CacheKeys {
private CacheKeys() {
}
public static String userProfileKey(long userId) {
return "user:{" + userId + "}:profile";
}
public static String userCartKey(long userId) {
return "user:{" + userId + "}:cart";
}
public static String userCouponKey(long userId) {
return "user:{" + userId + "}:coupon";
}
}
这样设计后:
都会落在同一 Slot,便于做同节点批量读取和 Lua 原子脚本。
6. Lua 脚本示例:购物车下单原子扣减
场景目标:
要求这些 Key 必须同槽,通常可按订单或用户维度设计 Hash Tag。
package com.example.rediscluster.service;
import java.util.List;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Service;
@Service
public class OrderAtomicService {
private final StringRedisTemplate stringRedisTemplate;
private final DefaultRedisScript<Long> script;
public OrderAtomicService(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
this.script = new DefaultRedisScript<>();
this.script.setLocation(new ClassPathResource("lua/order_submit.lua"));
this.script.setResultType(Long.class);
}
public boolean submitOrder(String stockKey, String cartKey, String orderStatusKey, String quantity) {
Long result = stringRedisTemplate.execute(
script,
List.of(stockKey, cartKey, orderStatusKey),
quantity
);
return result != null && result == 1L;
}
}
order_submit.lua:
local stock = tonumber(redis.call('GET', KEYS[1]))
local need = tonumber(ARGV[1])
if stock == nil or stock < need then
return 0
end
redis.call('DECRBY', KEYS[1], need)
redis.call('DEL', KEYS[2])
redis.call('SET', KEYS[3], 'SUBMITTED', 'EX', 1800)
return 1
这类脚本在集群里能稳定运行的前提只有一个:
KEYS[1..n] 必须在同一 Slot。
7. 防击穿实现:互斥回源
package com.example.rediscluster.service;
import java.time.Duration;
import java.util.UUID;
import java.util.function.Supplier;
import org.springframework.stereotype.Service;
import com.example.rediscluster.cache.ClusterCacheFacade;
@Service
public class ProductCacheService {
private final ClusterCacheFacade cacheFacade;
public ProductCacheService(ClusterCacheFacade cacheFacade) {
this.cacheFacade = cacheFacade;
}
public String queryProduct(String productId, Supplier<String> dbLoader) {
String dataKey = "product:" + productId;
String lockKey = "lock:product:" + productId;
String cached = cacheFacade.get(dataKey, String.class);
if (cached != null) {
return cached;
}
String token = UUID.randomUUID().toString();
Boolean locked = cacheFacade.getOrLoad(lockKey, Boolean.class, Duration.ofSeconds(5), () -> null) == null;
try {
if (locked) {
String dbValue = dbLoader.get();
if (dbValue != null) {
cacheFacade.set(dataKey, dbValue, Duration.ofMinutes(10));
}
return dbValue;
}
return dbLoader.get();
} finally {
cacheFacade.delete(lockKey);
}
}
}
上面是简化示意。真正生产里,分布式锁建议使用更严格的 SET NX EX + Lua 解锁或直接采用成熟组件,例如 Redisson。
8. 批量读取要按 Slot 分组
在集群下,如果做批量查询,比较稳妥的策略不是直接 MGET 整包扔过去,而是:
- 先按 Slot 分组
- 每组内做同节点批量读取
- 汇总结果
伪代码如下:
Map<Integer, List<String>> groupedBySlot = keys.stream()
.collect(Collectors.groupingBy(RedisSlotUtils::slot));
for (Map.Entry<Integer, List<String>> entry : groupedBySlot.entrySet()) {
// 同一个 slot 的 key 才适合做一组 pipeline 或 mget
}
这一步经常被忽略,但它直接决定批量查询在集群里的实际收益。
九、真实业务案例:电商大促下的 Redis Cluster 架构落地
下面用一个更接近真实生产的案例,把前面的机制串起来。
1. 业务背景
某电商平台在大促期间需要承载:
- 商品详情缓存
- 用户购物车
- 券库存与领券状态
- 活动页流量控制
- 分布式 Session
峰值预估:
- 总 QPS:45 万
- 峰值连接:8 万+
- 数据规模:220 GB
- 热点商品详情单 Key 峰值:4 万 QPS
2. 架构拆分方案
不能把所有业务都堆进一套 Redis Cluster。
更合理的拆法是:
集群 A:会话与认证
特点:
集群 B:商品与营销缓存
特点:
集群 C:库存与订单态缓存
特点:
3. 为什么不能“一套大集群打天下”
因为不同业务的瓶颈类型完全不同:
- 会话场景更怕连接与抖动
- 商品缓存更怕热点
- 库存场景更怕一致性误用
混在一起会导致:
4. 热点商品如何处理
某爆款商品详情页流量极高,解决方案分三层:
第一层:Nginx/OpenResty 本地缓存
对活动页和详情页做秒级本地缓存。
第二层:应用 JVM 本地缓存
使用 Caffeine 对热点对象做短 TTL 缓存。
第三层:Redis Cluster
作为分布式共享缓存源。
这样 Redis 不再直接承接全部热点请求,而是承担“共享一致视图”的职责。
5. 用户购物车为什么适合用 Hash Tag
购物车场景中,一次请求常常需要同时读取:
设计为:
cart:{userId}
coupon:{userId}
address:{userId}
好处:
- 可做同槽批量读取
- 可在单节点内执行 Lua 脚本
- 下单预校验延迟更稳定
6. 大促前的容量预热策略
典型做法:
- 提前预热热点商品
- 分批预热,避免瞬时写爆集群
- 对热点 Key 做 TTL 打散
- 扩容后先预迁槽,再压测验证
很多团队集群本身没问题,真正出事的是“大促前一小时全量预热把自己打挂了”。
十、扩容与迁移:如何不停机完成 Slot 重分布
Redis Cluster 最大的工程价值之一,就是支持在线扩缩容。
1. 扩容的本质不是加节点,而是迁 Slot
新增节点只是第一步,真正让它承担流量的是把一部分 Slot 分配给它。
流程通常是:
- 新节点启动
- 新节点加入集群
- 选定需要迁移的 Slot
- 将这些 Slot 从旧主迁到新主
- 为新主挂上从节点
- 客户端逐步感知新拓扑
2. 常用命令示例
# 新主节点加入集群
redis-cli --cluster add-node 10.0.0.21:6379 10.0.0.11:6379
# 新从节点加入并挂到指定主节点
redis-cli --cluster add-node 10.0.0.22:6379 10.0.0.11:6379 --cluster-slave --cluster-master-id <master-id>
# 对集群做槽迁移
redis-cli --cluster reshard 10.0.0.11:6379
3. 迁移期间客户端为什么还可以工作
因为 Redis 通过 ASK 和 MOVED 机制让客户端知道:
- 某个 Slot 已经永久搬走了
- 某个 Slot 正在临时搬迁中
Smart Client 会据此:
所以从业务视角看,扩容可以做到基本无感。
4. 扩容期最容易出的问题
问题一:客户端不支持 ASK
现象:
问题二:迁移过快导致源节点抖动
现象:
问题三:热点槽被迁移
现象:
更稳妥的做法:
- 避开高峰迁移
- 分批迁槽
- 先识别热点槽和大 Key
- 迁移前做压测与容量余量校验
十一、监控与排障:线上最容易踩的坑与处理方法
Redis Cluster 的线上治理,不能只看“是否可连通”,必须同时看拓扑、延迟、复制和热点。
1. 必看的监控指标
集群拓扑类
- 集群节点数
- 主从角色分布
- Slot 覆盖是否完整
- failover 次数
性能类
- QPS
- P99 延迟
- CPU 使用率
- 网络带宽
- 连接数
数据类
- used_memory
- 内存碎片率
- key 数量
- expired/evicted 指标
复制类
- 主从 offset 差值
- 全量同步次数
- backlog 命中情况
- 主从链路状态
风险类
2. 常见问题一:MOVED 频繁
可能原因:
- 集群刚发生 failover
- 刚执行过 reshard
- 客户端拓扑刷新不及时
处理思路:
- 检查客户端是否开启自适应刷新
- 检查是否有节点频繁切主
- 排查集群是否处于迁槽状态
3. 常见问题二:突然大量全量同步
可能原因:
- backlog 太小
- 网络抖动过长
- 从节点频繁重启
处理思路:
- 增大
repl-backlog-size
- 检查主从网络质量
- 排查是否存在容器频繁漂移或 OOM
4. 常见问题三:某个分片 CPU 打满,其他节点很闲
这通常不是“集群不均匀”,而是:
- 出现热 Key
- 某些业务错误使用了 Hash Tag
- 大量同槽脚本/事务集中到一个分片
处理思路:
- 排查热点 Key
- 检查 Key 分布策略
- 必要时拆分业务热点或引入本地缓存层
5. 常见问题四:业务偶发超时,但 Redis 看起来没挂
可能原因:
- 短暂 failover
- 节点 GC 或容器抖动
- 网络偶发丢包
- 大 Key 删除或复杂命令阻塞主线程
处理思路:
- 查 Redis 慢日志
- 查客户端超时与重试日志
- 排查
DEL 大 Key、HGETALL 大 Hash、ZRANGE 大范围请求
6. 推荐的排查命令
# 看集群拓扑
redis-cli -c -h 10.0.0.11 -p 6379 cluster nodes
# 看槽分布
redis-cli -c -h 10.0.0.11 -p 6379 cluster slots
# 看复制状态
redis-cli -h 10.0.0.11 -p 6379 info replication
# 看内存
redis-cli -h 10.0.0.11 -p 6379 info memory
# 看慢日志
redis-cli -h 10.0.0.11 -p 6379 slowlog get 20
# 看延迟事件
redis-cli -h 10.0.0.11 -p 6379 latency latest
十二、从单机到集群的演进路线
很多团队不是一开始就上 Redis Cluster,而是在业务增长中逐步演进。
阶段一:单机 Redis
适合:
核心目标:
阶段二:主从 + Sentinel
适合:
核心收益:
核心局限:
阶段三:业务分库分片或客户端分片
适合:
核心问题:
- 扩容迁移成本高
- 多语言治理困难
- 运维与拓扑统一性差
阶段四:Redis Cluster
适合:
- 数据规模、并发规模都明显增长
- 需要自动故障转移
- 需要在线迁移
- 团队已具备基础运维与监控能力
一个实用判断标准
当你的系统同时满足以下条件中的两到三项,就该认真评估 Redis Cluster 了:
- 单实例内存已经逼近安全上限
- 写流量或连接数已经接近单机瓶颈
- 高可用要求提升,不能接受人工切换
- 扩容需要尽量不停机
- 多业务共享一套 Redis 已开始互相影响
十三、总结:把 Redis Cluster 真正用对
Redis Cluster 最容易被误解成“Redis 的分布式版本”。实际上,它是一套围绕分片、复制、选举、路由和迁移构建起来的分布式缓存系统。
真正需要记住的核心点只有几条:
1. Hash Slot 是分片核心
CRC16(key) % 16384
- 用 Slot 而不是节点做数据调度
- 在线迁移的本质是迁 Slot
2. 主从复制提供高可用,但不是强一致
- Redis Cluster 默认是异步复制
- 故障切换时可能丢失最近写入
- 对强一致业务必须有额外补偿与兜底
3. 故障转移依赖去中心化控制平面
- Gossip 做状态传播
- PFAIL 到 FAIL 做分层故障判断
- 从节点通过投票竞争晋升
4. Smart Client 是集群可用性的关键组成
- 客户端负责计算 Slot 和节点路由
- 要正确处理
MOVED 与 ASK
- 要支持拓扑刷新、超时、重试与降级
5. 工程成败往往不在“集群能不能搭起来”,而在“边界有没有处理好”
最常见的失败都不是 Redis 挂了,而是:
- Key 设计失控,导致热点和大 Key
- 客户端不支持正确重定向
- 把 Redis 当强一致数据库使用
- 扩容迁移前没有识别热点槽
- 缺乏监控、告警和回源兜底
如果你要用一句话概括 Redis Cluster 的设计哲学,可以这样理解:
Redis Cluster 用固定 Slot 解耦数据与节点,用异步复制提升可用性,用去中心化通信维护拓扑,用 Smart Client 消化动态变化,最终把“高性能缓存”演进成“可扩展的分布式缓存底座”。
当你真正理解了这一点,就不会再把 Redis Cluster 只看作几个 Redis 实例的组合,而会把它当成一套需要从客户端、服务端、运维与业务边界共同设计的分布式系统。如果你想深入探讨更多关于高可用架构的话题,欢迎来 云栈社区 与其他开发者交流。