缓存架构:谁来填、谁来失效

比较 Cache-aside、应用托管 Write-through、Write-behind 与 CDC 驱动失效四种缓存拓扑:数据由谁放进缓存、写在什么时候算成功、旧值由谁清掉,以及每种架构的故障与恢复。

加缓存不难,难的是三件事:数据由谁放进缓存、写在什么时候算成功、旧值由谁负责清掉。 这三个问题的答案不同,就是不同的缓存架构。本章只比较拓扑;Cache-aside 和 Write-through 的代码级细节见 Cache 模式详解

想亲手走一遍四种拓扑、注入故障再恢复,打开互动 Lab:/system-design-lab/cache-architectures

有约束的设计问题

一个电商后台把商品、账户和价格存在数据库里,前面挡一层共享的 Redis。商品详情读多写少,晚几秒看到新值没关系;账户资料改完必须立刻读到;设备每秒上报的计数数据库扛不住同步写;价格则由批处理、后台任务和其他服务直接改库,应用代码根本看不到这些写入。四类数据各该用哪种缓存架构?

先定一条前提:数据库永远是系统真相(system of record),缓存只是派生副本,从不作为提交的依据。

四种 topology signature

架构谁把数据放进缓存谁写数据库写在何时算成功谁失效 / 刷新适合主要代价
Cache-aside应用,在读未命中时应用数据库写入提交应用写后删 key;TTL 兜底读多写少、能接受短暂旧值未命中三趟;写后短暂不一致;一致性靠应用
Write-through(应用托管)写协调者,每次写;读未命中仍回填写协调者,在同一事务里带 outbox数据库提交且缓存更新同一协调者;修复任务重放失败的更新写后必须立即读到新值写多一跳;不是分布式事务,要 outbox 和修复
Write-behind应用,每次写缓存的存储适配器,延迟后异步写缓存收下刷库覆盖;合并丢掉中间版本写入突发、能接受小窗口丢失确认过的写在落库前只在缓存里
CDC 驱动失效应用,在读未命中时应用和任何其他写入方数据库写入提交CDC 消费者,从事务日志数据库有很多写入方滞后窗口;事件至少送达一次,失效必须幂等

1. Cache-aside:应用自己回填,写后删 key

读:先查缓存,未命中就去数据库读,再把结果连同 TTL 放进缓存。写:先更新数据库,再删缓存 key,下一次读重新回填。缓存和数据库之间没有任何直接连线。

  • 顺序不能反。先删 key 再写库,会留下一个窗口:另一个读者未命中、读到旧值、把旧值放回缓存,一直到 TTL 到期。
  • 写后到下一次读之间,读者可能未命中或短暂读到旧值,这正是它和 write-through 的区别。
  • 只缓存被读过的数据,新节点为空也不致命,只是延迟变高;代价是未命中三趟,以及数据库被别人改了缓存不知道。
  • 不要缓存空值;进程内的本地缓存各实例会不一致,要么换共享缓存,要么把过期设得很短。
  • 热 key 到期时成百上千个请求同时未命中一起打数据库,用单飞或租约让一个请求回源,其余等它,并给 TTL 加抖动。

2. 应用托管 Write-through:两边都成功才返回

所有写收口到一个写协调者:校验请求、检查幂等编号、在同一个数据库事务里提交业务变更和一条 outbox 意图(「这个 key 要更新缓存」),提交成功后把新值连同版本号和 TTL 写进缓存,两边都成功才向用户返回完成。

  • 数据库和缓存不是一个事务。缓存更新失败时,意图留在 outbox 里 pending,向用户返回 202 Accepted 或可重试的失败,而不是正常完成;修复任务稍后读 pending 的意图,按版本号幂等地补写缓存。
  • 版本号(rowversion、时间戳或 ETag)防止旧的重试或修复任务把新值改回旧值;TTL 仍然保留,兜住漏掉的更新、直接改库和部署 bug。
  • 只用在读多、且用户写完必须立刻读到的路径;很少被读、变化极快或命中率低的数据不值得。
  • 缓存库自带的 write-through(例如 Hazelcast MapStore 写延迟为零)是同一形状的简化版:每次写同步到外部存储,缓存永不过时,但新空节点没有数据。

3. Write-behind:缓存先确认,稍后批量落库

