Redis 缓存策略深度实战:穿透、击穿、雪崩与一致性一次性讲透(2026)
凌晨一点,群里突然炸了——订单详情接口从平时 20ms 一路干到 10 秒,告警一分钟响一次。我爬起来第一件事不是改代码,是打开 Redis 看监控:命中率从 95% 掉到 30%,一堆 key 恰好同一秒过期,数据库瞬间被打满。这就是典型的缓存雪崩。
加缓存这件事人人都懂,可真到生产环境,怎么加、加错了会出什么事故、怎么保证「缓存和数据库数据是对的」,绝大多数人其实是糊的。这篇我把踩过的坑一次性写清楚:穿透、击穿、雪崩三种边界怎么区分、怎么防,以及最容易让人翻车的数据一致性究竟该怎么办。
一、先弄清楚:缓存到底在防什么
很多人的缓存就是「查不到就去数据库查,再写回 Redis」。这套 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
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 更新逻辑,会撞上一个经典竞态:
- 线程 A 更新数据库 → 删缓存
- 线程 B 读:缓存没命中 → 去数据库读到旧值 → 写回缓存
- 结果:脏数据写进了缓存,且因为 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}") # 第二次删,清掉脏数据
更可靠:删缓存不靠代码,靠 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 并发编程选型对比(高并发下锁与异步怎么选)。