缓存架构:谁来填、谁来失效
比较 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,批处理改完库几秒内缓存自动失效。
面试时这样回答
- 先复述约束:读写比、能不能接受旧值、写后要不要立即读到、数据库有几个写入方。
- 说数据路径:点名图上的边,例如「未命中后应用读库再 SET 回填」「协调者同一事务提交加 outbox 再更新缓存」「缓存先确认再延迟刷库」「事务日志经连接器和事件流到更新器」。
- 说系统真相在哪:数据库永远是;write-behind 要额外说清确认后落库前谁持有这条写。
- 说代价与一个故障:例如先删后写把旧值回填回缓存,修法是先写库再删 key,TTL 兜底。