不糟糕的语义失效
Source: Dev.to
缓存问题
如果你在一个 web 应用上工作过一段时间,你一定了解缓存的情况。你加入缓存后,一切都变快了,但随后有人更新了某些数据,用户却看到旧的数据——价格、库存等等。TTL 能帮忙,但你总是在新鲜度和负载之间做权衡。
常见的解决办法是手动失效:更新商品后,失效对应的缓存键。这对单一接口有效,但当该商品有评论、评论有回复、商品属于某个店铺、店铺又属于某个组织时,情况会迅速变得混乱。你必须追踪大量关系,并在各处失效键。
介绍 ZooCache
ZooCache 是一个基于 Rust 核心的 Python 缓存库,专注于 语义失效——依据 什么 发生了变化来失效,而不仅仅是 何时。
注册依赖
当你缓存某个结果时,需要声明它所依赖的数据标签(dependencies):
from zoocache import cacheable, invalidate, add_deps, configure
configure()
@cacheable()
def get_product(pid):
add_deps([f"product:{pid}"])
return db.get_product(pid)
@cacheable()
def get_reviews(pid):
add_deps([f"product:{pid}:reviews"])
return db.get_reviews(pid)
@cacheable()
def get_store_products(sid):
add_deps([f"store:{sid}:products"])
return db.get_store_products(sid)
@cacheable()
def get_org_stores(oid):
add_deps([f"org:{oid}:stores"])
return db.get_org_stores(oid)
这些标签形成层级结构。例如,org:1:stores:2:products:42 就是 PrefixTrie 中的一条路径。
失效标签
当数据变化时,失效相应的标签:
def update_product(pid, data):
db.update_product(pid, data)
invalidate(f"product:{pid}")
def update_store(sid, data):
db.update_store(sid, data)
invalidate(f"store:{sid}")
def update_org(oid, data):
db.update_org(oid, data)
invalidate(f"org:{oid}")
失效 org:1 会清除其下的所有内容——商品、评论、店铺商品等。你不再需要记住哪些函数缓存了哪些数据。
失效操作的时间复杂度是 O(D),其中 D 为标签深度,且与缓存条目数量无关。
跨实例的一致性
如果你运行多个实例,ZooCache 使用 Hybrid Logical Clocks (HLC) 来保证一致性。每一次失效都会带上一个时间戳,该时间戳考虑了时钟漂移,确保即使系统时钟不同步,后来的失效也拥有更高的时间戳。
被动重新同步
每个缓存条目都会存储版本信息。当一个节点从另一个节点读取数据时,会检查这些版本;如果发现更新的版本,节点会自动追上最新状态。这样即可在无需额外协调的情况下保持读取的一致性。
防止缓存击穿
当缓存条目过期且大量请求同时击中数据库时,会出现 击穿(stampede)现象。ZooCache 通过 SingleFlight 模式来缓解:第一个请求执行实际工作,其他请求等待,随后所有请求都得到相同的结果。数据库只会收到一次查询,而不是多次。
功能一览
- 存储后端:内存、LMDB 或 Redis
- 框架集成:FastAPI、Django、Litestar
- 序列化:使用 LZ4 压缩的 MsgPack
- 可观测性:日志、Prometheus、OpenTelemetry
- CLI 用于监控
- Rust 核心 并提供 Python 绑定
基准测试可在文档站点查看。
安装
uv add zoocache
链接
欢迎尝试并分享你的想法——无论是问题、建议还是其他任何反馈。我们始终乐于倾听,帮助改进。