命中率98%跌至23%,17条告警齐发:Redis缓存三大故障复盘

简介: 从618促销缓存雪崩事故切入,深度解析缓存穿透、击穿、雪崩的底层机制、生产级防御方案与监控告警策略,附布隆过滤器实现和分布式锁代码

大家好,我是数据库小学妹👋我踩过的坑,你别再踩。

618凌晨我值班,凌晨一点二十分,监控群同时弹出17条告警。Redis命中率从98%断崖式跌到23%。MySQL连接池直接打满。网关返回502的时间,每单支付平均慢了四秒。事后查了三个小时,根因是一批过期key的TTL设成了同一秒。同一时间全部过期,几千个请求同时打到数据库。这就是典型的缓存雪崩。

这次事故让我意识到,缓存穿透、击穿、雪崩这三个词很多教程混着讲,但底层机制完全不同,防御策略也完全不同。今天我把生产环境验证过的方案完整拆开讲,希望能帮你少走弯路少踩坑。

先说结论:三者不是一个东西

先看一张对比表,一眼分清。

维度 缓存穿透 缓存击穿 缓存雪崩
触发条件 查询不存在的key 单个热点key过期瞬间 大量key同时过期
命中情况 永远不会命中 命中瞬间归零后恢复 命中率断崖式下降
攻击面 恶意请求或脏数据 正常业务热点数据 TTL集中到期
DB压力 持续高,无效查询 瞬时打满 瞬间洪峰
核心防御 布隆过滤器+空值缓存 分布式锁+逻辑过期 TTL随机化+多级缓存
排查特征 请求分散无热点key 某个key请求量异常集中 大量key同时miss

搞混了防御手段,等于用创可贴止大动脉出血。

第一案:缓存穿透,查一个永远不存在的数据

商品表有100万条数据,有人恶意请求ID为999999999的商品。这个ID不存在,缓存里自然没有。每次请求都穿透到数据库,一万次就是万次无效查询,数据库CPU直接飙到90%。

穿透的本质是缓存层没有拦截无效请求的能力。每一次miss都变成一次数据库查询。请求量大的话数据库连接池很快被打满,正常的业务请求也被阻塞。

生产方案用布隆过滤器加空值缓存。布隆过滤器解决的是快速判断key是否存在。底层是一个二进制位数组加上多个哈希函数。元素经过多个哈希映射到位数组的多个位置。查询时只要有一个位置是0就说明不存在。误判率可调,但不会漏判。它说不存在的一定不存在。它说存在的小概率不存在。

// Guava BloomFilter 初始化
BloomFilter<String> bf = BloomFilter.create(
    Funnels.stringFunnel(Charset.forName("UTF-8")),
    1000000,  // 预期元素量
    0.01      // 误判率1%
);

// 预加载所有存在的商品ID
for (Long id : allProductIds) {
   
    bf.put(String.valueOf(id));
}

// 查询时先过布隆过滤器
public Product getProduct(Long id) {
   
    String key = "product:" + id;
    if (!bf.mightContain(String.valueOf(id))) {
   
        return null;  // 布隆说没有,直接返回
    }
    Product p = redis.get(key);
    if (p != null) return p;  // 缓存有直接返回
    p = db.getById(id);
    if (p != null) {
   
        redis.setex(key, 3600, p);
    } else {
   
        redis.setex(key, 60, "NULL");  // 数据库也没有,缓存空值
    }
    return p;
}

空值缓存的TTL设60秒到120秒。设太长的话万一新数据写入缓存还是旧的null。设太短防不住高频攻击。

我踩过的坑是布隆过滤器误判率一开始设了0.001。当时以为精度越高越好,结果内存占了好几百兆。后来改成0.01,误判率1%对缓存场景完全可接受,内存降了一个数量级。不过如果是安全敏感场景,0.001仍有必要,不能一概而论。业内推荐误判率在0.1%到5%之间就够。

