Redis 缓存策略深度实战:穿透、击穿、雪崩与一致性一次性讲透(2026)

📝 313 字 · ☕ 1 分钟阅读

Redis 缓存策略深度实战:穿透、击穿、雪崩与一致性一次性讲透(2026)

凌晨一点,群里突然炸了——订单详情接口从平时 20ms 一路干到 10 秒,告警一分钟响一次。我爬起来第一件事不是改代码,是打开 Redis 看监控:命中率从 95% 掉到 30%,一堆 key 恰好同一秒过期,数据库瞬间被打满。这就是典型的缓存雪崩。

加缓存这件事人人都懂,可真到生产环境,怎么加、加错了会出什么事故、怎么保证「缓存和数据库数据是对的」,绝大多数人其实是糊的。这篇我把踩过的坑一次性写清楚:穿透、击穿、雪崩三种边界怎么区分、怎么防,以及最容易让人翻车的数据一致性究竟该怎么办。

一、先弄清楚:缓存到底在防什么

很多人的缓存就是「查不到就去数据库查,再写回 Redis」。这套 Cache-Aside 本身没错,问题在于它只防了「慢」,没防「冲击」。当请求量涨到一定程度,三个边界会依次来敲门:

Redis缓存策略示意图:缓存穿透、击穿、雪崩三种失效边界与Cache-Aside延迟双删一致性流程
三种缓存失效边界 + Cache-Aside 延迟双删一致性流程

记住这张图,下面每一节都是它的展开。

二、缓存穿透:请求打到「永远不存在」的 key

穿透指的是——有个 id=99999999 的单子,数据库里根本没有,但有人不停地用超范围 id 刷接口。每次请求都查不到缓存,于是全部打到 MySQL,数据库被这些毫无意义的查询拖死。

这是最阴的一种:它不依赖缓存失效,只要有人持续构造不存在的 key,就能把库打垮。

两种常用解法,最好组合着用:

1. 空值缓存

查库发现是空的,也往 Redis 写一条「空值」并设很短的过期(比如 60s)。这样相同的「不存在」请求进缓存就直接返回了:

# 伪代码:空值缓存
def get_order(order_id):
    val = redis.get(f"order:{order_id}")
    if val is not None:
        return None if val == b"__empty__" else val
    row = db.query(order_id)                 # 回源
    if row is None:
        redis.setex(f"order:{order_id}", 60, "__empty__")  # 缓存空值,TTL 设短
        return None
    redis.setex(f"order:{order_id}", 600, row)
    return row
坑:空值缓存会把一堆「假 key」塞进 Redis 占内存。TTL 一定要短,而且只在「确认确实不存在」时才写,别把查询异常也一起缓存了。

2. 布隆过滤器(Bloom Filter)

请求进来先过一层布隆过滤器:它能用很小内存判断「这个 key 一定不存在」。一定不存在的直接在入口拦掉,只有「可能存在」的才放行进缓存、查库。它能挡住绝大多数穿透流量,但要记住它偶尔会误判(把真实存在的判断成不存在),所以适合拦截那种「明显不存在的海量请求」。

三、缓存击穿:热点 key 过期的瞬间

击穿和穿透只差一个字,但完全是两码事。击穿说的是——一个大家都在用的热点 key(比如秒杀商品、爆款文章),恰好过期了。那一瞬间几千个请求同时没命中缓存,齐刷刷回源。一个热点就足以把库打挂。

核心思路就两个:别让所有请求同时回源

方案 A:互斥锁(单飞 / singleflight)

import redis

r = redis.Redis()

def get_hot(id):
    key = f"hot:{id}"
    val = r.get(key)
    if val is not None:
        return val
    # 只让一个线程拿锁去回源,其余等待
    if r.setnx(f"lock:{key}", "1", ex=5):   # 拿锁,5s 过期防止死锁
        try:
            row = db.query(id)
            r.setex(key, 300, row)
            return row
        finally:
            r.delete(f"lock:{key}")         # 释放锁
    else:
        time.sleep(0.05)
        return get_hot(id)                  # 简化重试,或自旋等缓存
注意:setnx 拿锁一定要设置过期(ex),否则持锁方一挂锁就永远不释放,所有请求排队到死。判断+设置必须是原子的 SET key val NX PX,别拆成「先 GET 判空再 SET」两步——那是经典死锁源。

方案 B:逻辑过期

不依赖 Redis 的 TTL 自动失效,而是在 value 里自己存「过期时间戳」:读的时候发现「逻辑上过期了」,但先返回旧值,再在后台异步刷新。用户永远拿得到数据,失效只在后台悄悄发生。适合「可以容忍极小延迟」的场景。

四、缓存雪崩:大批 key 同时失效

雪崩是击穿的放大版——不是一两个 key,而是一大片 key 在同一时间段集中过期(常见是设置了相同 TTL,或者缓存服务重启、扩容导致缓存层瞬时不可用)。数据库被「一波流」打穿。

防雪崩三板斧

