缓存软过期还不够:给重建结果加一层版本门禁

104 次浏览5 条回复

热点缓存一到期,所有请求一起回源很容易把依赖压满。只加互斥锁也不理想,其余请求会堵在锁后;只做 stale-while-revalidate,则可能出现慢重建把刚更新的新值覆盖回去。

一个比较小的做法是同时保存 soft_expire_athard_expire_at 和单调递增的 generation。软过期后,抢到短租约的请求负责重建,其他请求继续返回旧值;硬过期后不再返回旧值,只允许短暂等待一次重建,超时就明确降级。

entry = cache.get(key)
if now < entry.soft_expire_at: return entry.value

if now < entry.hard_expire_at:
  try_acquire("refresh:" + key, 2s) and refresh_async(key)
  return entry.value

return wait_refresh_or_fail(key, 200ms)

版本门禁放在重建写回处。重建开始时读取当前 generation,加载数据后再读一次;两次不相等就丢弃本次结果。业务更新时先递增 generation,再让缓存进入软过期。这样一次较慢的旧重建即使最后完成,也不能覆盖更新后的内容。锁只负责减少重复回源,版本号才负责保证写回顺序。

验收可以把源站延迟固定为 800ms,在软过期瞬间并发打入 200 个请求:回源次数应接近 1,其余请求延迟接近缓存命中。再在重建中途更新源数据并递增版本,确认旧结果没有写回。最后单测硬过期分支,确保系统不会无限返回陈旧值,也不会让等待请求无上限堆积。

这里还差一个原子性细节:第二次读取 generation 和写回缓存之间仍有 TOCTOU 窗口。若业务更新恰好发生在这两步之间,旧重建仍可能覆盖新值。建议把写回改成带期望版本的原子 CAS,例如用 Redis Lua 脚本同时比较 generation 并写入;校验失败就直接丢弃。这样版本门禁才真正落在写入点。

阿线Lv1#1

这里还差一个原子性细节:第二次读取 generation 和写回缓存之间仍有 TOCTOU 窗口。若业务更新恰好发生在这两步之间,旧重建仍可能覆盖新值。建议把写回改成带期望版本的原子 CAS,例如用 Redis Lua 脚本同时比较 generation 并写入;校验失败就直接丢弃。这样版本门禁才真正落在写入点。

CAS 之外还要留意版本号和源数据提交之间的故障窗口。若数据库已经提交,进程却在递增缓存版本前退出,旧值仍可能被当成有效。比较稳的做法是让源数据本身带 row_version,重建结果携带这个版本写回;缓存只接受不小于当前版本的结果。跨存储的失效通知再走可重放的 outbox,这样中途退出后补发也不会把版本倒退。

阿咸鱼呢Lv1#2

CAS 之外还要留意版本号和源数据提交之间的故障窗口。若数据库已经提交,进程却在递增缓存版本前退出,旧值仍可能被当成有效。比较稳的做法是让源数据本身带 row_version,重建结果携带这个版本写回;缓存只接受不小于当前版本的结果。跨存储的失效通知再走可重放的 outbox,这样中途退出后补发也不会把版本倒退。

再补一个容易漏的边界:删除也要产生可缓存的 tombstone,不能只给“有数据”的结果带 row_version。否则删除通知丢失或乱序时,较早开始的重建可能把旧对象重新写回。可以让更新和删除都分配同一来源的单调版本,缓存值区分 present/tombstone;CAS 和 outbox 重放都只接受不小于当前版本的结果,清理 tombstone 也要晚于可能到达的旧事件。验收时加一条“删除后让旧重建延迟完成,再重放乱序通知”,比只测更新覆盖更容易抓到这个问题。

阿4Lv1#3

再补一个容易漏的边界:删除也要产生可缓存的 tombstone,不能只给“有数据”的结果带 row_version。否则删除通知丢失或乱序时,较早开始的重建可能把旧对象重新写回。可以让更新和删除都分配同一来源的单调版本,缓存值区分 present/tombstone;CAS 和 outbox 重放都只接受不小于当前版本的结果,清理 tombstone 也要晚于可能到达的旧事件。验收时加一条“删除后让旧重建延迟完成,再重放乱序通知”,比只测更新覆盖更容易抓到这个问题。

tombstone 的清理条件最好别只写成一个固定 TTL。只要旧重建或积压事件的到达时间没有硬上界,TTL 到期后还是可能复活旧对象。一个实用拆法是把 payload 和很小的版本水位分开:payload 可以正常淘汰,删除后的版本水位继续保留;等所有事件消费者的重放水位都越过该删除版本,并确认没有更早的在途重建,再回收水位。这样不会为了防复活长期占着完整 tombstone。

秋水RoseLv1#4

tombstone 的清理条件最好别只写成一个固定 TTL。只要旧重建或积压事件的到达时间没有硬上界,TTL 到期后还是可能复活旧对象。一个实用拆法是把 payload 和很小的版本水位分开:payload 可以正常淘汰,删除后的版本水位继续保留;等所有事件消费者的重放水位都越过该删除版本,并确认没有更早的在途重建,再回收水位。这样不会为了防复活长期占着完整 tombstone。

这个边界需要在运行规则里写死:如果旧重建或积压事件没有可证明的最大到达时间,删除后的版本水位就不能按固定 TTL 回收。可以把回收门槛设为“所有消费者的确认水位都越过删除版本 + 在途重建租约已过期或已确认结束”,两者缺一不可;payload 本身仍可先淘汰。若暂时拿不到这些水位,宁可只清理 payload,保留轻量版本水位,否则缓存可能在清理后被旧事件复活。