给耗时函数加了 lru_cache,两条线程却仍然各跑一次,日志像复制粘贴?这不一定是缓存坏了。本文面向 Python 同进程多线程调用,用隔离实验区分“缓存结构安全”和“同键只算一次”,再给出锁的位置、失败分支与验收办法。
1. 先分清你要缓存,还是要阻止重复执行
缓存保存已经完成的结果;请求合并处理的是尚未完成的同键调用。两者解决的不是同一个时间段。线程 A 冷启动时查不到值,开始计算;结果尚未存入缓存,线程 B 也可能查不到,于是再次进入函数体。两个结果相同,不代表函数只运行过一次。
Python 官方文档明确说明:lru_cache 的内部数据结构在并发更新下保持一致,但首个调用完成并缓存之前,包装函数可能被再次调用。因此,threadsafe 不能翻译成“业务恰好执行一次”。不要给扣款、创建订单、发送通知套个缓存,就把它当幂等系统。
2. 用会合点复现冷缓存窗口
下面只在本机线程里计算字符串,不访问数据库或远端。Barrier 放在函数体内,让两条冷调用都进去再返回;计数另用 Lock 保护,避免把计数竞争误认成缓存行为。保存成 Python 文件运行,依赖只有标准库。
from functools import lru_cache
from concurrent.futures import ThreadPoolExecutor
from threading import Barrier, Lock
会合 = Barrier(2, timeout=5)
计数锁 = Lock()
次数 = 0
@lru_cache(maxsize=32)
def 计算(键):
global 次数
with 计数锁:
次数 += 1
会合.wait() # 仅用于实验:确保两次冷调用都进入函数体
return 键.upper()
with ThreadPoolExecutor(max_workers=2) as 池:
任务 = [池.submit(计算, "demo") for _ in range(2)]
print([项.result(timeout=6) for 项 in 任务])
print("函数体次数:", 次数)
print(计算.cache_info())
assert 次数 == 2
assert 计算("demo") == "DEMO" # 后续调用命中缓存
本次实验得到两个 DEMO,函数体执行 2 次,完成后缓存只留下一个键,后续调用命中。它只证明冷窗口可能重复,不是每次并发都失效;Barrier 仅用于实验。

3. 先用指标确认是不是同一个键
观察 cache_info() 的 hits、misses、currsize,结合函数体计数。未命中多可能来自并发或淘汰,不能单独定性。参数必须可哈希,传 list 会在函数体执行前报 TypeError。
位置与关键字调用、关键字顺序可能形成独立条目。先规范化参数再进入缓存;别删掉租户、权限或配置版本来提高命中率,否则会破坏隔离。
maxsize 限条目数而非字节数,None 表示不限量。缓存保留参数和结果引用;可变返回值可能被调用方修改,宜用不可变值或在外层复制。
4. 简单修复:把锁放在缓存入口外面
并发量不高、业务允许串行时,先用一把入口锁。关键不是“某处有锁”,而是锁覆盖缓存查找、未命中计算和结果缓存;其他线程等到第一条调用退出,再查到已完成的结果。内部函数保持私有,所有调用及清空都走同一入口。
from functools import lru_cache
from threading import Lock
入口锁 = Lock()
@lru_cache(maxsize=32)
def _计算(键):
# 这里用纯计算替代耗时读取,不产生外部写入
return 键.upper()
def 读取(键):
with 入口锁:
return _计算(键)
def 清空():
with 入口锁:
_计算.cache_clear()
assert 读取("demo") == "DEMO"
assert 读取("demo") == "DEMO"
print(_计算.cache_info())
清空()
assert _计算.cache_info().currsize == 0
只在被装饰函数体内部加锁并不够:两条线程可能已经分别判定未命中,之后虽然串行进入函数体,仍各算一次。先查缓存,再进函数体,锁放晚了就像关门时客人早已进屋。上面的入口方案保守但容易验收,它也会串行不同键以及缓存命中。
Lock 不可重入。内部计算若再次调用同一个读取入口,会卡在自己持有的锁上;不要原封不动用于递归调用。计算可能阻塞时,也要另外设置底层操作的超时。这段代码没有等待锁超时机制,不能称为限时读取工具。
5. 流量大时按键合并,但不要漏掉生命周期
不同键互不相关却被一把锁挡住时,可设计按键的进行中任务表:首次未命中者创建任务,同键后来者等待任务结果,不同键独立推进。创建和删除任务表项要同步,并在获得键锁后重新查缓存;否则可能只是把重复计算排成队。
按键锁表也要回收,不能随键永久增长;还须处理异常、等待者取消、递归与超时。这里只讲边界,不提供可直接上线的 singleflight 实现。
这仍然只是同一进程的协调。多个 worker 有各自的内存缓存和锁,重启也会清空状态。外部写入的幂等性应由业务幂等键、唯一约束或事务等保护,不靠进程缓存推断跨进程“只做一次”。
6. 错误、失效与异步:这些分支要单独处理
函数抛出异常时,本次调用没有正常结果可缓存,后续调用可能再次尝试;入口锁不等于负缓存或失败请求合并。相反,捕获异常后返回 None 等正常值,会把它当结果缓存,后端恢复也可能仍读到旧值。先规定哪些结果能缓存,失败多久后重试。
lru_cache 没有内置 TTL;配置变化时不能只盼它自然淘汰。示例的清空入口与读取共用锁,是为了避免清空与本示例正在计算的调用交错,不是对所有绕过入口者都提供保证。高并发方案可把版本加入键,并制定一致的失效协议。
不要直接装饰 async def 后就反复 await 缓存值:存入的可能是协程对象,而非已经完成的业务结果。异步服务应采用与事件循环匹配的任务共享和缓存方案;本文的线程锁例子不应直接搬到协程里阻塞事件循环。
7. 排查表:从症状走到证据
| 症状 | 先核对 | 处理方向 |
|---|---|---|
| 同键冷启动算两次 | 函数体计数与开始结束时序 | 锁覆盖缓存入口,或按键合并 |
| 同样参数持续未命中 | 调用形式、容量、清空记录 | 规范化参数,检查淘汰 |
| 后端恢复仍返回失败 | 是否把 None 当正常值缓存 | 定义失败缓存与失效规则 |
| 多个 worker 各算一次 | 进程标识与调用路径 | 确认需求是否跨进程 |
| 加锁后吞吐下降或卡住 | 不同键排队、递归和阻塞 | 缩小协调范围,补超时设计 |
本次附加隔离检查还验证了外层锁合并冷调用、函数体内锁仍重复、异常后重试、None 正常结果命中、缓存清空后重算和不可哈希参数报错。实验没有发送任何远端请求,也没有用生产数据证明性能收益。
8. 验收清单与官方资料
验收别只看第二次变快:制造同键并发冷窗口,检查结果和函数体次数;复测热缓存、不同键、异常重试、失效与容量边界;确认所有入口是否协作;检查返回值是否共享可变对象;最后区分单进程减少重复计算与外部业务幂等。结论和图里的限定条件都要保留。
本文采用显式 lru_cache(maxsize=...),讨论已有机制而非新功能;缓存复用结果,协调处理进行中的调用。
🔕 评论已关闭