先删缓存还是先删库?——缓存一致性与穿透/击穿/雪崩实战(含并发实测)
适用读者:给接口加过 Redis 缓存、被"缓存和数据库对不上""缓存一挂数据库就崩"坑过的后端工程师
技术栈:Redis 7 + Python 3.13;一致性时序推演不绑定框架,Django / Spring Boot / Go 通用
阅读时间:约 30 分钟 | 难度:中高级 | 收获:一套"缓存到底该怎么写"的决策框架 + 三种缓存故障的可复现实测 + 上线 Checklist
前言:一个"改了昵称不生效"的工单
用户改了昵称,接口返回 200,数据库里也是新昵称,可刷新页面还是旧的。客服把工单转给你,你打开 Redis 一看:
GET user:1001:nickname
"张三" # 用户明明已经改成了「三丰」
这就是
缓存一致性问题。它不是 bug,是缓存这种"用空间换时间"的架构自带的代价:
只要你在两个地方存了同一份数据,就一定存在两个地方对不上的窗口。
如果说《
Redis 分布式锁:从 SETNX 到 Redlock》解决的是"并发写同一份数据",那这一篇解决的是它的孪生问题:
同一份数据,存在缓存和数据库两个副本,怎么让它们尽量一致,以及缓存失效的那一刻系统会不会被打穿。
缓存用错,会带来三类经典故障,业界叫"三兄弟":
这篇文章按"先建立框架、再逐个拆解、最后实测"的顺序来:
目录
- 缓存的四种读写策略:先搞清楚你在用哪种
- 一致性:Cache-Aside 的四条路线与不一致窗口
- 缓存穿透:布隆过滤器 vs 缓存空值
- 缓存击穿:互斥锁 vs 逻辑过期
- 缓存雪崩:TTL 抖动 vs 多级缓存
- 三兄弟对比与决策树
- 并发实测:一段 Python 复现三种故障
- 上线 Checklist + 高频 FAQ
1. 缓存的四种读写策略:先搞清楚你在用哪种
聊一致性之前,先确定你的读写路径。缓存有四种经典模式,
90% 的业务用的是第一种,也只需要第一种。
Cache-Aside 的标准骨架,几乎每个后端都写过:
def get_article(article_id):
key = f"article:{article_id}"
cached = redis.get(key)
if cached is not None:
return json.loads(cached)
article = Article.objects.get(id=article_id) # miss 才回源
redis.set(key, json.dumps(serialize(article)), ex=300)
return serialize(article)
def update_article(article_id, data):
Article.objects.filter(id=article_id).update(**data) # 1. 先写库
redis.delete(f"article:{article_id}") # 2. 再删缓存
这套代码有两个决策点,也是面试和事故的高发区:
- 写的时候,是先操作数据库还是先操作缓存?
- 写的时候,缓存是"删除"还是"更新"?
下面逐条讲清楚。
先说结论:Cache-Aside 下,写操作应该是"先更新数据库,再删除缓存",而不是"先删缓存再更库",也不是"更新缓存"。 理由在下一章。
2. 一致性:Cache-Aside 的四条路线与不一致窗口
2.1 路线一:先更新 DB,再删除缓存(主流答案)
写请求:UPDATE db → DEL cache
读请求:GET cache → miss → GET db → SET cache
这是绝大多数系统的选择。它
不保证强一致,但把不一致窗口压到了极小的概率,且实现最简单。
为什么安全?看一个并发推演。假设缓存里没有数据(已删除),此时来一个读请求 R 和一个写请求 W:
时刻 线程 R(读) 线程 W(写) 缓存/DB 状态
────────────────────────────────────────────────────────────────
t1 GET cache → miss cache: 空
t2 UPDATE db = 新值 db: 新值
t3 GET db → 新值(读到新值) db: 新值
t4 DEL cache cache: 空
t5 SET cache = 新值 cache: 新值 ✅
结果一致。真正会出问题的,是
读请求在写请求"更库"和"删缓存"之间,读到了旧值并回填:
时刻 线程 R(读) 线程 W(写) 缓存/DB 状态
────────────────────────────────────────────────────────────────
t1 GET cache → miss cache: 空
t2 UPDATE db = 新值 db: 新值
t3 GET db → ???(读旧值快照) db: 新值
t4 DEL cache cache: 空
t5 SET cache = 旧值 ⚠️ cache: 旧值 ❌
注意 t3:只有当读请求
在 t2 之前就发起了 SELECT(拿到的是旧快照),却
在 t5 才把旧值写进缓存,才会出现"缓存里长期是旧值"。
这个窗口有多宽?取决于三件事:
- 读请求 t3 的 SELECT 是否慢(慢查询、行锁等待);
- 写请求 t2→t4 之间是否慢(同一事务里还有别的操作);
- 网络/GC 抖动。
结论:这个窗口存在,但很窄。 除非读接口有慢 SQL(这恰好是我在《
索引建了,为什么排序还是没用上?》里修过的那类问题),否则概率极低。
2.2 路线二:先删除缓存,再更新 DB(危险)
写请求:DEL cache → UPDATE db
很多教程推荐它,理由是"删了缓存,读请求就会回源拿新值"。但并发下它是
灾难:
时刻 线程 R(读) 线程 W(写) 缓存/DB 状态
────────────────────────────────────────────────────────────────
t1 DEL cache cache: 空
t2 GET cache → miss cache: 空
t3 GET db → 旧值 db: 旧值
t4 SET cache = 旧值 ⚠️ cache: 旧值
t5 UPDATE db = 新值 db: 新值
❌ 缓存旧值,DB 新值
t1 删除后、t5 更新前,只要有一个读请求进来,就会把
旧值回填进缓存。而缓存的有效期(比如 300 秒)内,所有读请求拿到的都是旧值——
不一致窗口从"毫秒级"变成了"一个 TTL"。
这就是"先删缓存"被诟病的核心:它把不一致窗口从"写操作耗时"放大到了"缓存 TTL"。
除非你的写库耗时极短、且能接受读请求回填旧值,否则不要用。
2.3 为什么是"删除"而不是"更新"缓存?
"写库 + 更新缓存"看起来更直观,其实更糟:
- 更新缓存的代价可能很大:缓存里存的往往是聚合结果(列表页、统计值),每次更新都要重算一遍,浪费 CPU;
- 并发更新顺序无法保证:A、B 两个写请求,A 先算好缓存值但网络慢,B 后算好先写入,最后 A 的值覆盖了 B —— 缓存里留下旧值;
- 删除是幂等的、便宜的:删掉就完事,下一次读自然会回源。
记住这条:"删除缓存"比"更新缓存"更安全,因为它把"值是否正确"的判断推迟到了读的时候,天然规避了写写乱序。
2.4 路线三:延迟双删(对付"先删缓存"的补丁)
如果你确实需要"先删缓存再更库"(比如为了把缓存击穿的影响前移),业界有个补丁叫
延迟双删:
def update_article(article_id, data):
redis.delete(key := f"article:{article_id}") # 1. 先删
Article.objects.filter(id=article_id).update(**data) # 2. 更库
time.sleep(0.5) # 3. 等读请求回填完
redis.delete(key) # 4. 再删一次
第 3 步的
sleep 要略大于"读请求回源 + 回填缓存"的耗时。缺点是:
sleep 让接口 RT 直接涨 0.5 秒,不适合同步接口,得丢到异步任务里;
- sleep 时长靠猜,数据库慢一点就又失效了;
- 治标不治本。
我的建议:能用"先更库再删缓存",就不要用延迟双删。 延迟双删是给历史遗留代码打的补丁,不是首选方案。
2.5 路线四:最终一致 —— binlog 订阅
前面所有方案都在"应用层"做文章,本质是
尽力而为。要真正做到最终一致,应该把"失效缓存"这件事
从业务代码里剥离出来,交给数据库日志:
业务写 DB ──► binlog ──► Canal / Debezium ──► MQ ──► 消费者删缓存
这个方案的好处:
- 业务代码只写库,不用管缓存,不会漏删(新人写新接口时忘了删缓存,是这类 bug 的头号来源);
- 解耦:缓存失效失败可以重试,有 MQ 的可靠性兜底;
- 顺序性:binlog 天然有序,不会出现"旧值覆盖新值"。
代价是
架构复杂度:要维护 Canal/Debezium、MQ、消费者,还得处理消息积压。
个人项目、中小团队,别上;中大型系统、缓存 key 分散在几十个服务里,值得上。
2.6 四条路线对比
2.7 什么时候干脆别用缓存?
这是长文必须有的诚实一节。
强一致要求 + 高并发写的场景,缓存一致性本身就是伪命题,正确做法是
不缓存或
换存储:
- 资金、库存、余额:直接读库 +
SELECT ... FOR UPDATE / 原子 SQL,或把状态放 Redis 用 Lua 保证原子(见分布式锁那篇的 6.2 节);
- 读多写少、且能容忍秒级延迟:才适合缓存,比如文章详情、用户资料、商品描述;
- 读写比 < 3:1:缓存收益抵不过一致性成本,别加。
一句话:
缓存是为"读多写少"设计的,不要拿它去扛"写多"的一致性。
3. 缓存穿透:查一个不存在的 ID
3.1 现象
GET article:999999 # 数据库里根本没有 id=999999
→ 缓存 miss
→ 查库,返回空
→ 不缓存(因为值是 null,很多人直接跳过)
→ 下次再来,缓存还是 miss
→ 查库……
正常的 id 查不到,用户不会反复请求。但
恶意攻击会:用脚本刷
id=-1、
id=999999、随机大整数,每次都不命中缓存,
每个请求都打到数据库。缓存不但没起到保护作用,反而成了"永远 miss 的收费站"。
3.2 方案一:缓存空值
最简单粗暴:
查不到也缓存,只是 TTL 短一点。
EMPTY = "<empty>"
def get_article(article_id):
key = f"article:{article_id}"
cached = redis.get(key)
if cached is not None:
return None if cached == EMPTY else json.loads(cached)
article = Article.objects.filter(id=article_id).first()
if article is None:
redis.set(key, EMPTY, ex=30) # 空值缓存 30 秒,短 TTL 防堆积
return None
redis.set(key, json.dumps(serialize(article)), ex=300)
return serialize(article)
要点:
- 空值的 TTL 要明显短于正常值(30s vs 300s),否则攻击者用海量随机 id 能把缓存撑爆;
- 如果攻击者 id 空间极大(比如 UUID),空值缓存会被"每个 id 存一条"打爆内存——这时要靠方案二。
3.3 方案二:布隆过滤器
布隆过滤器(Bloom Filter)是一个
概率型数据结构,用极少的内存回答一个问题:
"这个 key 可能存在,还是一定不存在?"
- 说"不存在"→ 一定不存在,直接拦截,不查库;
- 说"存在"→ 可能存在(有误判),放行去查库。
它用
位数组 + k 个哈希函数实现:
add(x):对 x 算 k 个哈希,把对应位数组的 k 个位置 1
query(x):对 x 算 k 个哈希,只要有一个位置是 0 → 一定不存在
手写一个,20 行:
import hashlib
import math
class BloomFilter:
def __init__(self, capacity, error_rate=0.01):
self.m = max(8, int(-(capacity * math.log(error_rate)) / (math.log(2) ** 2)))
self.k = max(1, round((self.m / capacity) * math.log(2)))
self.bits = bytearray(self.m // 8 + 1)
def _positions(self, item):
h = hashlib.sha256(str(item).encode()).digest()
h1 = int.from_bytes(h[:8], "big")
h2 = int.from_bytes(h[8:16], "big") or 1
return [(h1 + i * h2) % self.m for i in range(self.k)] # 双重哈希构造 k 个
def add(self, item):
for p in self._positions(item):
self.bits[p >> 3] |= 1 << (p & 7)
def __contains__(self, item):
return all(self.bits[p >> 3] & (1 << (p & 7)) for p in self._positions(item))
布隆过滤器的关键参数(
n = 预计元素数,
p = 期望误判率):
位数组大小 m = -n·ln(p) / (ln2)²
哈希个数 k = (m/n)·ln2 ≈ 0.693·(m/n)
用
n=1000, p=1% 算出来
m=9585 位(约 1.2 KB),
k=7。实测误判率
1.056%,与理论值 1% 几乎一致(见第 7 章)。
生产上别手写,直接用现成的:
- Redis 4.0+ 的
BF.ADD / BF.EXISTS(RedisBloom 模块),支持分布式;
- Guava
BloomFilter(Java 单机)、pybloom-live(Python)。
布隆过滤器的局限要写清楚:
数据会删除的场景(比如文章会被删),布隆过滤器会"误报存在",但不会"误报不存在"——所以删文章时把缓存也删掉,让读请求回源拿到空值并缓存,就没问题了。布隆只负责拦"从来没存在过的 id"。
3.4 方案三:参数校验 + 限流
这是最被低估的一层,也是
成本最低的一层:
def get_article(request, article_id: int):
if article_id <= 0 or article_id > 10_000_000: # 1. 边界校验
raise Http404
# 2. 接口限流:同一 IP 每秒最多 20 次(见 DRF 三件套那篇)
throttle(request, "20/s")
...
一个
id <= 0 的判断,就能挡掉大量扫描脚本。
永远不要相信客户端传来的 id。
3.5 三方案对比
生产推荐:参数校验 + 限流打底,缓存空值兜常用 id,布隆过滤器防大范围随机 id。三层叠加。
4. 缓存击穿:热点 key 失效的那一瞬间
4.1 现象
注意它和"穿透"的区别:
穿透是查不存在的数据,击穿是查存在、但特别热的数据。
一个秒杀商品,缓存 TTL 到了,或者被主动删除了。这一瞬间,
成千上万个并发请求同时 miss,同时回源查库、同时回填缓存:
热点 key 过期瞬间:
1000 个并发 ──► 全部 miss ──► 1000 个请求同时 SELECT 同一行 ──► DB 连接池打满
这就是
惊群(thundering herd)。我在第 7 章实测:300 并发下,无保护时产生了
183 次数据库查询(本该只有 1 次)。
4.2 方案一:互斥锁重建(最常用)
思路:
只让一个线程去重建缓存,其他线程等一下。锁用 Redis 的
SET NX,正是《
Redis 分布式锁》里 V2 的写法。
import time
import uuid
def get_hot_article(article_id):
key = f"article:{article_id}"
cached = redis.get(key)
if cached is not None:
return json.loads(cached)
lock_key = f"lock:rebuild:{key}"
token = uuid.uuid4().hex
if redis.set(lock_key, token, nx=True, px=5000): # 抢到锁 → 去重建
try:
cached = redis.get(key) # 双检:可能已被别人建好
if cached is not None:
return json.loads(cached)
article = Article.objects.get(id=article_id)
redis.set(key, json.dumps(serialize(article)), ex=300)
return serialize(article)
finally:
# 解锁要用 Lua 比较 token(见分布式锁那篇 4.1)
redis.eval(UNLOCK_LUA, 1, lock_key, token)
else:
time.sleep(0.05) # 没抢到 → 短暂等待后重试
return get_hot_article(article_id)
实测:300 并发下 DB 查询从
183 次降到 1 次。
代价是
吞吐下降:没抢到锁的请求要 sleep 重试,P99 会变高(实测 P95 从 20.7ms 变 20.0ms,但因为要等锁,实际业务中会略涨)。对秒杀这种场景,用"牺牲延迟换 DB 安全"完全值得。
注意:这里的锁 TTL 要短(5 秒),因为重建逻辑通常很快;且必须用 Lua 解锁,否则会误删别人的锁。
4.3 方案二:逻辑过期(高并发首选)
互斥锁的缺点是"要等"。
逻辑过期换了个思路:
缓存永不物理过期,而是在 value 里存一个逻辑过期时间。
def get_hot_article(article_id):
key = f"article:{article_id}"
raw = redis.get(key)
if raw is None:
return rebuild_and_cache(article_id) # 冷启动,只能同步重建
data = json.loads(raw)
if data["expire_at"] > time.time():
return data["payload"] # 逻辑上没过期,直接返回
# 逻辑已过期:先返回旧值,再异步刷新
if redis.set(f"lock:refresh:{key}", "1", nx=True, px=3000):
threading.Thread( # 或丢给 Celery / MQ
target=refresh_cache, args=(article_id,), daemon=True
).start()
return data["payload"] # 返回旧数据,用户无感
秒杀、大促、首页这种"宁可旧一点也不能卡"的场景,逻辑过期是更优解。
4.4 方案三:热点 key 永不物理过期
对
极少数确定的热点 key(比如首页 banner),干脆
不给 TTL,靠后台定时任务或数据变更时主动刷新:
# 后台每 5 分钟刷一次首页缓存,业务侧永不 miss
def refresh_homepage_cache():
while True:
redis.set("homepage:feed", json.dumps(build_feed()), ex=None) # 不过期
time.sleep(300)
代价是
数据变更到生效有延迟,且要保证刷新任务本身高可用(用分布式锁防重复执行——又回到那篇了)。
4.5 三种方案怎么选
热点 key 失效要不要等新值?
├─ 要(商品库存、配置)→ 互斥锁重建
└─ 不要(首页、榜单、秒杀列表)
├─ key 数量少且固定 → 永不过期 + 后台刷新
└─ key 数量多且动态 → 逻辑过期 + 异步刷新
5. 缓存雪崩:大批 key 同时失效
5.1 现象
击穿是"一个热点 key",雪崩是"
一大批 key 同时失效"。
最常见的起因:
批量预热时用了同一个 TTL。
# 凌晨预热 1 万个商品
for product in products:
redis.set(f"product:{product.id}", json.dumps(data), ex=3600) # 全是 3600 秒
这 1 万个 key 会在
同一秒集体过期。届时所有请求全部回源,DB 瞬间被打满——
缓存不但没保护 DB,还成了"定时炸弹"。
另一种起因:
Redis 实例本身挂了。所有请求瞬间打到 DB,如果没做降级,DB 直接跟着挂,形成"缓存挂 → DB 挂 → 全站挂"的雪崩链。
5.2 方案一:TTL 加随机抖动(最低成本,必做)
import random
# 基础 TTL 300 秒,额外加 0~150 秒随机
ttl = 300 + random.randint(0, 150)
redis.set(key, value, ex=ttl)
一行改动,就能把"同一秒集体过期"打散成"在 150 秒内均匀过期"。第 7 章实测:
峰值失效密度从 1000 个/秒降到 12 个/秒。
这是性价比最高的一招,任何用了缓存的系统都应该加。 记住口诀:TTL 一定要加抖动。
5.3 方案二:多级缓存
在 Redis 前面再挡一层
进程内缓存(Caffeine / Guava /
functools.lru_cache):
请求 ──► 本地缓存(进程内,纳秒级) ──► Redis(毫秒级) ──► DB
好处:
- 本地缓存命中时根本不碰网络,RT 极低;
- Redis 挂了,本地缓存还能扛一阵,给 DB 争取恢复时间。
代价:
- 本地缓存各进程独立,一致性更难保证(一个进程更新了,其他进程不知道)——只能靠短 TTL(秒级)或消息广播失效;
- 占用应用内存,要注意容量上限。
适用:
读极多、变更极少的数据,比如字典表、配置、榜单。
5.4 方案三:熔断 + 降级
即使前面都做了,也要假设"缓存一定会挂"。这时需要
熔断降级:
- 熔断:当 DB 错误率/慢查询比例超阈值,直接拒绝后续请求(快速失败),而不是让请求堆在 DB 上把它压死;
- 降级:缓存不可用时,返回兜底数据(默认值、静态页、上次的快照),而不是报错。
Java 用 Sentinel / Resilience4j,Python 可以用
pybreaker:
import pybreaker
db_breaker = pybreaker.CircuitBreaker(fail_max=5, reset_timeout=30)
def get_article_safe(article_id):
try:
return db_breaker.call(lambda: Article.objects.get(id=article_id))
except pybreaker.CircuitBreakerError:
return DEFAULT_ARTICLE # 降级:返回兜底内容,而不是 500
5.5 方案四:Redis 高可用
雪崩的"另一半"是缓存服务本身不可用。生产环境 Redis 必须做到:
- 主从 + 哨兵,或 Cluster 分片;
- 持久化(RDB + AOF),重启能快速恢复数据,避免"重启后缓存全空 → 全部回源";
- 容量监控 + 淘汰策略(
maxmemory-policy allkeys-lru),别让 Redis 被写满 OOM。
5.6 四方案对比
6. 三兄弟对比与决策树
把三者的本质摊开对比:
一张决策树收尾:
缓存出问题了?
├─ 查的是不存在的数据?
│ ├─ id 空间小、可枚举 → 缓存空值
│ └─ id 空间大、随机 → 布隆过滤器 + 限流
├─ 单个热点 key 失效,DB 被打穿?
│ ├─ 要求读到新值 → 互斥锁重建
│ └─ 可容忍短暂旧值 → 逻辑过期 + 异步刷新
└─ 大批 key 同时失效 / Redis 挂了?
├─ 同时失效 → TTL 加随机抖动(必做)
├─ Redis 挂 → 多级缓存 + 熔断降级 + 高可用
└─ 都要 → 上面全上,再加监控告警
7. 并发实测:一段 Python 复现三种故障
网上讲这三种故障的文章很多,但大多只给"结论"。我写了一段
可复现的压测脚本(
tools/bench_cache_problems.py),用进程内 TTL 缓存 + 带 20ms 延迟的 Mock DB,把三种故障的真实数字跑出来。
为什么不用真 Redis?因为穿透/击穿/雪崩是并发属性,不是 Redis 属性——它们描述的是"多少请求同时回源",与缓存用什么实现无关。用 Mock 反而更容易控制变量、复现结论。
python tools/bench_cache_problems.py --concurrency 300 --rounds 5 --db-latency-ms 20
7.1 穿透:1500 次请求,3 种防护
E1 缓存穿透:5 轮 x 300 并发,全部查不存在的 id=999999
A 无防护 DB 查询 1500 次 | P50 20.4ms P95 20.7ms P99 20.8ms
B 缓存空值 DB 查询 198 次 | P50 0.0ms P95 20.5ms P99 20.6ms
C 布隆过滤器 DB 查询 0 次 | P50 0.0ms P95 0.0ms P99 0.0ms
读法:
- 无防护:1500 个请求 → 1500 次 DB 查询,1:1,缓存完全没起作用;
- 缓存空值:降到 198 次(-87%)。注意它没降到 0——因为每轮请求的第一批并发在"空值写入缓存"之前就已经 miss 了(20ms 延迟内涌入的请求都会回源)。这说明缓存空值能防"持续性攻击",但防不住"瞬时突发";
- 布隆过滤器:0 次。因为
999999 不在过滤器里,请求在进缓存之前就被拦掉了,连缓存都不用查,P99 直接是 0.0ms。
7.2 击穿:300 并发重建一个热点 key
E2 缓存击穿:热点 key 刚过期,300 并发同时来重建
A 无互斥重建 DB 查询 183 次 | P50 20.2ms P95 20.7ms P99 20.8ms
B 互斥锁重建 DB 查询 1 次 | P50 10.5ms P95 20.0ms P99 20.4ms
读法:
- 无互斥:300 并发 → 183 次 DB 查询。本该只查 1 次的数据,被查了 183 次,放大了 183 倍。真实业务里 DB 行锁 + 慢查询叠加,这个数字会直接把连接池打满;
- 互斥锁:1 次。只放一个线程去重建,其余等待。代价是 P50 从 20.2ms 变 10.5ms(大部分线程在锁外等待/命中缓存后立即返回,反而更快),但极端情况下要等锁。
183 这个数字说明了一件事:击穿不是"多查几次",而是"并发放大"——并发越高,放大倍数越大。1000 并发时,就是 1000 次查同一行。
7.3 雪崩:1000 个 key 的失效密度
E3 缓存雪崩:1000 个 key 的失效密度(同一 1000ms 窗口内过期的数量)
A 固定 TTL 峰值 1000 个 key / 1000ms 窗口 → 瞬时回源压力约为平时的 600.0 倍
B TTL 抖动 ±50% 峰值 12 个 key / 1000ms 窗口 → 瞬时回源压力约为平时的 7.2 倍
读法:
- 固定 TTL:1000 个 key 在同一秒全部过期,瞬时回源压力是平时的 600 倍——这就是雪崩;
- TTL 抖动 ±50%:把过期时间打散到 300 秒里,峰值只剩 12 个/秒,是平时的 7.2 倍,DB 完全扛得住。
一行 random.randint,把 600 倍削到 7 倍。 这就是为什么我说"TTL 抖动是性价比最高的一招"。
7.4 布隆过滤器:误判率实测
E4 布隆过滤器:实测误判率 vs 理论值
m=9585 bits k=7 hash n=1000
理论误判率 1.000% | 实测 1.056% (2111/200000)
n=1000, p=1% 时,位数组只需
9585 位(约 1.2 KB)、7 个哈希函数。20 万次探测里误判 2111 次,实测
1.056%,与理论值 1% 吻合。
1.2 KB 拦掉 100% 的"一定不存在"请求,只漏 1% 的误判给缓存空值兜底——这就是布隆过滤器的价值。
7.5 汇总
穿透 1500 次请求:无防护 1500 次 DB → 缓存空值 198 次 → 布隆 0 次
击穿 300 并发 :无互斥 183 次 DB → 互斥锁 1 次
雪崩 1000 key :固定 TTL 峰值 1000 → TTL 抖动 12
布隆 实测误判率 1.056%
8. 上线 Checklist + 高频 FAQ
Checklist(12 项)
- [ ] 写路径是先更新 DB,再删除缓存,不是"先删缓存"也不是"更新缓存"
- [ ] 缓存 key 有统一前缀 + 版本:
v1:article:1001,大改版切 v2
- [ ] 所有 TTL 都加了随机抖动(
base + random.randint(0, base//2))
- [ ] 热点 key 有互斥重建或逻辑过期保护,不会惊群
- [ ] 查不到的 id 缓存了空值(短 TTL),或前置了布隆过滤器
- [ ] 接口有参数边界校验(
id > 0)+ 限流
- [ ] Redis 配了主从/Cluster + 持久化 +
maxmemory-policy
- [ ] DB 侧有熔断降级,缓存挂时不至于 500
- [ ] 缓存删除失败有兜底(重试 / binlog 补偿 / 短 TTL 自愈)
- [ ] 监控了缓存命中率、DB QPS 突刺、Redis 内存与连接数
- [ ] 压测过"热点 key 过期""Redis 重启""批量 key 同时过期"三种故障
- [ ] 强一致场景(资金/库存)没有用 Cache-Aside 硬扛
FAQ
Q1:先更新 DB 再删缓存,就万无一失了吗?
A:不是。存在"读请求读到旧快照、在删缓存后才回填"的窄窗口(2.1 节)。要彻底解决,靠短 TTL 自愈或 binlog 订阅。
Q2:为什么删缓存而不是更新缓存?
A:更新缓存要重算(贵),且并发写会乱序导致旧值覆盖新值;删除幂等、便宜,把正确性判断推迟到读的时候(2.3 节)。
Q3:TTL 设多长?
A:按"能容忍多久的旧数据"来设,再加随机抖动。文章详情 5 分钟、用户资料 1 分钟、字典表 1 小时,都行,
关键是加抖动。
Q4:布隆过滤器支持删除吗?
A:标准布隆不支持。需要删除就用
计数布隆过滤器(Counting Bloom),或者干脆"删除数据时同时删缓存",让读请求回源拿到空值。
Q5:本地缓存和 Redis 不一致怎么办?
A:本地缓存 TTL 设短(秒级),或变更时发 MQ 广播失效。
本地缓存只适合"读极多、变更极少"的数据。
Q6:Redis 挂了,怎么保证 DB 不挂?
A:多级缓存(本地扛一阵)+ 熔断降级(快速失败)+ 限流(削峰)。核心是
别让请求堆在 DB 上。
Q7:缓存和数据库的一致性,能做成强一致吗?
A:能,但没必要。强一致要靠分布式事务(2PC/TCC),性能和复杂度代价极高。
缓存场景的正确目标是"最终一致 + 不一致窗口足够短",不是强一致。
Q8:Django 里怎么优雅地做缓存?
A:
django.core.cache 已经封装好了
cache.get_or_set()、
cache.delete_pattern()(Redis 后端),配合
@cache_page 装饰器。但
别用 @cache_page 缓存在线用户相关页面,会串号。
结语
缓存这个话题,绕来绕去就三句话:
- 写路径:先更库、再删缓存,别先删缓存,别更新缓存;不一致窗口靠短 TTL 自愈。
- 防穿透:空值缓存 + 布隆过滤器 + 参数校验,三层叠加,别只做一层。
- 防击穿雪崩:热点 key 用互斥锁/逻辑过期,所有 TTL 都加随机抖动,缓存挂了要能降级。
回到开头那个"改了昵称不生效"的工单:如果你当初写的是
DEL cache 而不是
UPDATE cache,如果 TTL 加了抖动,如果接口校验了
id——这个工单根本不会出现。
缓存不是"加个 Redis 就完事",它是一套需要被设计、被压测、被监控的架构。 下次有人跟你说"加个缓存吧",把这篇的 Checklist 甩给他:TTL 加抖动了吗?热点 key 防惊群了吗?布隆装了吗?Redis 挂了 DB 扛得住吗?四个问题答完,才算真的"加了缓存"。