给列表页加缓存本来是为了提速。加上之后确实快了——QPS 从 60 涨到 400,然后我就去干别的了。
两周后连续出问题:
- 有个页面的缓存命中率一直是 0,加了等于没加(穿透);
- 某个热点数据过期的一瞬间,数据库 QPS 冲到平时的 20 倍,差点打挂(击穿);
- 有天下午缓存集体失效,数据库直接被冲垮,全站 502(雪崩);
- 运营改了内容,页面上还是旧的,改完半小时才生效(一致性)。
这四个问题在教科书里都有名字,但真到自己系统里出的时候,表现跟书上写的不太一样。这篇把我实际遇到的现象、排查过程和最后的代码写下来。
TL;DR:缓存问题的四个经典坑——穿透(查不存在的 key,永远回源)用空值缓存或布隆过滤器挡;击穿(热点 key 过期瞬间大量回源)用互斥锁(
cache.add做 SETNX)或逻辑过期;雪崩(大批 key 同时过期)用过期时间加随机抖动;一致性用 Cache Aside(先更新数据库、再删缓存),不要"先删缓存再更新库"。另外:Redis 不要和 Celery broker 共用一个库,大 key 和热 key 要定期扫描,纯缓存场景maxmemory-policy用allkeys-lru。
目录
- 一、先想清楚:什么值得缓存
- 二、Django 里怎么用缓存
- 三、穿透:一个永远不命中的 key
- 四、击穿:热点 key 过期的那一瞬间
- 五、雪崩:一批 key 集体失效
- 六、一致性:更新了数据库,缓存怎么办
- 七、key 怎么设计
- 八、Redis 侧:内存、大 key、热 key
- 九、实测数据
- 十、坑清单
一、先想清楚:什么值得缓存
不是所有东西都该缓存。我的判断标准(三条全中才缓存):
| 条件 | 说明 | 反例 |
|---|---|---|
| 读多写少 | 读的次数远大于写 | 每请求都变的计数器不该缓存 |
| 计算/查询代价高 | 数据库查询慢或者计算贵 | 主键查询单条记录,加缓存收益极小 |
| 能容忍短暂不一致 | 几十秒到几分钟的延迟可接受 | 库存、余额、支付状态不能缓存 |
不适合缓存的典型场景:
- 实时性要求高的数据(库存、订单状态);
- 每个用户都不一样且访问频次低的数据(缓存命中率低,白白占内存);
- 数据量极小、查询本身很快的东西(比如查一条配置,0.5ms,缓存反而多一次网络往返)。
有个简单的判断方法:看命中率。低于 50% 的缓存基本是浪费内存,要么调整 key 粒度,要么干脆去掉。
我们的命中率监控(利用 django-redis 的统计,或者自己埋点):
# 简单埋点:在统一的缓存工具里统计
import logging
from django.core.cache import cache
logger = logging.getLogger('cache')
_stats = {'hit': 0, 'miss': 0}
def cached_get(key):
val = cache.get(key)
_stats['hit' if val is not None else 'miss'] += 1
return val
上线一周后看 hit / (hit + miss),低于 0.6 的我都会复查。
二、Django 里怎么用缓存
配置(我们用的是 django-redis):
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.redis.RedisCache',
'LOCATION': 'redis://:密码@127.0.0.1:6379/2', # 用独立的 db,别跟 Celery 混
'OPTIONS': {
'client_class': 'django_redis.client.DefaultClient',
'CONNECTION_POOL_KWARGS': {'max_connections': 50},
},
'KEY_PREFIX': 'viddown',
'TIMEOUT': 300,
}
}
KEY_PREFIX 一定要设。它的作用是把所有 key 加一个前缀,好处是:
- 多个应用共用一个 Redis 时不打架;
- 需要整体失效的时候知道该删哪些(
cache.delete_pattern('viddown:*')); - 部署多套环境(测试/生产)共用一个 Redis 时不会串(虽然不推荐共用)。
常用写法:
from django.core.cache import cache
# 基础
cache.set('key', value, timeout=300)
value = cache.get('key') # 不存在返回 None
cache.delete('key')
# 不存在时才设置(原子,可当锁用)
added = cache.add('lock:xxx', '1', timeout=10) # 返回 True/False
# 拿不到就用默认值算一个并写回
value = cache.get_or_set('key', lambda: expensive_query(), timeout=300)
# 批量
cache.set_many({'a': 1, 'b': 2}, timeout=300)
cache.get_many(['a', 'b'])
# 自增(计数场景)
cache.incr('counter:xxx')
get_or_set 很好用但它不防击穿(并发下多个线程都会执行那个 expensive 函数)。防击穿见第四节。
视图级缓存:
from django.views.decorators.cache import cache_page
@cache_page(60 * 5) # 缓存 5 分钟,按 URL 做 key
def index(request):
...
@cache_page 对登录用户要小心:它会给同一个 URL 的所有用户返回同一份内容。如果页面上有用户名之类的个性化内容就串了。解决办法是用 vary_on_cookie:
from django.views.decorators.vary import vary_on_cookie
@cache_page(300)
@vary_on_cookie
def index(request):
...
或者干脆不用 cache_page,自己按用户维度做 key 更可控。我一般只在"完全公开且不变的页面"上用 cache_page。
三、穿透:一个永远不命中的 key
现象:监控发现某个接口的缓存命中率长期是 0,数据库 QPS 没降。
原因:这个接口查的是"某个 ID 的资源详情",而攻击者(或者爬虫)不断用不存在的 ID 来请求。逻辑是:
def get_detail(pk):
data = cache.get(f'detail:{pk}')
if data is None:
data = query_db(pk) # 查不到,返回 None
if data: # ← 只有查到才写缓存
cache.set(f'detail:{pk}', data, 300)
return data
不存在的数据永远不会写缓存,于是每次请求都打到数据库。如果有人用脚本循环请求不存在的 ID,数据库就一直被刷。
解法 1:空值缓存(最实用)
NULL = object() # 用一个哨兵对象表示"查过了,确实没有"
def get_detail(pk):
key = f'detail:{pk}'
data = cache.get(key)
if data is None:
data = query_db(pk) or NULL
# 空值缓存时间要短(60 秒),避免真的新增了却长时间查不到
cache.set(key, data, 60 if data is NULL else 300)
return None if data is NULL else data
关键点:
- 用哨兵对象而不是
None,因为cache.get返回None既可能是"没缓存"也可能是"缓存了空值",无法区分; - 空值的过期时间要短(30~60 秒),否则新增的数据要等很久才能查到。
解法 2:布隆过滤器(数据量极大时)
把所有存在的 ID 放进布隆过滤器,查询前先过一遍,不存在的直接返回。Redis 有 RedisBloom 模块,Python 侧可以用 pybloom-live。
我的建议:先用解法 1。布隆过滤器引入了额外的一致性问题(新增数据要同步加入过滤器),除非数据量到了千万级、空值缓存撑不住,否则不值得。
解法 3:参数校验——最容易被忽略但最有效。如果 ID 是正整数,请求 ?id=abc 或 ?id=-1 直接在入口层挡掉。
四、击穿:热点 key 过期的那一瞬间
现象:某个 key 过期后的 1~2 秒内,数据库 QPS 出现尖峰(我们观察到过 20 倍),随后恢复。
原因:这个 key 是热点(比如首页的推荐列表),QPS 很高。它过期的瞬间,所有并发请求同时发现缓存没了,同时去查数据库。
# 有问题的写法:并发下 N 个请求全部执行 expensive_query()
value = cache.get(key)
if value is None:
value = expensive_query() # ← 100 个请求同时执行
cache.set(key, value, 300)
解法 1:互斥锁(推荐)
import time
from django.core.cache import cache
def get_with_lock(key, loader, ttl=300, lock_ttl=10, wait=0.05, max_wait=3.0):
"""缓存击穿防护:只有一个请求去加载,其他等待并复用结果。"""
value = cache.get(key)
if value is not None:
return value
lock_key = f'lock:{key}'
# cache.add 是原子的(SETNX),只有第一个能拿到锁
if cache.add(lock_key, '1', timeout=lock_ttl):
try:
value = loader()
cache.set(key, value if value is not None else '', ttl)
return value
finally:
cache.delete(lock_key)
else:
# 没拿到锁:短暂等待后重试读缓存
waited = 0.0
while waited < max_wait:
time.sleep(wait)
waited += wait
value = cache.get(key)
if value is not None:
return value
# 兜底:实在等不到就自己查一次(避免死锁导致永久不可用)
return loader()
要点:
cache.add对应 Redis 的SETNX,是原子的,不会两个请求同时拿到锁;- 锁必须有过期时间(
lock_ttl),否则持锁进程挂了就是死锁; finally里释放锁;- 等待有上限,超时后自己查一次兜底(可用性优先于一致性)。
解法 2:逻辑过期(热点数据永不过期)
不给 key 设 Redis 过期,而是把过期时间存进 value 里:
import json
import time
def get_logical(key, loader, ttl=300):
raw = cache.get(key)
now = time.time()
if raw:
payload = json.loads(raw)
if payload['expire_at'] > now:
return payload['data'] # 没过期,直接返回
# 逻辑过期:返回旧值,同时异步刷新
if cache.add(f'lock:{key}', '1', timeout=10):
try:
data = loader()
cache.set(key, json.dumps({'data': data, 'expire_at': now + ttl}))
finally:
cache.delete(f'lock:{key}')
return payload['data'] # 返回旧数据,不阻塞
data = loader()
cache.set(key, json.dumps({'data': data, 'expire_at': now + ttl}))
return data
好处:用户永远不会被阻塞(拿不到锁就直接返回旧值),代价是可能短暂看到过期数据。适合"热点 + 对实时性要求不高"的场景,比如首页推荐、排行榜。
我们最后的做法:热点数据(首页、热门列表)走逻辑过期,普通数据走互斥锁。
五、雪崩:一批 key 集体失效
现象:某天下午 14:00 整,数据库 QPS 瞬间冲到 8000(平时 400),持续 3 分钟后全站 502。
原因:我们有一批数据是"每天凌晨 2 点预热 + 缓存 12 小时",还有一批是"发布时批量写入"。加上有一次批量导入时,脚本给 3000 个 key 设了完全相同的过期时间——它们在同一秒集体失效。
解法:
1. 过期时间加随机抖动(最简单有效)
import random
def set_with_jitter(key, value, base_ttl=300):
ttl = base_ttl + random.randint(0, base_ttl // 5) # ±20% 抖动
cache.set(key, value, ttl)
300 秒的 key,实际过期时间在 300~360 秒之间随机分布,3000 个 key 就散开在 60 秒的窗口里,数据库压力被摊平。
我们的规范要求:所有批量写入缓存的地方,必须加抖动。 现在我写 cache.set 几乎都走封装函数,不直接写死 timeout。
2. 多级缓存(本地内存 + Redis)
from django.core.cache import cache
from django.core.cache.backends.locmem import LocMemCache
local = LocMemCache('local', {'OPTIONS': {'MAX_ENTRIES': 1000}})
def get_two_level(key, loader, ttl=300):
v = local.get(key) # 一级:进程内,无网络开销
if v is None:
v = cache.get(key) # 二级:Redis
if v is None:
v = loader()
cache.set(key, v, ttl + random.randint(0, 60))
local.set(key, v, min(60, ttl)) # 本地短一点,避免太旧
return v
即使 Redis 全部失效,本地缓存还能扛一波。代价是本地缓存不一致窗口更长、多进程之间不一致。只在"能容忍不一致"的数据上用。
3. 缓存预热
在业务低峰(凌晨)主动把热点数据加载进缓存,而不是等用户访问时才加载。我们有个 beat 任务做这件事(并且预热时也加抖动)。
4. 兜底:数据库侧的保护
即使雪崩发生了,也要保证数据库不被打死:
- 前面那篇讲的
statement_timeout; - 数据库的
max_connections留余量; - 应用层的并发闸门(限制同时回源的线程数)。
六、一致性:更新了数据库,缓存怎么办
这是最容易被忽略、也最容易出线上问题的一节。
先说结论:用 Cache Aside,且顺序是"先更新数据库,再删缓存"
def update_resource(pk, **kwargs):
# 1. 更新数据库
obj = Resource.objects.get(pk=pk)
for k, v in kwargs.items():
setattr(obj, k, v)
obj.save()
# 2. 删除缓存(不是更新缓存!)
cache.delete(f'detail:{pk}')
为什么是"删"而不是"更新"缓存?
因为更新缓存的代价更高且更容易出错:你可能在缓存里写了一个和数据库不完全一致的对象(比如漏了某个字段),或者并发写入导致顺序错乱。删掉最简单,下次读的时候自然重建。
为什么是"先数据库,后缓存"?
看反过来的顺序会出什么事:
请求 A(更新):删除缓存 → 更新数据库(还没完成)
请求 B(读取):缓存没了 → 查数据库(拿到旧值!)→ 写入缓存(旧值)
请求 A(更新):更新数据库完成
结果:数据库是新值,缓存是旧值 → 永久不一致,直到缓存过期
而"先数据库后删缓存":
请求 A(更新):更新数据库 → 删除缓存
请求 B(读取):缓存有旧值 → 返回旧值(短暂不一致)
请求 C(读取):缓存没了 → 查数据库(新值)→ 写缓存(新值)
结果:只有一个很短的窗口会返回旧值,之后自动恢复
"先库后删"只在一种情况下出问题:删除缓存失败(网络抖动)。所以生产上我会加一次延迟双删:
import threading
import time
def update_resource(pk, **kwargs):
obj = Resource.objects.get(pk=pk)
for k, v in kwargs.items():
setattr(obj, k, v)
obj.save()
cache.delete(f'detail:{pk}')
# 延迟双删:500ms 后再删一次,清掉"删缓存期间被写进去的旧值"
threading.Timer(0.5, lambda: cache.delete(f'detail:{pk}')).start()
用一个后台线程(或者扔给 Celery 的 apply_async(countdown=1))延迟几百毫秒再删一次。这是个低成本的保险,能覆盖掉大部分并发窗口。
批量更新怎么办
如果一个操作影响了很多 key(比如改了一个分类,下面 500 篇文章的缓存都要失效),逐个删会很慢。用模式删除:
from django.core.cache import cache
# django-redis 提供(需要 DefaultClient)
cache.delete_pattern('viddown:detail:*')
delete_pattern 内部是 SCAN + 删除,不会阻塞 Redis(不像 KEYS)。但数据量大时依然有开销,别在请求里做——扔给异步任务。
另一个思路:版本号前缀。改分类不删 key,而是把版本号 +1,key 变成 v2:detail:123,旧的自然过期。彻底避免批量删除。
def cache_key(kind, pk):
version = cache.get(f'version:{kind}', 1)
return f'v{version}:{kind}:{pk}'
适合"整类数据一起失效"的场景,比 delete_pattern 优雅得多。
七、key 怎么设计
几条规范(踩过坑之后的):
<前缀>:<业务>:<标识>[:<子标识>]
viddown:detail:12345
viddown:list:page:2:tag:ffmpeg
viddown:user:99:quota
- 统一前缀(
KEY_PREFIX或手动),便于整体管理和删除; - 不要有空格和特殊字符(Redis 无所谓,但调试和
delete_pattern时麻烦); - key 不要太长(占内存,且
MEMORY USAGE会变大)。用 ID 而不是完整标题; - value 不要太大(见下节大 key);
- 版本化:结构变更时换 key 前缀,避免旧数据解析失败。
序列化方面,django-redis 默认用 pickle:
'OPTIONS': {
'client_class': 'django_redis.client.DefaultClient',
'SERIALIZER': 'django_redis.serializers.json.JSONSerializer', # 可选
}
pickle 能存任意 Python 对象,但有安全风险(反序列化可执行代码,如果 Redis 被入侵);json 更安全、跨语言可读,但只能存基础类型。我们用的默认 pickle,因为要缓存一些模型对象和 datetime;如果你的 Redis 暴露在不太可信的网络里,换成 json。
八、Redis 侧:内存、大 key、热 key
内存与淘汰策略
maxmemory 2gb
maxmemory-policy allkeys-lru
纯缓存场景用 allkeys-lru:内存满了淘汰最久没用的 key,不管它有没有设过期时间。
但如果这个 Redis 还存了别的东西(比如 Celery 的 broker、或者持久化的业务数据),绝不能用 allkeys-lru——它会把你的任务消息或者持久数据淘汰掉。这时候用 volatile-lru(只淘汰设了过期时间的 key)。
我们的做法:缓存和 Celery broker 用不同的 db index,但同一个 Redis 进程。这样 maxmemory-policy 只能设一个,所以要保守(用 volatile-lru,并且给缓存 key 都设过期时间)。更稳妥是分开两个 Redis 实例——量不大的时候一个实例两个 db 够用,但要知道风险。
绝对不要做的事:在存了 Celery 队列的 db 上执行 FLUSHDB。 我见过有人为了清缓存 redis-cli -n 0 FLUSHDB,结果把几千个待处理任务清掉了。
大 key
单个 key 的 value 太大(比如缓存了一个 10MB 的列表)会导致:
- 读取时网络传输慢,阻塞其他请求;
- 删除时(尤其是集合类)卡顿;
- 内存碎片。
扫描:
redis-cli --bigkeys
redis-cli MEMORY USAGE viddown:list:xxx
我们的规范:单个 value 不超过 1MB,集合元素不超过 1 万个。 超了就拆(分页缓存)或者改存数据库。
热 key
某个 key 被极高频率访问(比如首页 banner),会打满单个 Redis 实例的 CPU(Redis 单线程)。
发现办法:
redis-cli --hotkeys(需要maxmemory-policy是 LFU 类);- 客户端埋点统计。
缓解:
- 本地缓存(前面讲的多级缓存);
- 拆成多个 key(比如
banner:1~banner:10,随机读一个),把压力分散(Redis Cluster 下能分散到不同节点)。
九、实测数据
优化前后的对比(列表页接口):
| 指标 | 无缓存 | 朴素缓存 | 加防护后 |
|---|---|---|---|
| QPS | 62 | 410 | 405 |
| P95 延迟 | 780 ms | 45 ms | 48 ms |
| 数据库 QPS | 62 | 15 | 8 |
| 缓存命中率 | — | 87% | 96% |
| 击穿尖峰(过期瞬间 DB QPS) | — | 1200+ | 22 |
| 雪崩事故 | — | 2 次 | 0 |
朴素缓存(不加任何防护)的时候,虽然平均性能很好,但过期瞬间有 1200 QPS 的尖峰打向数据库。加了互斥锁和抖动之后,尖峰降到 22。
这个对比说明一件事:缓存的"平均效果"好看不代表系统稳。真正的风险在那些瞬间的尖峰。
十、坑清单
cache.get返回None分不清"没缓存"和"缓存了空值" → 用哨兵对象。- 查不到就不写缓存 → 穿透,一直打到数据库。空值缓存(短 TTL)。
get_or_set以为能防击穿 → 不能,并发下都会执行 loader。要加锁。- 锁没有过期时间 → 持锁进程挂了就死锁。
- 批量写缓存用相同 TTL → 雪崩。加随机抖动。
- 先删缓存再更新数据库 → 并发下写入旧值,永久不一致。改成先库后删。
- 删除缓存失败没有补偿 → 延迟双删。
- 批量失效用
KEYS命令 → 阻塞 Redis。用SCAN(delete_pattern)。 - 缓存和 Celery 共用一个 db,然后
FLUSHDB→ 任务全没了。 - 纯缓存场景没设
maxmemory→ Redis 内存无限增长直到被 OOM kill。 - 混用场景用了
allkeys-lru→ 淘汰掉不该淘汰的持久数据/队列消息。 - 缓存了大对象(几 MB) → 网络阻塞、删除卡顿。拆分页。
@cache_page用在个性化页面 → 所有用户看到同一份内容。加vary_on_cookie或自己控制 key。- 不监控命中率 → 加了等于没加也不知道。
- 缓存了不该缓存的(库存、余额、权限) → 数据错误,比慢更严重。
- key 没前缀 → 多应用冲突、无法批量清理。
最后说说我对缓存的整体看法。
缓存是"用一致性换性能"的交易,所以每次加缓存都应该明确回答两个问题:
- 这个数据能容忍多久的延迟?(决定 TTL)
- 如果缓存挂了/被清空,系统会怎样?(决定要不要熔断保护)
第一个问题决定 TTL 设多长——我见过 TTL 设 24 小时的"配置缓存",改个配置要等一天才生效,这种就是没想清楚。
第二个问题更重要:很多系统加缓存之后,就变成了"依赖缓存才能扛住"。一旦 Redis 挂了或者被清空,数据库瞬间被打垮。所以要么保证数据库能扛住全量(哪怕慢一点),要么在缓存失效时有限流/熔断保护。别让缓存成为系统的单点。
我们现在的做法是:所有回源路径都经过限流闸门(下一节会讲),即使缓存全丢,数据库收到的请求也是被限制过的,最多是变慢,不会挂。这个设计让我们在两次 Redis 重启中都没有出事故。