title: 缓存 TTL 全设成 30 分钟那天,整点 QPS 把 DB 打到 100% CPU:穿透、击穿、雪崩的 3 套解法怎么搭
tags: [Redis, 缓存雪崩, 缓存击穿, 缓存穿透, Java]
category: 后端
事故是从一句「统一一下配置」开始的
那次是电商大促前的缓存预热。我们有个商品详情缓存,历史上 TTL 写得很乱:有的 10 分钟,有的 2 小时,有的干脆没设过期时间靠手动删。Code Review 的时候一位同事提了个看起来很合理的意见:「TTL 别东一个西一个,统一成 30 分钟吧,好维护。」
我当时点了赞。这个赞后来让我在故障复盘会上讲了 40 分钟。
预热脚本在 09:30 把 120 万个 SKU 的详情一次性刷进 Redis,TTL 统一 1800 秒。10:00 整点大促开抢。10:30:00 到 10:30:12 这 12 秒里:
- Redis 命中率从 99.2% 掉到 8.7%
- MySQL 主库 QPS 从 3400 冲到 41000
- 主库 CPU 100%,慢查询堆积,连接池(HikariCP,maximumPoolSize=50)全部占满
- 详情接口 P99 从 45ms 涨到 8.6s,网关大面积超时
原因不复杂:120 万个 key 是同一时刻写进去的,TTL 又完全一致,于是它们在同一秒集体过期。这就是教科书上的缓存雪崩,只不过教科书不会告诉你,它常常是被「统一配置」这种善意举动触发的。
这篇把那次事故之后我们重新梳理的三类问题(穿透、击穿、雪崩)和真正落地的解法写清楚。环境是 Redis 6.2.6 主从 + Sentinel、Spring Boot 2.7.5、MySQL 8.0.28、JDK 11。
三个词经常被混着用,先把边界划清楚
我面试时问过几十个候选人这三者的区别,能一次说准的不到三成。多数人的问题是把「击穿」和「雪崩」当成一回事。它们的触发条件、影响范围、解法完全不同。
| 问题 | 触发条件 | key 是否存在 | 影响范围 | 典型解法 |
|---|---|---|---|---|
| 穿透 | 查询一个数据库里也不存在的数据 | 缓存无、DB 也无 | 持续性,可被恶意放大 | 空值缓存、布隆过滤器、参数校验 |
| 击穿 | 某个热点 key 恰好过期 | 缓存无、DB 有 | 单 key,瞬时打穿 | 互斥重建、逻辑过期、热点永不过期 |
| 雪崩 | 大批 key 同时失效或 Redis 挂掉 | 缓存无、DB 有 | 大面积 | TTL 打散、多级缓存、限流降级、集群高可用 |
一句话区分:穿透是查不存在的东西,击穿是一个热点没了,雪崩是一片全没了。
我们那次是雪崩。但复盘时发现,同一晚其实三个都出现了——雪崩把 DB 打慢之后,重建缓存变慢,热点 key 在重建窗口内被反复击穿;同时有爬虫在刷不存在的 SKU ID,穿透流量也叠了上来。真实事故很少只有一种病因。
雪崩:TTL 打散不是「加个随机数」这么随意
最直接的解法是给 TTL 加随机扰动。但扰动区间怎么定,很多人是拍脑袋的。
@Component public class CacheTtlPolicy { private static final Duration BASE_TTL = Duration.ofMinutes(30); // 扰动比例:基础 TTL 的 ±20% private static final double JITTER_RATIO = 0.2; /** * 返回带扰动的过期秒数。 * 用 ThreadLocalRandom 而不是共享 Random,避免高并发下 CAS 自旋争用 seed。 */ public long ttlWithJitter() { long baseSeconds = BASE_TTL.getSeconds(); // 1800 long jitterRange = (long) (baseSeconds * JITTER_RATIO); // 360 // nextLong 的区间是 [-360, 360),最终落在 [1440, 2160) 秒 long jitter = ThreadLocalRandom.current().nextLong(-jitterRange, jitterRange); return baseSeconds + jitter; } /** * 针对预热场景的额外错峰:按 key 的哈希把写入分散到不同 TTL 桶。 * 好处是同一个 key 每次重建落到的桶是稳定的,便于排查。 */ public long ttlForPreheat(String key) { long baseSeconds = BASE_TTL.getSeconds(); int bucket = Math.abs(key.hashCode() % 60); // 0-59 个桶 return baseSeconds + bucket * 10L; // 每桶错开 10 秒,跨度 600 秒 } }逐段说一下这里的取舍:
JITTER_RATIO = 0.2不是随便定的。扰动太小(比如 ±5%,也就是 ±90 秒)在 120 万 key 的量级下,平摊到每秒仍有约 6600 个 key 过期,DB 照样吃不消;扰动太大(比如 ±50%)会让缓存有效期波动到 15-45 分钟,业务侧对数据新鲜度的预期就没法保证了。我们最后按「峰值 QPS 能承受的回源速率」倒推:DB 能扛 5000 QPS 回源,120 万 key 要摊到至少 240 秒以上,±20% 给了 720 秒的跨度,够用。ThreadLocalRandom这个细节容易被忽略。java.util.Random内部用AtomicLong存 seed,多线程调nextLong会在 CAS 上自旋。我们压测时在 200 并发下测过,Random比ThreadLocalRandom慢了大约 3 倍。缓存写入是高频路径,这点开销值得省。ttlForPreheat是专门给批量预热用的。随机扰动的问题是同一个 key 每次重建的 TTL 都不一样,排查「为什么这个 key 又过期了」时很难复现。按 hash 分桶保证了确定性。
我不建议只靠 TTL 打散就收工。打散解决的是「同一时刻过期」,解决不了「Redis 整个挂掉」。我们后来加了两道兜底:本地 Caffeine 做 L1(容量 10 万,TTL 60 秒),以及回源侧的信号量限流。
@Service public class ProductCacheService { private final Cache<Long, Product> localCache = Caffeine.newBuilder() .maximumSize(100_000) .expireAfterWrite(Duration.ofSeconds(60)) .recordStats() .build(); // 回源到 DB 的并发闸门:最多允许 100 个线程同时打到数据库 private final Semaphore dbGate = new Semaphore(100); public Product get(Long skuId) { Product local = localCache.getIfPresent(skuId); if (local != null) { return local; } Product fromRedis = readRedis(skuId); if (fromRedis != null) { localCache.put(skuId, fromRedis); return fromRedis; } // Redis 也没有,才考虑回源,且必须过闸门 if (!dbGate.tryAcquire()) { // 拿不到许可就返回降级数据,而不是排队等着——排队等于把线程池也拖死 return Product.degraded(skuId); } try { Product fromDb = productMapper.selectById(skuId); writeRedis(skuId, fromDb); localCache.put(skuId, fromDb); return fromDb; } finally { dbGate.release(); } } }tryAcquire()不带超时参数是刻意的。早期版本我们写的是tryAcquire(200, TimeUnit.MILLISECONDS),想着「等一下说不定就有位置了」。压测时发现这是个陷阱:Tomcat 工作线程(200 个)会全部卡在这 200ms 上,整个应用的吞吐直接归零,连健康检查都返回不了。在雪崩场景里,快速失败比排队等待重要得多。
击穿:互斥重建和逻辑过期,我更倾向后者
热点 key 过期的瞬间,成百上千个请求同时发现缓存没了,一起冲向 DB。经典解法是互斥锁重建。
public Product getWithMutex(Long skuId) { String key = "product:" + skuId; Product cached = readRedis(key); if (cached != null) { return cached; } String lockKey = "lock:rebuild:" + skuId; String token = UUID.randomUUID().toString(); // SET key value NX PX 10000:只有第一个线程能拿到重建权 Boolean acquired = stringRedisTemplate.opsForValue() .setIfAbsent(lockKey, token, Duration.ofSeconds(10)); if (Boolean.TRUE.equals(acquired)) { try { // 双检:可能在拿锁的间隙别人已经把缓存写好了 cached = readRedis(key); if (cached != null) { return cached; } Product fromDb = productMapper.selectById(skuId); writeRedis(key, fromDb, ttlPolicy.ttlWithJitter()); return fromDb; } finally { releaseLock(lockKey, token); } } else { // 没抢到锁,短暂自旋后重读缓存 try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return Product.degraded(skuId); } return getWithMutex(skuId); } }这段代码有三个地方值得展开:
第一,setIfAbsent必须带过期时间。如果拿锁的线程在重建过程中进程被 kill,锁没有 TTL 就会永久残留,后续所有请求全部走 else 分支无限递归。我们线上真出现过一次,那次是发布时 SIGKILL 了实例,锁 key 留在 Redis 里,那个 SKU 的详情接口连续 CPU 打满了十几分钟才被发现。
第二,releaseLock必须校验 token。直接DEL lockKey的写法在锁超时的情况下会误删别人的锁。正确做法是 Lua 脚本原子比对:
if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end第三,递归重试没有次数上限,这是我留下的坑。如果 DB 一直查不出来(比如 SKU 被下架但缓存策略没处理 null),else 分支会无限递归直到 StackOverflowError。后来改成了循环 + 最多重试 3 次。
互斥重建的问题在于:没抢到锁的线程要么阻塞要么自旋,热点 key 的 QPS 越高,被阻塞的线程越多。对于秒杀这类场景,我更倾向逻辑过期。
@Data public class LogicalExpireWrapper<T> { private T data; private long expireAt; // 逻辑过期时间戳,Redis 上的物理 TTL 设为永不过期 } public Product getWithLogicalExpire(Long skuId) { String key = "product:le:" + skuId; LogicalExpireWrapper<Product> wrapper = readRedisAsWrapper(key); if (wrapper == null) { // 逻辑过期方案要求数据必须预热,没预热到的直接走降级或同步回源 return loadAndCache(skuId); } if (wrapper.getExpireAt() > System.currentTimeMillis()) { return wrapper.getData(); // 没过期,直接返回 } // 逻辑上过期了,但物理数据还在:先返回旧数据,异步刷新 String lockKey = "lock:le:" + skuId; if (tryLock(lockKey)) { rebuildExecutor.submit(() -> { try { Product fresh = productMapper.selectById(skuId); writeRedisWithLogicalExpire(key, fresh, Duration.ofMinutes(30)); } finally { releaseLock(lockKey); } }); } return wrapper.getData(); // 无论有没有抢到刷新权,都返回旧值 }逻辑过期的核心是牺牲一致性换可用性:过期后的短暂窗口内所有请求拿到的都是旧数据,但没有一个请求会被阻塞,DB 只承受一个刷新线程的压力。
我们商品价格用互斥重建(不能返回旧价),商品描述、图片、详情富文本用逻辑过期(旧几十秒无所谓)。同一个系统里两种策略并存是正常的,按字段的时效性要求分,比一刀切合理。
穿透:空值缓存的 TTL 我踩过一次坑
穿透的常见解法是缓存空值。看起来很简单,但空值的 TTL 设多长是个真问题。
我们最初设成和正常数据一样的 30 分钟。结果运营在后台新建了一个商品,前台 30 分钟内一直显示「商品不存在」。运营连着提了三个工单,我们才反应过来是空值缓存没失效。
后来改成两点:
public Product getWithNullCache(Long skuId) { String key = "product:" + skuId; String raw = stringRedisTemplate.opsForValue().get(key); if (raw != null) { if (NULL_PLACEHOLDER.equals(raw)) { // 命中空值缓存,直接返回,不打 DB return null; } return JSON.parseObject(raw, Product.class); } Product fromDb = productMapper.selectById(skuId); if (fromDb == null) { // 空值 TTL 独立配置,且远短于正常数据:120 秒 stringRedisTemplate.opsForValue() .set(key, NULL_PLACEHOLDER, Duration.ofSeconds(120)); return null; } stringRedisTemplate.opsForValue() .set(key, JSON.toJSONString(fromDb), Duration.ofSeconds(ttlPolicy.ttlWithJitter())); return fromDb; }两个改动:空值 TTL 独立成 120 秒;商品创建/上架的写路径上主动DEL对应的 key。第二点更关键——依赖 TTL 自然失效来保证一致性,本质上是把问题推给时间。
至于布隆过滤器,我们评估过但没在这个场景用。原因是商品会新增,布隆过滤器不支持删除,新增元素后误判率会持续上升,需要定期重建。对于 SKU 这种每天几千条新增的数据,重建成本比省下来的 DB 查询更贵。布隆过滤器更适合数据集相对稳定、恶意穿透流量巨大的场景,比如短链跳转、风控黑名单。
三套方案的落地成本对比
| 方案 | 开发成本 | 运行开销 | 一致性影响 | 我们的采用情况 |
|---|---|---|---|---|
| TTL 随机扰动 | 极低,10 行代码 | 无 | 无 | 全量采用 |
| 本地 Caffeine L1 | 中,要处理集群内失效广播 | 每实例约 200MB 堆 | 最长 60 秒不一致 | 详情、配置类数据采用 |
| 回源信号量限流 | 低 | 无 | 触发时返回降级数据 | 全量采用 |
| 互斥重建 | 中,锁的正确释放容易写错 | 每次重建一次 SET NX | 无 | 价格、库存采用 |
| 逻辑过期 | 高,要改数据结构 + 预热任务 | 需要额外线程池 | 刷新窗口内返回旧值 | 详情、榜单采用 |
| 空值缓存 | 极低 | 少量内存 | 需配合写路径删除 | 全量采用 |
| 布隆过滤器 | 高,参数计算 + 重建任务 | 位数组内存 | 存在误判 | 未采用 |
复盘之后真正改掉的东西
那次事故后我们做了四件事,按收益排序:
- TTL 扰动 + 预热分桶。改完之后再做一次 120 万 key 的预热演练,过期时段的 DB 回源峰值从 41000 QPS 降到 3800 QPS。这一项就解决了 90% 的问题。
- 回源信号量。压测中模拟 Redis 整体不可用,接口 P99 从「全部超时」变成「1.2s 内返回降级数据」,Tomcat 线程池没有被打满。
- 空值 TTL 独立配置 + 写路径主动删除。运营工单归零。
- 加了一条监控:按分钟统计即将过期的 key 数量(用
SCAN+PTTL采样,不是全量扫描),超过阈值告警。这条监控后来又提前发现过一次配置中心批量刷新导致的准雪崩。
有一件事我们讨论过但没做:Redis 的maxmemory-policy从noeviction改成allkeys-lru。反对意见是 LRU 淘汰会让「本该命中」的数据被踢掉,问题从可预测的雪崩变成不可预测的抖动。最后保持noeviction,靠容量规划和监控兜底。这个选择不通用——如果你的 Redis 里存的都是纯缓存、丢了无所谓,allkeys-lru是更省心的选择。
留给你的三个问题
- 如果热点 key 的重建耗时是 3 秒,互斥锁的 TTL 你会设多久?设短了会怎样,设长了又会怎样?
- 本地缓存 L1 引入之后,集群内 8 个实例的数据不一致窗口怎么收敛?用 Redis Pub/Sub 广播失效消息有什么坑?
- 逻辑过期方案下,如果某个 key 一直没有请求进来,它的数据会一直是旧的。这种「冷 key 数据陈旧」的问题,你打算怎么处理?
如果你们线上也用「统一 TTL」这种配置,建议现在就去查一下同时过期的 key 有多少。这个数字往往比想象中大得多。