读穿透缓存给调用者提供统一的读取接口:命中时返回已存值,未命中时调用加载函数,再把结果保存起来。关键边界是由谁拥有这个 loader。把它放到缓存接口后面,可以减少重复的查找逻辑,但并不会自动解决并发、写入、访问授权或不同缓存副本之间的一致性。模式名称不是这些能力已经具备的证明。

找到真正负责加载的一层

在 cache-aside 模式中,应用显式查询缓存,未命中后访问数据源并回填;读穿透则让缓存组件拥有这条未命中路径。它可以是应用进程里的库,也可以是远程缓存服务。两种部署方式各有更新边界:修改进程内库的 loader,仍然需要把变更部署到使用它的进程,不能笼统宣称所有读者都会免部署地自动获得新逻辑。

单独一个 Redis GET 也不是读穿透加载器。字符串键不存在时,它返回 nil,不会自动发现并查询你的数据库。客户端库、缓存服务或应用适配层必须另外提供加载逻辑。下面的实验用带记忆功能的函数包装器提供统一读取接口,缓存和数据源都在当前进程内;这不是 Redis 服务端加载功能的演示。

读取模式没有定义写入模式

读穿透本身没有规定写操作经过哪里,写穿透是另一项安排。即便读写共用一个组件,仍需要定义数据源写入和缓存更新分别失败时的行为。绕过组件的其他写入方,可能让缓存保持旧值。应沿真实业务更新路径检查参与者,不能因为使用了一个模式名称,就假定所有写入都会配合清理。

TTL 限制的是某个缓存条目的复用时间,并不保证下一次重载从滞后副本或有缺陷的 loader 中取到最新真值。过期也不一定立即从内存中释放字节。寿命应依据数据需求选择,而非套用统一的几秒或几分钟;用户数据的 TTL 再短,也不构成权限隔离。命中率很高同样不能证明内容正确。

坏掉的样子

记录不存在与数据源不可用,是两个不同结果。缓存一次合法的“不存在”可以减少重复查询,但如果随后创建了记录,这个否定结果也可能继续遮住新记录,直到到期或失效。若把超时转换成一次正常返回的空值,就可能把故障写成错误的缓存内容。必须明确哪些结果允许保存,以及调用者如何区分缺失和失败。

并发加载是另一项独立契约。Cachetools 明确说明:锁保护缓存对象的访问,但被包装的函数在锁外运行,因此只有锁并不能抑制重复加载。它的 condition 选项另行处理同键的并发执行。下面的实验只运行单线程,刻意不使用这两个参数,不能把代码当成已经具备线程安全或分布式协作的实现。

失效还可能与一个已经读到旧源版本的加载操作竞争。删除某个键,并不天然阻止那个在途结果稍后重新写入。应根据真实的写入和加载协议选择协调方式,并测试操作顺序。也不要把大范围按键模式删除当作默认修复:它会让更多条目变冷,却没有证明源头缺陷已经解决。

快速验证

需要 Python 3.10 或更新版本;本次使用 Python 3.12.14 和 cachetools 7.1.8。安装固定依赖后,用 "$readthrough_lab/venv/bin/python" 执行 Python 代码。缓存存储、到期和函数记忆行为都由现有库负责;测试手动推进专用时钟,无需睡眠,也不修改系统时间。实验不联系 Redis 或外部数据库,只有依赖安装需要联网。

readthrough_lab=$(mktemp -d)
python3 -m venv "$readthrough_lab/venv"
"$readthrough_lab/venv/bin/python" -m pip install cachetools==7.1.8
from collections import Counter

from cachetools import TTLCache, cached

clock = [0]
cache = TTLCache(maxsize=8, ttl=10, timer=lambda: clock[0])
source = {"tea": 100}
failures = set()
loads = Counter()


@cached(cache=cache, info=True)
def price(sku):
    loads[sku] += 1
    if sku in failures:
        raise OSError("source unavailable")
    return source.get(sku)


assert price("tea") == price("tea") == 100
assert loads["tea"] == 1
source["tea"] = 120
clock[0] = 9
assert price("tea") == 100 and loads["tea"] == 1
clock[0] = 10
assert price("tea") == 120 and loads["tea"] == 2
print("hit does not extend TTL: old value at t=9; reload at t=10")

source["tea"] = 130
cache.pop(price.cache_key("tea"))
assert price("tea") == 130 and loads["tea"] == 3
print("explicit eviction: next read loads the updated source")

assert price("coffee") is None
source["coffee"] = 200
assert price("coffee") is None and loads["coffee"] == 1
cache.pop(price.cache_key("coffee"))
assert price("coffee") == 200 and loads["coffee"] == 2
print("None is cached: newly created data needs expiry or invalidation")

cache.pop(price.cache_key("tea"))
failures.add("tea")
for _ in range(2):
    try:
        price("tea")
    except OSError:
        pass
    else:
        raise AssertionError("failure was hidden")
assert loads["tea"] == 5
assert price.cache_key("tea") not in cache
failures.remove("tea")
assert price("tea") == price("tea") == 130
assert loads["tea"] == 6
print("exceptions are not cached: two failed loads, then one recovery load")
print(price.cache_info())

前两次读取只执行一次 loader。修改数据源之后,仍然有效的缓存价格不会随之改变,在第九秒仍返回 100;到了第十秒,条目过期,包装器才加载 120。这验证的是从插入开始计算的寿命,而不是每次访问都延长的滑动过期。随后按包装器实际使用的缓存键移除条目,下一次读取才取得新的价格 130。

不存在的 coffee 价格会以 None 形式缓存。仅创建源记录不会替换这份已存的空结果,显式移除后才能重新加载到 200。连续两次源故障都会执行 loader 并抛出异常,缓存中不留下 tea 条目;恢复以后,一次成功加载可以供应两次读取。最后报告四次命中和八次未命中,失败的加载也计入未命中。

这些断言证明的是这个包装器实际观察到的未命中、到期、空结果与错误行为,没有证明写入原子性、并发未命中抑制、内存压力下的淘汰或分布式一致性。验收真实服务时,要把 loader 的执行次数和结果分类与缓存统计一起记录。两次 HTTP 请求变快,或者 Redis 全局命中计数增加,都无法单独指出某个应用 loader 是否真的执行过。

延伸阅读

来源