有一个老笑话,说计算机科学里只有两个难题:缓存失效和命名。笑话经久不衰,是因为它是真的。添加缓存很容易,但要保持正确却很难,几乎所有的缓存 bug 其实都是失效 bug:缓存正在提供与数据库不匹配的数据。本文介绍了保持缓存数据正确的策略,从简单的 TTL 到显式和事件驱动的失效,以及如何判断应该使用哪种策略。
这是 Redis 大师班的第 13 部分,承接 refresh-ahead。
为什么失效很难
难点在于缓存和数据库是同一份数据的两份副本,数据库一旦发生变化,缓存就会出错,直到有东西把它修复。每一种策略都回答了「缓存如何得知底层数据发生了变化」这个问题,而每种策略在新鲜度、复杂度和正确性之间做出了不同的权衡。没有完美的答案,这正是笑话的真正含义:你总是在做权衡。
策略 1:TTL 过期
最简单的策略是不主动失效,只是让条目自行过期。你设置一个 TTL,过期后数据会重新加载为最新值。陈旧程度受 TTL 限制:如果 TTL 为 60 秒,缓存最多滞后 60 秒。
SET user:1 "..." EX 60 # 接受最多 60s 的陈旧数据
Enter fullscreen mode Exit fullscreen mode
当数据可以容忍一定程度的陈旧时,这是正确的选择,而这样的数据比人们想象的多。一个 30 秒过期的商品列表、一分钟滞后的仪表盘、5 分钟内更新的配置:对于所有这些,仅靠 TTL 就足够了,而且极其简单。根据数据能容忍的陈旧程度选择 TTL。缺点是无法同时实现完全新鲜和长时间缓存:新鲜度和命中率相互制约。
策略 2:写入时显式失效
当不能接受陈旧数据时,需要显式失效:每当写入数据库时,删除对应的缓存键,以便下次读取时重新填充。这就是 cache-aside 文章中的 delete-on-write 规则:
async function updateUser(id, data) {
await db.update('users', id, data);
await redis.del(`user:${id}`); // 立即失效
}
Enter fullscreen mode Exit fullscreen mode
现在,数据变化的瞬间缓存就会被修正,而不是等到 TTL 秒之后。挑战在于完整性:必须失效写入所影响的每一个缓存键,很容易漏掉一个。如果用户数据同时存在于 user:1、users:list 缓存和 search:results 缓存中,更新该用户时必须失效这三者。漏掉一个就会留下陈旧条目。显式失效精确但要求你跟踪数据被缓存的所有位置,并在变化时全部失效。
策略 3:基于标签/分组的失效
当一次变化需要失效多个键时,逐个跟踪容易出错。基于标签的失效将相关键分组,以便一起失效。一种方法是用 Redis 的 set 来记录哪些键属于某个分组:
# 缓存时,把键记录到它的标签下
SADD tag:user:1 user:1 profile:1 posts:by:1
# 失效用户 1 的所有内容:
# 读取 set,删除所有这些键,再删除 set
Enter fullscreen mode Exit fullscreen mode
现在更新用户 1 时,一次操作就能失效整个分组,而不需要在每个写入点手动列出键。这把显式失效扩展到了复杂缓存场景:单个实体的数据分散在许多键中。它增加了记账工作(维护标签集合),但消除了「是否记住了每个键」的风险。
策略 4:事件驱动的失效
在多服务或多副本系统中,写入的服务可能不是持有陈旧缓存的服务。事件驱动的失效会广播变化,让所有感兴趣的参与者进行失效。Redis 的 pub/sub(后面会介绍)或 change stream 携带「user 1 changed」消息,每个服务订阅并清除其相关缓存:
# 写入方在变化时发布
PUBLISH cache:invalidate '{"entity":"user","id":1}'
# 每个服务订阅并删除匹配的键
Enter fullscreen mode Exit fullscreen mode
这能让分布式系统中的缓存保持一致,因为单个节点上的 DEL 不足以覆盖所有副本。它功能最强大,也最复杂,需要消息机制和到处部署的订阅者。只有在真正分布式的缓存场景中,当本地失效无法到达所有副本时才使用。
版本化键:无需删除的失效
一个巧妙的替代方案是通过改变键来绕过失效。在缓存键中包含版本号,更新版本号即可「失效」而无需删除任何内容:
GET user:1:v5 # 当前版本
# 变化时,递增版本指针,读取操作会使用 v6
Enter fullscreen mode Exit fullscreen mode
旧版本的键会自然停止被请求并在 TTL 后过期,而新读取会使用新版本。这避免了删除和重新填充交错导致的竞态条件,适合难以精确失效的昂贵缓存。代价是旧版本会在内存中保留直到过期。
选择策略
决策取决于数据:
- 能容忍陈旧? 仅使用 TTL。最简单,足够满足大量数据。
- 需要即时新鲜度,单服务? 显式 delete-on-write,当一次变化影响多个键时加上标签分组。
- 跨服务的分布式缓存? 通过 pub/sub 或 change stream 进行事件驱动失效。
- 失效竞态是问题? 版本化键。
大多数真实系统会组合使用这些策略:为所有内容设置 TTL 作为安全后备(即使漏掉失效,最终也会自我修正),同时对必须新鲜的数据使用显式失效。这种组合效果很好,因为 TTL 会捕获显式失效遗漏的任何内容。
缓存失效之所以难,是因为它本质上是要保持两份副本同步,而每种策略都在新鲜度与复杂性之间做权衡。从 TTL 开始,在需要新鲜度的地方添加显式失效,当变化影响多个键时用标签分组,仅在分布式场景中才使用事件驱动失效。始终保留 TTL 作为底层后备,因为你遗忘的失效就是你即将发布的 bug。
接下来我们将讨论几种模式都暗示过的具体失败场景:缓存 stampede,即热点键过期后,一大群请求同时涌向数据库。
要点总结
- 几乎所有的缓存 bug 都是失效 bug:缓存提供的数据已不再与数据库匹配。
- TTL 过期无需主动操作即可限制陈旧程度,适用于能容忍一定陈旧的数据。
- 显式 delete-on-write 提供即时新鲜度,但需要失效变化影响的所有键。
- 标签分组可一起失效多个相关键;事件驱动失效能让分布式缓存保持一致。
- 在显式失效下保留 TTL 作为后备,这样漏掉的失效仍能自我修正。
常见问题
为什么缓存失效被认为很难?
因为缓存和数据库是同一数据的两份副本,数据库一旦变化,缓存就会出错。每种策略都是对「缓存如何获知这种变化」的不同且不完美的回答,每种都在新鲜度与复杂性之间做权衡。
最简单的缓存失效策略是什么?
TTL 过期:设置存活时间,让条目过期后重新加载。陈旧程度受 TTL 限制,无需主动失效。只要数据能容忍稍许过时,这就足够了。
数据变化时如何失效缓存?
写入数据库后立即删除受影响的缓存键(delete-on-write),下次读取时会重新填充新鲜值。难点在于失效变化影响的所有键;当一次变化影响多个键时,标签分组可以提供帮助。
如何在多个服务间保持缓存一致?
使用事件驱动失效:通过 Redis pub/sub 或 change stream 广播变化消息,让每个服务订阅并清除其相关缓存。单次本地删除无法触及由其他服务持有的缓存。
即使使用显式失效,是否仍应使用 TTL?
是的。保留 TTL 作为后备,这样即使在某些写入路径上忘记失效某个键,陈旧条目也会在过期时自我修正。它把永久 bug 变成了临时 bug。
延伸阅读
本文最初发布于 amanksingh.com/blog/redis-cache-invalidation。
关于作者
Aman Kumar Singh 是印度诺伊达的一名团队负责人兼高级软件工程师,撰写关于 TypeScript、Next.js、NestJS、PostgreSQL 和 Redis 的全栈工程、系统设计和生产级 SaaS 的文章。
- 个人作品集:amanksingh.com
- GitHub:amansingh1501
- LinkedIn:amansingh1597
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.