《Python编程实战》8.1 Redis 缓存层次与键设计

本节把缓存拆成三个可决策的问题:本地缓存与分布式缓存怎么分工,Cache-Aside 与 Write-Through 各自适合什么写场景,以及键命名、TTL 与淘汰策略如何一起设计。再用 fakeredis 2.39.0 实测穿透、击穿、雪崩三类故障的工程对策,并给出基于 SET NX EX 与 WATCH 的分布式锁实现。

本节目标:把「加个缓存」从一句口号拆成可落地的设计——选对缓存层次与读写模式,设计出能安全失效的键,并为穿透、击穿、雪崩和并发写入备好对策。
适用版本: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 的方式安全释放。

缓存挡住了读压力,但有些活天生不该在请求线程里做——发邮件、生成报表、调用慢第三方,它们应该被丢进队列异步执行。下一节我们就来讲任务队列,以及失败之后怎么重试。

阅读导航:上一节:版本演进与向后兼容 · 下一节:任务队列与重试 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「python」更多文章

  1. 《Python高级编程》目录
  2. 《Python高级编程》11.3 PEP 流程与版本迁移策略
  3. 《Python高级编程》11.2 嵌入式与自由线程运行时