《热点 Key 过期那一刻:缓存击穿、穿透、雪崩的三类处理》

举报
yd_295233921 发表于 2026/11/04 15:03:20 2026/11/04
【摘要】 热点 Key 过期那一刻:缓存击穿、穿透、雪崩的三类处理去年一次线上故障,根因不在缓存变慢,而在一个商品详情 Key 过期后的 30 秒里,几百个请求同时穿透到数据库,连接池直接打满。事后复盘,击穿、穿透、雪崩是三类不同的失效,挡法也不同,混成一种处理,等于没处理。 一、三类失效,对应三类请求击穿:一个热点 Key 刚好过期,同一时刻大量请求同时发现缓存没命中,一起去查库。平时 1 次的查...

热点 Key 过期那一刻:缓存击穿、穿透、雪崩的三类处理

去年一次线上故障,根因不在缓存变慢,而在一个商品详情 Key 过期后的 30 秒里,几百个请求同时穿透到数据库,连接池直接打满。事后复盘,击穿、穿透、雪崩是三类不同的失效,挡法也不同,混成一种处理,等于没处理。

一、三类失效,对应三类请求

  • 击穿:一个热点 Key 刚好过期,同一时刻大量请求同时发现缓存没命中,一起去查库。平时 1 次的查询,过期瞬间变成几百次。
  • 穿透:请求的数据根本不存在。缓存里没有,库里也没有,每次请求都绕过缓存查库。参数是随机 id 或者恶意构造的 id 时,穿透可以被持续放大。
  • 雪崩:大批 Key 在同一时刻过期,或者缓存层整体故障,所有请求在同一时间段压到库上。单个 Key 的问题变成全站的问题。

击穿挡的是并发,穿透挡的是不存在的数据,雪崩挡的是同一时刻。一种手段挡不住三种场景。

二、击穿:只放一个请求去查库

击穿的处理是合并重复请求。第一个请求去查库,其他请求等它的结果,不要各自查一遍。下面是一段单机演示代码,用锁加二次检查实现这个合并:

import threading

cache = {}
lock = threading.Lock()

def get_or_load(key, load_from_db):
    value = cache.get(key)
    if value is not None:
        return value
    with lock:
        value = cache.get(key)  # 进锁后再查一次,前面排队的请求不用重复查库
        if value is None:
            value = load_from_db(key)
            cache[key] = value
        return value

这段代码有两个边界要注意:

  • 锁里查库没有超时上限,查库卡住时,等待的线程会一直堆着。生产环境要给锁和查库都设超时,超时后返回旧值或者降级结果,不要无限等。
  • 这是单机锁,只挡得住同一个进程里的并发。多实例部署时,每个实例各放一个请求进库,击穿没有真正挡住。多实例要用分布式锁,或者在网关层做请求合并。

合并请求省的是数据库调用次数,不是调用方的等待时间。第一个请求查库要多久,其他请求就要等多久,这个代价要认。

三、穿透:空值也要有个位置

穿透的挡法是让“不存在”也有一个可以命中的结果,分三层:

  • 参数校验:id 格式非法、范围明显越界的请求,直接拒绝,不进缓存,不进库。很多穿透本来就是脏参数,不值得查一次库来确认。
  • 空值缓存:查库确认不存在后,把空结果也写进缓存,但 TTL 要短。比如正常数据缓存 3600 秒,空值只缓存 60 秒。空值 TTL 太长,刚写入的真实数据长时间不可见;太短,穿透挡不住。
  • 布隆过滤器前置:请求过布隆过滤器,过滤器说不存在,就一定不存在,直接返回,不用查库。过滤器说存在,不一定真的存在,还要再查一次确认。新增数据要同步更新过滤器,否则新数据会被过滤器挡在外面。

空值缓存和布隆过滤器解决的是“每次都要查库确认不存在”,不是把不存在变成存在。

四、雪崩:不要让所有 Key 同一时刻过期

雪崩的根源是过期时间扎堆。三个做法:

  • TTL 加随机抖动:基础 TTL 上下浮动 10% 到 20%。比如基础 3600 秒,实际在 3240 秒到 3960 秒之间随机取。批量写入、定时任务、上线重启造成的同批 Key,不会在同一秒一起过期。
  • 逻辑过期:缓存值里带一个过期时间戳,逻辑上过期后仍然返回旧值,后台只放一个线程去刷新。这个做法只适用于读得多、对旧数据能容忍的场景,比如商品详情、文章列表。价格、库存、余额这类数据不能返回旧值。
  • 缓存层故障时限流:缓存挂了,数据库前面要有限流。宁可让部分请求排队,或者返回降级结果,也不要把全量请求一次性压到库上。库被压垮后,恢复时间比缓存故障本身长得多。

TTL 抖动防的是自己制造的扎堆,逻辑过期防的是热点 Key 的集中刷新,限流防的是缓存层整体故障。三者补的是不同的缺口。

五、本地模拟:50 个线程同时撞上过期 Key

为了看清楚合并请求的效果,我在本地跑了一组模拟,不是生产数据。参数写在前面:50 个线程在热点 Key 过期后同时请求,模拟的查库函数每次睡 0.05 秒,不模拟数据库的连接竞争和锁竞争。脚本在 cache_stampede_sim.py,两次运行结果一致:

策略 数据库调用次数 耗时
不做合并 50 次 约 0.056 秒
单机锁合并 1 次 约 0.054 秒

两点值得注意:

  1. 数据库调用次数从 50 次降到 1 次,这是合并请求的实际收益。50 个线程拿到的是同一个值,模拟里做了断言校验。
  2. 两次耗时差不多,因为模拟的查库函数没有竞争,50 次调用是并行睡过去的。真实数据库有连接数、锁、磁盘 I/O,50 个并发查询的代价远高于 1 次。这个模拟证明的是调用次数,不是墙钟时间。

六、容易踩的坑

  • 锁里查库没有超时:查库卡住,等待线程越堆越多,最后连正常请求也被拖住。
  • 把错误结果写进缓存:数据库报错、超时返回的空结果,被当成正常值缓存下来,后续请求全部命中错误结果。
  • 空值 TTL 和正常 TTL 一样长:新数据写入后,用户长时间看到“不存在”。
  • 所有 Key 用同一个 TTL:批量导入、定时刷新、上线重启都会在同一时刻制造一批过期 Key。
  • 单机锁用到多实例:每个实例各查一次库,热点 Key 过期瞬间,数据库仍然收到实例数量那么多次查询。

七、上线前检查清单

  1. 这次失效属于击穿、穿透、雪崩中的哪一类?挡法是不是对得上?
  2. 热点 Key 过期时,有没有合并重复请求?多实例下合并还生效吗?
  3. 查库不存在时,空值缓存的 TTL 是不是比正常值短得多?
  4. 所有 Key 的 TTL 有没有加抖动?同批写入的 Key 会不会同一秒过期?
  5. 锁和查库有没有超时?超时后返回的是旧值、降级结果,还是直接报错?
  6. 逻辑过期返回旧值的数据,是不是用户能接受旧值的类型?价格、库存、余额有没有排除在外?

把这六条过一遍,再决定缓存失效策略。缓存的收益在平时,风险在失效那一刻,失效策略就是缓存策略的一部分。

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

评论(0)

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

全部回复

上滑加载中

设置昵称

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

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

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