为什么需要集群
单机 Redis 存在三个天花板:
- 并发量:官方单机 QPS 上限 10w,主从只能扩读能力不能扩写能力。
- 数据量:传统机器内存 16~256 GB,业务需要 500 GB 时单台放不下。
- 网络流量:单机千兆网卡不满足业务需求时,需要多机分摊。
解决思路:从”主从 + 哨兵”(解决高可用)升级到”集群”(解决扩展性)。
数据分布算法
数据如何分散到多个节点上,决定了集群的能力上限。Redis 经历了三代分布算法:
顺序分区(客户端分片)
数据分散度易倾斜,键值业务相关,可顺序访问。

- 优点:支持顺序扫描(如
ZRANGE 0 -1),与业务结合紧密。 - 缺点:分布不均(连续键会集中到一个节点),不易扩展。
- 代表:Bigtable、HBase。
哈希分布之节点取余
# 客户端 hash(key) % N
node = hash(key) % node_count
优点:实现简单、数据分散度高、键值分布与业务无关。 缺点:无法顺序访问,节点伸缩时大量数据漂移——3 节点扩到 4 节点约 80% 数据迁移。
缓解办法:翻倍扩容(3 → 6 节点),迁移量约 50%。

哈希分布之一致性哈希
将数据和服务节点都映射到一个 0 ~ 2^32 的闭合哈希环上,key 顺时针找最近节点。

问题:加减节点时,相邻节点之间的请求会被全打到下一个节点,造成热点压力。
解决:虚拟节点

- 每个真实节点映射到多个虚拟节点(如 200 个)。
- 哈希环上分布均匀,单节点故障时分摊到多个真实节点。
- 主流实现:Memcached 客户端(
libmemcached)。
哈希分布之虚拟槽分区(Redis Cluster 采用)

预设 16384 个虚拟槽,每个槽映射一个数据子集,槽数量大于机器节点数。对 key 用 CRC16 哈希后取 16384 余数,落到对应槽,由管理该槽的节点负责数据。
虚拟槽分区的特点:
- 解耦数据和节点:节点维护”槽 → 节点”映射,新增节点只需迁移部分槽。
- 节点自身维护映射:客户端无需或代理服务维护槽分区元数据。
- 支持节点、槽、键映射查询:用于数据路由、在线伸缩。
虚拟槽分区的限制:
mget/mset等批量操作只支持同一 slot。- 事务只支持同一 slot。
- 不能将一个大对象(list、map)映射到不同节点。
- 不支持多数据空间,只支持
db0。 - 从节点只能复制主节点,不能级联。
Redis Cluster 官方集群
集群基础:meet

A 向 B 发送 meet,B 回应;A 向 C 发送 meet,C 回应;然后 B 就能找到 C。最终所有节点两两互通。
集群节点间通过 Gossip 协议(ping/pong 消息)维持集群视图。每个节点每秒选择若干节点发 ping,pong 响应包含已知节点信息,最终全集群收敛到一致状态。
指派槽

启动时或运行时通过 CLUSTER ADDSLOTS 给每个 master 节点指派一段槽号。所有槽都被指派后集群进入 online 状态。
集群伸缩之扩容
- 准备新节点(启动一个空 Redis 实例)。
- 新节点加入集群(在新节点执行
CLUSTER MEET <ip> <port>与任一现有节点握手)。 - 新节点是 master 且没有槽。
- 从现有节点迁移部分槽到新节点:
- 制定迁移计划(哪些槽迁移)。
- 逐 key 迁移(
MIGRATE命令),边迁移边接收新写入。
- 给新节点添加从节点(高可用)。

集群伸缩之缩容
- 下线迁移槽(如果该节点有槽):把槽全部分给其他节点。
- 忘记节点:向集群每个节点发
CLUSTER FORGET <node-id>,需要在指定时间内向每个节点头发送通知才有效。 - 关闭节点:最后一步下线物理节点。

客户端路由:moved

