上个月大促前做一次压测,我把商品详情服务的 Redis 缓存整个清空,想验证"无缓存冷启动"的数据库承压。结果脚本跑起来第 8 分钟,DB 连接池告警、慢查询堆积,MySQL 的 CPU 直接打到 100%。那一刻我才意识到:缓存三兄弟——穿透、击穿、雪崩——不是三个名词,是三种能把数据库直接送走的真实路径。这篇用我们那次的翻车现场,把三种问题和对应的解法讲透,并附上能直接抄的 Java 代码。
先分清:三兄弟到底差在哪
很多人把"缓存失效"笼统叫"缓存击穿",其实三者触发条件完全不同:
- 穿透:查一个数据库里根本不存在的 key(比如 id=-1 或已被删除的订单)。请求每次都绕过缓存直接打到 DB,缓存形同虚设。
- 击穿:某个热点 key 在过期瞬间,恰好被高并发集中访问。大量请求同时发现缓存没了,一起涌向 DB 重建缓存。
- 雪崩:大量 key 在同一时间段集中过期,或 Redis 实例挂掉,导致请求像雪崩一样全部砸向 DB。
三者共同点只有一条:请求最终落到了数据库。区别在"为什么会落"——穿透是 key 本就不该查 DB,击穿是单点过期+并发,雪崩是多点同时失效。解法和代价也完全不同,下面分开讲。
缓存穿透:空值和布隆过滤器两道闸
穿透的本质是"查不存在的数据"。最简单的错误写法是直接查缓存、没有就查库:
// 错误示范:穿透的源头 public Product getProduct(long id) { String cache = redis.get("product:" + id); // 1. 缓存里没有 if (cache != null) return parse(cache); Product p = productMapper.selectById(id); // 2. 直接查库——id=-1 也查 if (p != null) redis.set("product:" + id, toJson(p), 300); // 3. 查到才回写 return p; // 4. 查不到就返回 null,缓存依旧空 }逐行:第 1 行查缓存,不存在返回 null;第 2 行不管 id 存不存在都查库,攻击者用 id=-1 或随机大 id 狂打,DB 被无意义的查询淹没;第 3 行只有查到才回写,所以查不到的 key 永远不会进缓存,下一次同样的非法请求还是打到 DB。这就是穿透——缓存对非法 key 没有防御能力。
解法一:缓存空值。查到 null 也写一份短过期(比如 60 秒)的空标记,让后续请求在缓存层被挡住。
// 解法一:空值缓存 public Product getProductSafe(long id) { String cache = redis.get("product:" + id); if (cache != null) { return "NULL".equals(cache) ? null : parse(cache); // 1. 空标记直接返回 null } Product p = productMapper.selectById(id); if (p != null) { redis.set("product:" + id, toJson(p), 300); // 2. 真值回写 } else { redis.set("product:" + id, "NULL", 60); // 3. 空标记,短过期防误删后长期穿透 } return p; }逐行:第 1 行如果缓存是 "NULL" 标记直接返回,不再查库;第 3 行把查不到的结果也写入缓存,60 秒过期是为了避免"数据其实刚插入、却被空标记挡住"的一致性问题。空值缓存成本极低,但有个隐患:如果攻击者用海量不同的非法 id,缓存里会堆满无用的空标记,浪费内存。
解法二:布隆过滤器拦在缓存之前。我们最终用的是 Redisson 的RBloomFilter,在写入真实数据时同步加进过滤器,查询前先问过滤器"这个 id 可能存在吗"。
// 解法二:布隆过滤器前置拦截 RBloomFilter<Long> bf = redisson.getBloomFilter("product:ids"); bf.tryInit(1000000L, 0.01); // 1. 预计 100 万元素,误判率 1% public Product getProductWithBf(long id) { if (!bf.contains(id)) return null; // 2. 一定不存在,直接挡住 // 3. 可能存在(有误判可能),走"空值缓存 + 查库"流程 return getProductSafe(id); }逐行:第 1 行tryInit设定容量和误判率,布隆过滤器用位数组+多个哈希函数判断"一定不存在/可能存在";第 2 行返回 false 表示一定不存在,这一步能挡掉绝大多数非法请求,缓存层几乎碰不到穿透流量。注意布隆过滤器有误判(可能说"存在"但其实没有),所以第 3 行还要接空值缓存兜底。我们组合"布隆过滤器 + 空值缓存"后,那次压测里 DB 的无效查询从每秒 4 万降到个位数。
缓存击穿:单点热 key 过期的并发重建
击穿发生在热点 key 过期那一瞬。比如首页爆款商品,缓存 300 秒过期瞬间,几百个并发请求同时发现缓存没了,一起查库重建——DB 被一拥而上的查询打穿。
解法一:互斥锁。只有一个线程能去查库重建,其余线程等待或返回旧值。
// 解法一:分布式互斥锁重建(击穿) private static final String LOCK_PREFIX = "lock:product:"; public Product getWithLock(long id) { String cache = redis.get("product:" + id); if (cache != null) return parse(cache); // 1. 缓存还在直接返回 String lockKey = LOCK_PREFIX + id; String uuid = UUID.randomUUID().toString(); try { boolean locked = redis.setnx(lockKey, uuid, 30); // 2. 抢锁,30 秒自动过期防死锁 if (locked) { Product p = productMapper.selectById(id); // 3. 只有抢到锁的线程查库 redis.set("product:" + id, toJson(p), 300); // 4. 重建缓存 return p; } else { Thread.sleep(50); // 5. 没抢到,短暂等待后重试 return getWithLock(id); } } finally { if (uuid.equals(redis.get(lockKey))) redis.del(lockKey); // 6. 只删自己的锁 } }逐行:第 2 行setnx抢锁,用唯一 uuid 防止误删别人的锁;第 3 行只有持锁线程查库,把"几百个并发查库"压成"1 个查库";第 5 行没抢到的线程睡 50 毫秒重试,拿到刚建好的缓存;第 6 行finally里校验 uuid 才删锁,避免把别人刚抢到的锁删了。这是我们线上主用方案,简单可靠。
解法二:逻辑过期(不设物理 TTL,值里带过期时间)。适合"数据可以短暂不一致、但不能阻塞"的场景,比如排行榜。它的好处是不用抢锁,坏处是重建期间可能返回旧数据。
// 解法二:逻辑过期(不阻塞,返回旧值) class LogicalValue { Object data; long expireAt; } public Product getLogical(long id) { LogicalValue v = parse(redis.get("product:" + id)); if (v == null) return getWithLock(id); // 1. 真没有才走锁重建 if (v.expireAt > System.currentTimeMillis()) return (Product) v.data; // 2. 没过期返回 // 3. 逻辑过期:开新线程重建,当前请求返回旧值 executor.submit(() -> { if (redis.setnx("rebuild:" + id, "1", 20)) redis.set("product:" + id, wrap(productMapper.selectById(id))); }); return (Product) v.data; }逐行:第 2 行没过期直接返回;第 3 行过期了不阻塞用户,而是异步提交重建任务、当前请求仍然返回旧数据。代价是重建完成前用户看到的是旧值——对价格、库存这类强一致字段我们不这么做,只用在对一致性要求低的展示数据上。
缓存雪崩:别让 key 一起死
雪崩是大量 key 同时失效。最常见诱因是统一设了相同 TTL,比如全部 300 秒,到点一起过期。解法核心是"打散过期时间 + 兜底"。
// 解法:TTL 加随机抖动,避免集中过期 int base = 300; // 1. 基础 300 秒 int jitter = new Random().nextInt(120); // 2. 0~120 秒随机抖动 redis.set("product:" + id, value, base + jitter); // 3. 实际过期 300~420 秒,错峰逐行:第 2 行生成 0 到 120 秒的随机值;第 3 行每个 key 的过期时间在基础值上浮动,把"同一时刻集体过期"拆成"几分钟内陆续过期",DB 不会瞬间承压。除此之外,雪崩还有两个工程手段:其一是多级缓存(本地 Caffeine + Redis),Redis 挂了本地还能扛一部分;其二是熔断降级,DB 压力过大时直接返回默认页或走限流,别让请求无脑打到库里。
我们那晚翻车的复盘
回到开头的压测事故。根因是脚本清缓存后,几千并发同时查不存在的商品 id(穿透)+ 部分热点 key 过期(击穿),三兄弟在同一时间窗口叠满,DB 连接池被占满。我们当晚的修复顺序:
- 先上一道布隆过滤器,把非法 id 在网关层挡掉 90% 的穿透流量;
- 热点 key 加互斥锁重建,击穿的并发查库压成单查;
- TTL 全部加随机抖动,并给核心接口加 Sentinel 熔断,DB 压力大时直接降级。
救活的是第 1 步——布隆过滤器一上,DB QPS 从 4 万掉到 3000 以内,雪崩和击穿的压力随之瓦解。空值缓存和逻辑过期我们在非核心接口上才用,核心交易链路只用"锁重建 + 多级缓存",因为那里不能接受返回旧值。
我的取舍:别把三个问题用一套方案糊弄
我的观点很明确:穿透用"布隆过滤器 + 空值缓存"组合,击穿用"互斥锁"优先于"逻辑过期",雪崩用"TTL 抖动 + 熔断"。不建议为了省事只上一道"空值缓存"应对所有场景——空值缓存挡不住雪崩(key 是真存在的,只是同时过期),也挡不住高误判场景下的内存浪费。我更不建议在交易链路上用逻辑过期,旧值返回引发的差错比缓存击穿本身更难查。方案选型就一句话:先看请求"为什么落到 DB",再对症下药的分别处理,而不是找一个"万能缓存框架"指望它全包。
思考题
假设你的热点 key 缓存时间设 300 秒,但数据每 10 秒就会变一次(比如实时库存)。互斥锁重建和逻辑过期两种方案,哪种更不合适?为什么?如果要兼顾"不击穿"和"数据不太旧",你会怎么组合?
写在最后
缓存三兄弟不是考试题,是三种能把数据库打挂的真实路径。穿透是查不存在的数据、击穿是单点过期并发、雪崩是多点同时失效。我们那晚的教训是:别等压测才想起它们,上线前就该按"请求为什么落 DB"把三道防线布好。布隆过滤器挡穿透、互斥锁挡击穿、TTL 抖动加熔断挡雪崩,三道闸各管一摊,DB 才稳得住。