大促开始三秒,数据库先倒下了
先讲个我亲历的事故。去年大促,运营把一款爆款的详情页挂上了首页 C 位,零点刚过,流量像开闸一样涌进来。前两秒风平浪静——缓存热着,接口稳定在 20 毫秒。第三秒,告警群炸了:商品库 CPU 直接干到 100%,连接池耗尽,详情接口全线超时,页面白屏。
翻日志发现一个非常巧合的事实:被首页疯狂访问的热点 key product:detail:1001,过期时间恰好落在了流量洪峰的正中央。缓存失效的那一毫秒,上万个请求同时未命中,又同时冲向数据库——一个 key,干翻了一个库。
这就是缓存击穿。注意它和上一篇讲的穿透不是一回事:数据在数据库里好好躺着,只是缓存恰好在最不该失效的时刻失效了。穿透是「查无此人」,怎么查都查不到;击穿是「检票员恰好在这个节骨眼上离开了检票口」——队伍还在以每秒几千人的速度变长。
先分清:穿透、击穿、雪崩到底差在哪
缓存经典的「三问」经常被混为一谈。排查线上问题最怕对号入座对错了病,先用一张表划清边界:
| 问题 | 缓存里 | 数据库里 | 典型触发 | 核心解法 |
|---|---|---|---|---|
| 缓存穿透 | 没有 | 也没有 | 恶意请求不存在的 id | 参数校验、空值缓存、布隆过滤器 |
| 缓存击穿 | 没有 | 有(单点热点) | 热点 key 在高峰期恰好过期 | 互斥锁、逻辑过期 |
| 缓存雪崩 | 大批没有 | 有(大面积) | 大批 key 同时到期或 Redis 宕机 | 过期时间加随机、多级缓存、集群高可用 |
记忆口诀:穿透查无此数,击穿单点失效,雪崩集体失效。上一篇的四道防线管的是穿透,这一篇的两种方案管击穿,至于雪崩——留给下一篇细说。
方案一:互斥锁,只放一个人去查库
思路朴素到近乎笨拙:缓存未命中时先抢一把分布式锁,抢到的才有资格去查库、回填缓存;没抢到的原地稍等,稍后再读一次缓存。数据库面对的并发瞬间从「上万」收敛成「1」,洪水被捏成了一根水管。
public Product getProduct(Long id) {
String key = "product:detail:" + id;
String lockKey = "lock:product:" + id;
for (int i = 0; i < 10; i++) {
Product p = redisTemplate.opsForValue().get(key);
if (p != null) {
return p;
}
// 抢锁:SET NX EX,全场只放一个人去查库
String requestId = UUID.randomUUID().toString();
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, requestId, Duration.ofSeconds(10));
if (Boolean.TRUE.equals(locked)) {
try {
// 双重检查:前面排队的人可能刚把缓存回填好
p = redisTemplate.opsForValue().get(key);
if (p == null) {
p = productMapper.selectById(id);
redisTemplate.opsForValue().set(key, p, Duration.ofMinutes(30));
}
return p;
} finally {
// 生产环境建议用 Lua 脚本校验 requestId 后再删
redisTemplate.delete(lockKey);
}
}
// 没抢到锁:稍等片刻,下一轮先读缓存
try {
Thread.sleep(50);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
throw new IllegalStateException("查询过于频繁,请稍后重试");
}这段代码里有四个细节,一个都不能省:
- 锁必须带过期时间:万一持锁的机器在查库时宕机,这把锁就永远悬在那,后面所有请求集体陪葬。setIfAbsent 的第三个参数是保命符,不是可选项。
- 双重检查必不可少:拿到锁之后必须再读一次缓存——你前面排队的那位,可能刚把数据回填好。不查这一次,等于大家白排队。
- 锁要挂在 key 上:lock:product:1001 只锁这一个商品。要是全站共用一把锁,卖包的和卖鞋的互相阻塞,击穿还没发生,内耗先开始了。
- 删锁前最好验明正身:示例里直接 delete 是为了简洁,严谨做法是用 Lua 脚本「确认 value 是自己的 requestId 再删」。否则自己的锁恰好超时被别人接管,你恢复后手起刀落,删掉的是别人的锁。
顺带一提:锁的过期时间要略大于「查库 + 回填」的最坏耗时。锁给了 10 秒、查库要 15 秒,第 10 秒锁过期,第二个请求大摇大摆进来又查一遍库——互斥锁等于白加。
方案二:逻辑过期,让热点 key 永不失效
互斥锁的代价是「总得有人排队」。如果你运营的是秒杀级的超热 key,哪怕 50 毫秒的等待也会被千万 QPS 放大成一次体验事故。那就换个思路:干脆不让 key 过期。
物理 TTL 不设了,把「逻辑过期时间」塞进 value 一起存。读的时候先看一眼逻辑上过期没有:没过期,正常返回;过期了,先把旧数据还回去保命,同时抢锁异步重建缓存。等重建完成,后续请求自然拿到新数据。
类比一下:超市货架上的牛奶到了保质期,没人会把货架清空、让顾客干着等补货——正常操作是牛奶先摆着,店员同时去仓库搬新的。
// value 里包一层:业务数据 + 逻辑过期时间戳
public class CacheWrapper {
private Product data;
private long expireAt; // 逻辑过期时间(毫秒时间戳)
// 省略 getter/setter
}
public Product getProductV2(String key) {
String json = redisTemplate.opsForValue().get(key);
if (json == null) {
return null; // key 压根不存在,交给上一篇的四道防线
}
CacheWrapper wrapper = JSON.parseObject(json, CacheWrapper.class);
if (wrapper.getExpireAt() > System.currentTimeMillis()) {
return wrapper.getData(); // 还没过期,正常返回
}
// 逻辑过期了:先返回旧数据保命,再尝试异步重建
String lockKey = "lock:rebuild:" + key;
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", Duration.ofSeconds(30));
if (Boolean.TRUE.equals(locked)) {
rebuildExecutor.execute(() -> {
try {
Product fresh = productMapper.selectById(wrapper.getData().getId());
CacheWrapper next = new CacheWrapper();
next.setData(fresh);
next.setExpireAt(System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(30));
redisTemplate.opsForValue().set(key, JSON.toJSONString(next));
} finally {
redisTemplate.delete(lockKey);
}
});
}
// 不管有没有触发重建,先把旧值还回去
return wrapper.getData();
}优雅,但有两个代价必须想清楚:
- 短暂脏读:重建完成前,所有人读到的都是旧数据。商品详情、活动配置这类场景通常无所谓;换成库存、价格这种强一致数据,就得掂量掂量。
- 内存常驻:TTL 永不生效,key 自己删不掉自己。热点下架后要靠主动删除或定期巡检清理,否则 Redis 会被「僵尸热点」慢慢占满。
怎么选?一张表说清楚
| 维度 | 互斥锁 | 逻辑过期 |
|---|---|---|
| 数据一致性 | 强,读到的一定是新数据 | 弱,重建完成前返回旧值 |
| 请求体验 | 未抢到锁的请求需要等待重试 | 零等待,直接返回 |
| 实现复杂度 | 低,几十行代码搞定 | 高,要包装 value、维护异步线程池 |
| 额外内存 | 无 | 热点数据常驻内存,需主动清理 |
| 适合场景 | 一般热点、一致性敏感 | 大促级超热 key、体验敏感 |
我的选型经验是这样的:
- 一致性优先、热点等级一般 → 互斥锁。几十行代码,绝大多数业务够用。
- 大促级超热 key、体验优先 → 逻辑过期。用短暂旧读换零等待。
- 共同前提是你能识别热点 key。拿不准就上热点探测(Redis 自带 hotkey 分析,或者运营后台手动标记),别指望每条数据都享受 VIP 待遇。
还有一个简化玩法:热点 key 永不过期 + 后台定时任务主动刷新。相当于逻辑过期的手动挡,粗,但稳,适合 key 数量少且可枚举的场景。
到这里,「缓存三问」已经解完两问。下一篇讲第三问——缓存雪崩:当大批 key 集体失效、甚至整个 Redis 都宕掉的时候,怎么让数据库活着撑过那一劫?过期时间加随机值只是入门,多级缓存和集群高可用才是重头戏。下篇见。
咖啡凉了,记得趁热喝。
评论 (0)