客户端向任意节点发送命令:
- 服务端计算槽和对应节点。
- 如果指向自身,直接执行命令。
- 如果不指向自身,回复
-MOVED <slot> <target-ip>:<port>,客户端重定向到目标节点重新发送命令。
redis-cli -c 已帮我们封装:自动捕获 moved 异常并跳转目标槽节点。
# 计算 hello 的槽值
cluster keyslot hello
# 192.168.0.1:6379> CLUSTER KEYSLOT hello
# (integer) 1346
# 集群模式客户端
redis-cli -c -p 6379
ask 重定向(槽迁移过程中的特殊场景)
- moved:数据永久不在当前节点。
- ask:数据暂时还在当前节点(正在迁移过程中)。
moved 是客户端主动重定向并修改目标节点缓存;ask 是客户端只对本次命令重定向,不修改缓存(因为迁移完成后目标节点可能变了)。
smart 客户端(Jedis)
Redis 作者的设计建议:直连目标槽,不要用代理(代理层会成为性能瓶颈)。Jedis 的 smart 客户端设计:
- 启动之初连接任意节点。
- 发送
CLUSTER SLOTS命令获取所有槽与节点的映射关系。 - 客户端缓存这个映射,直接路由到目标节点。
- 监听集群变动(订阅节点的配置变更消息)刷新本地映射。
- 批量操作自动按节点分组(见下文优化)。
集群批量操作优化
因为 mget/mset 要求所有 key 在同一节点,所以需要特殊处理:
| 方案 | 原理 | 网络次数 | 复杂度 |
|---|---|---|---|
| 串行 m | 拆分 key 单独执行 | N | 简单 |
| 串行 IO | 按节点分组 + Pipeline | 节点数 | 中 |
| 并行 IO | 分组后多线程并发 | ceil(节点数 / 并发数) | 复杂 |
| hash_tag | 包装 key 保证同 slot | 1 | 简单 |
hash_tag 用法:
user:{1001}:profile和user:{1001}:orders这两个 key 用{}包裹同样的内容,Redis 计算槽时只对{}内部分哈希,保证落在同一槽。
代理方案对比
| 方案 | 类型 | 优点 | 缺点 |
|---|---|---|---|
| Redis Cluster | 官方无中心 | 性能高、社区活跃 | 不支持多数据库、批处理受限、运维复杂 |
| Codis(豌豆荚) | 客户端代理 | 兼容单机 Redis 协议、支持平滑扩容 | 引入代理层、Proxy 单点 |
| Twemproxy(Twitter) | 代理 | 简单稳定 | 不支持在线扩容、运维复杂 |
选型建议:新项目直接用 Redis Cluster;老项目需要平滑扩容选 Codis;对延迟极其敏感可以考虑自研 Proxy。
分布式锁
四特性
- 互斥性:任意时刻只有一个客户端获取锁。
- 安全性:只能由持有锁的客户端删除。
- 死锁:持有锁的客户端宕机需要有机制释放锁(超时)。
- 容错:部分节点宕机仍可以获取锁(红锁要求)。
setnx 实现
SETNX key val # 设置成功返回 1,失败返回 0
为防止死锁,需要给 key 设置过期时间:
EXPIRE key 60 # 但这两步不是原子的,中间崩溃会死锁
原子解决方案:SET NX EX
SET key val [EX seconds] [PX milliseconds] [NX|XX]
EX:过期秒数。PX:过期毫秒数。NX:键不存在才设置(加锁)。XX:键存在才设置(更新)。- 成功返回
OK,失败返回nil。
防过期集体失效
缓存集体失效会造成雪崩,给过期时间加随机值:
SET lock:order:1234 uuid NX PX 30000
# 30 秒过期 + 随机抖动(如 ±5 秒),避免多 key 同时过期
Redlock 算法(多实例高可用)
针对 Redis 单点故障下 SET NX EX 仍然不够安全的问题(master 挂了还没同步到 slave 时锁会丢失),Antirez 提出 Redlock:
- 客户端获取当前时间 T1。
- 依次向 N 个独立的 Redis 实例请求加锁(设置 NX PX)。
- 客户端记录当前时间 T2,计算
T2 - T1 < 锁超时时间才算加锁成功。 - 加锁成功的实际有效时间 = 锁超时时间 - (T2 - T1)。
- 如果任一实例加锁失败(或超时),则向所有实例发送解锁命令。
争议:Martin Kleppmann 在《How to do distributed locking》中指出 Redlock 在时钟跳跃、网络分区等场景下并不安全,推荐用 ZooKeeper 或 etcd 等强一致性系统实现分布式锁。
实战选型:业务锁(允许偶尔失效)用 Redis SET NX PX;强一致锁(资金/库存核心扣减)用 ZooKeeper 或 etcd。
缓存的问题点
缓存穿透
问题描述:缓存架构中请求先查缓存,未命中查 DB。如果请求的 key 根本不存在(爬虫、攻击),则每次都不能命中缓存,全部打到 DB。
怎么发现:
- 监控指标:总调用数、缓存命中数、存储层命中数(命中率显著下降)。
- 业务响应时间异常。
- DB QPS 异常飙高,但 Redis 命中率几乎不变。
解决方案:
-
缓存空对象:把”不存在的 key”也缓存起来(设较短 TTL,如 5 分钟),下一次同样的请求直接命中空值,不再打到 DB。
- 实现简单,对代码侵入小。
- 问题:可能产生大量短 TTL 的”空 key”,需要监控内存;空值期间 DB 新增了真实数据,会出现短期不一致。
-
布隆过滤器(推荐生产方案):在缓存前置一层布隆过滤器,key 不存在则直接拦截,存在则放行查缓存。
- 优点:内存占用极小(千万级 key 只需几 MB);拦截在缓存之前,对 DB 零压力。
- 缺点:存在误判率(可控的极小概率,约 0.1%),需要容忍;不支持删除,DB 删除 key 后过滤器无法同步(需定期重建)。
// 初始化:把全量有效 key 灌进布隆过滤器 BloomFilter<Long> filter = BloomFilter.create( Funnels.longFunnel(), 10_000_000, 0.001); for (User u : userDao.findAllIds()) { filter.put(u.getId()); } // 查询:先过过滤器,不存在直接返回 public User getById(long id) { if (!filter.mightContain(id)) { return null; // 一定不存在,拦截 } User u = (User) redis.get("user:" + id); if (u == null) { u = userDao.findById(id); if (u != null) redis.setex("user:" + id, 3600, u); } return u; } -
请求入口校验:在业务层对参数做合法性校验(id 范围、格式、长度等),把明显非法的请求挡在最外层。
- 适合有明确业务规则约束的场景(用户 id > 0、订单号符合规则等)。
缓存雪崩
描述:缓存层集体宕机或不稳定,或大量 key 同时过期,所有流量打到后端,造成级联故障。
两种典型场景:
- 缓存宕机雪崩:Redis 主节点挂掉,从节点顶上需要时间,期间所有请求直达 DB。
- 集中失效雪崩:大批 key 用了相同 TTL(如整点刷新),到期瞬间全部失效,请求洪峰打向 DB。
解决方案:
- 缓存高可用:Redis Cluster / Sentinel 保障缓存层不挂;多副本 + 自动故障转移。
- 后端限流 + 熔断降级:DB 前置限流(令牌桶/滑动窗口),超出阈值直接返回兜底数据;用 Sentinel/Hystrix/Resilience4j 做熔断,避免压垮。
- 多级缓存:本地缓存(Caffeine/Guava) + Redis + DB 三级。Redis 挂了,本地缓存还能撑。
- 过期时间加随机抖动:避免大量 key 同时过期(已在分布式锁部分提过)。基础 TTL +
±10%随机偏移。 - 缓存预热:系统启动时或定时任务把热点 key 提前加载到缓存,避免冷启动时全部打 DB。
- 请求隔离:对 DB 的不同业务读写做线程池隔离(舱壁模式),单个业务打爆不影响其他业务。
- 压力测试 + 演练:模拟缓存宕机的应急演练,提前发现瓶颈。
实战选型:核心交易链路必须多级缓存 + 限流 + 降级三件套齐备,不能依赖单一 Redis。
无底洞
描述:分布式节点越多,单个 mget 操作越慢——客户端需要向多个节点分别获取数据,节点数 N 增长时延迟也跟着增长。
这不是 Redis 的 bug,是分布式系统的固有特性。增加节点只能增加吞吐量,不能降低单次延迟。
优化方向:见上文”集群批量操作优化”章节。
热点 key 重建
描述:某个 key 缓存未命中时,多个线程同时查 DB 并回写缓存,造成 DB 被打爆。

