同一个空缓存键,同时来了 16 个请求。我在 Windows 11 上用 Python 3.13.13 跑了五轮:改动前每轮模拟回源 16 次,改动后每轮 1 次,两组返回的值都正确。
最容易漏掉这个问题的地方在测试。你逐个调用函数,会看到第一次写缓存、第二次直接命中。请求同时进来时,十六个线程却都可能在第一次写入之前看到空字典。只验结果的测试照样通过,重复工作留在了后端。
下面两段代码只依赖 Python 标准库,每轮用 40 毫秒等待模拟加载。先在同一个冷键上把问题跑出来,再看一把锁能解决到哪一步。排查实际服务时,可以给真正回源的函数加计数,按缓存键聚合,顺手把缓存未命中次数也记下来。两条曲线贴在一起并不稀奇,重要的是看它们会不会随并发同时抬高。
看起来没错的几行代码,问题究竟在哪
最常见的本地缓存逻辑是先查看字典有没有键,有就返回,没有就读取数据再写入字典。单线程执行时,这段代码完全合理:第一次请求加载,后续请求复用。测试人员只要顺序调用几次,缓存命中率就很好看。
但并发请求不是排队等着前一个人执行完。冷启动、统一到期、服务刚扩容、流量突然出现同一个热门参数时,多个请求可能同时走进“键不存在”的判断。每个请求都发现缓存为空,于是同时向后端加载。即使最后字典里只剩下一份正确值,也不代表只做了一次昂贵工作。
这里最容易误判的地方是把“返回正确”当成“执行正确”。如果加载动作是数据库查询,可能只是增加开销;如果它顺带触发远程调用或付费推理,成本就会跟着增加;如果动作本身有副作用,问题会比浪费资源更严重。
为了把问题说清楚,下面不接真实数据库,也不连接外部 API。我们用一个固定返回 42、等待 40 毫秒的函数模拟加载,刻意把并发竞争窗口放大。这种实验说明的是临界区问题,不是对生产数据库性能的测量。
先不修,让16个请求同时进来
第一段代码可以直接放进 Python 运行。屏障让 16 个工作线程尽量在同一时刻开始读缓存;加载函数等待 40 毫秒,模拟一次需要耗时的后台操作。我们同时记录后台加载次数,而不只检查用户拿到的值。
from concurrent.futures import ThreadPoolExecutor
from threading import Barrier, Lock
from time import sleep
cache = {}
counter = [0]
counter_lock = Lock()
gate = Barrier(16)
def load():
sleep(0.04)
with counter_lock:
counter[0] += 1
return 42
def read(_):
gate.wait()
if "key" not in cache:
cache["key"] = load()
return cache["key"]
with ThreadPoolExecutor(max_workers=16) as pool:
values = list(pool.map(read, range(16)))
print("后台加载次数:", counter[0])
print("返回值是否一致:", set(values) == {42})
这段代码的重点不是字典会不会损坏,而是“判断不存在”和“写入缓存”没有被保护成一个整体。所有线程都能读到相同的空状态,再分别调用加载函数。最早完成的线程会先写入,后完成的线程还会再次覆盖同一个键;因为示例的返回值相同,表面上看不出错。
还有一个值得留意的细节:给计数器加锁只能让统计准确,不能阻止重复加载。保护监控变量和保护业务临界区是两件事。把日志计数改成线程安全,并不会让缓存本身变得安全。
真正需要保护的是哪一小段
这次要做到的不是“所有读取都排队”,而是只在同一个键还没有缓存时,让一个请求负责加载,其他请求复用它的结果。最直接的教学版修复可以先用一把锁,把判断、加载、写回这三个动作包起来。
下面给出独立可运行的修复版本。注意锁外可以先快速检查一次缓存,拿到锁后还必须再检查一次。这个第二次检查就是关键:等待锁的线程不能沿用等待之前看到的空状态。
from concurrent.futures import ThreadPoolExecutor
from threading import Barrier, Lock
from time import sleep
cache = {}
cache_lock = Lock()
counter = [0]
counter_lock = Lock()
gate = Barrier(16)
def load():
sleep(0.04)
with counter_lock:
counter[0] += 1
return 42
def read(_):
gate.wait()
if "key" in cache:
return cache["key"]
with cache_lock:
if "key" not in cache:
cache["key"] = load()
return cache["key"]
with ThreadPoolExecutor(max_workers=16) as pool:
values = list(pool.map(read, range(16)))
print("后台加载次数:", counter[0])
print("返回值是否一致:", set(values) == {42})
这个版本没有依赖第三方框架,也没有改变业务结果。它改动的是请求在缓存为空时的协作方式:第一个线程进入锁内加载;其他线程进入锁后发现值已经存在,就不再重复执行加载动作。
这里不能省略锁内的判断。把检查放在锁外,再不经复核就写入,仍然可能让多个已经排队的请求依次重复加载。实际代码里很多看似加了锁却没有解决问题,根源正是漏掉了第二次检查。
五轮实测,差异不在返回值
我在当前 Windows 11 设备上用 Python 标准库单独跑了验证脚本。每组都使用 16 个工作线程、同一个初始为空的键,以及 40 毫秒模拟加载;每种实现各跑 5 轮,每轮都重新创建缓存,避免上一轮的热缓存影响结果。
| 对照项 | 未保护的检查后加载 | 锁内复核后加载 |
|---|---|---|
| 每轮并发请求 | 16 | 16 |
| 五轮加载次数 | 16、16、16、16、16 | 1、1、1、1、1 |
| 五轮返回值正确 | 全部正确 | 全部正确 |
| 是否消除本实验中的重复加载 | 否 | 是 |
注意这不是“性能提升16倍”的结论。我们测的是固定条件下的后台加载次数,不是生产接口的吞吐、延迟、CPU 或数据库 QPS。模拟等待 40 毫秒也是故意设置的,并不代表真实服务的查询耗时。把局部复现实验包装成线上百分比收益,是技术文章里很容易犯的错误。
即便如此,这个对照仍然回答了一个有价值的问题:为什么接口的功能测试全绿,负载上来之后后端工作量却会不成比例地增长?如果一个测试只断言所有返回值等于 42,它甚至无法发现两种实现之间的差别。正确的测试必须再断言加载次数。
一把全局锁能演示问题,不一定适合上线
上面的修复特意保持简单,但不能直接当成所有业务场景的最终答案。全局锁会让不同缓存键的首次加载也互相等待。假设 A 键需要 40 毫秒,B 键也需要 40 毫秒,它们完全可以分别加载;全局锁却可能让 B 必须等待 A。键很多、冷键很多时,这种串行化会抵消缓存本来要获得的并行收益。
因此,真实工程里往往会考虑按键锁、单飞请求合并,或者带有相似语义的缓存库。它们的共同点是尽量让同一个键的并发未命中共享一次加载,又不阻塞其他无关的键。选择实现之前,需要认真回答键的生命周期、锁的清理、内存占用和异常传播等问题。
如果后端加载直接失败,等待中的请求应该一起得到异常,还是由下一个请求继续重试?如果加载函数卡住,其他请求要等多久?如果结果允许缓存 30 秒,但 30 秒一到有几百个请求一起出现,是不是又回到冷启动问题?这些不是加一行锁就能自动解决的。
尤其要区分单进程和多实例部署。Python 的线程锁只在当前进程内协作,不会自动协调两台服务器或多个工作进程。应用一旦扩容到多个实例,仅靠这一把本地锁仍可能跨进程重复加载。有没有必要进一步引入分布式协调,要根据成本、延迟、重复加载后果来判断,不是看到“并发”两个字就上复杂组件。
写测试时,把回源次数也断言进去
现在让 AI 助手写缓存代码并不难,几秒钟就能得到可以运行的字典封装。危险的地方在于,它也可能给出功能正确、并发行为不符合预期的答案。如果验收提示词只要求“相同 key 返回相同值”,很多真正影响成本的缺陷会被漏掉。
一个更有效的工作方式是先写出反例,再让 AI 或开发者提出实现。比如把“16 个并发请求读取同一个冷键时,加载次数应该是多少”写成可执行断言;再增加“不同键可以并发加载”“加载失败后不会永久锁死”“超时后能够恢复”等测试。实现是否漂亮可以讨论,但失败用例必须能重复运行。
很多时候,能把错误行为缩成一个稳定、独立、可运行的例子,比在大工程里不断提示 AI“你再检查一下”更有价值。至少这个例子会迫使讨论围绕实际观察,而不是围绕代码看起来是否优雅。
回归时先把模拟加载换成一个调用次数可计数的替身,把十六个请求放到同一个屏障后面。让新实现同时通过两条断言:每个请求都拿到正确值,加载函数只被调用一次。后续从单进程迁到多 worker 时,可以把这个用例放进集成测试,单独观察每个 worker 与共享后端的请求计数。
热点键过期时,要不要先返回旧值
完全没有缓存值的冷启动,只能等加载结束或走业务自己的降级。热点键过期就多了一个选择:如果业务允许短时间读旧值,可以继续返回旧结果,由一个请求负责后台刷新。比如商品详情上的静态介绍,晚几秒更新通常有商量空间。对库存校验这类依赖即时结果的读请求,应该先定义能接受多旧的数据,再选方案。
我会把验证拆成两次。先让同一个键在一批请求里过期,记录回源次数和等待时间。再人为让加载失败,检查等待方是否都能及时拿到明确结果。同步等待要有超时上限,后台刷新也要限制旧值最多能保留多久。这样跑出来的指标才对得上用户真正遇到的等待和陈旧数据。
你遇到热点键过期时,会让并发请求等同一次回源,还是先返回旧值、后台刷新? 如果愿意,带上业务场景和最多能接受的数据滞后。遇到和这里不一样的案例,我会优先按那个条件补一轮复现。