找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

5166

积分

0

好友

724

主题
发表于 1 小时前 | 查看: 8| 回复: 0

当业务从单体走向分布式、从日常流量走向大促洪峰,Redis 的问题就不再只是“快不快”,而变成了“能不能横向扩展、能不能扛故障、能不能平滑扩容、客户端能不能自动路由”。

真正的 Redis Cluster,不是多开几个节点那么简单,而是一整套围绕分片、复制、选举、重定向、迁移和客户端路由构建出来的分布式缓存体系。

本文不只讲概念,而是按架构师和一线工程实践的视角,把 Redis Cluster 拆成四个核心问题讲透:

  1. 数据如何分片,为什么是 16384 个槽
  2. 数据如何复制,主从切换时一致性边界在哪里
  3. 节点如何感知故障并自动故障转移
  4. 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 解决的不是所有问题,而是三个核心问题

  1. 水平扩容:通过 Slot 分片将数据分散到多个主节点。
  2. 高可用:每个主节点挂从节点,主故障后可自动提升从节点。
  3. 在线迁移:通过 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 内部其实有两套通信链路

普通客户端访问端口

  • 默认是 6379
  • 用于业务读写命令

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. 复制的两个阶段

全量复制

触发场景:

  • 从节点第一次接入主节点
  • 断线太久,复制积压缓冲区不够
  • 节点重建或切主

大致过程:

  1. 从节点发起同步请求
  2. 主节点生成 RDB 快照
  3. 主节点把 RDB 发给从节点
  4. 从节点加载快照
  5. 主节点再补发快照生成期间积压的写命令

增量复制

当从节点短暂断开后重连,只要主节点的 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 后,对应从节点会:

  1. 增加自己的配置纪元
  2. 向其他主节点请求投票
  3. 获取多数主节点授权
  4. 晋升为新的主节点
  5. 接管原主节点负责的 Slot
  6. 通过 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 的核心职责

一个真正可用的集群客户端,至少要完成五件事:

  1. 获取集群拓扑
  2. 本地缓存 Slot 到节点的映射
  3. 根据 Key 计算 Slot 并路由
  4. 处理 MOVEDASK 重定向
  5. 在拓扑变化时自动刷新元数据

3. 客户端初始化流程

通常只需要提供部分种子节点:

  1. 客户端连接任意一个种子节点
  2. 执行 CLUSTER SLOTSCLUSTER NODES
  3. 获取完整的 Slot 映射关系
  4. 将其缓存在本地
  5. 后续大多数请求直接路由到目标节点

4. MOVED 与 ASK 的差异

MOVED

表示某个 Slot 已经稳定迁移到新节点。

客户端应该:

  • 更新本地路由表
  • 后续访问直接走新节点

ASK

表示 Slot 正在迁移过程中,当前请求需要临时去另一个节点。

客户端应该:

  • 向目标节点先发送 ASKING
  • 仅对本次请求做临时跳转
  • 不立即改写整张本地路由表

这是很多自研客户端出问题的根源之一。如果把 ASK 当成 MOVED 处理,扩容期就会出现大量路由抖动。

5. 为什么生产上更推荐 Lettuce

Java 生态里常见两类客户端:

  • Jedis
  • Lettuce

生产上越来越多团队选择 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";
    }
}

这样设计后:

  • profile
  • cart
  • 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 整包扔过去,而是:

  1. 先按 Slot 分组
  2. 每组内做同节点批量读取
  3. 汇总结果

伪代码如下:

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:会话与认证

  • 用户 Token
  • Session
  • 登录态相关

特点:

  • 小 Value
  • 高频读写
  • 对可用性要求高

集群 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 分配给它。

流程通常是:

  1. 新节点启动
  2. 新节点加入集群
  3. 选定需要迁移的 Slot
  4. 将这些 Slot 从旧主迁到新主
  5. 为新主挂上从节点
  6. 客户端逐步感知新拓扑

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 通过 ASKMOVED 机制让客户端知道:

  • 某个 Slot 已经永久搬走了
  • 某个 Slot 正在临时搬迁中

Smart Client 会据此:

  • 临时重定向
  • 或刷新本地拓扑

所以从业务视角看,扩容可以做到基本无感。

4. 扩容期最容易出的问题

问题一:客户端不支持 ASK

现象:

  • 扩容时大量报错
  • 请求频繁失败或重试暴涨

问题二:迁移过快导致源节点抖动

现象:

  • 源节点 CPU 飙高
  • 请求延迟抬升

问题三:热点槽被迁移

现象:

  • 热门业务抖动明显
  • 迁移窗口内延迟劣化

更稳妥的做法:

  • 避开高峰迁移
  • 分批迁槽
  • 先识别热点槽和大 Key
  • 迁移前做压测与容量余量校验

十一、监控与排障:线上最容易踩的坑与处理方法

Redis Cluster 的线上治理,不能只看“是否可连通”,必须同时看拓扑、延迟、复制和热点。

1. 必看的监控指标

集群拓扑类

  • 集群节点数
  • 主从角色分布
  • Slot 覆盖是否完整
  • failover 次数

性能类

  • QPS
  • P99 延迟
  • CPU 使用率
  • 网络带宽
  • 连接数

数据类

  • used_memory
  • 内存碎片率
  • key 数量
  • expired/evicted 指标

复制类

  • 主从 offset 差值
  • 全量同步次数
  • backlog 命中情况
  • 主从链路状态

风险类

  • 热点 Key
  • 大 Key
  • 慢查询
  • 重定向次数

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 和节点路由
  • 要正确处理 MOVEDASK
  • 要支持拓扑刷新、超时、重试与降级

5. 工程成败往往不在“集群能不能搭起来”,而在“边界有没有处理好”

最常见的失败都不是 Redis 挂了,而是:

  • Key 设计失控,导致热点和大 Key
  • 客户端不支持正确重定向
  • 把 Redis 当强一致数据库使用
  • 扩容迁移前没有识别热点槽
  • 缺乏监控、告警和回源兜底

如果你要用一句话概括 Redis Cluster 的设计哲学,可以这样理解:

Redis Cluster 用固定 Slot 解耦数据与节点,用异步复制提升可用性,用去中心化通信维护拓扑,用 Smart Client 消化动态变化,最终把“高性能缓存”演进成“可扩展的分布式缓存底座”。

当你真正理解了这一点,就不会再把 Redis Cluster 只看作几个 Redis 实例的组合,而会把它当成一套需要从客户端、服务端、运维与业务边界共同设计的分布式系统。如果你想深入探讨更多关于高可用架构的话题,欢迎来 云栈社区 与其他开发者交流。




上一篇:韩国市场SSD价格对比:SN850X等型号较中国市场涨幅最高达94%
下一篇:基于Kafka消费者组延迟实现Kubernetes Operator开发与弹性伸缩
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-4-11 20:25 , Processed in 0.594357 second(s), 40 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表