3种常用的缓存读写策略详解
引入缓存后,数据同时活在数据库和缓存两处,”写的时候先动谁、读 miss 了谁去回填”就成了必须回答的问题。本文拆解 Cache Aside、Read/Write Through、Write Behind 三种经典读写策略的路径差异,重点剖析 Cache Aside 在高并发下的一致性缝隙,以及延迟双删、binlog 订阅这些工程上的补丁。
它解决什么问题
没有缓存时,数据库是唯一数据源,读写路径没有歧义。缓存加入后,一次更新动作涉及两个存储,而它们之间没有分布式事务保护:先写库再写缓存,中间任何一步失败或被并发插入,都会留下两份不一致的数据。读写策略要回答的核心问题就是:以谁为准、失败怎么收、并发怎么排。
三种策略代表了三种责任划分。Cache Aside 把缓存当作”可有可无的加速器”,应用自己同时维护两份数据;Read/Write Through 把同步职责整体下沉给缓存组件,应用只面对缓存;Write Behind 则更进一步,连数据库写入都改成异步批量,用一致性换写性能。不存在最优解,只有与业务一致性要求、写频、团队能力匹配的选择。
核心原理
Cache Aside(旁路缓存)
应用是指挥官,缓存和数据库都由它直接操作。这是互联网应用的事实标准,尤其适合读多写少。
读路径: 读缓存 -> 命中返回 |
注意写路径是”删除”而非”更新”缓存。一是懒加载:被频繁更新但少有人读的数据,不值得每次都重写缓存,等下次读时再按需回填更经济;二是并发安全:两个写线程各自”更新缓存”时,后完成数据库更新的线程可能最后才写缓存,把旧值盖在新值上,而删除是幂等的,天然规避了覆盖顺序问题;三是成本:更新往往只动了对象的部分字段,重写缓存可能需要重新组装整个对象,删除则是 O(1)。
Read/Write Through(读写穿透)
应用把缓存当作唯一入口,缓存组件自己对数据库负责:
- Read Through:读 miss 时由缓存组件去数据库加载、写入自身再返回,对应用透明。本质是把 Cache Aside 里”miss 后回填”的逻辑从应用侧下沉到缓存层。
- Write Through:写缓存后,缓存组件同步地把数据写入数据库,两边都成功才向应用返回成功。
应用代码因此非常干净,一致性责任收敛在一处。代价是缓存组件必须实现与数据库的同步逻辑——这正是 Redis 裸服务不具备的能力,所以该模式多见于自带持久层对接的缓存产品或中间件,业务自己实现的成本高。
Write Behind(异步写回)
写操作只更新缓存便立即返回,缓存组件把变更放进队列,由后台线程异步、批量地刷回数据库。写延迟降到内存操作级别,且多次对同一 key 的修改可以合并成一次数据库写,吞吐极高。这个思想并不陌生:InnoDB 的 Buffer Pool、操作系统的页缓存,都是”先写内存、异步刷盘”的 Write Behind 实践。代价同样直白——缓存宕机时队列里未刷盘的数据全部丢失,所以它只配得上浏览量、点赞数这类允许丢失窗口的高频写场景,绝不能用在订单与库存上。
深入机制:Cache Aside 的一致性缝隙
Cache Aside 看似简单,两个时序问题却是面试和生产的常客。
第一问:写路径能不能先删缓存、再更新数据库? 不能。看下面的并发时序,写线程 A 与读线程 B 交错:
1. A 删除缓存 |
先删缓存制造了一个”缓存空窗期”,读请求在这个窗口内会把旧值重新种回去。反过来”先更新数据库、再删除缓存”,空窗期不存在:缓存要么还是旧值(删除前读到,勉强可忍),删除后 miss 必然读到新值。
第二问:先更新数据库、再删缓存就绝对安全吗? 也存在缝隙,只是概率低得多:
1. A 读缓存 miss,从数据库读到旧值(此刻缓存恰好失效) |
这个缝隙要求”读线程的回填动作”恰好晚于”写线程的删除动作”,而读路径通常远快于写路径(写要走事务),实际触发概率很低。多数业务接受这个理论窗口,再配上较短 TTL 兜底自愈即可。
补丁一:延迟双删。 对一致性要求更高的场景,写线程在”更新数据库、删除缓存”之后,休眠几百毫秒再删一次缓存,把上述时序里可能被回填的旧值清掉。延迟时间要大于一次读请求回填的耗时,实践中常取主从复制延迟加几百毫秒缓冲。缺点也明显:休眠时长靠经验拍,第二次删除仍可能失败,且写线程被拖长。
补丁二:binlog 订阅。 更彻底的兜底是让缓存失效彻底脱离业务代码:用 Canal 这类组件伪装成从库订阅数据库 binlog,解析出数据变更后投递消息,由消费者删除对应缓存(多级缓存时同时广播失效本地缓存)。业务代码只写数据库,缓存失效由数据变更事件驱动,重试、幂等都可以在消费链路里做,可靠性远高于应用内双删。代价是引入了 Canal 与消息队列一整套基础设施,运维复杂度上了一个台阶。
工程实践
- 默认 Cache Aside:绝大多数读多写少业务用它起步,配短 TTL 自愈,配合穿透、击穿防护就是及格线以上的方案。
- 一致性分级:把业务数据按一致性要求分级。商品详情这类容忍秒级旧值的走普通 Cache Aside;价格、库存展示这类敏感数据上延迟双删或 binlog 订阅;强一致的数据(如账户余额)干脆不缓存,直读数据库。
- 删除失败要有补偿:删除缓存失败时把 key 写入重试队列或消息队列异步重试,避免一次网络抖动留下长期脏数据。
- 更新而非删除的特殊场景:写频率极高且每次都要读的场景,删除-回填会造成缓存频繁抖动,可考虑加分布式锁的”更新数据库同时更新缓存”,但要清楚锁的开销与复杂度。
- Write Behind 要量化丢失窗口:用之前先算清楚宕机最坏丢多少数据、业务赔不赔得起,并给刷盘线程配积压监控。
对比与权衡
| 维度 | Cache Aside | Read/Write Through | Write Behind |
|---|---|---|---|
| 读写路径 | 应用直连缓存与数据库 | 应用只连缓存,缓存同步数据库 | 应用只写缓存,缓存异步刷数据库 |
| 一致性 | 最终一致,存在小概率缝隙 | 缓存与数据库同步写,较优 | 最弱,宕机丢未刷盘数据 |
| 写延迟 | 数据库写 + 一次删除 | 两个存储同步写,偏高 | 极低,内存写即返回 |
| 实现成本 | 低,业务代码即可 | 高,缓存组件需支持持久层对接 | 高,需队列、批量与补偿机制 |
| 典型场景 | 读多写少的通用业务 | 想收敛一致性逻辑、有成熟中间件 | 计数、行为日志等高频可丢失写 |
选型一句话:默认 Cache Aside;团队有能力维护中间件且希望应用无感知时考虑 Through;写流量大到数据库扛不住且业务容忍丢失,才轮到 Write Behind。
面试视角
Cache Aside 为什么先更新数据库、再删缓存? 反过来”先删缓存再更新库”会制造缓存空窗期,并发读会把数据库旧值回填进缓存形成长期脏数据。先更新库再删缓存,删除前的读到旧值只是短暂窗口,删除后必然读新值。答出时序推演过程比背结论更有说服力。
为什么写操作是删除缓存而不是更新缓存? 三个理由:懒加载避免为没人读的数据反复重算缓存;删除幂等,规避并发写”后写库先写缓存”导致的旧值覆盖;部分字段更新时重算整个缓存对象成本过高。删除把复杂度和一致性风险都降到了最低。
先更新库再删缓存还有没有不一致?怎么补? 有,读线程 miss 后读库、回填之间,恰好插入一次完整写操作,旧值会被种回缓存,概率低但存在。轻量补救是短 TTL 自愈加延迟双删;彻底方案是 Canal 订阅 binlog,让缓存失效由数据库变更事件驱动,脱离业务代码时序。
Write Behind 适合什么场景?最大风险是什么? 适合写极频繁、允许丢失少量数据的场景,如浏览量、点赞计数,InnoDB Buffer Pool 和操作系统页缓存都是同一思想的落地。最大风险是缓存宕机丢失未刷盘数据,所以用之前必须量化丢失窗口并确认业务可承受,强一致数据禁用。
Read Through 和 Cache Aside 本质区别是什么? 读的语义完全一样,都是 miss 后从数据库加载并回填;区别只在回填动作由谁执行——Cache Aside 是应用代码,Read Through 是缓存组件。后者对应用透明、职责更收敛,但要求缓存层具备对接数据库的能力,这是 Redis 原生不提供的。
小结
- 读写策略的本质是在两个无事务保护的存储之间划分一致性责任:Cache Aside 责任在应用,Through 责任在缓存组件,Behind 用一致性换写性能。
- Cache Aside 的”先更新库、再删缓存”是并发时序推演的结论,不是教条;它仍有小概率缝隙,靠 TTL、延迟双删、binlog 订阅分级兜底。
- 删除优于更新,赢在低复杂度和幂等性;Write Behind 赢得写性能,输掉数据安全,使用前先量化丢失窗口。
- 工程上按数据一致性要求分级选型,比寻找”银弹策略”更接近正确答案。

