缓存穿透、击穿、雪崩怎么防:基于华为云 DCS for Redis 的代码级实践与压测验证

举报
yd_292302676 发表于 2026/11/02 09:18:00 2026/11/02
【摘要】 从一次命中率断崖下跌的故障出发,区分缓存穿透、击穿、雪崩的成因,给出空值缓存、布隆过滤器、互斥锁重建、逻辑过期、TTL 打散的代码实现,并整理 DCS for Redis 的实例选型、热 key 与大 key 排查要点和压测对比数据。

问题的起点:缓存不是加了就安全

一个促销日的晚上,商品详情页的 QPS 从 3000 涨到 12000,Redis 命中率从 99.2% 掉到 61%,下游数据库连接数在四分钟内打满,接口超时率冲到 30%。事后复盘,真正压垮系统的不是流量,而是三件被忽略的小事:一个不存在的商品 ID 被爬虫反复请求、一个热点 key 在同一秒过期、一批 key 的 TTL 被写成了同一个数值。这三种情况分别对应缓存穿透、缓存击穿和缓存雪崩。它们经常被混为一谈,但成因和防线完全不同,用错方案等于没做。

三类问题的本质区别

先把概念对齐,后面的方案才不会串味。判断标准很简单:问一句"这个请求最终有没有落到数据库上"。

问题 触发条件 典型现象 主要防线
穿透 查询的数据在缓存和数据库里都不存在 命中率低但数据库 QPS 高,慢查询集中在主键等值查询 空值缓存 + 布隆过滤器 + 参数校验
击穿 单个热点 key 过期瞬间,大量并发同时回源 某一秒数据库出现尖峰,随后迅速回落 互斥锁重建 + 逻辑过期 + 热点永不过期
雪崩 大批 key 同时过期,或缓存实例整体不可用 数据库持续高负载,命中率断崖式下跌 TTL 打散 + 多级缓存 + 熔断限流

缓存穿透:让不存在的 key 也付出代价

穿透的根源是"查不到"这个结果没有被缓存下来。每次请求都穿透到数据库,而数据库返回空,缓存里依然什么都没有,下一次请求继续穿透。爬虫只要换着 ID 扫一遍,就能把数据库打穿。

第一道防线是最便宜的:在入口把明显非法的参数挡掉。自增主键必须是正整数,长度超过 20 位的 ID 直接返回参数错误,这一步能过滤掉相当一部分扫描流量。第二道防线是空值缓存,把"不存在"也当成一种结果写进缓存,但 TTL 要短,通常 30 到 60 秒,避免数据后来被创建却一直读不到。

public Product getProduct(Long id) {
    String key = "product:" + id;
    String cached = redis.get(key);
    if (cached != null) {
        // 用特殊标记表示"确认不存在",与"缓存未命中"区分开
        return EMPTY_FLAG.equals(cached) ? null : JSON.parseObject(cached, Product.class);
    }
    Product p = productMapper.selectById(id);
    if (p == null) {
        redis.setex(key, 45, EMPTY_FLAG);   // 空值缓存,TTL 要短
        return null;
    }
    redis.setex(key, 1800 + ThreadLocalRandom.current().nextInt(300), JSON.toJSONString(p));
    return p;
}

空值缓存防不住"每次 ID 都不同"的恶意扫描,因为缓存会被大量无意义的 key 撑爆。这时候需要布隆过滤器在前面挡一层:把所有合法商品 ID 预先写进过滤器,请求进来先判断"可能存在",判定为不存在就直接返回。注意布隆过滤器的语义是"可能存在或一定不存在",它只会误判存在,不会漏判,所以放在缓存之前是安全的。新增商品时需要同步写入过滤器,或者用定时任务按增量重建。

缓存击穿:热点 key 过期的那一秒

击穿只发生在热点 key 上。一个被每秒请求上万次的 key 一旦过期,这一瞬间所有请求都发现缓存没了,于是一起去查数据库。数据库扛不住的不是持续压力,而是这个瞬间的并发尖峰。

最常用的解法是互斥锁重建:只允许一个线程去回源,其余线程短暂等待后重读缓存。

public Product getWithMutex(Long id) {
    String key = "product:" + id;
    Product p = readFromCache(key);
    if (p != null) return p;

    String lockKey = "lock:product:" + id;
    // SET NX EX 保证原子性,value 用请求标识便于安全释放
    boolean locked = redis.set(lockKey, requestId, "NX", "EX", 10);
    if (!locked) {
        sleep(50);
        return readFromCache(key);   // 拿不到锁就等一小会儿再读,不要自旋太久
    }
    try {
        p = readFromCache(key);      // 双重检查,避免重复回源
        if (p != null) return p;
        p = productMapper.selectById(id);
        if (p != null) redis.setex(key, 1800, JSON.toJSONString(p));
        return p;
    } finally {
        // 只有自己加的锁才删,防止误删别人的锁
        if (requestId.equals(redis.get(lockKey))) redis.del(lockKey);
    }
}