两种解决方案:
方案 1:互斥锁(mutex)
- 第一个线程获取不到缓存时,设置互斥锁(
SET NX PX)。 - 其他线程等待(轮询或短睡)直到第一个线程回写完成。
- 问题:造成大量线程等待,延迟增加。
方案 2:逻辑过期
- key 不过期,但在 value 中设置一个
logical_expire时间字段。 - 正常请求直接从缓存获取(即使逻辑过期也允许读)。
- 起一个独立的后台线程定时扫描过期 key,主动加载到缓存。
- 在独立线程加载完成前,其他工作线程读到的就是”过期数据”。
方案 2 的优势:不阻塞用户线程,可用性更高,适合读多写少、允许短暂数据不一致的场景(商品详情、热门资讯等)。
缓存一致性
问题描述:DB 与缓存是两个独立的数据源,双写一致性是分布式系统的经典难题。任何一边更新失败,都会导致数据不一致。
四种典型异常场景(分析读写顺序与失败点):
- 更新 DB 成功,更新缓存失败 → 缓存是旧值,读请求拿到旧数据(脏读)。
- 更新缓存成功,更新 DB 失败 → 缓存是新值但 DB 没更新,事务回滚后缓存无法回滚,数据错误。
- 更新 DB 成功,淘汰(delete)缓存失败 → 同场景 1,缓存仍是旧值。
- 淘汰缓存成功,更新 DB 失败 → 缓存没了,下次查询会从 DB 读旧值回写,但至少不会”短时拿到新值后又回滚”,是相对安全的顺序。
关键结论:先淘汰缓存、再更新 DB(Cache-Aside 模式)比”先更新 DB、再更新缓存”更安全,因为场景 4 只会导致 cache miss,而不会导致缓存是错误新值。
业界三种主流一致性策略:
策略 1:Cache-Aside 旁路缓存(最常用)
读流程:
- 读缓存,命中直接返回。
- 未命中,读 DB。
- 把 DB 值回写缓存。
写流程:
- 先淘汰缓存(
DEL)。 - 再写 DB。
- (可选)延迟双删:异步延迟一段时间(如 500ms)再删一次缓存,兜底”主从同步延迟”导致的脏数据。
// 写操作:先删缓存,再写 DB
public void updateUser(User u) {
redis.del("user:" + u.getId());
userDao.update(u);
// 延迟双删:兜底主从延迟
scheduledExecutor.schedule(
() -> redis.del("user:" + u.getId()),
500, TimeUnit.MILLISECONDS);
}
// 读操作:miss 时回写
public User getById(long id) {
User u = (User) redis.get("user:" + id);
if (u == null) {
u = userDao.findById(id);
if (u != null) {
redis.setex("user:" + id, 3600, u);
}
}
return u;
}
优缺点:实现简单,业务侵入小;是最终一致性(不是强一致),适合绝大多数读多写少业务。
策略 2:Read/Write Through 读写穿透
把缓存当作主数据源,应用层只跟缓存交互,缓存自己同步写 DB。
- Read Through:缓存未命中,缓存自己加载 DB 并回填,应用无感知。
- Write Through:应用写缓存,缓存同步写 DB。
需要专门的缓存代理或框架支持(如 Spring Cache + 自定义 CacheLoader),业务代码简单但依赖中间件。
策略 3:Write Behind 异步写回
应用只写缓存,缓存异步批量写 DB(类似 OS 页缓存回写磁盘)。
性能最高(写路径只需更新内存),但一致性最弱——缓存宕机时未刷盘的数据会丢失。适合允许少量数据丢失的场景(日志、点赞数、浏览量等非关键计数)。
实战选型决策树:
| 业务特征 | 推荐策略 |
|---|---|
| 通用业务,读多写少 | Cache-Aside(默认选) |
| 强一致要求(资金/库存) | 不走缓存,直接 DB + 分布式锁 |
| 高频写、可丢少量数据 | Write Behind(点赞/计数) |
| 想减少业务侵入 | Read/Write Through(需框架支持) |
核心原则:没有任何缓存策略能保证强一致。如需强一致(资金扣减、库存),就别用缓存,直接走 DB + 事务。