拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

数据库与缓存双写模式:从原子性缺失到事件驱动架构演进

数据库与缓存双写模式:从原子性缺失到事件驱动架构演进

1. 双写模式的起点:为什么大家都喜欢"先DB后Redis"

先聊一个我在很多团队里都见过的场景:业务代码里需要同时更新数据库和缓存,不少人的第一反应是写下这样两行逻辑——先执行UPDATE语句把数据落库,再执行SET命令把缓存更新掉。理由也很朴素:数据库是权威数据源,写成功了才算数,缓存只是加速层,晚一点跟上也没关系。

这个思路放在单机、低并发、系统刚起步的阶段确实够用。数据量不大,请求量不高,就算缓存和数据库短暂不一致,过几分钟缓存过期也就自动纠正了。可一旦系统进入分布式环境,问题就没那么温柔了。用户请求可能被负载均衡打到任意一台机器,多个副本同时处理写请求,缓存集群分片散落在不同节点,这时候"先DB后Redis"的简单思路会连续踩中几个隐藏很深的坑。

先说这个方案的前提假设:它默认"数据库写成功"和"缓存写成功"之间不存在冲突,也默认两次操作之间不会有人插入别的请求。但分布式系统里,时间片是共享的,A请求刚提交完DB事务,还没来得及写Redis,B请求就带着同一份数据的新值进来了。于是B把DB更新成新值,又把缓存更新成新值,然后A姗姗来迟,用旧值覆盖了Redis。最终缓存里躺着一份比数据库更旧的数据,而且这份脏数据可能一直活到缓存过期。

这就是传统双写模式最核心的缺陷:两步操作无法形成原子性,也无法保证时序。你以为是"先写DB再写Redis"的精巧设计,实际只是把问题从"缓存先行"换成了"DB先行",但缓存被旧值覆盖的窗口依然存在。

再往下挖一步,问题比"时序混乱"更严重的是部分失败。DB写成功了,Redis却因为网络抖动、连接池耗尽、或Redis进程OOM而写入失败。这个时候数据库是新值,缓存是旧值,读请求在缓存过期之前都会拿到过期数据。如果系统对一致性要求不高,这可能只是体验问题;但如果这个数据是库存、是余额、是订单状态,那就是线上事故。

所以先给结论:双写模式在分布式环境下的本质缺陷不是"先DB还是先Redis"的顺序问题,而是这两个存储系统之间天然缺乏事务边界。你没办法像操作单个数据库那样,用一个BEGIN TRANSACTION把MySQL和Redis圈进同一个原子提交范畴。于是无论顺序怎么排,都会留下不一致窗口,只是窗口大小和出现概率不同而已。

我在实际项目里见过最典型的案例是库存扣减。业务方为了"保险",先扣DB库存,再删Redis缓存。结果DB扣减成功,Redis删除操作因为网络超时返回失败,服务端做了重试,重试又因为连接池已满没发出去。最终缓存里还是原来的库存数字,用户下单时看到有货,点进去却提示已售罄。这类问题排查起来极其痛苦,因为DB侧数据是对的,缓存侧数据是旧的,两边都"有据可查",却对不上账。

2. 不可靠的时序:并发请求下的覆盖与回退

双写模式第二个大坑,是并发环境下缓存被旧值覆盖的概率比大多数人想象的高得多。很多人觉得"先DB后Redis"已经给了数据库一个优先写入权,起码数据库不会错。但数据库不错,不代表缓存不错,更不代表读到的数据不错。

用一个具体场景说明。假设商品价格字段,初始值是100元。两个请求几乎同时进来,请求A要把价格改成80元,请求B要把价格改成120元,最终期望的DB结果是120元(按提交顺序)。理想时序是:

  • A写DB,价格变80
  • A写Redis,缓存变80
  • B写DB,价格变120
  • B写Redis,缓存变120

但分布式环境没有这么听话的时序。实际可能变成:

  • A写DB成功,内存中价格字段是80
  • B写DB成功,DB价格变120
  • B写Redis成功,缓存变120
  • A此时才发起Redis SET,携带的还是80这个旧值

最终结果是数据库里是120,缓存里是80。用户刷新页面看到80元,下单时却按120元结算。这个案例里的根本问题在于,缓存写操作不是读取数据库的最新值再写,而是拿着调用方业务代码里残留的旧内存值直接覆盖。竞态条件在代码层面是存在的:业务方从DB读到旧值,经过一段耗时逻辑处理后写缓存,而这个处理窗口内其他请求已经更新了DB。

