2024 年某个凌晨两点,我们的商品详情页突然大面积 502,值班电话被打爆。排查发现:前一天大促预热时,运营批量导入了一批商品缓存,过期时间统一设成了 24 小时 — 于是这批缓存在凌晨两点同时失效,瞬间几万 QPS 全部穿透到 MySQL,数据库连接池被打满,整个商品服务雪崩。紧急重启 + 预热缓存后,投了一周做缓存稳定性专项治理,从雪崩、击穿、穿透到数据一致性全部重做。本文复盘 Redis 缓存的完整实战。
问题背景
业务:商品详情页,Redis 缓存商品信息,日均 PV 8000 万
架构:应用 → Redis(缓存)→ MySQL(回源)
缓存:Redis 3 主 3 从,商品缓存约 200 万 key
事故时间线:
02:00:01 一批 38 万商品缓存同时过期(都是 24h 前导入的)
02:00:02 缓存命中率从 99.2% 暴跌到 41%
02:00:03 MySQL QPS 从 3000 飙到 6.8 万,连接池(200)被打满
02:00:05 大量请求拿不到 DB 连接,商品服务线程池耗尽
02:00:08 商品详情页 502,首页推荐位连带挂掉
02:18:00 人工重启 + 脚本预热缓存,逐步恢复
复盘出的三类缓存问题:
1. 缓存雪崩:大量 key 同一时刻过期 → 请求集中打到 DB
2. 缓存击穿:单个热点 key 过期瞬间,高并发同时回源重建
3. 缓存穿透:查不存在的数据,缓存永远不命中,每次都打 DB
外加一个长期隐患:
4. 缓存与 DB 数据不一致:更新商品后偶发读到旧价格
修复 1:缓存雪崩 — 过期时间打散
// === 根因:统一过期时间 ===
// 错误:所有商品缓存都设 24h,导入时刻一致 → 过期时刻也一致
// redis.set(key, value, 24, TimeUnit.HOURS);
// === 解法 1:过期时间加随机抖动,把失效时刻摊开 ===
public void cacheProduct(String key, Product p) {
// 基础 24h + 0~2h 随机,38 万 key 的过期点散布在 2 小时窗口里
long baseSeconds = 24 * 3600;
long jitter = ThreadLocalRandom.current().nextLong(0, 2 * 3600);
redis.opsForValue().set(key, serialize(p),
baseSeconds + jitter, TimeUnit.SECONDS);
}
// === 解法 2:多级缓存,Redis 挂了还有本地缓存兜底 ===
// 本地用 Caffeine,容量小、过期短,扛热点
private final Cache<String, Product> localCache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofMinutes(5))
.build();
public Product getProduct(Long id) {
String key = "product:" + id;
// L1:本地缓存
Product p = localCache.getIfPresent(key);
if (p != null) return p;
// L2:Redis
p = getFromRedis(key);
if (p != null) {
localCache.put(key, p);
return p;
}
// L3:回源 DB(下面的修复 2 会加锁保护)
return loadFromDbWithLock(id, key);
}
// 多级缓存:即便 Redis 整个不可用,本地缓存仍能扛住大部分热点读
// === 解法 3:回源处加熔断,DB 扛不住时快速降级 ===
// 用 Resilience4j,当回源 DB 异常率过高 → 熔断 → 返回兜底数据
@CircuitBreaker(name = "productDb", fallbackMethod = "productFallback")
public Product loadFromDb(Long id) {
return productDao.selectById(id);
}
public Product productFallback(Long id, Throwable t) {
// 降级:返回简化的兜底商品信息,保证页面不白屏
return Product.degraded(id);
}
修复 2:缓存击穿 — 热点 key 重建
// === 击穿:某个超热 key(如爆款商品)过期的一瞬间 ===
// 上万并发同时发现缓存没了,同时去回源 DB → DB 被一个 key 打挂
// === 解法 1:互斥锁,只放一个线程回源,其余等待 ===
public Product loadFromDbWithLock(Long id, String key) {
Product p = getFromRedis(key);
if (p != null) return p;
String lockKey = "lock:product:" + id;
String token = UUID.randomUUID().toString();
// 只有抢到锁的线程去回源,SET NX PX 原子加锁
boolean locked = tryLock(lockKey, token, 3000);
if (locked) {
try {
// 双重检查:可能在抢锁期间别的线程已重建好
p = getFromRedis(key);
if (p != null) return p;
// 回源 DB 并写回缓存
p = productDao.selectById(id);
cacheProduct(key, p);
return p;
} finally {
unlock(lockKey, token);
}
} else {
// 没抢到锁:短暂等待后重试读缓存(此时大概率已被重建)
sleep(50);
return getFromRedis(key);
}
}
// 效果:1 万并发回源,实际只有 1 个线程打 DB,其余读重建好的缓存
// === 解法 2:逻辑过期,热点 key 永不物理过期 ===
// 适合"绝对扛不住回源抖动"的超级热点(秒杀爆品)
public class LogicalExpireValue {
private Object data;
private long expireAt; // 逻辑过期时间戳,key 本身不设 TTL
}
public Product getWithLogicalExpire(Long id) {
String key = "product:logical:" + id;
LogicalExpireValue v = getFromRedis(key); // key 永不过期,一定有值
if (v == null) return loadFromDbWithLock(id, key);
if (System.currentTimeMillis() < v.getExpireAt()) {
return (Product) v.getData(); // 逻辑未过期,直接返回
}
// 逻辑已过期:返回旧数据(略脏但可用)+ 异步重建,不阻塞任何请求
if (tryLock("lock:rebuild:" + id, UUID.randomUUID().toString(), 3000)) {
asyncExecutor.submit(() -> rebuildCache(id, key));
}
return (Product) v.getData(); // 重建期间所有人拿旧值,绝不打 DB
}
// 逻辑过期:用"返回短暂的旧数据"换"回源永不阻塞用户"
修复 3:缓存穿透 — 不存在的数据
// === 穿透:查询根本不存在的数据(如恶意刷不存在的商品 id)===
// 缓存永远不命中(因为 DB 里也没有)→ 每次请求都打到 DB
// === 解法 1:缓存空值 ===
public Product getProductWithNullCache(Long id) {
String key = "product:" + id;
String cached = redis.opsForValue().get(key);
if (cached != null) {
// 命中了"空值标记" → 直接返回 null,不再查 DB
if (NULL_FLAG.equals(cached)) return null;
return deserialize(cached);
}
Product p = productDao.selectById(id);
if (p == null) {
// DB 查不到:也写进缓存,但用短 TTL(防缓存被空值占满)
redis.opsForValue().set(key, NULL_FLAG, 5, TimeUnit.MINUTES);
return null;
}
cacheProduct(key, p);
return p;
}
// 空值缓存简单有效,但 TTL 要短,且无法防"海量不同的不存在 id"
// === 解法 2:布隆过滤器,从源头挡掉不存在的 id ===
// 启动时把所有有效商品 id 灌进布隆过滤器
public class ProductBloomFilter {
private RBloomFilter<Long> bloomFilter;
public void init() {
bloomFilter = redisson.getBloomFilter("product:bloom");
// 预期 500 万元素,误判率 0.1%
bloomFilter.tryInit(5_000_000L, 0.001);
for (Long id : productDao.selectAllIds()) {
bloomFilter.add(id);
}
}
public Product getProduct(Long id) {
// 布隆过滤器说"不存在" → 一定不存在,直接拦截,连 Redis 都不查
if (!bloomFilter.contains(id)) {
return null;
}
// 说"可能存在" → 走正常缓存逻辑(有极小误判率,被空值缓存兜住)
return getProductWithNullCache(id);
}
}
// 布隆过滤器特性:判定"不存在"绝对准确,判定"存在"有极小误判
// 新增商品时记得同步 add 进过滤器
// 空值缓存 + 布隆过滤器双层:布隆挡掉绝大多数,漏网的被空值缓存兜住
修复 4:缓存与 DB 一致性
// 长期隐患:更新商品后,偶发读到旧价格
// === 错误姿势 1:先更新缓存,再更新 DB ===
// 缓存更新成功、DB 更新失败 → 缓存是新的、DB 是旧的,彻底脏
// === 错误姿势 2:先更新 DB,再更新缓存 ===
// 两个并发写,更新顺序可能交错 → 缓存留下旧值
// === 正确姿势:Cache Aside —— 更新 DB,然后删除缓存(不是更新)===
public void updateProduct(Product p) {
productDao.update(p); // 1. 先更新 DB
redis.delete("product:" + p.getId()); // 2. 再删缓存(下次读自然重建)
}
// 删除而非更新:避免并发写导致缓存留旧值;下次读 miss 时回源拿最新
// === 但仍有一个并发缝隙 ===
// 线程A读(miss)→ 查到旧DB值 → (此时线程B更新DB+删缓存)→ A把旧值写回缓存
// 概率极低(读比写快),但要彻底解决:延迟双删
public void updateProductWithDoubleDelete(Product p) {
redis.delete("product:" + p.getId()); // 1. 先删一次
productDao.update(p); // 2. 更新 DB
// 3. 延迟一会再删一次,清掉"缝隙期被写回的旧值"
delayExecutor.schedule(
() -> redis.delete("product:" + p.getId()),
500, TimeUnit.MILLISECONDS);
}
// === 最可靠:订阅 binlog 异步刷缓存(Canal)===
// 业务代码只管写 DB,Canal 监听 binlog 变更 → 自动删除对应缓存
// 优点:缓存维护和业务代码解耦,不会漏删;天然处理各种更新入口
public void onBinlogEvent(BinlogEvent event) {
if ("t_product".equals(event.getTable())) {
Long id = event.getColumnValue("id");
redis.delete("product:" + id); // DB 一变,缓存就失效
}
}
// 一致性方案选型:
// - 一般业务:Cache Aside(更新 DB + 删缓存)够用
// - 并发高、要求严:延迟双删
// - 多入口写 DB / 要彻底解耦:Canal 订阅 binlog
// 注意:缓存一致性只能做到"最终一致",要强一致就别用缓存
修复 5:热 key 与大 key
# === 热 key:单个 key QPS 过高,打垮某个 Redis 分片 ===
# 爆款商品的缓存 key,QPS 可能到几十万,单分片扛不住
# 定位热 key
$ redis-cli --hotkeys
# 或开启 monitor 抽样(生产慎用,有性能损耗)
# 热 key 解法 1:本地缓存兜一层(见修复 1 的多级缓存)
# 热 key 解法 2:key 打散,把一个热 key 拆成 N 个副本分散到多分片
# product:888 → product:888:0 ~ product:888:9
# 读时随机选一个副本,写时全部更新
// 热 key 副本打散:把单 key 的压力摊到 N 个分片
public Product getHotProduct(Long id) {
int replica = ThreadLocalRandom.current().nextInt(HOT_KEY_REPLICAS);
String key = "product:" + id + ":" + replica; // 随机读一个副本
Product p = getFromRedis(key);
if (p != null) return p;
// 某副本 miss → 回源重建该副本(加锁防击穿)
return loadFromDbWithLock(id, key);
}
// === 大 key:单个 value 过大,操作时阻塞 Redis 单线程 ===
// 例:把某分类下 10 万个商品 id 全塞进一个 List/Set
// 危害:序列化慢、网络传输大、del 时阻塞、内存分布不均
// 排查大 key
// $ redis-cli --bigkeys
// $ redis-cli MEMORY USAGE product:category:1
// 大 key 治理:
// 1. 拆分:大 Hash 按 field hash 分桶,big_hash → big_hash:0 ~ big_hash:N
// 2. 删除用 UNLINK 而非 DEL(异步释放,不阻塞主线程)
redisTemplate.unlink("product:category:huge");
// 3. 不要把"全量集合"塞一个 key,改成分页存储或换数据结构
修复 6:监控告警
# redis_exporter + 业务埋点,Prometheus 告警
groups:
- name: redis-cache
rules:
# 1. 缓存命中率骤降(雪崩 / 击穿的最直接信号)
- alert: CacheHitRateLow
expr: |
rate(cache_hit_total[5m])
/ (rate(cache_hit_total[5m]) + rate(cache_miss_total[5m])) < 0.9
for: 3m
annotations:
summary: "{{ $labels.app }} 缓存命中率 < 90%,排查雪崩/击穿"
# 2. 回源 DB QPS 突增(穿透 / 雪崩导致请求打到 DB)
- alert: CacheMissToDbSpike
expr: rate(cache_db_fallback_total[5m]) > 1000
for: 2m
annotations:
summary: "{{ $labels.app }} 回源 DB > 1000/s,缓存可能失效"
# 3. Redis 内存使用率
- alert: RedisMemoryHigh
expr: |
redis_memory_used_bytes / redis_memory_max_bytes > 0.85
for: 5m
annotations:
summary: "{{ $labels.instance }} Redis 内存 > 85%,排查大 key/淘汰"
# 4. Redis 命令延迟
- alert: RedisSlowCommand
expr: rate(redis_commands_duration_seconds_sum[5m])
/ rate(redis_commands_duration_seconds_count[5m]) > 0.01
for: 5m
annotations:
summary: "{{ $labels.instance }} Redis 平均命令耗时 > 10ms,排查大 key/热 key"
# 5. 大量 key 同时过期(雪崩前兆)
- alert: RedisMassExpire
expr: rate(redis_expired_keys_total[1m]) > 5000
for: 1m
annotations:
summary: "{{ $labels.instance }} 每分钟过期 key > 5000,警惕雪崩"
优化效果
指标 治理前 治理后
=============================================================
缓存命中率 99.2%(会骤降) 99.6%(稳定)
雪崩时 MySQL QPS 6.8 万 峰值 4200
热点 key 过期回源并发 上万线程打 DB 1 线程回源
不存在 id 的请求 全部打 DB 布隆过滤器拦截 99.9%
缓存与 DB 不一致 偶发 Canal 订阅,基本消除
商品详情页可用性 99.5% 99.99%
凌晨缓存过期事故 发生过 过期时间打散后未复发
压测(商品查询 5 万 QPS + 模拟批量过期):
- 治理前:批量过期瞬间 DB 被打挂,服务雪崩
- 治理后:批量过期被随机 TTL 摊开,DB QPS 平稳,无雪崩
排查与改造:
- 事故复盘 + 定位三类缓存问题:1 天
- 过期打散 + 多级缓存 + 互斥锁回源:3 天
- 布隆过滤器 + 空值缓存防穿透:1 天
- Canal 订阅 binlog 刷缓存:2 天
- 全链路压测(含故障注入):1 天
避坑清单
- 缓存过期时间一律加随机抖动,绝不让大批 key 同一时刻失效
- 多级缓存(本地 + Redis),Redis 挂了本地缓存仍能扛热点读
- 回源 DB 处加熔断降级,DB 扛不住时返回兜底数据而非雪崩
- 热点 key 重建用互斥锁,只放一个线程回源,其余等待
- 超级热点用逻辑过期,返回短暂旧数据换回源永不阻塞用户
- 防穿透用布隆过滤器从源头拦截,空值缓存做兜底,TTL 要短
- 缓存一致性用 Cache Aside:更新 DB 后删缓存,不是更新缓存
- 并发高用延迟双删,多入口写 DB 用 Canal 订阅 binlog 刷缓存
- 热 key 用本地缓存或副本打散,大 key 要拆分、删除用 UNLINK
- 缓存命中率、回源 QPS、过期速率都要上监控,雪崩有前兆
总结
这次凌晨的缓存雪崩事故,把缓存的三个经典问题一次性全暴露了出来,也让我对"缓存"这件事有了更敬畏的认识。最大的教训是:缓存不是简单地"把数据放进 Redis"就万事大吉,它是一道横在应用和数据库之间的防线,而这道防线本身也会失效——一旦失效,所有压力会瞬间穿透到后面那个脆弱得多的数据库。雪崩、击穿、穿透,本质都是"缓存没挡住、请求打到了 DB",区别只在规模:雪崩是大批 key 同时失效,击穿是单个热点 key 失效的一瞬间,穿透是数据压根不存在导致缓存永远不命中。对应的解药也各有侧重:雪崩靠过期时间加随机抖动把失效时刻摊开,再用多级缓存和熔断兜底;击穿靠互斥锁让单个线程回源、或用逻辑过期让重建永不阻塞用户;穿透靠布隆过滤器从源头拦截不存在的请求。第二个深刻的认知是缓存一致性——很多人下意识地"更新完 DB 就更新缓存",但并发场景下这会留下旧值,正确的做法是更新 DB 后删除缓存让下次读自然重建,并发要求高就延迟双删,要彻底解耦就订阅 binlog。但必须清醒:缓存一致性只能做到最终一致,真正要强一致的数据就不该走缓存。归根结底,用缓存就是在用一致性、复杂度去换性能,这笔交易划不划算、每一个失效场景有没有兜底,都得想清楚——否则它平时帮你扛住 99% 的流量,却会在某个凌晨两点把你彻底拖下水。
—— 别看了 · 2026