十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

热点 key 重建的 800 毫秒,30 万请求同时打进 DB:缓存穿透、击穿、雪崩的三套不同解法

热点 key 重建的 800 毫秒,30 万请求同时打进 DB:缓存穿透、击穿、雪崩的三套不同解法 title: 热点 key 重建的 800 毫秒30 万请求同时打进 DB缓存穿透、击穿、雪崩的三套不同解法date: 2026-08-22category: 缓存tags: [Java, 后端, Redis, 缓存]去年大促预热我们一个爆款商品详情页的缓存 key 在 20:00:00 整点过期。那一瞬间监控里 DB 的 CPU 从 12% 直接顶到 99%连接池被打满订单创建接口 P99 从 80ms 涨到 4.6s持续了大概 40 秒才缓过来。事后复盘我们发现这次其实是「击穿」和「雪崩」叠加但团队里一半人第一反应是「加锁」另一半说「加随机过期」还有人要把不存在的商品也缓存。三个人说的都是「缓存防护」但其实解决的是三件完全不同的事。这篇把穿透、击穿、雪崩拆开讲各自该上什么锁、该用什么数据结构、为什么不能混为一谈。先分清楚这三者根本不是一回事很多人把这三个词当同义词用但触发条件和解法天差地别缓存穿透查的是一个根本不存在的数据恶意刷 ID、爬虫撞库缓存和 DB 都没有请求每次都落到 DB。缓存击穿某个热点 key 突然过期重建缓存的瞬间海量并发请求同时发现缓存没了一起打到 DB。注意是「单点」。缓存雪崩大量 key 在同一时间段集中失效比如都设了整点过期、或者 Redis 宕机请求像雪崩一样铺满 DB。区别一句话穿透是「查没有的」击穿是「一个热的没了」雪崩是「一堆一起没了」。解法也因此完全不同下面逐个来。击穿单热点 key 过期靠「互斥重建」而不是「全量加锁」击穿的核心矛盾是缓存失效的瞬间不应该让 30 万个请求都去查 DB而应该只放一个请求去重建其他请求等它建完直接读缓存。我们一开始犯的错误是「查 DB 前 synchronized 一把大锁」——结果把整个商品查询接口变成了串行非热点商品也被拖慢。正确做法是分布式互斥锁只锁这一个 key。// 基于 Redis 的互斥锁只锁当前重建的 key不阻塞其他 key Component public class CacheRebuildLock { private final StringRedisTemplate redis; public CacheRebuildLock(StringRedisTemplate redis) { this.redis redis; } // 尝试获取锁value 用唯一标识避免误删别人的锁 public boolean tryLock(String lockKey, String requestId, long expireMs) { // SET key value NX PX expireMs不存在才设置并带过期时间 Boolean ok redis.opsForValue() .setIfAbsent(lockKey, requestId, expireMs, TimeUnit.MILLISECONDS); return Boolean.TRUE.equals(ok); } // 释放锁用 Lua保证「判断 value 删除」原子不误删别人持有的锁 private static final String RELEASE_LUA if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end; public void releaseLock(String lockKey, String requestId) { redis.execute(RedisScript.of(RELEASE_LUA, Long.class), Collections.singletonList(lockKey), requestId); } }这段代码里有三个我踩过的坑逐行说setIfAbsent一定要带PX过期时间。我们第一版漏了过期结果一个请求拿到锁后服务重启锁永远不释放那个 key 就再也没人能重建缓存成了死锁。value 必须放requestId而不是固定字符串。否则 A 拿到锁、超时释放前 B 也拿到锁A 处理完一把删了 B 的锁连锁错乱。释放锁用 Lua 脚本把「判断 value 是否自己 删除」合成原子操作。之前用get再del两步中间隙里锁可能被别人拿走删成了别人的。有了锁重建流程是缓存没命中 → 抢锁 → 抢到的人查 DB、写缓存、放锁没抢到的自旋等几十毫秒再读缓存。这样 30 万请求里只有 1 个真正查了 DB。穿透查不存在的数据靠「布隆过滤器」或「空值缓存」穿透查的是 DB 里也没有的数据。互斥锁对它无效——因为查 DB 返回 null缓存不写下次还是穿透。我们的选择是布隆过滤器做第一道闸门把已存在的商品 ID 全部装进去不在过滤器里的请求直接拒绝// Guava 布隆过滤器预计放 1 亿商品误判率 0.01 BloomFilterLong filter BloomFilter.create( Funnels.longFunnel(), 100_000_000, 0.01); // 商品写入 DB 时同步放进过滤器 public void addProduct(long productId) { filter.put(productId); productMapper.insert(productId); } // 查询前先过过滤器不在里面直接返回根本不进 DB public Product query(long productId) { if (!filter.mightContain(productId)) { return null; // 一定不存在直接挡掉 } return productMapper.selectById(productId); }布隆过滤器的代价是「宁可错杀、不可放过」它说不存在就一定不存在说存在可能不存在误判。我们把误判率设 0.01意味着万分之一的请求会穿透到 DB量级完全可控。另一种更省事的方案是缓存空值把查不到的结果也写进 Redis带短过期比如 5 分钟public Product queryWithNullCache(long productId) { Product p (Product) redis.opsForValue().get(prod: productId); if (p ! null) return p; p productMapper.selectById(productId); if (p null) { // 不存在也缓存但过期时间短避免恶意 key 把 Redis 撑爆 redis.opsForValue().set(prod: productId, NULL_MARKER, 5, TimeUnit.MINUTES); return null; } redis.opsForValue().set(prod: productId, p, 30, TimeUnit.MINUTES); return p; }两种方案怎么选布隆过滤器适合「能提前知道全集」的场景商品 ID 都在库里空值缓存适合「全集不知道、但穿透量不大」的场景。我们最后两个都上过滤器挡掉 99% 的恶意 ID空值缓存兜底正常业务里的偶发查无。雪崩批量 key 同时失效靠「错峰过期」和「逻辑过期」雪崩的根因是「大量 key 同一时刻失效」。最典型的就是当初我们给所有商品缓存都设了整点 30 分钟过期结果 20:00 一到几万个 key 同时失效。解法一给过期时间加随机抖动让 key 不要挤在同一秒失效// 基础 30 分钟叠加 0~600 秒的随机打散失效时间点 int base 30 * 60; int jitter new Random().nextInt(600); redis.opsForValue().set(key, value, base jitter, TimeUnit.SECONDS);解法二逻辑过期物理上 key 永不过期value 里带一个expireTime字段业务层判断是否逻辑过期过期了才异步重建// 缓存值包装逻辑过期时间Redis 本身不设 TTL class LogicalValueT { T data; long logicExpireAt; // 逻辑过期时间戳 } public Product queryLogical(long productId) { LogicalValueProduct v readFromRedis(productId); if (v null) { return loadFromDbAndCache(productId); // 首次直接查库 } if (System.currentTimeMillis() v.logicExpireAt) { return v.data; // 没逻辑过期直接返回 } // 逻辑过期了开一个线程异步重建当前请求先返回旧值 rebuildExecutor.execute(() - rebuildCache(productId)); return v.data; }逻辑过期的好处是永远不会出现「缓存全空」的瞬间——就算重建慢用户拿到的也是旧数据DB 不会被瞬时打爆。代价是有一小段时间数据不是最新的适合「一致性要求没那么苛刻」的商品详情这类场景。三套解法对比别再混着用问题触发条件推荐解法不适用场景缓存穿透查不存在的数据布隆过滤器 / 空值缓存布隆过滤器要求能预知全集缓存击穿单热点 key 过期分布式互斥锁重建锁实现不原子会死锁缓存雪崩大量 key 集中失效随机 TTL / 逻辑过期逻辑过期有短暂不一致我个人的取舍小团队先上「随机 TTL 空值缓存」两件套成本最低、能挡掉 90% 的事故布隆过滤器等 ID 量级上来、恶意流量变多再加。互斥锁重建只在确认有单点超热 key 时才上因为它本身也是把「并发查 DB」变成「串行重建」用错了反而拖累整体。复盘数字那次事故里20:00:00 起 40 秒内 DB 收到约 30 万次商品查询请求其中真正落到 MySQL 的峰值 QPS 约 1.2 万是日常的 40 倍。上线「随机 TTL 空值缓存」后同类整点场景 DB 峰值回落到日常的 1.3 倍以内一周后补上布隆过滤器恶意 ID 扫描的 DB 命中降到了千分之一以下。整个过程没改一行业务 SQL纯靠缓存层兜底。几个容易写错的点互斥锁的 value 一定要唯一释放一定要用 Lua 原子删不然就是新的并发坑。空值缓存必须设短 TTL否则攻击者用不同 key 能把 Redis 写满。逻辑过期的重建线程池要单独隔离别和主业务线程池混用否则重建慢了会反向拖垮业务。思考题如果你的热点 key 重建一次要 800 毫秒而业务要求接口 P99 不超过 200 毫秒互斥锁重建等待期间请求自旋和逻辑过期返回旧值你会选哪个当这个 key 同时有「价格变更」需要秒级生效时你的选择会变吗
返回列表