这种情况在缓存更新策略里叫"老线程覆盖新数据"。要缓解,常见做法是写缓存前先查一次DB当前值,只有值一致才写。但这样等于把一次缓存更新变成了三次存储交互,性能代价极大,而且查完后到写入前依然有极小的竞态窗口,只是概率被大幅压缩。

另一个容易被忽视的时序问题是跨机房或跨集群的时钟偏差。有些团队会尝试用时间戳来解决覆盖问题——给每条数据加一个lastModified时间,写入缓存前比较一下新旧时间戳,旧的不写。想法没错,但分布式系统的机器时钟即使做了NTP同步,依然存在毫秒级偏差。在极端情况下,慢的那台机器带着过期的业务时间戳,却因为系统时钟反而新,成功覆盖了快机器的正确数据。用时间戳做并发控制,在分布式环境里是出了名的坑,Cassandra和旧版Dynamo都在这方面吃过亏。

所以双写的时序问题,本质上是一个"无锁更新"问题。两个节点同时持有同一份数据的不同版本,各自向缓存提交自己的版本,缓存端没有类似数据库MVCC那样的版本裁决机制,旧值就可能胜出。要彻底避开,通常需要引入版本号、CAS(Compare And Swap)或分布式锁,而这已经是另一套设计思路了。

一个我在实践中验证过的规律:并发覆盖问题的出现频率,几乎和"业务处理链路长度"成正比。链路越短,DB和Redis之间的间隔越小,覆盖概率越低;链路里只要加一次远程调用、一次MQ消息发送、一次外部接口等待,窗口就会被拉大,问题就会更容易暴露。

3. 部分失败的灾难:DB写完Redis没写的三种典型情况

很多踩过坑的人都会说这样一句话:"双写模式最怕的不是并发,而是写一半失败。"这句话精准点出了问题的另一个维度:原子性缺失。

一个完整双写动作包含两个独立操作,任何一个都可能失败。我把实际中遇到的情况归纳成三类,每一类在线上都有非常具体的表现。

**第一类:DB成功,Redis失败。**这是最隐蔽的一类,因为数据库是权威,数据本身没错,但缓存旧了。读多写少的场景下,这个旧缓存会一直命中,直到过期。如果过期时间设置得特别长,比如一小时,那这一小时内所有读请求都拿到旧值。运营后台看到的数据和用户端看到的数据不一致,客服的工单就会堆起来。这类失败的根因通常是Redis端网络超时、连接被拒、或实例发生主从切换时短暂不可用。

**第二类:Redis成功,DB失败。**这类问题更严重,因为它产生了缓存里存在、数据库里不存在的"幽灵数据"。很多业务代码习惯先写Redis后写DB(觉得Redis快),结果Redis写入成功,DB事务因为唯一键冲突、约束校验失败或存储引擎报错而回滚。缓存里留下一条数据库里根本没有的记录。读请求命中后返回给用户一份"凭空出现"的数据,而且这份数据在Redis里还很"新鲜",过期时间被重新刷新过。等发现时往往已经影响了一大批订单或展示内容。

**第三类:两边都失败。**这个相对容易感知,系统会报错,调用方会重试或降级,虽然影响业务,但至少"故障是明账"。反而是前两类"半成功"状态最难处理,因为它不报错,数据就是不对,而且不对得悄无声息。

面对这三类问题,很多团队的第一反应是加大重试力度。DB失败重试DB,Redis失败重试Redis。重试确实能拉高成功率,但解决不了语义层面的问题——重试没有让两次写操作变成一次原子操作。极端情况下,重试本身还会放大另一类问题:如果DB提交成功、但响应确认丢失,调用方认为失败并重试,结果DB被写了两遍。这时候如果业务代码没有做好幂等控制,库存扣了两次、余额扣了两次,事态会从"缓存不一致"升级成"数据库数据错误"。

我在一个做积分系统的项目里遇到过真实的"半成功"事故。用户完成一笔订单,系统要增加账户积分。代码先更新DB积分,再写Redis缓存。某个晚上Redis集群的主节点发生故障切换,写缓存的操作全部超时。DB积分数值正确,缓存里的积分却停留在旧值。用户端App一直显示旧积分,但后台报表新积分。排查时花了大半天才定位到是"半成功",因为没有任何日志报错,只有两份数据对不上的怪异现象。

