缓存体系:本地、集群、锁与持久化全链路实战
Java Web 微服务系列 · 第 9 篇 · 缓存体系 阅读时长:约 50 分钟 本文写于 2026 年 6 月 配套版本:Redis 7.2.x / Redisson 3.27.x / Caffeine 3.1.x / Spring Boot 3.2.x 前置阅读:《数据库演化:MySQL → PostgreSQL → 分布式》(系列第 8 篇) 后续衔接:《异地多活:Java Web 微服务的高可用终极形态》(系列第 1 篇)
引子:双 11 零点,一个被缓存雪崩打挂的支付链路
2020 年 11 月 11 日 00:00:03,我盯着监控大屏,心跳比系统心跳还快。
- 流量曲线 3 秒内从 8 万 QPS 爬到 78 万 QPS,比去年峰值还高 30%
- 订单服务的 Redis 集群 CPU 突然从 25% 跳到 92%,几秒钟后集群报警「慢查询超过阈值」
- 0:00:08 库存服务的本地缓存(Caffeine)集体过期,所有请求穿透到 Redis
- 0:00:11 Redis 连接池打满,订单服务开始大量 timeout
- 0:00:15 网关层熔断器被触发,支付链路全链路降级
事后复盘,整条故障链清晰可见:
| |
整个故障持续 7 分钟,损失订单约 1200 万 GMV,CEO 在月度复盘会上点名批评。
复盘报告里我写下了八个字:"缓存是性能中枢,也是故障放大器。"
之后三年,我把这八个字拆成了 本地缓存、分布式缓存集群、主从同步锁、文件持久化、缓存三大问题、生产场景 六章,逐个补齐工程能力。这篇就是把六章的全部经验摊开——每章都给"原理 + 实战 + 坑 + 解决方案"。
一、本地缓存:JVM 进程内的速度加速器
本地缓存是缓存体系里最快、也最容易被低估的一层。它在 JVM 进程内部,没有网络、没有序列化、QPS 可以跑到百万级。但它的天花板也很明显:容量受限于堆内存、不能跨进程共享、GC 抖动会拖垮整个服务。
1.1 本地缓存 vs 分布式缓存的边界
| 维度 | 本地缓存(Caffeine/Guava) | 分布式缓存(Redis/Memcached) |
|---|---|---|
| 访问延迟 | 纳秒-微秒级 | 0.1-1ms(同机房) |
| 容量上限 | GB 级(受堆内存限制) | TB 级(集群扩展) |
| 一致性 | 进程内一致即可 | 需要主从同步、Redlock 保障 |
| 适用数据 | 配置、白名单、热点字典、用户 Session | 跨服务共享、订单、库存 |
| 失效模式 | TTL + LRU/W-TinyLFU | TTL + 主动失效 + 主从同步 |
| 失败影响 | 进程崩溃 = 数据全丢 | 单点故障可自动切换 |
💡 原理:本地缓存的 W-TinyLFU 算法为什么比 LRU 好
传统 LRU 淘汰策略有两个致命问题:
- 突发流量打爆缓存——突发访问的 Key 大量挤入,老的热点 Key 被踢出
- 缓存污染——一次全表扫把缓存塞满长期不用的 Key
Caffeine 用 W-TinyLFU(Window Tiny Least Frequently Used):
- 1% 窗口区(Window Cache):LRU,吸纳新访问
- 99% 主区(Main Cache):SLRU(分段 LRU),按访问频次分「受保护段 + 候选段」
- Count-Min Sketch 频率统计:新 Key 进入主区前用 Sketch 估算历史访问频次,比 LR 高的才准入
效果:相同的容量下,Caffeine 的命中率比 Guava Cache 高 30%。这是 2018 年后 W-TinyLFU 论文被工程化后的红利。
1.2 Caffeine 实战
pom.xml:
| |
三种缓存加载模式(根据业务场景选对,命中率天差地别):
| |
1.3 Spring Cache 集成(一行注解切实现)
| |
1.4 本地缓存的 6 个高频坑
🎯 避坑 1:maximumSize 设太小 → 缓存击穿
本地缓存
maximumSize=1000,热点字典有 5000 个 Key,前 1000 个热门常驻,后 4000 个永远 miss。建议:maximumSize= 业务总 Key 数 × 1.5,宁可浪费内存也别让热点被踢。
🎯 避坑 2:TTL 全部一样 → 雪崩
所有 Key 都设 30 分钟 TTL,00:00:00 写入的 Key 在 00:30:00 集体过期。对策:TTL 加随机偏移
expireAfterWrite(Duration.ofMinutes(30).plusSeconds(ThreadLocalRandom.current().nextInt(60)))。
🎯 避坑 3:没有上限 → OOM
用 Guava Cache 时忘记设
maximumSize,Key 无限增长直到 OOM。Caffeine 强制maximumSize或maximumWeight二选一,比 Guava 安全。
🎯 避坑 4:loader 抛异常 → 缓存击穿
loadingCache.get(key)时 loader 抛异常,Caffeine 不会缓存异常结果,下一个请求继续调 loader。如果 loader 是数据库查询,一次慢查询会拖垮 N 个并发请求。对策:loader 内部 try-catch,返回 null 或空集合,让缓存层能稳定返回。
🎯 避坑 5:value 是大对象 → GC 抖动
本地缓存里塞了完整的
List<Order>(单条 10KB),10w 条 = 1GB 堆内存,老年代 GC 一次 2 秒。对策:
- 大对象用
WeakReference:.weakValues()- 或者拆成
Map<orderId, OrderDto>单条存- 业务真的需要大集合,走 Redis 别用本地
🎯 避坑 6:进程间不一致 → 数据漂移
订单服务 A 把"商品 X 价格 99"缓存在本地,订单服务 B 的本地还是"商品 X 价格 100"。用户下单时 A 算的价是 99、B 算的是 100,同一笔订单价格不一致。对策:价格类强一致数据禁止本地缓存,必须走 Redis。
1.5 命中率监控
Caffeine 提供了 recordStats() 之后的所有统计:
| |
| 指标 | 健康范围 | 异常处理 |
|---|---|---|
| hitRate | > 0.8 | < 0.5 = 缓存容量不够或 TTL 太短 |
| missRate | < 0.2 | > 0.5 = 同上 |
| evictionCount | 稳定 | 暴增 = 触发淘汰, 检查 maximumSize |
| loadTime | P99 < 50ms | P99 > 200ms = loader 慢, 考虑异步 |
二、分布式缓存集群:水平扩展的容量与吞吐
本地缓存受限于单进程内存,业务规模上 10 万 QPS、单 Key 几 MB 后必须上分布式缓存。本节按"为什么需要集群 → Cluster 怎么分片 → Sentinel 怎么切换 → 容量怎么规划"展开。
2.1 集群方案对比
| 方案 | 一致性 | 扩展性 | 运维复杂度 | 适用场景 |
|---|---|---|---|---|
| Redis Sentinel | 主从异步复制 | 垂直扩展 | 低 | 中小规模(< 50GB) |
| Redis Cluster | 主从异步 + 槽路由 | 水平扩展 | 中 | 中大规模(50GB-10TB) |
| Codis(豌豆荚) | 主从异步 + Proxy | 水平扩展 | 中 | 历史方案,新项目不用 |
| Twemproxy(Twitter) | 主从异步 + Proxy | 水平扩展 | 中 | 历史方案,新项目不用 |
| Memcached | 无 | 客户端哈希 | 低 | 纯 KV、无持久化需求 |
🎯 决策:90% 场景选 Redis Cluster
Redis Cluster 解决了 Sentinel 的两个痛点:
- 数据分片——16384 个哈希槽分散到多 Master,容量不再是单节点瓶颈
- 自动故障转移——Cluster 内置 Raft 协议(其实是类 Paxos 的 gossip + 投票),不再需要 Sentinel 集群
唯一缺点:客户端需要 cluster-aware 客户端(Lettuce/Jedis 都已支持),不能在集群模式下用单节点命令(如
KEYS *)。
2.2 Redis Cluster 架构原理
| |
💡 原理:16384 个哈希槽的设计取舍
Redis Cluster 用 CRC16 算法对 key 计算 16 位 hash,然后取模 16384得到槽号。为什么是 16384 不是更多?
- 够用:16384 槽意味着单集群最多 16384 个节点,实际生产 100 节点以内,完全够
- 配置包小:每个节点的心跳包要带上自己负责的槽位 bitmap,16384 位 = 2KB,如果 65536 槽 = 8KB,每次心跳多 6KB,100 节点集群 = 600KB/s 流量浪费
- 重新分片粒度细:迁移时按槽为单位,16384 槽比 1024 槽更平滑
2.3 Cluster 部署(最少 6 节点)
生产环境铁律:
- 至少 3 主 3 从——单 Master 没意义
- Master 和 Replica 不要在同一台物理机——避免单机故障把一对主从一起带走
- 跨机房时按单元化部署——参见系列第 1 篇异地多活
| |
2.4 Spring Boot 接入
| |
| |
2.5 哈希槽定位的 4 个常见问题
🎯 避坑 1:不同 Key 必须落在同一 slot 才能用 MGET/MSET
批量操作
MGET key1 key2 key3要求所有 Key 在同一个哈希槽。对策:用hash tag——Key 中用{}包裹的部分会作为哈希依据。
1 2 3 4 5 6// ❌ 三个 Key 在不同 slot, MGET 报错 CROSSSLOT redisTemplate.opsForValue().multiGet(Arrays.asList("order:1", "order:2", "order:3")); // ✅ 用 hash tag 强制同 slot redisTemplate.opsForValue().multiGet(Arrays.asList( "{order}:1", "{order}:2", "{order}:3"));实际工程里用
multiGet不如用Pipeline——Pipeline 不受 slot 限制。
🎯 避坑 2:事务 (MULTI/EXEC) 不支持 Cluster
Redis Cluster 的事务要求 Key 在同一 slot,生产几乎不用 MULTI/EXEC。对策:用 Lua 脚本 替代事务——Lua 脚本在 Cluster 模式下也只允许同一 slot,但通过 hash tag 可以控制。
🎯 避坑 3:KEYS 命令禁用
KEYS *在 Cluster 下报错(每个节点都得扫,Redis 干脆不支持)。对策:用SCAN命令 + 游标分批。
1 2 3 4 5 6 7ScanOptions options = ScanOptions.scanOptions().match("order:*").count(1000).build(); try (Cursor<byte[]> cursor = redisTemplate.getConnectionFactory() .getConnection().keyCommands().scan(options)) { while (cursor.hasNext()) { // 处理 Key } }
🎯 避坑 4:跨 slot 错误
客户端在 Slot 迁移过程中访问 Key,可能返回
MOVED或ASK。Lettuce/Jedis 客户端会自动重试,但重定向超过max-redirects次会报错。对策:把max-redirects设为 3-5,业务代码处理RedisCommandExecutionException。
2.6 容量规划公式
| |
生产黄金法则:
- 单 Redis 实例内存不超过 64GB——
fork时间随内存线性增长,64GB 时fork已经 100ms+,主线程阻塞明显 - Redis 节点数不超过 200——Cluster 内部 gossip 消息 O(N²),200 节点后心跳开销指数增长
- 数据量超过 10TB——考虑业务拆分或上 Pika/SSDB 这种磁盘 Redis
三、主从同步与分布式锁:从原理到 4 大生产坑
主从同步是 Redis 高可用的基础,分布式锁是 Redis 最常被滥用的功能。这两件事看似独立,实际在生产里经常踩同一类坑——时钟漂移、网络分区、Master 切换时的锁丢失。
3.1 Redis 主从复制原理
| |
💡 原理:全量同步 vs 增量同步的触发条件
Redis 用
repl_backlog(复制积压缓冲区)记录 Master 最近的写命令流:
- 全量同步——Replica 第一次连上来 / 主从断开超过
repl-timeout/ Replica 需要的 offset 已被 backlog 覆盖- 增量同步——Replica 需要的 offset 还在 backlog 里(默认 1MB)
调优
repl-backlog-size:生产建议设为平均写入量 × 60 秒,10MB-100MB 都合理。⚠️ 主从复制是异步的——Master 写入成功后立即返回客户端,复制到 Replica 有延迟(同机房 1-5ms,跨机房 30-100ms)。这就是为什么 Redis 主从切换时可能有少量数据丢失。
3.2 Sentinel 主从切换流程
| |
📌 实践:Sentinel 至少 3 节点,跨机柜部署
- 3 节点 = 容忍 1 节点故障,2 节点 = 1 节点故障就脑裂
- 跨机柜部署避免单机柜断电时 Sentinel 也全军覆没
- Sentinel 本身不存数据,用 1 核 1GB 虚拟机足够
3.3 分布式锁的 4 大方案对比
| 方案 | 正确性 | 性能 | 复杂度 | 适用场景 |
|---|---|---|---|---|
| SETNX + EXPIRE | 差(两条命令非原子) | 极高 | 低 | ❌ 不推荐 |
| SET key value NX EX 30 | 中(Master 切换丢锁) | 高 | 低 | 单 Redis 实例 |
| Redlock 算法 | 较高 | 中 | 中 | 强一致场景 |
| Redisson | 较高 | 高 | 中 | 99% 场景推荐 |
| Zookeeper | 高 | 低 | 高 | 强一致 + 低 QPS |
| etcd | 高 | 中 | 高 | K8s 生态 |
3.4 Redlock 算法详解
核心思想:不在一个 Redis 实例上加锁,而在 N 个独立 Redis 实例上(官方推荐 5 个),获得 ≥ N/2+1 个实例的锁才算加锁成功。
| |
4 个关键参数:
- T1 = 获取锁前时间
- T2 = 获取锁后时间(获取耗时 = T2 - T1)
- Lock TTL = 锁有效期(建议 30 秒)
- 有效时间 = Lock TTL - 获取耗时(有效时间 < 0 = 加锁失败)
💡 原理:为什么 Redlock 比单 Redis 更安全
单 Redis 加锁的最大风险:Master 还没把锁复制到 Replica 就挂了。Sentinel 把 Replica 提升为新 Master,但新 Master 上根本没有这个锁——其他客户端就能加同一把锁,同一资源被两个客户端同时操作。
Redlock 用 5 个独立 Redis 实例,单个实例丢锁不影响整体——其他 4 个实例还有锁,第二个客户端拿不到过半。
⚠️ 争议:Martin Kleppmann 2016 年发表的《How to do distributed locking》指出 Redlock 也有缺陷(GC 停顿、时钟漂移),业界仍有讨论。Antirez(Redis 作者)做了反驳。结论:在 99% 业务场景,Redlock + 业务幂等性已经够用,不要追求绝对正确。
3.5 Redisson 实战(最推荐)
| |
| |
3.6 分布式锁的 4 大生产坑
🎯 坑 1:锁没释放 → 死锁
业务代码异常但
finally里没unlock,锁永远不释放,其他线程永远拿不到。对策:
finally里严格 unlock- 用
tryLock(timeout)限时等待,避免无限等- 锁 TTL 一定要设(Redisson 默认 30 秒 + 看门狗续期)
🎯 坑 2:锁误删 → 别人干活你删锁
客户端 A 加锁后,业务执行慢超过 TTL,锁自动释放,客户端 B 拿到锁开始干活。这时 A 业务执行完调
unlock——A 删的是 B 的锁。对策:每个锁有唯一 value(UUID/ThreadId),unlock 时用 Lua 脚本校验 value 是不是自己加的:
1 2 3 4 5 6// Redisson 内部已经处理, 你只要用 lock.unlock() 即可 // 自己写的话要这样: String lua = "if redis.call('get', KEYS[1]) == ARGV[1] then " + " return redis.call('del', KEYS[1]) " + "else return 0 end"; redis.eval(lua, Collections.singletonList(lockKey), uuidValue);
🎯 坑 3:锁粒度太粗 → 性能差
把整个订单服务都加一把大锁,QPS 100 时锁就成为瓶颈。
对策:按业务粒度加锁——
- 用户维度的锁:
lock:order:user:{userId}- 商品维度的锁:
lock:stock:product:{skuId}- 业务维度的锁:
lock:order:create(只锁关键操作,不锁整个流程)
🎯 坑 4:Redlock 性能不够 → 排队
Redlock 要访问 5 个独立 Redis,延迟 ≈ 单 Redis × 5。高 QPS 场景下锁本身成为瓶颈。
对策:
- 优先用单 Redis + 业务幂等——大多数业务能容忍「同一资源被处理 2 次」
- 把 Redlock 的 Redis 集群和业务 Redis 集群分开——避免业务流量抢占锁的连接池
- 锁续期用 watchdog 异步续,不阻塞业务线程
四、文件持久化:RDB / AOF / 混合,三种方案怎么选
缓存数据丢了怎么办?——答:看你是什么数据。电商购物车丢了用户重填,订单数据丢了老板提刀。持久化策略按"数据重要性 × 写入性能"权衡。
4.1 RDB 快照
| |
💡 原理:RDB 的 fork + COW 机制
bgsave触发时,Redis fork 一个子进程,父子进程共享内存页。子进程开始遍历内存写 RDB 文件,父进程继续处理写请求——如果父进程要修改某内存页,内核触发 COW(Copy-On-Write),复制出新的内存页给子进程。优点:主进程不阻塞(除 fork 那瞬间)。 缺点:
- fork 阻塞——64GB 实例 fork 大约 100ms,这 100ms 主进程不响应任何请求
- 数据丢失——两次快照之间的写操作丢失(默认 5 分钟窗口,最坏丢 5 分钟数据)
4.2 AOF 追加日志
| |
AOF 的 appendfsync 三种策略:
| 策略 | 性能 | 数据安全 | 适用场景 |
|---|---|---|---|
always | 差(每条命令 fsync) | 零丢失 | 金融、订单 |
everysec(默认) | 高 | 丢 ≤ 1 秒 | 99% 业务 |
no | 最高 | OS 决定 | 临时缓存 |
📌 实践:everysec 是 99% 业务的最优解
always性能下降到 1/10,主线程要等磁盘 fsync——Redis 本身是为了快,always 让它退化成了 MySQL。
everysec在主线程外异步刷盘,最多丢 1 秒。大多数业务都能接受——购物车、用户 Session 这类非关键数据,丢 1 秒重算即可。
4.3 混合持久化(Redis 4.0+ 默认推荐)
| |
配置:
| |
优势:
- 启动恢复时先全量加载 RDB(速度快),再重放 AOF 增量(补齐数据)
- 比纯 AOF 恢复快 10 倍(实测 16GB AOF 恢复从 30 分钟降到 3 分钟)
- 比纯 RDB 丢失数据少(只丢混合点之后的写命令)
4.4 持久化选型矩阵
| 业务场景 | 数据丢失容忍 | 推荐方案 | 关键配置 |
|---|---|---|---|
| 购物车 / 用户 Session | 丢几秒可接受 | RDB + AOF 混合 | appendfsync everysec |
| 订单 / 支付结果 | 绝不能丢 | AOF always | appendfsync always |
| 排行榜 / 计数器 | 丢几十分钟可接受 | 纯 RDB | save 900 1 |
| 配置 / 白名单 | 启动加载即可 | 纯 RDB | save 3600 1 |
| 临时验证码 | 丢无所谓 | 关闭持久化 | save "" + appendonly no |
4.5 fork 阻塞的 5 个优化
🎯 避坑 1:单实例内存 > 32GB → 慎用 bgsave
64GB 实例 fork 100ms,这 100ms 主进程完全卡死。对策:用 多个小实例(如 4 个 16GB 实例)替代单大实例。
🎯 避坑 2:禁用透明大页 THP
Linux 默认开启 THP(透明大页),THP 让 fork 复制内存页时变慢 10 倍。必须关:
1echo never > /sys/kernel/mm/transparent_hugepage/enabled
🎯 避坑 3:避免在高峰期 bgsave
Redis 自动 bgsave 触发条件
save 60 1000(60 秒内 1000 次写),高峰触发 bgsave 又叠 fork 阻塞 → 雪崩。对策:
- 业务低峰期(凌晨 4-5 点)手动
bgsave- 调高
save阈值:save 300 10000(5 分钟 1w 次才触发)
🎯 避坑 4:AOF 重写也 fork
bgrewriteaof同样要 fork,避免与 bgsave 同时触发(不然 fork 内存页被复制两次)。
🎯 避坑 5:用 SSD 而不是 HDD
AOF 顺序写,SSD 比 HDD 性能高 10-100 倍。HDD 上
appendfsync always写入 QPS 撑死 1000,SSD 上能撑到 5w+。
五、缓存三大问题:雪崩 / 穿透 / 击穿
缓存是性能加速器,也是故障放大器。三大问题是每个 Java 后端开发必须背得滚瓜烂熟的"老八股"——但 90% 的人答得似是而非,本节给出原理 + 场景 + 解决方案完整链路。
5.1 雪崩:缓存集体失效,请求打爆数据库
| |
🎯 解决方案 4 件套(按优先级)
1. TTL 加随机偏移——避免集体过期
1 2 3int baseTtl = 1800; // 30 分钟 int jitter = ThreadLocalRandom.current().nextInt(60); // 0-60 秒 redisTemplate.opsForValue().set(key, value, baseTtl + jitter, TimeUnit.SECONDS);2. 多级缓存——本地缓存兜底
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20public UserProfile getUser(Long id) { // L1: 本地 Caffeine UserProfile p = caffeineCache.getIfPresent(id); if (p != null) return p; // L2: Redis p = redisTemplate.opsForValue().get("user:" + id); if (p != null) { caffeineCache.put(id, p); return p; } // L3: DB p = userRepo.findById(id).orElse(null); if (p != null) { redisTemplate.opsForValue().set("user:" + id, p, 30 + jitter, TimeUnit.MINUTES); caffeineCache.put(id, p); } return p; }3. 熔断降级——Redis 挂了直接走 DB 限流
1 2 3 4 5 6 7 8 9@CircuitBreaker(name = "userService", fallbackMethod = "getUserFallback") public UserProfile getUser(Long id) { return redisTemplate.opsForValue().get("user:" + id); } public UserProfile getUserFallback(Long id, Throwable t) { // 走 DB 限流版本 return rateLimitedDbQuery(id); }4. 缓存预热——提前加载热点数据
1 2 3 4 5 6 7 8 9 10 11 12@Component public class CacheWarmer implements ApplicationRunner { @Override public void run(ApplicationArguments args) { // 启动时把热 Key 加载到 Redis List<Long> hotIds = hotKeyService.getHotIds(); hotIds.forEach(id -> { UserProfile p = userRepo.findById(id).orElse(null); if (p != null) redisTemplate.opsForValue().set("user:" + id, p, 30, TimeUnit.MINUTES); }); } }
5.2 穿透:查询不存在的数据,缓存形同虚设
| |
🎯 解决方案 3 件套
1. 缓存空值——DB 查不到也缓存一个 null 标记
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16public UserProfile getUser(Long id) { String key = "user:" + id; Object cached = redisTemplate.opsForValue().get(key); if (cached != null) { return cached == NULL_MARKER ? null : (UserProfile) cached; } UserProfile p = userRepo.findById(id).orElse(null); if (p != null) { redisTemplate.opsForValue().set(key, p, 30, TimeUnit.MINUTES); } else { // 缓存 null 标记, TTL 短一些 (5 分钟), 防止长期占空间 redisTemplate.opsForValue().set(key, NULL_MARKER, 5, TimeUnit.MINUTES); } return p; }2. 布隆过滤器——前置判断 Key 是否可能存在
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21@Component public class BloomFilterService { private final BloomFilter<Long> bloomFilter = BloomFilter.create( Funnels.longFunnel(), 10_000_000, 0.001); public void addUser(Long id) { bloomFilter.put(id); } public boolean mightExist(Long id) { return bloomFilter.mightContain(id); } } // 业务代码 public UserProfile getUser(Long id) { if (!bloomFilterService.mightExist(id)) { return null; // 一定不存在, 直接返回 } // 走正常查询 }3. 接口校验——业务层拒绝明显非法的查询
1 2 3 4 5 6public UserProfile getUser(Long id) { if (id == null || id <= 0 || id > MAX_USER_ID) { throw new IllegalArgumentException("非法的 userId"); } // ... }
5.3 击穿:单个热点 Key 过期瞬间被高并发打挂
| |
🎯 解决方案 3 种
方案 1:互斥锁(最常用)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22public UserProfile getUser(Long id) { String key = "user:" + id; UserProfile p = redisTemplate.opsForValue().get(key); if (p == null) { // 只让一个线程去查 DB if (tryLock("lock:user:" + id, 5, TimeUnit.SECONDS)) { try { p = userRepo.findById(id).orElse(null); if (p != null) { redisTemplate.opsForValue().set(key, p, 30, TimeUnit.MINUTES); } } finally { unlock("lock:user:" + id); } } else { // 其他线程 sleep 50ms 后重试 (此时大概率缓存已回填) Thread.sleep(50); p = redisTemplate.opsForValue().get(key); } } return p; }方案 2:逻辑过期(推荐)
缓存不设 TTL,改在 value 里加一个 expire 字段,业务发现过期后异步回填:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21@Data public class CacheItem<T> { private T data; private long expireAt; // 逻辑过期时间 } public UserProfile getUser(Long id) { String key = "user:" + id; CacheItem<UserProfile> item = redisTemplate.opsForValue().get(key); if (item == null) { return loadFromDbAndCache(id); } if (item.getExpireAt() < System.currentTimeMillis()) { // 已逻辑过期, 异步回填 CompletableFuture.runAsync(() -> refreshCache(id)); } return item.getData(); // 永远返回旧数据, 不阻塞 }方案 3:热点永不过期(业务侧可控)
真正的热点 Key(如商品详情、明星排行榜),直接不设 TTL——业务更新时主动
SET新值。不推荐:缓存与 DB 一致性管理变难。
六、生产场景 6 个真实案例
本节是 6 个真实生产事故 + 完整解决方案,每个都给出事故症状、根因、解决 SOP。
6.1 多级缓存架构设计
场景:电商商品详情页 QPS 50w,单 Redis 集群撑不住。
架构:
| |
关键设计:
- 每层命中率目标 ≥ 70%——L1 70% + L2 25% + L3 5%(数据库实际只扛 5%)
- 每层失效时间递增——L1 短、L2 中、L3 长,任意一层有数据就不穿透
- 数据变更推送到所有层——商品改价时
mq send 消息→ L1 主动失效 + L2 主动失效 + L3 写新值
6.2 大 Key 与热 Key 处理
场景 1:大 Key 拖慢集群
某运营配置单个 Key 存了 5MB 的 JSON(商品标签字典),DEL 这个 Key 直接阻塞 800ms——Redis 单线程执行 DEL,5MB 数据要遍历。
解决方案:
| |
场景 2:热 Key 打挂单节点
秒杀商品 stock:{skuId} 在 Master A 节点,5w QPS 集中打 A,A CPU 100%。
解决方案:
- Key 哈希分散——
stock:{skuId}:{shard}分到 10 个 Key,每个 Key 1w QPS - 读写分离——读路由到 Replica,写走 Master
- 客户端本地缓存——秒杀商品库存在客户端本地缓存 1 秒,本地没拿到再走 Redis
| |
6.3 缓存与 DB 一致性(Cache-Aside 模式的陷阱)
经典 Cache-Aside 模式:
| |
为什么"先更新 DB 再删缓存"比"先删缓存再更新 DB"安全?
| 时序 | 先删缓存再更新 DB | 先更新 DB 再删缓存 |
|---|---|---|
| 并发问题 | A 删缓存 → B 读 miss → 读 DB 旧值 → 回填旧缓存 → A 更新 DB → 缓存是旧值 | A 更新 DB → B 读缓存命中旧值 → A 删缓存 → 下次读 miss 走 DB 拿新值 |
| 不一致窗口 | 长(缓存被回填后才发现 DB 已更新) | 短(只在下一次读前有旧值) |
3 个进阶模式:
| 模式 | 一致性 | 性能 | 适用 |
|---|---|---|---|
| Cache-Aside | 最终一致 | 高 | 99% 业务 |
| Read-Through | 最终一致 | 中 | 缓存层统一管理 |
| Write-Through | 强一致 | 低 | 金融、订单 |
| Write-Behind | 弱一致 | 极高 | 计数、点赞 |
| 延迟双删 | 较强 | 中 | 写少读多 |
🎯 延迟双删(生产常用)
1 2 3 4 5 6 7 8 9 10 11public void updateProduct(Product p) { // 1. 先删缓存 cache.del(p.getId()); // 2. 更新 DB db.update(p); // 3. 延迟 500ms 再删一次 (异步) CompletableFuture.delayedExecutor(500, TimeUnit.MILLISECONDS) .execute(() -> cache.del(p.getId())); }第二次删除是处理并发场景——第一次删完到 DB 更新完之间,其他线程可能读了旧 DB 值并回填了缓存。第二次删除把这个旧值清掉。
6.4 慢查询引发的缓存污染
事故:某天 Redis 集群 CPU 突然飙到 90%,查 SLOWLOG GET 10 发现大量 HGETALL order:detail:12345 耗时 500ms+。
根因:单 order 详情 Hash 里有 5000 个 field(订单的多商品 + 多优惠 + 多日志),HGETALL 要遍历全部 field。
解决方案:
- 拆 Hash——
order:detail:12345:basic+order:detail:12345:items+order:detail:12345:logs - 业务上避免 HGETALL——用
HSCAN游标分批 - 大 Hash 监控——
redis-cli --bigkeys定期扫描,单 Key > 10KB 报警
6.5 Redis 内存淘汰策略选型
Redis 内存满时,根据 maxmemory-policy 决定淘汰哪些 Key:
| 策略 | 淘汰规则 | 适用场景 |
|---|---|---|
noeviction | 不淘汰,写报错 | 严禁丢数据 |
allkeys-lru | 所有 Key 中 LRU 淘汰 | 通用推荐 |
volatile-lru | 仅淘汰带 TTL 的 Key | 兜底 + 永久缓存混合 |
allkeys-lfu(Redis 4.0+) | 所有 Key 中 LFU 淘汰 | 热点数据保护更强 |
volatile-ttl | 淘汰快过期的 Key | TTL 多样时 |
allkeys-random | 随机淘汰 | 不推荐 |
生产推荐配置:
| |
6.6 监控告警体系
关键指标(Micrometer + Prometheus + Grafana):
| 指标 | 告警阈值 | 排查方向 |
|---|---|---|
redis_used_memory_bytes | > 80% maxmemory | Key 增长 / 大 Key |
redis_cpu_usage | > 70% | 慢查询 / 持久化 |
redis_connected_clients | > 80% maxclients | 连接泄漏 |
redis_instantaneous_ops_per_sec | 突增 > 3x | 业务异常 |
redis_slowlog_length | > 10 | 慢查询堆积 |
redis_keyspace_hit_rate | < 0.7 | 命中率下降 |
redis_blocked_clients | > 0 | BLPOP 阻塞 |
redis_master_link_down_since_seconds | > 10 | 主从断开 |
生产必须的 3 条告警:
- 命中率突降——
hit_rate5 分钟内下降 > 10% → 大概率有热点 Key 过期 - 主从断开——
master_link_down> 30 秒 → 自动切换可能触发 - 内存接近上限——
used_memory / maxmemory > 0.9→ 立刻扩容或清理
七、与系列其他篇的联动
缓存体系不是孤岛,它和数据库演化、熔断限流、异地多活都强相关。本节把这条联动链路拉通。
7.1 与系列第 8 篇《数据库演化》的联动
缓存与 DB 一致性是两者最核心的联动点:
- 强一致数据(订单状态、支付结果)→ 不走缓存,直接 DB
- 最终一致数据(商品详情、用户昵称)→ Cache-Aside 模式
- 统计类数据(阅读数、点赞数)→ Write-Behind 模式(先写缓存,异步刷 DB)
📌 原则:缓存是 DB 的"加速副本",不是"替代品"
任何缓存数据都必须能通过 DB 重建——这是分布式系统的不变式。缓存丢了 = 重建一次。如果缓存数据没法重建,就根本不该放缓存。
7.2 与系列第 7 篇《熔断限流 Sentinel》的联动
缓存击穿是熔断限流的典型触发场景:
| |
📌 实践:缓存三大问题必须配合熔断限流
单独的缓存击穿治理只能降低风险,配合熔断器才能在事故发生时防止雪崩蔓延。
7.3 与系列第 1 篇《异地多活》的联动
异地多活下缓存同步是个两难问题——
方案 A:每机房独立 Redis 集群
| |
✅ 优点:调用零延迟 ❌ 缺点:跨机房数据不一致(如商品价格)
方案 B:跨机房 Redis 主从
| |
✅ 优点:数据最终一致 ❌ 缺点:跨机房写延迟 30-100ms,主写从读模式下从可能延迟 1-5 秒
方案 C:CRDT + 多主(终极方案)
异地多活多写场景下用 CRDT(Conflict-free Replicated Data Types),每个机房独立写、独立读、后台异步合并冲突——实现难度高,但阿里这类大厂在用。
🎯 决策:业务用方案 A,少数汇总用方案 B
99% 业务走单元化 + 本机房缓存——本机房调本机房,跨机房调用很少。少数需要"全局数据"的场景(排行榜、汇总报表)用方案 B。不推荐方案 C——CRDT 复杂度极高,业务收益不匹配。
收尾:本篇要点 + 系列承上启下
✍️ 本篇核心结论:
- 本地缓存——Caffeine 的 W-TinyLFU 比 Guava 命中率提升 30%,TTL 必须加随机偏移防雪崩
- 分布式集群——Redis Cluster 用 16384 哈希槽分片,单实例内存不超过 64GB
- 主从同步锁——Redisson + watchdog 是 99% 场景的最优解,Redlock 在 5 个独立实例上过半才生效
- 文件持久化——混合持久化是 4.0+ 默认推荐,everysec 丢 ≤ 1 秒 是大多数业务的最优解
- 三大问题——雪崩(TTL 偏移+多级缓存)/ 穿透(空值+布隆)/ 击穿(互斥锁+逻辑过期)各有一套
- 生产场景——多级缓存 + 延迟双删 + 慢查询监控 + LRU 淘汰 + 8 条告警
📚 Java 微服务系列地图:
| # | 主题 | 关系 |
|---|---|---|
| 1 | 异地多活 | 跨机房缓存同步 |
| 2 | 流量调度(Nginx/LVS) | 缓存前哨 |
| 3 | K8s 容器编排 | Redis 部署 |
| 4 | 技术选型(SCA + Dubbo3) | 选型总结 |
| 5 | Nacos | 配置中心缓存 |
| 6 | Spring Cloud Gateway | 网关层缓存 |
| 7 | 熔断限流 Sentinel | 缓存击穿保护 |
| 8 | 数据库演化 | 缓存与 DB 一致性 |
| 9 | 本篇 缓存体系 | 本地 + 集群 + 锁 + 持久化 |
📖 参考资料:
- Redis 官方文档 —— Redis 7.2 完整命令 + 配置
- Caffeine Wiki —— W-TinyLFU 算法论文
- Redisson 官方文档 —— 分布式锁 + watchdog
- Redis 设计与实现(黄健宏) —— 源码级中文权威
- How to do distributed locking — Martin Kleppmann —— Redlock 争议原始论文
- Is Redlock safe? — antirez —— Redis 作者的反驳
- Spring Cache 官方文档 —— 注解 + 抽象
- Redis 4.0 混合持久化设计 —— RDB+AOF 实现原理
- 《数据密集型应用系统设计》(DDIA)—— 第 5、6 章缓存与复制
- 《Redis 深度历险:核心原理与应用实践》(钱文品)—— 国内 Redis 实战最佳读物
下一篇预告:系列第 10 篇《可观测性体系:Metrics + Tracing + Logging 三件套》——缓存只是性能加速器,怎么监控它的健康、定位它的瓶颈、追溯它的链路?下一篇把 Java 微服务的可观测性讲透。
