Redis 缓存雪崩把数据库打挂:穿透、击穿、雪崩与一致性治理实录

凌晨两点,一批 38 万商品缓存因统一 24h TTL 同时过期,几万 QPS 穿透到 MySQL,连接池打满,商品服务雪崩。一周缓存稳定性治理:过期时间随机抖动 + 多级缓存 + 互斥锁/逻辑过期防击穿 + 布隆过滤器防穿透 + Canal 订阅 binlog 保一致性 + 热key/大key 治理。

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 天

避坑清单

  1. 缓存过期时间一律加随机抖动,绝不让大批 key 同一时刻失效
  2. 多级缓存(本地 + Redis),Redis 挂了本地缓存仍能扛热点读
  3. 回源 DB 处加熔断降级,DB 扛不住时返回兜底数据而非雪崩
  4. 热点 key 重建用互斥锁,只放一个线程回源,其余等待
  5. 超级热点用逻辑过期,返回短暂旧数据换回源永不阻塞用户
  6. 防穿透用布隆过滤器从源头拦截,空值缓存做兜底,TTL 要短
  7. 缓存一致性用 Cache Aside:更新 DB 后删缓存,不是更新缓存
  8. 并发高用延迟双删,多入口写 DB 用 Canal 订阅 binlog 刷缓存
  9. 热 key 用本地缓存或副本打散,大 key 要拆分、删除用 UNLINK
  10. 缓存命中率、回源 QPS、过期速率都要上监控,雪崩有前兆

总结

这次凌晨的缓存雪崩事故,把缓存的三个经典问题一次性全暴露了出来,也让我对"缓存"这件事有了更敬畏的认识。最大的教训是:缓存不是简单地"把数据放进 Redis"就万事大吉,它是一道横在应用和数据库之间的防线,而这道防线本身也会失效——一旦失效,所有压力会瞬间穿透到后面那个脆弱得多的数据库。雪崩、击穿、穿透,本质都是"缓存没挡住、请求打到了 DB",区别只在规模:雪崩是大批 key 同时失效,击穿是单个热点 key 失效的一瞬间,穿透是数据压根不存在导致缓存永远不命中。对应的解药也各有侧重:雪崩靠过期时间加随机抖动把失效时刻摊开,再用多级缓存和熔断兜底;击穿靠互斥锁让单个线程回源、或用逻辑过期让重建永不阻塞用户;穿透靠布隆过滤器从源头拦截不存在的请求。第二个深刻的认知是缓存一致性——很多人下意识地"更新完 DB 就更新缓存",但并发场景下这会留下旧值,正确的做法是更新 DB 后删除缓存让下次读自然重建,并发要求高就延迟双删,要彻底解耦就订阅 binlog。但必须清醒:缓存一致性只能做到最终一致,真正要强一致的数据就不该走缓存。归根结底,用缓存就是在用一致性、复杂度去换性能,这笔交易划不划算、每一个失效场景有没有兜底,都得想清楚——否则它平时帮你扛住 99% 的流量,却会在某个凌晨两点把你彻底拖下水。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

MySQL 慢查询拖垮数据库:从一条 31 秒 SQL 说起的索引优化实录

2026-5-20 12:21:03

技术教程

秒杀被黑产刷崩了下单:Sentinel 限流、熔断、热点防护实战

2026-5-20 12:26:03

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索