解决"半成功"问题的惯性思维是补一个兜底任务,定期扫描DB和Redis的差异并纠正。这确实有效,但注意:兜底纠正只能修正"最终一致性"问题,纠正的延迟窗口内用户依然会读到脏数据。所以兜底是缓解方案,不是根治方案。

4. 为什么消息队列和订阅分发能替代双写

聊完了问题,必须给出一条走得通的路。目前业界最主流的替代方案,不是写两个存储系统,而是只写一个系统,然后通过消息或日志把数据的变更传播出去。核心思想很简单:MySQL作为唯一写入端,Redis的更新动作被异步触发,数据流从"应用同时写两个系统"变成"应用写一个系统,系统自动同步到另一个"。

最常见的落地方式是借助MySQL的Binlog。业务代码只操作数据库,Binlog会记录每一次数据变更。一个伪装成从库的同步组件订阅Binlog,解析出变更内容,再异步写入Redis。这就是Canal、Debezium这类工具的典型用法。这种方案有几个明显优势:

  • 应用层不再需要关心Redis写入是否成功,因为同步逻辑被从业务链路中剥离了
  • 不存在"DB成功Redis失败"的原子性问题,因为Redis写入被挪到了DB提交之后,而且同步组件自身有重试和断点续传机制
  • 并发覆盖问题也大幅缓解,因为Binlog本身就是有序的,变更按照提交顺序同步到Redis,旧值不会倒灌

但这个方案不是银弹。它引入了新的组件和新的运维负担,Binlog同步组件本身可能成为单点,同步延迟在高峰期也可能从毫秒级拉长到秒级。如果你的系统对缓存新鲜度要求极高,秒级延迟也会有体验差异。另外,如果业务里存在大量非DB触发的缓存更新逻辑(比如基于内存计算的统计值),Binlog方案就覆盖不到了。

除了Binlog,还有另一个方向是事务消息。把"写DB"和"发消息"放进一个本地事务里协调?这就要依赖RocketMQ这类支持事务消息的中间件。流程是:先发送一条半消息,然后执行DB事务,事务成功则提交消息,事务失败则回滚消息。消息消费者收到消息后更新Redis。这套方案的核心价值是:DB和消息之间建立了松散的事务关联,要么都成功,要么都放弃,不会出现只写一半的情况。

事务消息和Binlog方案各有适用场景。Binlog适合数据源头稳定、变更路径相对单一的传统业务库;事务消息适合已经重度依赖MQ、且希望业务代码显式控制发布时机的团队。两者都能把"双写"从同步变为异步,但注意,异步化带来的是最终一致性,读请求在缓存未更新的短暂窗口里,依然可能读到旧值。如果你连"秒级内读到旧值"都无法接受,那就不是缓存方案能解决的问题,而是需要重新设计读写路径,让读请求直连DB或走强一致的缓存策略。

我在实践中更倾向于Binlog方案,原因是它对业务代码的侵入最小。代码里完全不用再写"更新Redis"的逻辑,缓存同步是数据基础设施的一部分,业务团队只关心DB,Redis只是数据的一个订阅视图。这种"单一写入源+事件分发"的模型,在架构上比双写干净得多。

还有一个务实的补充做法:在Binlog同步之外保留一个兜底TTL。假设所有缓存都设置一个合理的过期时间,即使同步组件出现故障,缓存最终也会过期并回源DB。这个兜底不解决秒级一致性问题,但能防止"同步彻底挂了,缓存永远旧下去"的最坏情况。

5. 延迟双删到底有没有用——我的直接经历与使用建议

延迟双删(Delay Double Delete)可能是"先DB后Redis"基础上最常见的改良方案了。思路是:先删除缓存,再更新DB,然后延迟一段时间再次删除缓存。为什么能解决前面说的覆盖问题?

核心逻辑是这样的:第一次删除缓存后,读请求会把DB的旧值回填到缓存。如果此时并发写请求更新了DB,旧值就残留在缓存里了。延迟第二次删除,就是为了把这些在第一次删除到DB更新之间回填的旧缓存再清一次。