应用把写交给缓存后立刻得到成功;缓存把它排进延迟队列,到期后按批写进数据库,窗口内同一个 key 的多次修改只有最后一次落库(合并)。

  • 在 Hazelcast 里,写延迟秒数为零就是 write-through,大于零才是 write-behind;开启合并后窗口内只写最后一次;批大小决定一次刷多少。
  • 从确认到落库之间,缓存是这条写的唯一持有者。持有队列的节点在刷库前挂掉,窗口内所有已经返回成功的写一起丢失,除非队列有副本。这一点厂商文档没有明说,是从机制推出的结论。
  • 合并意味着中间版本永远到不了数据库:订单从待付款到已付款再到已取消,数据库只收到「已取消」,审计对不上。
  • 只用在写入突发、数据库扛不住同步写、中间版本无所谓、能丢几秒的数据上,例如计数和心跳;钱、订单、任何要审计的东西都不能用。

4. CDC 驱动失效:失效信号来自事务日志

应用写库时不碰缓存。一个 CDC 连接器读数据库的事务日志,把每一次提交变成有序事件发进事件流,再由一个消费者按事件删掉或刷新对应的缓存 key。读路径仍是应用查缓存、未命中回填。

  • 连接器监控一个上游数据库,捕获全部变更;同一张表的事件按提交顺序送达;消费者停掉再启动也能补上漏掉的事件;可以选择恰好一次或至少一次送达。
  • 因为写入方可以是应用、批处理或直接连库的人,应用管不住每一次失效,才把责任移到数据库日志。
  • 缓存旧多久等于捕获到应用的滞后;事件可能重复送达,删除天然幂等,写新值要带版本或日志位置。
  • 大促前批量改价几十万条、消费者只有一个时会落后几分钟:监控滞后并告警,按分区加消费者,TTL 兜底,必须立即生效的少数 key 由应用主动删 key 作补充。

故障与恢复

架构故障用户看到什么恢复
Cache-aside先删缓存再写库,旧值被回填一直读到旧值直到 TTL 到期先更新数据库再删 key;TTL 兜底
Cache-aside热 key 过期,请求同时回源数据库被同一条查询打满单飞或租约;TTL 加抖动;热 key 提前刷新
Cache-aside每个实例各有本地副本刷新两次看到一新一旧共享分布式缓存;本地缓存过期设很短
Write-through数据库提交了,缓存更新失败缓存里仍是旧值返回 202;outbox 保持 pending;修复任务按版本重放
Write-through旧的重试覆盖新值修改「丢了」带版本号写入,低版本跳过;幂等编号
Write-behind节点在落库前挂了看到过成功,数据库里没有队列做副本;缩短延迟;不能丢的改走 write-through
Write-behind合并吃掉中间版本历史和审计对不上对需要历史的 key 关闭合并或另记事件表
CDC消费者落后改过的 key 一直给旧值监控滞后并告警;加消费者;TTL 兜底
CDC同一事件送达两次缓存可能被改回旧版本删除天然幂等;写值带版本或日志位置

贯穿四种架构的几条原则:按接口和实体统计命中率而不是看全局数字;缓存前面放超时和熔断,缓存挂了只能在数据库测过的容量内直读;按用户或租户隔离的数据 key 里必须带范围,敏感数据不进共享缓存;热 key 拆值或缩小投影;一次数据库写可能要改多个缓存 key,优先缓存实体而不是宽查询结果。

怎么选

  • 读多写少、能接受短暂旧值、缓存没有自带读写穿透:Cache-aside,TTL 兜底、先写库后删 key。
  • 用户写完必须立刻读到新值、写路径能收口:应用托管 Write-through,outbox 加修复任务加版本号。
  • 写入突发、数据库扛不住同步写、中间版本无所谓、能丢几秒:Write-behind,只给计数和心跳这类数据。
  • 数据库有应用管不住的写入方:CDC 驱动失效,接受滞后,消费者必须幂等。
  • 静态数据别懒加载,启动时预热;命中率一直上不去的路径别缓存。

回到开头的电商后台:商品详情走 Cache-aside;账户资料走 Write-through;设备计数走 Write-behind;价格走 CDC,批处理改完库几秒内缓存自动失效。

面试时这样回答

  1. 先复述约束:读写比、能不能接受旧值、写后要不要立即读到、数据库有几个写入方。
  2. 说数据路径:点名图上的边,例如「未命中后应用读库再 SET 回填」「协调者同一事务提交加 outbox 再更新缓存」「缓存先确认再延迟刷库」「事务日志经连接器和事件流到更新器」。
  3. 说系统真相在哪:数据库永远是;write-behind 要额外说清确认后落库前谁持有这条写。
  4. 说代价与一个故障:例如先删后写把旧值回填回缓存,修法是先写库再删 key,TTL 兜底。

一手证据