跳到内容

第 5 卷 / 第 19 章 / 第 1 课

19.1 缓存协议、一致性窗口与失效 ​

缓存不是“多放一份数据”这么简单。它在权威数据与读请求之间增加了一个可能过期、可能丢失、可能被逐出的副本。性能收益来自命中率与更低访问成本,正确性则取决于缓存协议。

先写缓存契约 ​

在写代码前回答:

  • 权威数据源是谁?
  • 允许陈旧多久,哪些字段绝不能旧?
  • 缓存丢失后能否重建?
  • key 如何包含租户、权限、语言和 schema 版本?
  • 删除、更新和权限撤销怎样传播?
  • 回源被打满时如何降级?

如果缓存中的值能反向裁决库存、余额或权限,它就不再只是缓存,需要与权威存储同级的事务、持久化和审计设计。

命中率必须和代价一起算 ​

平均读取时间可粗略表示为:

text
E[latency] = hit_rate * cache_latency
           + miss_rate * (cache_latency + origin_latency)

这还没包含尾延迟、序列化、网络、失效广播和回源排队。高命中率也可能没有价值:如果命中的都是廉价查询,而少数 miss 是超重查询,p99 仍会很差。

假设命中率 95%、缓存往返 1 ms、回源另需 20 ms,则简化均值为 0.95×1 + 0.05×21 = 2 ms;不能由此推算 p99=2 ms,最慢那部分请求仍会回源并排队。

不要把“80% 请求访问 20% 数据”当定律。应从真实 key 频率、对象大小、TTL、复用间隔和源站代价估算 working set。

Cache-Aside:应用管理读取和失效 ​

基本读路径:

text
read cache
  hit  -> return
  miss -> read source -> populate cache -> return

写路径常采用“提交数据库后删除缓存”:

text
begin database transaction
update authoritative row
commit
delete cache key

先写缓存再写数据库,数据库失败会留下不存在的“新事实”;先删缓存再提交数据库,间隙内的并发读可能把旧值重新填回。因此通常在数据库提交后失效缓存。

但提交与删除仍是跨系统双写:数据库已提交、进程却在删缓存前崩溃时,旧值会保留到 TTL。更强方案包括:

  • 从数据库 WAL/outbox/CDC 可靠发出失效事件;
  • 缓存值携带 source version,拒绝旧事件覆盖新版本;
  • 对不能容忍陈旧的读直接回权威库或使用同一事务系统;
  • TTL 只作为最终收敛保险,不作为唯一正确性机制。

即使删缓存成功,还存在旧 loader 回填的交错:

text
reader: cache miss -> read source v7 -> pause
writer: commit source v8 -> invalidate cache
reader: resume -> populate cache with v7

只与缓存中现有值比较版本还不够,因为删除后 key 可能为空。需要原子检查一个独立保留的版本水位/generation,或让填充与失效使用可证明的协调协议;水位保留时间也要覆盖旧请求存活期。否则最多只能声明允许陈旧并依赖最终刷新。

所谓“延迟双删”依赖一个估计的等待时间,无法覆盖任意慢查询、调度暂停和网络延迟,不是通用一致性证明。

Read-Through 与 Write-Through 只是责任位置变化 ​

Read-through 由缓存库/代理在 miss 时加载源数据,应用看到统一读取接口。它减少重复代码,但 loader 的超时、批量、权限和负缓存仍需设计。

Write-through 在写入返回前同步更新缓存与后端。若两者不在同一原子协议中,依旧要处理一边成功一边失败;它不能只凭名称获得事务性。

Write-behind 先接受缓存写,再异步落后端,能合并写入并降低前台延迟,却把缓存变成暂时权威状态:需要持久队列、顺序、幂等、重试、积压监控和故障恢复。余额、库存等高价值数据不应仅凭一个异步线程承担这个风险。

Key 设计是正确性的一部分 ​

text
product:v3:tenant=42:locale=zh-CN:id=9182

Key 至少应考虑:

  • 租户和权限范围,避免跨用户数据泄漏;
  • 语言、币种、实验组等会改变结果的参数;
  • schema/序列化版本,支持安全发布;
  • 规范化参数顺序,防止等价请求产生多份 key;
  • 长度与敏感信息,避免把令牌或个人数据直接暴露在 key。

缓存空值要和“未命中”区分,可用显式 envelope:

json
{ "found": false, "source_version": 18 }

负缓存 TTL 通常应较短,因为不存在的对象可能很快被创建。

TTL 是业务陈旧上界的一部分 ​

TTL 选择应同时考虑:

  • 业务允许的 staleness;
  • 数据更新频率;
  • 重建成本与源站容量;
  • 缓存容量和逐出压力;
  • 故障时允许多久依赖旧值。

Redis 普通键在读取时会检查逻辑过期,后台物理回收晚一些不代表 GET 应继续返回过期值;但它不能替其他本地缓存、代理或 CDN 判定过期。参见 Redis 过期机制。

若从落后副本加载、慢 loader 在之后重新填入,或各层给同一旧值续一个完整 TTL,单层 TTL 就不是端到端陈旧上界。需要定义源数据年龄、加载最长期限和各层续期方式,再沿最坏读取路径计算;允许无界停顿或无限续期时,简单相加得不到有限保证。

对安全策略、封禁状态和价格等不同字段,不要为了方便共用一个任意 TTL。

多级缓存与版本化失效 ​

text
process local cache -> distributed cache -> source database

本地缓存最快,却无法由中心 Redis 删除自动清除。常见策略:

  • 广播失效事件,但要处理丢事件和重连补偿;
  • 本地短 TTL,分布式层较长 TTL;
  • key 中使用 generation/version,发布新版本后旧 key 自然不再读取;
  • 定期抽样与权威源对比,发现传播漂移。

版本化 key 适合整体发布或对象版本已知的场景,但旧 key 仍需靠 TTL/清理回收,且不能让版本计数本身成为不可靠双写。

本节检查点 ​

  • 能指出权威源、陈旧窗口和缓存丢失后的恢复路径。
  • 能分析 Cache-Aside 写后删除仍存在的双写窗口。
  • 不把 TTL、双删、write-through 名称当成一致性证明。
  • 能让 key 覆盖所有影响结果与权限的维度。

下一节处理缓存最危险的时刻:大量请求同时 miss、无效 key 绕过缓存,以及整个缓存层失效后的恢复风暴。

Built with VitePress | Software Systems Atlas