缓存 TTL 全设成 30 分钟那天,整点 QPS 把 DB 打到 100% CPU:穿透、击穿、雪崩的 3 套解法怎么搭

📅 2026/8/5 14:27:50
缓存 TTL 全设成 30 分钟那天,整点 QPS 把 DB 打到 100% CPU:穿透、击穿、雪崩的 3 套解法怎么搭
title: 缓存 TTL 全设成 30 分钟那天整点 QPS 把 DB 打到 100% CPU穿透、击穿、雪崩的 3 套解法怎么搭tags: [Redis, 缓存雪崩, 缓存击穿, 缓存穿透, Java]category: 后端事故是从一句「统一一下配置」开始的那次是电商大促前的缓存预热。我们有个商品详情缓存历史上 TTL 写得很乱有的 10 分钟有的 2 小时有的干脆没设过期时间靠手动删。Code Review 的时候一位同事提了个看起来很合理的意见「TTL 别东一个西一个统一成 30 分钟吧好维护。」我当时点了赞。这个赞后来让我在故障复盘会上讲了 40 分钟。预热脚本在 09:30 把 120 万个 SKU 的详情一次性刷进 RedisTTL 统一 1800 秒。10:00 整点大促开抢。10:30:00 到 10:30:12 这 12 秒里Redis 命中率从 99.2% 掉到 8.7%MySQL 主库 QPS 从 3400 冲到 41000主库 CPU 100%慢查询堆积连接池HikariCPmaximumPoolSize50全部占满详情接口 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 CacheLong, 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 被下架但缓存策略没处理 nullelse 分支会无限递归直到 StackOverflowError。后来改成了循环 最多重试 3 次。互斥重建的问题在于没抢到锁的线程要么阻塞要么自旋热点 key 的 QPS 越高被阻塞的线程越多。对于秒杀这类场景我更倾向逻辑过期。Data public class LogicalExpireWrapperT { private T data; private long expireAt; // 逻辑过期时间戳Redis 上的物理 TTL 设为永不过期 } public Product getWithLogicalExpire(Long skuId) { String key product:le: skuId; LogicalExpireWrapperProduct 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 数量用SCANPTTL采样不是全量扫描超过阈值告警。这条监控后来又提前发现过一次配置中心批量刷新导致的准雪崩。有一件事我们讨论过但没做Redis 的maxmemory-policy从noeviction改成allkeys-lru。反对意见是 LRU 淘汰会让「本该命中」的数据被踢掉问题从可预测的雪崩变成不可预测的抖动。最后保持noeviction靠容量规划和监控兜底。这个选择不通用——如果你的 Redis 里存的都是纯缓存、丢了无所谓allkeys-lru是更省心的选择。留给你的三个问题如果热点 key 的重建耗时是 3 秒互斥锁的 TTL 你会设多久设短了会怎样设长了又会怎样本地缓存 L1 引入之后集群内 8 个实例的数据不一致窗口怎么收敛用 Redis Pub/Sub 广播失效消息有什么坑逻辑过期方案下如果某个 key 一直没有请求进来它的数据会一直是旧的。这种「冷 key 数据陈旧」的问题你打算怎么处理如果你们线上也用「统一 TTL」这种配置建议现在就去查一下同时过期的 key 有多少。这个数字往往比想象中大得多。