本文目录导读:

- 核心原则:先更新数据库,再操作缓存
- 策略一:Cache-Aside Pattern(旁路缓存模式)—— 最常用
- 策略二:Read/Write Through Pattern(读写穿透模式)
- 策略三:Write Behind Cache Pattern(异步回写模式)
- 策略四:基于 Binlog/CDC(变更数据捕获)的最终一致性方案
- 各策略对比总结
- 两种“坑”与最佳实践
- 推荐方案(针对不同阶段)
实现数据库与缓存数据同步是保证系统数据一致性的关键,尤其在微服务和高并发场景下,没有一种“万能”方案,需要根据业务对一致性、性能和可用性的容忍度来选择。
以下是几种主流且经过生产验证的实现策略,从强一致性到最终一致性排序。
核心原则:先更新数据库,再操作缓存
绝对不要先更新缓存,再更新数据库,如果数据库更新失败,缓存就变成了脏数据,几乎所有可靠的方案,都是以数据库的更新为基准。
Cache-Aside Pattern(旁路缓存模式)—— 最常用
这是大多数应用默认采用的模式,核心思想是应用同时维护数据库和缓存。
- 读请求:
- 读缓存 -> 命中则直接返回。
- 未命中 -> 查数据库。
- 将查到的数据写入缓存。
- 返回数据。
- 写请求(更新数据):
- 更新数据库。
- 删除缓存(而不是更新缓存)。
为什么写操作是“删除”而不是“更新”?
- 延迟加载:只有下次读请求未命中时,才重新加载数据,相当于“懒更新”,这避免了更新缓存带来的计算开销(如复杂SQL查询结果)。
- 避免并发问题:如果同时有两个写请求,先更新缓存的后更新数据库,会导致缓存是旧值,删除则不会有此问题。
并发问题及其解决方案(一种典型的“脏读”场景):
- 场景:请求A写数据库并删除缓存后,请求B刚要读缓存,发现被删,于是查询数据库,但数据库更新可能还未完全生效(如主从延迟),请求B读到旧数据,并写入了缓存。
- 解决方案:
- “延迟双删”:更新数据库后,先删除一次缓存,延迟几百毫秒(等主从同步完成),再删除一次缓存,这是工程上的折中。
- “设置过期时间”:缓存必须设置一个合理的过期时间(TTL),即使临时脏读了,数据也会自动过期,最终达到一致,这是兜底策略。
适用场景:读多写少、对一致性要求不是极端严格(最终一致性即可)的场景。
Read/Write Through Pattern(读写穿透模式)
缓存作为数据库的代理层,应用只与缓存交互,缓存负责同步数据到数据库。
- 读请求:同上,缓存未命中时,缓存组件自动去数据库加载。
- 写请求:应用更新缓存,缓存组件自动将数据持久化到数据库。
特点:
- 优点:应用代码最简洁,无需关心数据同步逻辑。
- 缺点:实现复杂,需要开发或依赖成熟的缓存中间件(如Apache Ignite、Hazelcast),很多简单缓存(如Redis自己)不直接支持自动同步。
适用场景:数据模型简单、对缓存层有完全控制权的内部系统。
Write Behind Cache Pattern(异步回写模式)
这是“读写穿透”的变体,应用更新缓存后,不立即更新数据库,而是异步批量同步。
- 写请求:更新缓存 -> 将变更任务写入消息队列或日志 -> 直接返回成功。
- 后台任务:周期性或按策略批量将缓存变更应用到数据库。
特点:
- 优点:性能极高(写请求响应极快),能将随机写合并成顺序写或批处理,减轻数据库压力。
- 缺点:数据一致性风险极高,如果缓存或后台服务崩溃,未同步的数据会丢失(除非有持久化机制),数据库和缓存不再是实时一致。
适用场景:对数据一致性要求不高,但对写性能有极致需求的场景(如计数器、访问日志、排行榜)。
基于 Binlog/CDC(变更数据捕获)的最终一致性方案
这是目前互联网大厂处理高并发、高一致性场景的首选方案,核心思想是完全解耦应用代码和缓存同步逻辑。
- 应用A(数据生产者):只负责更新数据库,对缓存的存在无感知。
- 数据库:产生Binlog(如MySQL的binlog)或WAL(预写日志)。
- 同步组件(如Canal、Debezium、Maxwell):订阅并解析数据库的变更日志。
- 消息队列(可选,推荐):同步组件将变更事件发送到MQ(如Kafka、RocketMQ)。
- 应用B(缓存消费者):消费MQ消息,根据变更ID/类型,查询新数据并更新或删除缓存。
为什么好?
- 彻底解耦:业务代码不必写任何“先写库再删缓存”的逻辑,只需要更新数据库。
- 高可靠性:Binlog是数据库的原始日志,生产者不会丢失数据,MQ保证了数据传输。
- 并发安全:天然解决了“先读后写”的并发问题,因为同步是按照数据库的提交顺序执行的(。
- 可审计:所有变更都有日志可追溯。
适用场景:对一致性要求高、并发量大、团队有能力搭建维护数据管道的核心业务(如订单、支付、用户中心)。
各策略对比总结
| 策略 | 一致性等级 | 性能 | 实现复杂度 | 适用场景 |
|---|---|---|---|---|
| Cache-Aside | 最终一致性 | 高 | 低 | 标准业务(读多写少) |
| Read/Write Through | 强一致性(缓存层实现) | 中 | 高 | 有自研缓存中间件的场景 |
| Write Behind | 弱一致性 | 极高 | 高 | 日志、计数器、排行榜 |
| Binlog/CDC | 强最终一致性 | 高 | 高 | 高并发、高一致性核心业务 |
两种“坑”与最佳实践
-
不要使用“先删缓存,再更新数据库”
- 场景:A删缓存 -> B读缓存失败,读旧数据入缓存 -> A更新数据库。
- 结果:缓存永远是旧数据,永远不要用这个顺序。
-
缓存必须设置过期时间(TTL)
- 这是数据一致性的最后防线,即使所有同步机制都出问题(如程序bug、网络故障),缓存也会在TTL后自动失效,从数据库重新加载。
- 建议:TTL不宜过长(如秒级或分钟级)。
推荐方案(针对不同阶段)
- 小型/初创项目:Cache-Aside + 延迟双删 + 较短的TTL,简单、有效、成本低。
- 中型/标准项目:Cache-Aside + 可靠的MQ(如RabbitMQ),在写操作后,发送一条MQ消息,消费端去删除缓存,平衡了一致性和复杂度。
- 大型/核心项目:Binlog/CDC(Canal/Kafka)+ Cache-Aside,彻底解耦,由专门的基础设施团队维护数据管道。
没有银弹。 你需要根据业务对数据不一致的容忍度、团队的技术能力和系统架构来权衡,大多数场景下,先更新数据库,再删除缓存,并设置合理的TTL 是最稳健的起点。