互斥锁的代价是牺牲了一部分可用性:持锁线程如果卡在数据库上,等待的线程会堆积。所以锁的过期时间要给足但不能太长,等待方要有超时降级,超时后直接返回旧值或兜底数据,而不是无限等。

对极致热点,更彻底的做法是逻辑过期:缓存里存的不是业务对象而是"数据 + 过期时间戳",物理上永不过期。读的时候发现逻辑过期,就异步起一个线程去重建,当前请求先返回旧数据。这样任何时刻都不会有请求阻塞在回源上,代价是短时间内的数据不一致,对商品详情这类场景通常可以接受。

缓存雪崩:不要把 TTL 写成同一个数

雪崩有两种形态。一种是大批 key 因为同一批预热任务而拥有相同的过期时间,到点集体失效;另一种是缓存实例本身出问题,所有 key 一起不可用。前者的解法是打散,后者的解法是兜底。

打散的做法很简单,在基础 TTL 上叠加一个随机偏移,比如 30 分钟的基础过期时间加 0 到 5 分钟的随机数,让失效时间在时间轴上均匀铺开。批量预热时尤其要注意,别用同一个循环里的固定 TTL。

// 打散 TTL:基础值 + 随机偏移
int ttl = 1800 + ThreadLocalRandom.current().nextInt(300);
redis.setex(key, ttl, value);

实例级故障无法靠 TTL 解决,只能靠架构。常见的组合是本地缓存加分布式缓存的两级结构:本地 Caffeine 挡掉绝大部分读请求,即使 Redis 整体不可用,热点数据仍在进程内。同时要有熔断和限流,数据库连接池被打满之前就该拒绝掉一部分请求,保住核心链路。降级策略要提前写好并且演练过,而不是出事时临时加。

在 DCS for Redis 上的落地细节

方案落到具体产品上,有几个参数值得单独拿出来说。

实例选型。主备版适合数据量不大、以读为主的场景,故障时自动切换;集群版适合单实例内存逼近上限或写入量大的场景。要注意集群版下多 key 操作需要落在同一个 slot,用 hash tag 把相关 key 绑在一起,否则跨 slot 的命令会直接报错。

热 key 与大 key。热 key 会让单个分片的 CPU 打满,集群扩容也救不了。排查方式是开慢查询日志并结合监控里的分片 QPS 分布,定位到具体 key 后做本地缓存或拆分成多个副本 key 分散读压力。大 key 的危害是删除和过期时的阻塞,单个 String 建议控制在 10KB 以内,集合类型的元素数量控制在五千以内,删除大 key 用 UNLINK 而不是 DEL。

慢查询与连接数。把 slowlog 阈值调到 10 毫秒,每天扫一遍,很多问题在爆发前就能看到。连接数要设上限并配合连接池,客户端侧避免每次请求新建连接。

监控指标。命中率、每秒过期 key 数量、被驱逐 key 数量、内存使用率、主从延迟这五个指标最能反映缓存健康度。命中率突然下跌通常意味着大批 key 失效或者有穿透流量;被驱逐数量上升说明内存不够,需要考虑扩容或调整淘汰策略。

压测验证:数据比感觉可靠

方案写完必须压测,否则不知道防线是否真的生效。用固定并发对同一个热点 key 施压,分别在"无保护"和"加互斥锁"两种配置下记录数据库 QPS 峰值。

场景 并发 数据库 QPS 峰值 P99 延迟
热点 key 过期,无保护 500 约 4200 1800 ms
热点 key 过期,互斥锁重建 500 约 12 210 ms
随机不存在 ID,无空值缓存 200 约 2000 950 ms
随机不存在 ID,空值缓存 + 布隆过滤器 200 约 3 35 ms

数据说明一件事:防线的作用不是让系统变快,而是把数据库从"被打穿"变成"几乎没感觉"。互斥锁把回源次数从数千次压到个位数,代价是延迟从毫秒级变成百毫秒级,这个交换在绝大多数业务里是划算的。

一份可以直接抄的检查清单

  • 所有查询入口都做了参数合法性校验,非法 ID 不进入缓存层
  • 查不到的数据写空值缓存,TTL 控制在 60 秒以内
  • 高频扫描风险高的接口前置布隆过滤器,并考虑增量同步机制
  • 热点 key 使用互斥锁重建或逻辑过期,锁的释放要校验持有者
  • 所有 TTL 都带随机偏移,批量预热时尤其注意
  • 本地缓存作为第二层,Redis 故障时仍能服务热点数据
  • 限流与熔断阈值经过压测验证,不是拍脑袋定的数字
  • 慢查询、命中率、驱逐数量、主从延迟四项纳入日常监控告警

缓存问题的共同点是平时不显形,只在流量高峰或数据分布异常时集中爆发。把上面这些防线提前铺好,比出事之后通宵扩容要便宜得多。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。