这个方案确实能有效降低不一致窗口,但有两个前提必须满足:

  • 延迟时间必须大于"读请求回填缓存"的耗时。如果业务里读链路很长(比如要聚合多张表后写入缓存),延迟太短就删不到脏数据
  • 第二次删除也可能失败,所以往往需要配合重试机制或一个延迟任务兜底

我自己的使用经历里,延迟双删是"能用但不好用"的方案。它能解决旧值覆盖问题,却引入了新的复杂性:延迟时间的设置没有绝对正确值,调短了删不干净,调长了缓存空窗期变长,缓存命中率下降。更麻烦的是,如果业务代码里同时存在多个写入口,每个入口都要实现一遍双删逻辑,遗漏任何一个入口都会漏删。

另一个实际中容易翻车的地方是:延迟双删和主从复制延迟叠加。在MySQL主从架构下,如果写操作走主库、读操作走从库,第一次删除缓存后,读请求可能从从库回填了一个旧值,然后主库才完成DB更新。第二次删除如果按主库事务提交时间计算,可能延迟不够,从库尚未同步新值,第二次删除后又把旧值放进来。这种场景下,双删的效果会被主从延迟直接抵消。

所以我的判断是:如果团队还在用传统双写模式,又短期无法迁移到Binlog或事务消息方案,延迟双删算是一个合格的中转方案。但一定要意识到,它是一个补丁,不是架构演进方向。补丁的维护成本会随系统复杂度急剧上升,而且"延迟时长"这个参数最终要靠线上压测去猜,带着极大的经验主义成分。

实践中的参数建议:第一次删除后立即更新DB,第二次删除的延迟时间按照读链路P99耗时的3到5倍设置。比如读链路P99是50毫秒,延迟双删的等待时间可以设为150到250毫秒。注意,这个值不是越大越好,因为第二次删除前的这段窗口里,缓存是缺失的,所有读请求都会穿透到DB,延迟过长等于主动降低了缓存命中率。

线上实施延迟双删时,第二次删除务必做成异步且带重试。最常见的失败场景是:第二次删除丢弃在进程重启或网络抖动中,没有补偿机制的话,脏缓存还是会活到TTL。用MQ或延迟任务做补偿,比在同步链路里反复重试要稳得多。

6. 分布式锁能否拯救双写——什么场景真的需要加锁

另一个被频繁问到的方向是:给双写操作加一把分布式锁,是不是就能解决并发覆盖问题?直觉上确实如此——同一把锁约定了所有写操作串行执行,DB和Redis之间不会有其他请求插队。但实际落地时,需要问自己三个问题。

第一个问题:锁的范围是否覆盖了所有写入口?很多系统的写入口不止一个,有用户请求触发的写,有定时任务触发的写,有消息消费触发的写,有后台人工修正触发的写。如果只有一部分入口加了锁,另一部分入口照样并发执行,锁就形同虚设。所以用分布式锁前,必须先盘点所有可能写入同一份关键数据的代码路径,确保每个入口都走同一把锁。

第二个问题:锁的粒度是否合理?锁粒度太粗,比如一把锁锁住整个用户维度,会导致不同业务的写操作互相阻塞,吞吐量断崖式下降。锁粒度太细,比如只锁一个字段,那分布式锁本身的实现复杂度可能超过业务逻辑。分布式锁的最佳粒度是"同一份业务数据的同一生命周期节点",比如同一个订单只能被并发支付一次、同一个库存扣扣减单只能被并发处理一次。

第三个问题:锁的可靠性怎么样?如果是基于Redis的分布式锁,本身就有主从切换导致锁丢失的风险。虽然Redlock算法试图解决这个问题,但在复杂的网络分区场景下,Redlock本身也存在争议。如果你的分布式锁并不可靠,那它保护的双写操作也就不可靠。如果业务真的强依赖分布式锁来保证一致性,更稳妥的是改用数据库自身的行锁或乐观锁,至少在锁可靠性上可以对齐DB的强一致能力。

我的观点是:分布式锁能解决"并发覆盖"问题,却绕不开"部分失败"问题。锁只能保证同一时间只有一个线程在写,但它保证不了写Redis一定成功。Redis写入失败的情况下,脏数据依然会出现,锁不会帮你自动补偿。所以在架构设计时,把分布式锁当成并发控制手段可以,但别把它当成一致性保障手段。