还有个坑是布隆过滤器不支持删除。商品下架后位数组无法回退。我的做法是定期重建过滤器,凌晨两点全量刷一次。如果业务删除频繁,可以考虑布谷鸟过滤器,它支持删除操作,RedisBloom也原生支持。

对了,上面代码用的是Guava本地布隆过滤器。如果数据量大或者需要多实例共享,可以用RedisBloom模块。Redis 4.0之后就有这个模块,直接用BF.RESERVE、BF.ADD、BF.EXISTS命令操作。100万个ID大约只需1.2MB内存,过滤器和Redis同进程,没有额外网络开销。

第二案:缓存击穿,热点key过期的那一秒

首页推荐位有一个热点key,QPS三万,TTL设了300秒。第五分钟整key过期,同一瞬间200个线程同时发现key不在缓存。200个线程同时查数据库,连接数瞬间打满。

击穿和穿透的区别在于数据本身存在,只是缓存恰好在这个时间点过期。大量并发请求同时miss,瞬时压力比穿透更集中。

分布式锁的核心思路是key过期时只让一个线程去查数据库重建缓存,其他线程等待或返回旧值。

public Product getHotProduct(Long id) {
   
    String key = "product:" + id;
    String lockKey = "lock:product:" + id;
    Product p = redis.get(key);
    if (p != null) return p;

    boolean locked = redis.setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
    if (!locked) {
   
        Thread.sleep(100);  // 等100ms再查一次
        return redis.get(key);  // 大概率另一个线程已重建
    }
    try {
   
        p = db.getById(id);  // 拿到锁的线程查DB重建
        if (p != null) redis.setex(key, 300, p);
    } finally {
   
        redis.delete(lockKey);
    }
    return p;
}

但SETNX实现的分布式锁有个隐患。线程拿到锁后如果异常退出,锁可能不会释放。其他线程永远等不到。生产环境用Redisson。它有看门狗机制,默认30秒自动续期。线程异常退出后锁自动释放。

RLock lock = redisson.getLock(lockKey);
try {
   
    if (lock.tryLock(3, 10, TimeUnit.SECONDS)) {
   
        p = db.getById(id);
        redis.setex(key, 300, p);
    }
} finally {
   
    if (lock.isHeldByCurrentThread()) {
   
        lock.unlock();
    }
}

有些场景不允许线程等待,比如响应时间要求200ms以内。这时候用逻辑过期。缓存value里自带过期时间字段,实际Redis的TTL设成很长。查询时判断逻辑过期时间是否到了,到了就让后台线程异步重建,其他线程返回旧值。

@Data
class CacheWrapper<T> {
   
    private T data;
    private long expireTime; // 逻辑过期时间戳
}

public Product getProductWithLogicalExpire(Long id) {
   
    String key = "product:" + id;
    CacheWrapper<Product> wrapper = redis.get(key);
    if (wrapper == null) return loadAndCache(id);
    if (System.currentTimeMillis() > wrapper.getExpireTime()) {
   
        executor.submit(() -> loadAndCache(id));  // 逻辑过期,异步重建
    }
    return wrapper.getData();  // 当前返回旧值
}

这个方案零等待,缺点是可能返回过期数据,对一致性要求高的场景不适用。我当时的选择标准是用户中心用分布式锁方案要求强一致,商品详情页用逻辑过期允许秒级延迟,首页推荐用逻辑旧数据不影响体验。

第三案:缓存雪崩,TTL集中到期的连锁反应

这就是开头618事故的那一幕。促销活动期间上了5000个新商品,运营批量导入时缓存TTL统一设了3600秒。一小时整5000个key同时过期,每秒几千个miss涌向数据库。MySQL CPU从15%飙到98%,主从延迟超过30秒。

雪崩和击穿的区别在于规模。击穿是单个key,雪崩是大批key集中在同一时间点过期,压力不是一个量级。雪崩的另一个诱因是Redis节点宕机,整个缓存层不可用,所有请求直接打到数据库,这比TTL过期更致命。

TTL随机化是最基础的防御。给每个key的过期时间加上随机偏移量。

