缓存 Revalidation 正确性:Generation Fence、Singleflight 与陈旧写拒绝
Singleflight 能合并同时回源,却阻止不了旧重建者在失效后覆盖新值。本文用目标代际、重建租约、条件发布和有界 stale 策略封闭缓存击穿与迟到写窗口。
本文要点
- 主题聚焦:TypeScript、DevOps
- 适合人群:独立开发者、全栈工程师、AI 应用开发者
- 核心价值:快速理解背景、实践路径、常见误区和工程建议
Web 分层缓存策略说明 Cache-Control、CDN 与应用缓存如何分工,Redis 常见模式介绍互斥重建和击穿。真正棘手的竞态发生在“只允许一个回源者”之后:重建者 A 读取旧数据库快照,失效事件推进后重建者 B 写入新值,A 最后醒来又把旧值覆盖。Singleflight 只合并同一进程同一时刻的调用,分布式锁过期也不等于旧 owner 失去写能力。
缓存身份不只有业务 key
发布 cache.materialization.v1,把逻辑 slot 与不可变 value 分开。slot 由 tenant/auth scope、可枚举 dependency namespace、projection schema、查询参数摘要和策略版本组成;namespace 控制记录保存单调 requiredGeneration,slot 保存最近 committed generation/value digest、fresh/stale 期限与状态。动态集合查询绑定 collection/projection namespace,而不是要求源事务枚举任意 query digest。value envelope 还绑定 source snapshot、依赖 generation 向量、生成器版本和创建时间。
DependencyNamespace(scope, resourceOrCollection, projection)
requiredGeneration, authEpoch
CacheSlot(namespace, queryDigest, policy)
committedGenerationVector, committedDigest
CacheValue(slotDigest, generation, sourceRevision,
producerRevision, payloadDigest, freshUntil, staleUntil)
任何会改变内容的更新,都在源事务中提高对应资源、集合或 projection namespace 的 requiredGeneration 并写 outbox;删除使用更高代 tombstone。复杂查询若无法声明有限 dependency namespace 就不可缓存。物理 DEL key 只是清理优化,不能承担正确性,因为消息会丢、旧值会从副本回来。数据库 CDC可以输送变更,但消费者仍要按 generation 条件应用。
读取时校验 slot 身份及 value 的每个 dependency generation。授权事务还要同步提高权威 auth epoch;每次返回前必须与 live epoch 比较,鉴权存储不可用就 fail closed,异步 outbox 只负责重建和清理。权限撤销、租户停用和删除一律不允许 stale;普通公共内容才可按版本化策略在 staleUntil 前返回旧值。RFC 5861 不会替应用判断哪些字段可陈旧。
Singleflight 负责合并,Fence 负责拒绝迟到者
进程内先按完整 slot digest 使用 singleflight,减少同实例重复工作。跨实例重建通过控制存储原子领取 (slot, requiredGeneration),得到 rebuildAttemptId、递增 fence 和短 lease;只有一个 attempt 为 leader,其他请求返回允许的 stale、等待有界时间或走限流旁路。lease 用来判断何时可接管,fence 才决定谁能提交。
leader 在明确 snapshot 读取 dependency generation 向量,生成 payload 后条件发布:所有 namespace generation、当前 fence、projection/policy version 和 auth epoch 都必须仍与领取时相同。先写不可变 value,再 CAS committed pointer;失败的迟到结果只能丢弃。租约续期同样比较 attempt 与 fence,旧 leader 不能在接管后续租或删除新 leader 的锁。乐观锁与悲观锁提供存储原语,这里把版本比较延伸到缓存发布接受点。
若 value 已写但 pointer 响应丢失,恢复器读取 pointer 和摘要确认,而不是创建新 generation。若源读取跨多个表,必须使用同一数据库快照或带版本的 projection;“查询开始时看到一半旧、一半新”即使 generation 正确也不是合格 value。回源失败不缓存成正常空值;只有 schema 明确区分的 NOT_FOUND 才能生成短期负缓存,权限错误、超时和 5xx 保持失败。
限制陈旧窗口和资源放大
每个 slot 策略声明 maxBuildTime、freshTtl、maxStale、wait budget、最大候选字节和 fallback。热点 miss 不能让无限请求都阻塞等待 leader;等待者达到上限后返回已有 stale 或明确 503。重建重试、数据库客户端重试和请求层重试共享同一总预算,防止一次击穿放大成多层风暴。混沌工程应验证的不只是命中率,还包括 source QPS 和陈旧接受边界。
故障套件让 A 在读完旧 snapshot 后暂停,推进 namespace generation 并由 B 发布,再恢复 A;还覆盖 query 依赖多条记录、CDC 乱序、权限事务已提交但 outbox 延迟、auth store 不可用、lease 接管、旧 key 复活和万人同时 miss。断言 committed generation 向量不倒退,撤权后旧值接受数为 0,正常 generation 只有一次源查询,旧 fence 更新全部失败。lease 到期后计算可能短暂重叠;fence 只承诺一个 commit,不能把重复计算伪装成已消除。
上线门要求竞态测试中陈旧覆盖为 0、无接管故障的热点 1 万并发 miss 对每个 slot/generation 只发起 1 次源查询、接管时重叠计算不超过 2 个且仅 1 个 commit、失效到新代可读的 P99 小于 5 秒、普通内容 stale 超窗接受为 0、权限类 stale 接受为 0,重建失败时缓存层内存和等待队列保持上限。先 shadow 计算 generation,再双写 envelope 不读,随后对公共低风险 slot 启用。回滚时关闭重建并旁路到源或只读已验证旧协议值;提高全局 cache epoch 让新 reader 拒绝问题代,不能重新启用无 fence 的旧 writer。
延伸阅读
Related