真正需要分布式锁的场景,一般是"业务层面必须串行处理同一份数据"——比如防止超卖、防止重复下单、防止余额并发扣减。这类场景即使没有缓存,DB本身也需要锁或事务来约束。缓存只是顺带的问题,分布式锁的核心价值是为DB层面的数据正确性兜底,而不是为Redis的一致性兜底。

我见过不少团队把分布式锁直接包在"先DB后Redis"的外面,以为这样就是最优解。结果锁确实挡住了并发覆盖,但Redis一旦抖动,DB成功Redis失败的问题照旧。最后还是要加补偿任务把缓存刷新回来。绕了一圈,本质问题并没有因为锁而消失。

分布式锁和延迟双删一样,是治标思路。它们能把问题从"高频可见"降低到"低频偶发",但无法让两次写入具备原子性。只有把缓存更新彻底移出业务主链路,才能真正脱离双写模式的泥潭。

7. 给还在用双写模式的团队:从线性代码到事件驱动的一次平滑迁移

如果看到这里,你已经认同"双写模式在分布式里面有问题",那么最后一章直接讲讲怎么从现有的双写代码平滑迁移到事件驱动方案。这里不是让你明天就上Canal、后天就切MQ,而是给一条可行、可回退的迁移路径。

**第一步:盘点所有写缓存的地方。**把代码仓库里所有执行Redis SET/DEL/HSET的地方捞出来,按业务归属、写入频率、数据一致性要求分好类。这个盘点结果决定了你的同步组件要订阅哪些表、处理哪些事件。不要跳过这一步,很多团队迁移到一半才发现有一个定时任务在偷偷写缓存,导致DB和Redis持续不一致,排查起来非常痛苦。

**第二步:引入一套Binlog订阅组件,先做"旁路同步"。**在你原有的双写代码还存在的阶段,先让Binlog同步组件运行起来,实时把DB变更同步到Redis。注意,这个阶段要设置好优先级——应用代码写缓存时会覆盖同步组件写的缓存,但至少同步组件开始"接管"数据的时效性。等组件稳定运行一段时间,确认同步延迟、重试机制、失败补偿都OK后,再逐步删除业务代码里的Redis写操作。

**第三步:按读多写少、一致性敏感度从低到高分批下线双写代码。**先处理那些即使出现短暂脏数据也不影响核心流程的缓存,再处理交易核心链路。每下线一批,观察一段时间线上监控,确认缓存命中率、数据一致性指标没有恶化,再进行下一批。

**第四步:为同步组件设置完善的监控和补偿体系。**Binlog组件必须监控同步位点(位点落后多少)、消费耗时、写入Redis的成功率。一旦出现大量写入失败,要有自动积压和人工介入机制。另外,所有缓存务必设置TTL作为最终兜底。TTL设多长取决于业务容忍度,一般建议15分钟到2小时之间,太短会放大DB压力,太长会延长脏数据生命周期。

**第五步:如果涉及事务消息的场景,单独设计消息可靠性。**比如用RocketMQ事务消息时,需要实现check接口来确认本地事务状态,消费者端还要做幂等处理,防止消息重复投递导致Redis被写两次。这块比Binlog方案多一些代码工作,但可控性更好,适合无法依赖Binlog解析的异构数据源。

整个迁移过程的节奏,我的经验是控制在2到4周。太快容易漏掉写入口,太慢团队会失去动力。中间如果出现线上事故,优先保证业务可用,可以临时回滚到双写模式,等稳定后再继续。迁移完成后,原来的"先DB后Redis"代码就成为历史,架构图上双写链路消失,缓存变成一份由DB变化驱动的订阅视图。

最后说一个容易被忽略的细节:迁移完成后,原来的缓存写入工具类、辅助方法该删就删。留着不用,会导致后来的人以为系统还在用双写,新业务有样学样。代码库的整洁程度会直接影响架构演进的成功率。

我在自己维护的系统里完成过两次类似的迁移,一次是Canal同步Redis,一次是RocketMQ事务消息。两次都不是一帆风顺——Canal方案遇到过主从切换导致的位点回退,事务消息方案遇到过check接口超时引发的消息悬挂。但这些问题的修复成本,远低于在双写模式里反复处理脏数据问题的成本。方向对了,具体问题都是可以一个一个解决的。

返回列表