int baseTtl = 3600;
int randomTtl = baseTtl + ThreadLocalRandom.current().nextInt(300);
redis.setex(key, randomTtl, value);

偏移量设基础TTL的5%到10%。三千六百秒加三百秒随机,key的过期时间就均匀分布在六十一分钟内,不会同时到期。

多级缓存是更稳的方案。本地缓存Caffeine做第一层,Redis做第二层,数据库是最后兜底。Redis挂的时候本地缓存还能扛一阵。

public Product getMultiLevel(Long id) {
   
    String key = "product:" + id;
    Product p = localCache.getIfPresent(key);
    if (p != null) return p;  // 第一层本地缓存
    p = redis.get(key);
    if (p != null) {
   
        localCache.put(key, p);
        return p;  // 第二层Redis
    }
    p = db.getById(id);
    if (p != null) {
   
        redis.setex(key, 3600, p);
        localCache.put(key, p);  // 第三层数据库
    }
    return p;
}

降级熔断是最后的防线。用Sentinel或Resilience4j,数据库响应时间超过阈值就触发降级,直接返回兜底数据。

SentinelConfig.loadRules(List.of(
    new DegradeRule("dbQuery")
        .setGrade(CircuitBreakingStrategy.SLOW_REQUEST_RATIO)
        .setSlowRatioThreshold(0.5)
        .setTimeWindow(30)
        .setMinRequestAmount(20)
));

监控和告警:别等炸了才知道

这三个问题的共同点是发现的时候已经炸了。事后我补了这套监控。关键指标有四个。

  • 缓存命中率按小时统计,miss率突增超过10%就要告警。
  • Redis慢查询日志设10毫秒阈值。
  • 每秒miss数的QPS曲线,陡增必有异常。
  • 数据库连接池使用率超过80%就预警。
    Grafana大盘上我把这四个指标放在一起,配合Redis自带的INFO命令。instantaneous_ops_per_sec看吞吐,keyspace_hits和keyspace_misses算命中率,connected_clients看连接数。

除了监控,还有两件事要提前做。一是Redis集群高可用,生产环境建议主从加哨兵或Redis Cluster,避免单点故障直接引发雪崩。二是新业务上线前做好缓存预热,别让第一批请求直接打到数据库。

避坑清单

布隆过滤器误判率在0.1%到5%之间就够,缓存场景1%完全可接受,不是越低越好。安全敏感场景可以设更低,但普通业务没必要追求千分之一。

缓存空值的TTL要单独管理,别和业务数据用同一套策略,太长会导致新数据写入后仍然返回null,太短起不到防护作用。

Redisson看门狗默认30秒续期,业务查询耗时超过30秒锁会被其他线程抢走,慢查询场景要把watchdogTimeout调大或改用tryLock带超时参数。

TTL随机化的偏移量别超基础值的百分之十,偏移量太大会拉长数据一致性窗口。雪崩场景下别用KEYS命令排查,生产环境执行KEYS会让Redis阻塞十几秒,用SCAN代替。商品删除频繁的场景可以考虑布谷鸟过滤器替代布隆过滤器,它支持删除操作。

你们生产环境遇到过哪种缓存事故?布隆过滤器和逻辑过期你们选哪种?评论区聊聊你的方案。

我是数据库小学妹,帮你少走弯路少踩坑,咱们下篇见👋

