本文目录导读:
这是一个非常核心且复杂的分布式系统问题,没有一种“万能”的缓存更新策略,最优方案取决于业务对数据一致性的要求、并发读写的强度、以及系统可用性的容忍度。
下面我会系统地梳理常见策略及优化方向,并给出选择建议。
核心痛点
缓存更新的根本矛盾在于:数据库和缓存是两个独立的存储系统,无法保证原子性更新,无论是先更新DB还是先更新缓存,都会出现短暂的不一致窗口,甚至可能导致数据永久不一致(如并发下的脏写)。
主流策略详解与优化
淘汰(删除)缓存 vs. 更新缓存
-
更新缓存:直接修改缓存中的值。
- 问题:如果这个缓存是一个复杂的计算或聚合结果(如“用户近30天消费总额”),更新成本极高,且在并发下容易导致“写写冲突”,导致缓存和DB数据不一致。
- 强烈不推荐,绝大多数场景下应淘汰(删除)缓存。
-
淘汰(删除)缓存:更新DB后,直接删除缓存中对应的key,下次读取时,Miss后回源DB并重建缓存。
- 优点:实现简单,避免复杂计算,天然适合“懒加载”。
- 优化方向:延迟双删、异步删除。
几种经典更新模式
Cache Aside(旁路缓存) —— 最常用,推荐
这是最经典的策略,也是上述“先更新DB,后淘汰缓存”的规范实现。
-
读流程:读缓存 -> Miss -> 读DB -> 写缓存。
-
写流程:先更新DB,再删除缓存。
-
为什么是“先更新DB,再删缓存”而不是“先删缓存,再更新DB”?
- 风险:先删缓存,在更新DB的间隙,另一个线程读Miss后将旧数据写入缓存,导致缓存与DB永久不一致。
- 先更新DB再删缓存:即使删除缓存失败,不一致窗口很小(只有DB更新成功到删除缓存成功之间),下次读Miss时会读到新数据。
-
优化策略(处理删除失败):
- 重试机制:删除缓存失败时,异步重试(如写入消息队列)。
- 延迟双删:先删除缓存 -> 更新DB -> sleep一小段时间(如几百毫秒) -> 再次删除缓存,这是为了应对“更新DB期间,读线程写入了旧数据”的极端情况,缺点是sleep会影响性能。
Read/Write Through(读写穿透)
缓存层是数据的主要访问层,上层应用只与缓存打交道,缓存负责与DB同步。
- 流程:应用写 -> 写缓存(缓存更新DB) -> 缓存返回成功。
- 优点:对业务代码透明,简化了DB操作。
- 缺点:对缓存中间件要求高(通常需要自行实现),实现复杂。
- 注意:这是缓存中间件(如Redis + 自定义代理)提供的服务,而不是简单的客户端策略。
Write Behind / Write Back(异步回写)
所有写操作只写缓存,不直接写DB,由后台异步线程批量将缓存中的数据合并后写入DB。
- 典型场景:微博点赞数、秒杀库存扣减。
- 优点:极致写性能、可合并批量操作、降DB负载。
- 缺点:数据丢失风险(缓存宕机时,未落盘的数据丢失,除非有持久化+主从备份)。
- 优化策略:
- 异步持久化:使用RDB/AOF等持久化机制。
- 写合并:同一key的多次写操作合并为一次DB更新。
- 削峰填谷:利用MQ进行削峰,缓存作为缓冲。
针对高并发、强一致性的特别优化
策略在极高并发读写下,仍可能因删除缓存失败或并发时序问题导致短暂不一致。
结合 Binlog 的最终一致性(推荐用于核心数据)
核心思路:不再由应用代码主动删除缓存,而是监听DB的变更日志(如MySQL的 Binlog/Canal,PostgreSQL的 WAL),从日志层删除缓存。
- 流程:
- 应用直接更新DB。
- DB写入Binlog。
- Canal监听Binlog,解析出变更的key。
- Canal异步删除缓存或更新缓存。
- 优点:彻底解耦,不依赖应用代码的健壮性(避免应用代码bug导致不一致),天然支持重试和顺序保证。
- 缺点:引入Canal/Kafka等组件,架构复杂。
- 优化方向:将写DB和删缓存做成强一致操作,例如使用Redis的Lua脚本将删除操作封装到DB事务提交的后置回调中,但这依赖于事务边界(如分布式事务)。
内存队列 + 串行化
核心思路:针对同一个用户/同一笔订单的请求,强制路由到同一个线程/队列中串行处理。
- 流程:
- 写入请求A(更新DB)。
- 读取请求B(读缓存)。
- 路由策略:根据key(如userId)哈希到唯一的队列。
- 串行执行:队列确保先执行请求A的更新,再执行请求B的读Miss回源,这样不会读到旧数据。
- 优点:理论上可以做到绝对一致(在串行化执行期间)。
- 缺点:严重损失吞吐量,死锁风险高,不适合高并发场景,实现复杂。
- 适用场景:对一致性要求极高(如支付、库存扣减)、并发量不高的场景。
读写锁(乐观锁/版本号)
核心思路:为缓存数据维护一个版本号。
- 流程:
- 读缓存时,一起读取版本号(如
version)。 - 写缓存时,先比较版本号,如果缓存版本号 > DB版本号,说明是旧数据,拒绝写入。
- 或者使用CAS(Compare And Swap)操作:Redis的
WATCH/MULTI/EXEC或SET key value NX+ 版本号。
- 读缓存时,一起读取版本号(如
- 优点:避免脏写。
- 缺点:实现略复杂,每次读需携带版本号,写需校验版本,适合写少读多的场景。
如何选择最优策略?
量化决策矩阵
| 场景 | 推荐策略 | 优化重点 |
|---|---|---|
| 低并发,可容忍秒级不一致 | Cache Aside + 延迟双删 | 实现简单,失败重试 |
| 高并发读,低并发写 | Cache Aside + 异步删除(Binlog) | 降低DB写入压力,保证最终一致 |
| 极高并发写(秒杀/计数) | Write Behind (异步回写) | 使用Redis RDB/AOF持久化,MQ削峰 |
| 强一致性要求(金融/库存) | 内存队列 + 串行化 或 直接DB | 放弃部分性能,甚至可直接降级为不缓存 |
| 数据是复杂计算结果 | Cache Aside + 异步删除 | 避免计算,懒加载 |
最终建议(优先级从高到低)
- 初学/标准场景:Cache Aside(先更新DB,再删缓存)+ 失败重试机制,这是最稳妥、最简单的方案。
- 数据一致性敏感,但可接受最终一致:Cache Aside + Binlog监听(如Canal),解耦能力强,应用层无感。
- 极高写入量,容忍短暂丢失或不一致:Write Behind,性能极致,但需做好持久化和告警。
- 绝对不能丢失数据或顺序错误(支付等):直接操作DB + 本地缓存(如Guava Cache)+ 乐观锁,或者使用分布式锁(ZooKeeper)控制写操作,但性能会下降。
核心优化思维
- 弱化“更新”操作:能删就不改,除非你能证明计算缓存值的成本远低于回源成本。
- 解耦:将缓存删除操作从业务主流程中剥离(通过MQ或Binlog),避免应用代码故障污染数据一致性。
- 兜底:总要为“删除缓存失败”设计重试和补偿逻辑。
- 监控:监控缓存Miss率、缓存删除失败率、数据不一致告警。
没有银弹,最好的策略是结合你的业务场景、并发量级、一致性容忍度,从最简单的Cache Aside开始,逐步引入更复杂的方案(如Binlog),并做好回滚预案。
标签: 更新频率