# 1) 过期时间加随机抖动,避免集体到期
import random
ttl = 600 + random.randint(0, 300)   # 600~900s 随机,错峰过期
redis.setex(key, ttl, value)

# 2) 多级缓存:本地(进程内/Redis 上级) + Redis + 兜底
# 3) 限流/熔断:缓存层扛不住时先降级,别让请求直接打到 DB

我对随机抖动深有体会。之前上线商城首页,商品列表统一 setex(key, 86400) 一天后过期,结果第二天上午十点一到,首页商品缓存全部同时到期,接口 30 秒超时。改成 86400±3600 随机 TTL 后,就再没出过这问题。

五、命门:缓存和数据库的一致性

前面三种防的是「缓存没了怎么办」,这一节防的是「缓存错了怎么办」——这是最容易让团队吵起来的地方。

如果你用「先更新数据库,再删缓存」这套 Cache-Aside 更新逻辑,会撞上一个经典竞态:

  1. 线程 A 更新数据库 → 删缓存
  2. 线程 B 读:缓存没命中 → 去数据库读到旧值 → 写回缓存
  3. 结果:脏数据写进了缓存,且因为 TTL 长,脏数据能活很久

延迟双删

最朴素的解法:更新完数据库后,先删一次缓存,睡一小会儿,再删第二次。第一次删是为了让「刚刚读到的旧值」尽快失效,第二次删是为了清掉上面那步竞态里可能写回的脏值。

def update_order(order_id, data):
    db.update(order_id, data)
    redis.delete(f"order:{order_id}")   # 第一次删
    time.sleep(0.5)                      # 延迟,等潜在的旧读回写
    redis.delete(f"order:{order_id}")   # 第二次删,清掉脏数据
延迟时间怎么定?要比「读+回源写回」的耗时略长一点。经验值 300ms~1s,但必须实测——你的慢查询多长,延迟就得拖多长。而且这方案极端并发下仍不敢说 100% 可靠,只能当兜底。

更可靠:删缓存不靠代码,靠 binlog 订阅

真要彻底解决,别用「代码里删缓存」,改用 Canal/MQ 订阅 MySQL binlog,数据库一变就异步删缓存。竞态从源头消失,但架构变重,量小不一定值得。

六、一个能落地的封装

拆开讲完了,我再给你一个能直接用的 Python 封装思路,把「读缓存-回源-写缓存-防穿透-防击穿-防雪崩」串在一起:

class CacheAside:
    def __init__(self, redis, db, ttl=600):
        self.redis = redis
        self.db = db
        self.ttl = ttl

    def get(self, key, load_fn):
        val = self.redis.get(key)
        if val is not None:
            return val
        # 防击穿:单飞
        if self.redis.setnx(f"lock:{key}", "1", ex=5):
            try:
                row = load_fn()
                if row is None:
                    # 防穿透:空值短缓存
                    self.redis.setex(key, 60, "__empty__")
                    return None
                # 防雪崩:随机 TTL
                self.redis.setex(key, self.ttl + random.randint(0, 300), row)
                return row
            finally:
                self.redis.delete(f"lock:{key}")
        return self.get(key, load_fn)   # 简化:重试

真上线还得加监控:把命中率、回源次数、缓存延迟打进日志——可以用我写的结构化日志那套方案(Python 结构化日志实战),命中率掉到 70% 以下就该报警了。

常见问题(FAQ)

Q:缓存穿透和缓存击穿到底怎么分?

穿透是「查的东西本来就不存在」,靠空值缓存/布隆过滤器拦;击穿是「一个热点 key 恰好过期」,靠互斥锁/逻辑过期挡。区别在于「这个 key 是否存在」。

Q:为什么「先删缓存再更新数据库」是错的?

因为删缓存和写库之间有空窗,并发读会回源读到旧值再写回缓存,造成脏数据。正确顺序是「先更库再删缓存」,再用延迟双删兜底竞态。

Q:缓存一致性想 100% 保证,最稳妥的方案是什么?

靠 binlog 订阅(Canal/MQ)+ 异步删缓存,从数据变更源头消除竞态。代价是架构复杂,量小时用延迟双删更划算。

总结

缓存不是无脑加一句「查不到就回源」就完事。动手前先想清楚:

  • 穿透——防「不存在的海量请求」→ 空值缓存 + 布隆过滤器
  • 击穿——防「单个热点 key 过期」→ 互斥锁 / 逻辑过期
  • 雪崩——防「大批 key 同时失效」→ 随机 TTL / 多级缓存 / 限流熔断
  • 一致性——防「缓存脏数据」→ Cache-Aside + 延迟双删 / binlog 订阅

这几个边界往往不是孤立的,常常一起冒出来。真到出事故那天,把命中率监控和报警先放上去,比什么都强。

想继续深入?这几个方向值得接着看:Python @lru_cache 的三个致命陷阱(进程内缓存的坑)、FastAPI 性能调优全记录(把缓存接进真实接口)、MySQL 慢查询优化实战(数据库压力到底怎么降)、Python 并发编程选型对比(高并发下锁与异步怎么选)。

📤 分享这篇文章