前言
缓存,几乎是现在互联网项目中最常见的一种加速工具了。 通过缓存,我们能大幅提升接口响应速度,减少数据库的访问压力,还能支撑各种复杂的业务功能,比如排行榜、风控系统、黑名单校验等等。
不管你用的是本地缓存,还是像 Redis
、Memcached
这样的分布式缓存,它们和 MySQL 这类数据库之间,都属于 “异地、异质存储” ——也就是说,它们不在一个地方,数据结构和操作方式也不一样。
这就带来一个问题:在分布式系统中,我们很难保证数据库和缓存在更新时 “要么一起成功,要么一起失败” 。一旦中间出错,就可能出现数据不一致——数据库是最新的,但缓存还是旧的;或者缓存更新了,但数据库失败了。
为了应对这种情况,业界衍生出了很多种“如何保持缓存和数据库数据一致性”的解决方案。这些方案在一致性保障、性能开销等方面各有优缺点。只有我们真正理解了这些方案的原理和适用场景,才能在面对实际复杂业务时,选出最合适的技术路径。
一、 缓存读写策略
在介绍数据库与缓存一致性方案之前,我们先来看一下经典的三种缓存读写策略,这三种策略是出现在计算机系统中的三大基本策略,而数据库与缓存一致性方案,也是基于这三种策略的思想,进行设计的。
🍎Cache Aside Pattern(旁路缓存模式)
在平时的开发中,我们最常用的一种缓存读写策略就是 Cache Aside Pattern(旁路缓存模式)。它特别适合读多写少的场景,比如商品详情页、用户资料页、排行榜等。
这种模式的特点是:缓存不是自动更新的,而是由应用服务自己“旁路”来操作缓存和数据库。而且,无论缓存和数据库怎么配合,最终还是以数据库的数据为准。
📝 写数据:
- 先更新数据库:因为数据库才是最终的数据源,必须先保证它的数据正确。
- 再删除或更新缓存:确保下一次读取时不会拿到旧数据。
(有时为了简单或避免并发问题,写完数据库后会选择“删缓存”而不是“改缓存”,让下一次读请求自动加载最新数据。)
📖 读数据:
- 先从缓存里查,如果有,就直接返回,速度非常快。
- 如果缓存没命中(缓存穿透),就去查数据库;
- 查完数据库后,再把结果写入缓存,方便下次读用。
🍎Read/Write Through Pattern(读写穿透)
除了旁路缓存(Cache Aside),还有一种缓存策略叫做 Read/Write Through Pattern,中文通常称为读/写穿透。它的思路是:把缓存当作“主战场”,应用程序只和缓存打交道,缓存自己负责跟数据库同步。
也就是说,缓存成了真正的“前线”,数据库则被藏在了后面,由缓存服务来决定什么时候读写数据库。
这种模式在日常开发中其实不太常见,主要原因是我们常用的 Redis、Memcached 等分布式缓存,本身并不支持自动把数据写回数据库。所以这个模式更多出现在一些特定的场景或者缓存服务本身就集成了这种能力的系统中。
📝 写数据(Write Through):
- 如果缓存中不存在数据,就直接把数据写入数据库(有些实现也可能先写入缓存,然后由缓存去同步数据库);
- 如果缓存中有数据,就直接更新缓存,由缓存服务自动同步数据到数据库。
也就是说,写操作是走缓存 → 缓存再同步到数据库,这对应用开发者来说是“看不见的”。
📖 读数据(Read Through):
- 应用先从缓存中读取数据;
- 如果缓存中没命中,就自动从数据库加载,再由缓存服务把数据写入缓存,最后返回给应用。
在这个模式下,缓存系统不仅是加速器,还是“数据库代理”,自己负责读写落库逻辑,对调用方来说完全透明。
🔍 和 Cache Aside 有什么区别?
Read/Write Through 和 Cache Aside 看起来很像,但有一个核心差别:
- 在 Cache Aside 模式 中,缓存更新是由客户端(业务服务)来处理的;
- 而在 Read/Write Through 模式 中,缓存更新是由缓存系统自己处理的,对客户端是透明的。
可以理解为:Cache Aside 是“你自己去超市买东西”,而 Read/Write Through 是“你告诉助手你要什么,助手帮你搞定所有操作”。
🍎Write Behind Pattern/Write Back(异步缓存写入/写回)
除了读/写穿透(Read/Write Through),还有一种更激进的缓存写入策略,叫做 Write Behind Pattern(异步缓存写入/写回)。
它和 Read/Write Through 很像,都是由缓存系统来负责数据库的读写工作,但它最大的不同在于写操作是“先写缓存,稍后再异步更新数据库”。
也就是说:应用写数据时,只写到缓存里,数据库并不会立刻更新,而是缓存系统稍后“批量”写入数据库。
📝 写数据(Write Behind):
- 应用将数据写入缓存;
- 缓存服务暂时保留数据(比如放在内存队列里);
- 缓存服务在某个时间点,异步地、批量地把数据写入数据库。
📖 读数据:
和其他模式一样,优先从缓存读,缓存未命中再从数据库加载。
⚠️ 优势与挑战:
优点:
- 因为数据库的写入是延迟+批量的,写性能非常高;
- 适合高频更新的场景,比如点赞数、浏览量,不需要每次都写数据库。
挑战:
- 数据一致性风险更大:如果还没来得及写入数据库,缓存服务就挂了,那这些数据可能就“丢了”;
- 更复杂的故障恢复和数据容错机制(比如 WAL、落盘日志、数据回溯等)才能保证安全。
下面针对这三种策略,介绍一些更详细的方案。
二、缓存一致性方案
CAP
在聊缓存一致性方案之前,我们先来了解一个非常经典的分布式系统理论——CAP 定理,它对我们后续的讨论非常关键。
CAP 定理,又叫 布鲁尔定理(Brewer’s Theorem),是理论计算机科学中关于分布式系统的一个重要结论。它告诉我们:
一个分布式系统,不可能同时满足以下三件事,只能选其二。
一致性(Consistency)
所有节点看到的数据必须是一样的。你读到的内容就是最新写入的结果,哪怕读的机器不同。可用性(Availability)
每次请求都必须有响应(不能返回超时或错误),哪怕返回的不是最新数据。分区容错性(Partition Tolerance)
系统在网络分区(比如服务之间通信延迟、网络中断)时,仍然能继续运作。
根据定理,分布式系统只能满足三项中的两项而不可能满足全部三项。理解CAP理论的最简单方式是想象两个节点分处分区两侧。允许至少一个节点更新状态会导致数据不一致,即丧失了C性质。如果为了保证数据一致性,将分区一侧的节点设置为不可用,那么又丧失了A性质。除非两个节点可以互相通信,才能既保证C又保证A,这又会导致丧失P性质。
而对于我们的缓存架构来说,分区容错性是我们必须保证的,那么,这是否代表,一致性和可用性无法同时保证呢?
让我们带着这个疑惑,来看一下缓存一致性方案。
2.1 Cache Aside Pattern
更新DB,再更新缓存
这种方式虽然保证了数据库数据的准确性,但也可能引发缓存和数据库不一致的问题。
举一个例子来说明:
假设有两个请求「A」和「B」,几乎同时对同一条数据进行更新。
- 请求 A 先执行,将数据库中的数据更新为 1;
- 然而,还没来得及更新缓存,请求 B 又将数据库的数据更新为 2,并紧接着更新缓存为 2;
- 此时,A 继续完成它的流程,把缓存又更新成了 1。
最终,数据库的值是 2,而缓存中却是旧值 1 —— 出现了明显的数据不一致问题。
这种现象,从专业角度来看,就是一种脏写(Dirty Write):
即后写入的较新数据被前一个旧写操作覆盖,造成数据倒退。
在高并发写操作的场景下,这种问题极容易发生,严重时可能导致业务逻辑混乱,数据紊乱。
更新DB,再删除缓存
既然更新缓存会导致脏写问题,那我们是否可以选择不写缓存、只删除缓存,让读请求时自动重建缓存,从而避免脏写?看似可行,但这是否就万无一失了呢?
来看下面这个场景:
- 请求A先去读缓存,发现缓存未命中,读取数据库的值为20
- 与此同时,请求B更新数据库的值为21
- 请求B继续删除缓存
- 请求A回设缓存值为20
最终结果:
- 数据库是 21
- 缓存是 20(请求 B 删除后导致缓存重建为旧值)
这就是典型的脏读问题:请求顺序错乱导致旧值覆盖新值。
虽然理论上存在这个问题,但在实际中它的发生概率非常低。因为:
- 数据库读操作通常比写操作快;
- 如果请求 A 在请求 B 之前完成了缓存写入,那请求 B 的删除操作不会影响后续的数据一致性;
- 即使不一致,也只是短暂的,后续请求会从数据库中重新拉取数据。
总结:
- 这种「先更新数据库,再删除缓存」的方式可以大概率保证数据一致性;
- 配合合理的缓存过期时间,可实现最终一致性。
但还没完,另一个风险点是:删除缓存失败。
如果数据库更新成功,但缓存删除失败,旧缓存仍然存在,导致数据不一致。这是分布式系统中典型的部分成功问题。
如何解决?
- 重试机制虽然可以一定程度缓解,但难以控制重试次数,太少不稳,太多影响接口响应时间;
- 更优方案是:异步可靠删除缓存。
做法是:
- 将删除缓存操作写入消息队列或任务队列中,由后台服务异步拉取并执行;
- 配合失败重试与日志告警机制,确保缓存删除最终成功。
这样既保证了数据一致性,又不会阻塞主业务流程,是更推荐的实践方式。
更新DB,异步删除缓存
我们在更新数据库后删除缓存时,可以通过异步方式来处理缓存删除操作。常见的异步实现方式包括以下三种:
- 线程池异步执行删除任务
- 消息队列异步投递删除请求
- 基于数据库 Binlog 日志消费(如 Canal)
前两种方式虽然常用,但它们对业务代码侵入较大:每当更新数据库时,开发者都必须显式编写删除缓存的逻辑,容易遗漏、维护成本高。
相比之下,基于 Binlog 的异步删除方式是当前业界较为推荐的方案。其核心流程如下:
- 数据库完成写入
- Canal 监听 Binlog 日志变化并解析
- 将变化内容发送到消息队列
- MQ 消费者接收到消息,触发缓存删除
由于 MQ 的可靠投递机制,能大幅提升缓存删除的成功率,从而增强缓存与数据库的一致性。
延伸思考
1. 为什么选择删除缓存而不是更新缓存?
因为更新缓存容易产生并发乱序问题。例如:一个旧值覆盖了新值,造成脏写,难以控制。而删除缓存是幂等操作,天然具备更好的一致性保障。
2. 有没有办法不删除缓存也能解决脏写?
有,比如加分布式锁,通过串行化更新操作防止乱序。但分布式锁使用复杂、对性能影响大,一般只在强一致场景中才推荐。
3. 删除缓存是否也有副作用?
是的,主要有两点:
- 删除后首次访问会触发缓存重建,若是热点数据(hotkey),可能造成缓存击穿。
- 一部分请求会落到数据库,导致缓存命中率下降,影响性能表现。
4. 异步机制会有什么问题?
异步天然存在延迟,在这段时间内,缓存和数据库可能处于不一致状态。不过通常这种不一致窗口较小,可通过优化消费速率、重试机制等方式缓解。
2.2 Read/Write Through Pattern
虽然这种策略看起来与旁路缓存(Cache Aside Pattern)类似,但两者之间还是存在一些区别。最主要的不同点在于:该方案并不是由业务逻辑直接控制缓存的写入,而是由专门的缓存服务(如 CacheSetter)负责完成缓存回源与更新。
正因为如此,这种策略更多的是一种架构层面的演进,并不是一种新的缓存一致性策略,因此我们在本文中不再对其展开详细讨论。
2.3 Write Behind Pattern
在前文中,我们介绍了多种缓存一致性策略,而这一节要讨论的“先更新缓存,异步更新数据库”的方式,则属于Write Behind(异步缓存写入/写回)模式,它与前述策略的最大区别是:只更新缓存,不立即更新数据库,而是通过异步手段,延迟、批量地将数据刷新到数据库中。
写流程:
- 客户端写入请求直接更新缓存;
- 同步或异步地将修改写入队列(如消息队列、内存队列);
- 后台线程池 / MQ 消费者 / 定时任务从队列中批量取出数据写入数据库。
读流程:
- 优先从缓存中读取数据;
- 若缓存不存在,则从数据库读取,并同步回写缓存。
该方案的优势在于:
- 写吞吐能力强:所有写操作都直接打到缓存,极大减轻数据库写压力;
- 数据即时可读:用户写入后立刻可读缓存,体验更好;
- 适合高并发写入场景:如秒杀库存、日志上报、计数器类应用等。
⚠️存在的风险与缺陷:
- 数据库和缓存可能不一致:缓存中的数据是最新的,而数据库是延迟更新;
- 数据可能丢失:如果缓存宕机、消息队列丢失消息、服务重启等,都可能导致更新未写入数据库;
- 一致性难以保障:特别在关键业务中,数据丢失可能带来严重问题。
方案对比汇总
策略 | 存在问题 | 优点 | 适用场景 |
---|---|---|---|
更新数据库 → 更新缓存 | 并发可能导致脏写 | 缓存命中率较高 | 对一致性要求不高,注重命中率 |
更新数据库 → 更新缓存(加锁) | 分布式锁影响性能 | 保证强一致性 | 写请求较少,强一致性场景 |
更新数据库 → 删除缓存 | 命中率略降,少数并发异常可能 | 简洁,最终一致性好 | 主流方案,适用于大多数业务 |
更新数据库 → 异步删除缓存 | 有延迟窗口,不是强一致 | 性能优,兼顾一致性和吞吐 | 主流推荐方案,适合大多数系统 |
更新缓存 → 异步更新数据库 | 数据可能丢失,无法保障一致性 | 写性能极强,延迟低 | 日志、计数器、非关键业务 |
三、真实业务中的缓存一致性实践
某手缓存方案
在某手,目前主流的缓存架构方案是将数据库中的热点数据写入到 Memcached中,以加速读请求,降低数据库压力。为确保缓存与数据库之间的数据一致性,采用的是 Cache Aside Pattern(旁路缓存模式) 的一种变种策略:
即在数据更新时,先更新数据库,再通过异步方式消费 binlog 日志,删除或更新对应缓存。
📖 读流程
- 先查缓存:优先从缓存(Memcached)中查询目标数据。
- 未命中则回源:如果缓存未命中,则调用
CacheSetter
服务进行回源。 - 读取并更新缓存:CacheSetter 从数据库中读取最新数据并更新缓存,再返回结果。
✍️ 写流程
- 更新数据库:应用服务处理用户写请求,直接更新数据库。
- 写请求结束:数据库更新成功后,用户请求结束。
- 异步同步缓存:
- kbus 服务监听数据库 binlog 变更;
- 收到变更后,异步调用
CacheSetter
; CacheSetter
再从数据库中读取最新值,更新缓存。
✅ CacheSetter 如何保证缓存一致性?
由于异步更新存在并发修改缓存的可能,CacheSetter
被设计为一个特殊的 RPC 服务,核心职责是 在高并发场景下,避免缓存更新过程中的“脏写”问题,确保最终一致性。
CacheSetter 保证针对同一id,同一时间,只有单一线程,在更新缓存,因此可以 避免并发问题 ,可以保证了缓存的 最终一致性 。
具体机制包括:
- 同 ID 路由固定:客户端请求使用一致性Hash算法,确保相同 ID 的请求路由到固定的
CacheSetter
实例,避免多实例并发更新同一份缓存。 - 并发访问收敛:
CacheSetter
内部通过CountDownLatch
等并发控制手段,确保同一时刻对于同一 ID 的并发 load 请求,只会命中一次DB,其他线程等待结果复用即可。
当一台实例上一个key已经在进行load的操作的时候,如果这时候又有这个key的请求要load cache,此时这次请求的这个key将不会在进行load操作。CacheSetter会为某个key分配一个 CountDownLatch ,当某个key请求CacheSetter时,会先检查下当前是否为这个key分配了CountDownLatch,如果已经分配了,说明这个key已经在loading了,就不再执行load操作了,只会通过CountDownLatch.await,在这个key load完成之后再返回;如果没有分配,就创建一个新的CountDownLatch,并将id和该CountDownLatch保存到map,等到load操作执行完,会执行CountDownLatch.countDown操作,并从map中移除该key和对应的CountDownLatch。
当然,这种方案依赖于异步 binlog 消费流程,所以不能保证实时一致性,但在大多数读多写少的场景下效果良好。
四、总结:缓存与数据库一致性策略的本质与抉择
在每种方案中,我们结合其在一致性与性能方面的权衡,明确了各自适用的业务场景。通过对比可以看出,缓存与数据库一致性问题的根本原因主要有两个:
- 指令乱序问题:即多个请求之间操作缺乏原子性保障;
- 分布式系统中的不确定性:包括网络失败、节点宕机等,导致单个请求的多个操作无法组成原子事务。
所有的一致性方案,实质上就是围绕着如何缓解这两大问题,进行策略设计与技术落地。
回到最初的问题:CAP 中的 C 和 A 是否能兼得?
经过前文的分析可以发现,在实际场景中:
- 当追求强一致性(Consistency)时,往往需要加锁、顺序执行或等待异步流程完成,带来的副作用就是性能下降,可用性降低;
- 而当强调可用性(Availability)与高性能时,则必然在一致性上做出一定让步,常见的表现如最终一致性或顺序一致性等。
写在最后
缓存与数据库一致性问题没有“银弹”式的完美解决方案。所有架构设计都是权衡的艺术。技术选型和策略制定,应该回归到业务的实际需求、系统的承载能力、以及对一致性与性能的优先级判断上。
愿本文内容,能为你在系统设计与架构演进过程中提供一些参考与思考。