本节目标:把「加个缓存」从一句口号拆成可落地的设计——选对缓存层次与读写模式,设计出能安全失效的键,并为穿透、击穿、雪崩和并发写入备好对策。
适用版本:Python 3.12+(实测 3.14.6);fakeredis 2.39.0
8.1 Redis 缓存层次与键设计
7.3 节我们让 API 在版本演进中保持向后兼容,那是接口层的稳定性。但接口稳定了,流量一大,压力会原样穿透到数据库。缓存的职责就是在这条链路上挡下一部分读请求——它看起来只是「存一份、读一份」,真正做起来却处处是取舍:缓存放哪一层?写的时候先动缓存还是先动库?键叫什么名字?多久过期?缓存挂了系统是崩还是慢?
这一节不讲 Redis 命令大全,只讲决策。所有代码都用 fakeredis 实测(本机没有 Redis 服务端,fakeredis 2.39.0 是纯 Python 的内存实现,客户端 API 与 redis-py 兼容,GET / SET / EXPIRE / pipeline 等都可真跑)。
8.1.1 缓存层次:本地还是分布式
第一层决策是「缓存放哪里」。最常见的是两级缓存:进程内一份本地缓存兜最热的键,进程外一份分布式缓存兜全量。
| 维度 | 本地缓存(进程内 dict / lru_cache) | 分布式缓存(Redis) |
|---|---|---|
| 访问延迟 | 纳秒级,零网络 | 亚毫秒级,一次网络往返 |
| 容量 | 受单进程内存限制 | 可独立扩容,跨进程共享 |
| 一致性 | 多实例之间各自为政,难以同步失效 | 一处写入,全体可见 |
| 适用数据 | 配置、字典表、几乎不变的热点 | 会话、业务实体、需要跨实例共享的数据 |
关键认知:本地缓存的一致性代价很高。有 N 个进程就有 N 份副本,一次失效要通知到所有进程。因此本地缓存只该放「变不变都无所谓」或「变更频率极低」的数据;一旦数据会频繁更新,就用分布式缓存。
两级缓存的读路径是「先本地、后 Redis、最后回源」,写路径则要两级一起失效,否则本地那份会一直返回旧值:
import json
import fakeredis
redis = fakeredis.FakeRedis(decode_responses=True)
_local: dict[str, dict] = {} # 进程内一级缓存
def get_profile(uid: int) -> dict:
key = f"app:v1:user:{uid}"
if key in _local: # 1. 本地命中,零网络开销
return _local[key]
cached = redis.get(key) # 2. 分布式缓存
if cached is not None:
_local[key] = json.loads(cached)
return _local[key]
row = {"id": uid, "name": f"user{uid}"} # 3. 回源
redis.set(key, json.dumps(row), ex=300)
_local[key] = row
return row
def invalidate(uid: int) -> None:
key = f"app:v1:user:{uid}"
_local.pop(key, None) # 两级都要清
redis.delete(key)
print(get_profile(7), "本地命中:", "app:v1:user:7" in _local)
invalidate(7)
print("失效后 本地:", "app:v1:user:7" in _local, " Redis:", redis.exists("app:v1:user:7"))
{'id': 7, 'name': 'user7'} 本地命中: True
失效后 本地: False Redis: 0
注意 invalidate 里本地和 Redis 必须同时清。只清 Redis 的代码在多实例下会「看起来正常」——因为你本地那份还在——直到另一个实例读到旧值,问题才暴露。
8.1.2 Cache-Aside:读路径的标准答案
Cache-Aside(旁路缓存)是最主流的模式:应用自己管缓存,读时先查缓存、未命中再查库并回填。Redis 对应用是透明的,没有「缓存和数据库谁先写」的魔法。
import json
import fakeredis
redis = fakeredis.FakeRedis(decode_responses=True)
DB = {"user:42": {"id": 42, "name": "Ada", "plan": "pro"}}
db_calls = 0
def db_get_user(uid: int) -> dict:
global db_calls
db_calls += 1
return DB.get(f"user:{uid}", {})
def get_user(uid: int) -> dict:
key = f"app:v1:user:{uid}"
cached = redis.get(key) # 1. 查缓存
if cached is not None:
return json.loads(cached) # 命中
row = db_get_user(uid) # 2. 未命中,回源
redis.set(key, json.dumps(row), ex=300) # 3. 回填,TTL 300s
return row
print("第一次:", get_user(42), "db_calls =", db_calls)
print("第二次:", get_user(42), "db_calls =", db_calls)
print("TTL(s):", redis.ttl("app:v1:user:42"))
第一次: {'id': 42, 'name': 'Ada', 'plan': 'pro'} db_calls = 1
第二次: {'id': 42, 'name': 'Ada', 'plan': 'pro'} db_calls = 1
TTL(s): 300
两次读,db_calls 始终是 1——第二次直接命中缓存。回填时一定带 TTL,这是 Cache-Aside 的纪律:没有 TTL 的缓存就是一份永不失效的脏数据。
8.1.3 写路径:Write-Through 与 Write-Behind
读路径简单,麻烦的是写。写的时候,缓存和数据库谁先动?三种主流策略的取舍如下:
| 策略 | 写入顺序 | 一致性 | 写延迟 | 适用场景 |
|---|---|---|---|---|
| Cache-Aside(旁路) | 写库,再删缓存 | 最终一致 | 低 | 读多写少,最常用 |
| Write-Through(写穿) | 写库 + 同步写缓存 | 较强 | 高(多一次写缓存) | 写后必读、要求低延迟读 |
| Write-Behind(写回) | 先写缓存,异步刷库 | 弱(有丢失窗口) | 极低 | 写吞吐极高、可容忍短暂不一致 |
Cache-Aside 的写操作是「删缓存」而不是「更新缓存」,这是最容易写错的一点。原因:并发更新时,两个写请求更新缓存的顺序可能与它们写库的顺序相反,导致缓存里留下旧值;而「删缓存」让下一次读自然回源,天然避免了这个竞态。删除失败怎么办?配一个短 TTL 兜底,让脏值最多存活 TTL 秒。
Write-Through 适合「写完立刻要被读到」的场景,代价是每次写都要等两次写(库 + 缓存)。Write-Behind 把刷库做成异步,写延迟最低,但缓存进程崩溃时未刷盘的数据会丢——只有业务能容忍这种丢失时才用。
8.1.4 键命名与命名空间
键名不是随便起的字符串,它是运维的接口。一套可维护的键命名至少包含四段:<应用>:<版本>:<实体>:<标识>。例如 app:v1:user:42。
| 段 | 作用 | 示例 |
|---|---|---|
| 应用名 | 多业务共用一套 Redis 时隔离 | app |
| 版本 | 结构变更时整体换命名空间,旧键自然过期 | v1 |
| 实体 | 一眼看出存的是什么 | user |
| 标识 | 主键或参数 | 42 |
v1 这一段在实战里价值极高:当 user 的 JSON 结构要加字段时,直接升到 v2,新键写新结构,旧键随 TTL 淘汰,不需要写迁移脚本。批量读取用 pipeline 一次往返,避免 N 次网络开销:
redis.set("app:v1:cfg:theme", "dark")
redis.set("app:v1:cfg:lang", "zh")
pipe = redis.pipeline()
for k in ("app:v1:cfg:theme", "app:v1:cfg:lang"):
pipe.get(k)
print("pipeline 批量取值:", pipe.execute())
pipeline 批量取值: ['dark', 'zh']
需要按前缀整体失效时,不要用 KEYS——它会阻塞整个 Redis 实例。用 SCAN 游标分批遍历:
deleted = 0
for key in redis.scan_iter(match="app:v1:user:*"):
deleted += redis.delete(key)
print("按前缀失效删除:", deleted, "键")
按前缀失效删除: 1 键
SCAN 只保证「遍历期间一直存在的键一定会返回」,不保证返回的键在遍历时仍然存在,也不保证不重复——这正好够用:删不存在的键是幂等的。
8.1.5 TTL 与淘汰策略
TTL 是缓存的第一道防线,但光有 TTL 不够:内存总会满。满了以后 Redis 按 maxmemory-policy 决定淘汰谁:
| 策略 | 淘汰范围 | 适合 |
|---|---|---|
noeviction | 不淘汰,写直接报错 | 当作数据库用(不推荐做缓存) |
allkeys-lru | 所有键里挑最久未用的 | 纯缓存场景首选 |
volatile-lru | 只淘汰设了 TTL 的键 | 同实例混放持久数据与缓存 |
用 ttl 可以随时查看一个键还剩多久过期:返回 -1 表示键存在但没有过期时间,-2 表示键不存在。缓存键出现 -1 要警惕,多半是回填时忘了传 ex。
8.1.6 穿透、击穿、雪崩:三类故障与对策
这三个词常被混用,其实指向三种不同的故障,对策也完全不同:
| 故障 | 成因 | 现象 | 对策 |
|---|---|---|---|
| 穿透 | 查不存在的键,缓存永远不命中 | 请求直打数据库 | 空值占位(短 TTL)、布隆过滤器 |
| 击穿 | 热点键过期的瞬间并发回源 | 单个热点打爆数据库 | 单飞(互斥回源)、逻辑过期 |
| 雪崩 | 大批键同时过期或 Redis 宕机 | 数据库整体被打垮 | TTL 加随机抖动、多级缓存、熔断降级 |
穿透的典型场景是恶意扫不存在的 ID。对策是「把不存在也缓存起来」,用一个占位符挡住后续请求:
NULL = "__NULL__"
db_hits = 0
def get_with_null_cache(uid):
global db_hits
key = f"app:v1:user:{uid}"
v = redis.get(key)
if v is not None:
return None if v == NULL else json.loads(v)
db_hits += 1 # 回源(数据库里也没有)
redis.set(key, NULL, ex=60) # 空值占位,挡住后续穿透
return None
for _ in range(5):
get_with_null_cache(999)
print("穿透防护: 5 次查询 -> DB 命中次数 =", db_hits)
穿透防护: 5 次查询 -> DB 命中次数 = 1
空值占位的 TTL 要短(这里 60 秒):万一之后该数据真的被创建了,也不会因为占位缓存太久而读不到。
击穿的对策是「单飞」——同一时刻只让一个请求去回源,其余请求等它把缓存填好:
import time
import fakeredis
redis = fakeredis.FakeRedis(decode_responses=True)
db_calls = 0
def slow_db_load():
global db_calls
db_calls += 1
time.sleep(0.05) # 模拟慢查询
return "hot-value"
def get_hot(key, ttl=300):
if (v := redis.get(key)) is not None:
return v
if redis.set(key + ":lock", "1", nx=True, ex=5): # 抢到锁的才回源
try:
value = slow_db_load()
redis.set(key, value, ex=ttl)
return value
finally:
redis.delete(key + ":lock")
time.sleep(0.05) # 没抢到锁:等锁持有者填好缓存
return redis.get(key) or slow_db_load()
for _ in range(3):
print("读:", get_hot("app:v1:hot"))
print("3 次并发读 -> 真正回源次数 =", db_calls)
读: hot-value
读: hot-value
读: hot-value
3 次并发读 -> 真正回源次数 = 1
雪崩最廉价的对策是给 TTL 加随机抖动:把 ex=300 改成 ex=300 + random.randint(0, 60),让一批键的过期时刻散开,避免它们在同一秒集体失效。此外,多级缓存(本地挡一层)和熔断降级(缓存不可用时返回兜底数据而不是直接打库)也是常规手段。
8.1.7 分布式锁:SET NX EX 与安全释放
并发写同一份资源时,缓存之外还需要一把锁。Redis 分布式锁的核心是一条原子命令:SET key value NX EX ttl——「键不存在才设置,并带过期时间」。
NX:保证同一时刻只有一个持有者。EX:给锁设过期时间,持有者崩溃时锁会自动释放,避免死锁。
释放锁有个陷阱:不能直接 DEL。如果 A 的锁因超时自动过期,B 拿到了锁,此时 A 才执行 DEL,就会误删 B 的锁。正确做法是「校验值再删」——值用持有者自己的随机 token。生产环境用 Lua 脚本保证「比较 + 删除」的原子性;本机 fakeredis 未装 lupa,无法执行 EVAL,因此这里用等价的 WATCH 乐观事务实现:
import uuid
import fakeredis
redis = fakeredis.FakeRedis(decode_responses=True)
class Lock:
def __init__(self, redis, name, ttl=10):
self.redis, self.name, self.ttl = redis, name, ttl
self.token = None
def acquire(self) -> bool:
token = uuid.uuid4().hex
ok = self.redis.set(self.name, token, nx=True, ex=self.ttl)
if ok:
self.token = token
return bool(ok)
def release(self) -> bool:
if self.token is None:
return False
with self.redis.pipeline() as pipe:
while True:
try:
pipe.watch(self.name)
if pipe.get(self.name) != self.token:
pipe.unwatch()
return False
pipe.multi()
pipe.delete(self.name)
pipe.execute()
return True
except fakeredis.WatchError:
continue
lock = Lock(redis, "app:lock:order:1001", ttl=5)
print("A 获取:", lock.acquire())
print("B 获取(应失败):", Lock(redis, "app:lock:order:1001").acquire())
print("锁 TTL:", redis.ttl("app:lock:order:1001"))
print("A 释放:", lock.release())
print("释放后存在:", redis.exists("app:lock:order:1001"))
print("A 重复释放(应 False):", lock.release())
A 获取: True
B 获取(应失败): False
锁 TTL: 5
A 释放: True
释放后存在: 0
A 重复释放(应 False): False
B 抢锁失败、A 释放成功、A 重复释放返回 False——每一步都符合预期。还有两个要点值得记住:锁的 TTL 要覆盖业务的最坏执行时间,否则业务没跑完锁就过期了;执行时间不确定的长任务,需要「看门狗」后台续期,但续期逻辑本身会引入复杂度,能用短任务解决的场景就别上长任务。
延伸阅读:Redis 与任务队列的整体架构可参考专题 Python Celery 任务队列 。
小结
- 缓存分两级:本地缓存零网络开销但一致性代价高,只放几乎不变的数据;分布式缓存跨实例共享,是业务数据的主力。
- Cache-Aside 是读路径标准答案,回填必带 TTL;写路径优先「写库后删缓存」而非更新缓存,避免并发写竞态。
- 键命名用
<应用>:<版本>:<实体>:<标识>;升版本即可整体换命名空间,无需迁移脚本。批量失效用SCAN,绝不用KEYS。 - 内存会满,淘汰策略要选
allkeys-lru(纯缓存)或volatile-*(混合存放);缓存键必须有 TTL。 - 穿透用空值占位、击穿用单飞互斥、雪崩用 TTL 抖动加多级缓存;分布式锁用
SET NX EX获取,用校验 token 的方式安全释放。
缓存挡住了读压力,但有些活天生不该在请求线程里做——发邮件、生成报表、调用慢第三方,它们应该被丢进队列异步执行。下一节我们就来讲任务队列,以及失败之后怎么重试。
阅读导航:上一节:版本演进与向后兼容 · 下一节:任务队列与重试 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。