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

发布时间:2026/9/23 9:52:06

缓存 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 有多少。这个数字往往比想象中大得多。
延伸阅读

更多相关文章

2026/9/20 0:26:12

Android开发实战:基于Eclipse的完整项目构建与核心技能解析

1. 项目概述与核心价值 最近几年,移动开发的热度有所起伏,但Android开发作为移动生态的基石,其市场需求依然稳定且庞大。很多朋友,无论是刚毕业的学生还是希望转型的开发者,都面临一个共同的问题:理论知识学…

2026/9/23 9:48:00

6个渠道搞定建行怎么查开户行,附速查手册

6个渠道搞定建行怎么查开户行,附速查手册 刚接到个急单,客户要在下周一前完成对公账户的跨行转账,但财务那边卡住了,原因是不知道具体的开户网点信息。更头疼的是,之前用的那个老版网银接口升级后,API…

2026/9/23 9:48:00

大模型如何革新法律检索:从原理到实践

1. 法律检索的技术革命:当大模型遇上法律条文去年处理一起劳动纠纷案时,我花了整整三天时间在数百页判例中寻找类似案例。直到偶然尝试用大模型进行法律检索,原本需要72小时的工作在15分钟内就找到了关键判例。这个经历让我意识到&#xff1a…

2026/9/23 9:48:00

C++在单片机上如何实现零开销抽象:从C迁移到C++的工程实践

1. C在单片机上的真实定位与认知纠偏1.1 为什么会有“C能不能跑单片机”这个问题很多人第一次听到“用C写单片机”,脑子里蹦出来的第一个念头就是:那玩意儿不是写桌面软件和游戏的吗,放到只有几KB RAM的单片机上,不是分分钟把内存…

2026/9/23 9:48:00

RTX5060是假消息?2026游戏本选购避坑指南

1. 先泼一盆冷水:RTX5060与RTX5070Ti在2026年9月根本不会存在如果你刚在某电商页面看到“RTX5060游戏本首发预售”“RTX5070Ti性能暴涨70%”这类标题,点进去还配着炫酷渲染图和“限时早鸟价”,请立刻关掉页面——这不是新品预告,而…

2026/9/23 9:48:00

3个致命陷阱:中国电信积分兑换商城源码避坑指南

3个致命陷阱:中国电信积分兑换商城源码避坑指南 面试被问到积分系统高并发下的数据一致性,你答不上来?别慌,这不只是面试尴尬,更是业务崩溃的前兆。中国电信积分兑换商城源码避坑指南,直接带你拆解官方源码仓库中的核心逻辑。很多应届生只盯着前端页面…

2026/9/23 9:42:59

曹阿瞒面试突击:新手避坑指南,3天搞定原理与代码

曹阿瞒面试突击:新手避坑指南,3天搞定原理与代码 面试现场,考官盯着你的眼睛问:“讲讲这个底层原理,别背八股文。”你脑子里一片空白,手心冒汗,只能支支吾吾地答出几个名词,却串不起逻辑链。这种 面试被问原理答不上来…

2026/9/22 10:02:42

GAMP 5 基于风险的计算机化系统验证:软件分类与审计追踪实践

简介:《A Risk-Based Approach to Compliant GxP Computerized Systems》即业内熟知的GAMP 5指南,面向制药企业质量与IT合规人员、验证工程师及计算机化系统管理者,用于解决GxP法规环境下系统合规性难以科学落地的问题。文档以风险管理为主线…

2026/9/22 9:07:39

安全托管MSSP实战:从静态防御到人机协同的攻防运营与应急响应

简介:这份PPT围绕互联网业务安全托管服务展开,面向企业安全负责人、IT运维人员及关注MSSP/MSS选型的读者,重点回应传统安全过度依赖人工、碎片化静态防御难以对抗产业化攻击等痛点。资源共1个pptx文件,包体约30.63MB,以…

2026/9/23 0:01:54

3个实战技巧搞定形式英语:从看教程到跑通性能优化

3个实战技巧搞定形式英语:从看教程到跑通性能优化 看了一堆教程还是不会写项目?别慌,这种“眼高手低”的困境在开发者圈子里太常见了。很多人以为卡点在语法,其实真正拦路虎是缺乏将知识点串联成完整链路的能力。今天咱们不聊虚的,直接拿【形式英语】这…

2026/9/22 16:34:32

USB Type-C PCB布局分区设计:电源、高速信号与PD协议全攻略

做硬件这行,Type-C接口算是典型的“看着简单,做起来全坑”的东西。光引脚就24个,高低速信号、电源、控制线全部塞在一个小小的连接器里,如果PCB布局不做规划,打样回来基本就是“插上没反应”、“高速掉线”、“静电一打…

2026/9/22 20:01:30

系统编程学习原型如何补齐稳定性边界

系统编程学习原型如何补齐稳定性边界预算有限时&#xff0c;我先优化明显多余的复制&#xff0c;而不是猜测性地换容器。用借用传递只读数据通常就能减少分配&#xff1a; fn parse(line: &str) -> Result<Item, Error> { /* ... */ }用基准确认热点确实在分配&am…

2026/9/22 13:25:41

雨花区哪家财务公司代理记账比较好?

在雨花区&#xff0c;企业处理财税事务常常面临诸多挑战&#xff0c;选择一家靠谱的财务公司至关重要。湖南巨勤财务管理咨询有限公司就是本地正规实体财税服务机构&#xff0c;深耕本地工商财税行业多年&#xff0c;熟悉当地工商局、税务局最新政策与申报流程。主营公司注册、…

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
咨询二维码