相关文章
|
2月前
|
存储 搜索推荐 关系型数据库
纯向量库架构上线两周出事故,我帮他们重构后发现了3个选型误区
从一次生产事故出发,拆解向量数据库爆火的真实原因,深入底层索引机制和架构取舍,分析融合趋势。给从业者一个清醒的判断框架。
|
2月前
|
存储 固态存储 关系型数据库
DBA凌晨查账单:每月5万的云数据库竟有一半在空转,我的六个优化动作和数据验证
从一次真实的云数据库成本优化复盘出发,分享实例规格合理选型、冷热数据分层、存储压缩、弹性伸缩策略、清理历史数据、预留实例规划六个关键步骤,附优化前后的成本对比数据和操作要点。
|
2月前
|
存储 关系型数据库 MySQL
读写混合TPS差六倍,PostgreSQL与MySQL架构差异实测
从架构设计、索引实现、事务隔离、复制机制、运维体验五个维度深度对比PostgreSQL与MySQL,覆盖MySQL 9.0向量检索与PostgreSQL 17新特性,附权威基准数据和选型决策框架
|
2月前
|
存储 架构师 数据库
从DBA到数据架构师:技术债务管理是分水岭
“架构债”在业务快速迭代中被不断放大,最终成为系统稳定性的定时炸弹。本文从数据架构的视角出发,拆解数据库技术债务的四种典型类型,提供识别、评估和偿还的完整方法论,帮助读者从“数据库管理员”升级为“数据架构师”。
|
2月前
|
存储 固态存储 关系型数据库
从月账单5万到3.5万:云数据库成本优化的完整复盘
上云本应是降本增效,但很多企业上云之后,账单反而越滚越大。实例规格买高了、历史数据堆在SSD上、测试环境没人关、过期快照没清理——每一笔费用都在悄悄累积。本文从云账单的三大“黑洞”出发,拆解云成本失控的根因,给出实例降配、冷热数据分层、僵尸资源清理三条可落地的优化路径,帮助DBA和运维工程师用数据驱动成本优化,让每一分钱都花在刀刃上。
|
2月前
|
SQL 运维 监控
慢查询日志的“高级用法”:从找慢SQL到做容量规划
慢查询日志是DBA最熟悉的工具,但大多数人只用它来找“跑得慢的SQL”。如果只做到这一步,你只用了慢查询日志20%的价值——剩下的80%是建立性能基线、预测容量瓶颈、评估优化效果、发现潜在风险。本文从慢查询日志的进阶用法出发,讲解如何通过持续记录慢查询建立性能基线、如何通过慢查询趋势预测容量瓶颈、如何将慢查询日志从“故障排查工具”升级为“容量规划工具”,帮助读者从“出了问题再查”升级到“看着趋势主动调整”。
|
2月前
|
存储 关系型数据库 MySQL
查询从45秒降到0.3秒,存储从1.2TB缩到180GB:IoT时序数据选型复盘
5万台IoT设备日增4.3亿行数据,MySQL三天崩溃的完整复盘。从写入模型、B+树瓶颈、Gorilla压缩原理对比时序库与关系型数据库的根本差异,含宽窄表重构SQL、冷热分离迁移策略、time_bucket查询优化,以及3条实战避坑经验。
|
2月前
|
缓存 NoSQL 关系型数据库
CXL内存池化趋势:数据库架构师需要提前关注什么
CXL 3.0开始送样,4.0规范已发布,内存池化正在成为现实。从缓冲池、缓存层到存算分离,聊聊这项技术会让哪些数据库架构受益,哪些被动挨打。
|
2月前
|
SQL JSON 算法
SQL执行计划的“成本模型”:读懂cost,理解优化器为什么选这个计划
EXPLAIN能告诉你优化器选了哪个执行计划,但说不出它为什么这么选——明明有索引它却走全表扫描,明明A计划更快它却选了B计划。优化器不靠猜,它靠一套成本模型(Cost Model)做决策。本文从优化器的成本模型出发,拆解cost的构成(IO_cost、CPU_cost、memory_cost),讲解如何通过EXPLAIN FORMAT=JSON和OPTIMIZER_TRACE看到优化器的“思考过程”,并通过真实案例展示优化器“算错账”的根因,帮助读者从“知道选了谁”升级到“理解为什么选它”。
|
2月前
|
关系型数据库 MySQL 测试技术
压测前TPS 3000,调优后12000:我的MySQL压测实战复盘
从一次大促前的数据库压测实战出发,完整梳理sysbench压测全流程:环境搭建、四种测试场景设计(OLTP读写混合/只读/只写/点查)、关键指标解读(TPS/QPS/延迟分位数)、常见压测误区排查,以及从压测结果反推配置优化的实操方法。