新闻详情

新闻详情

首页 / 资讯中心 / 详情

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

发布时间:2026/9/30 3:39:12来源:尧图网络
数据库与缓存双写模式:从原子性缺失到事件驱动架构演进
1. 双写模式的起点为什么大家都喜欢先DB后Redis先聊一个我在很多团队里都见过的场景业务代码里需要同时更新数据库和缓存不少人的第一反应是写下这样两行逻辑——先执行UPDATE语句把数据落库再执行SET命令把缓存更新掉。理由也很朴素数据库是权威数据源写成功了才算数缓存只是加速层晚一点跟上也没关系。这个思路放在单机、低并发、系统刚起步的阶段确实够用。数据量不大请求量不高就算缓存和数据库短暂不一致过几分钟缓存过期也就自动纠正了。可一旦系统进入分布式环境问题就没那么温柔了。用户请求可能被负载均衡打到任意一台机器多个副本同时处理写请求缓存集群分片散落在不同节点这时候先DB后Redis的简单思路会连续踩中几个隐藏很深的坑。先说这个方案的前提假设它默认数据库写成功和缓存写成功之间不存在冲突也默认两次操作之间不会有人插入别的请求。但分布式系统里时间片是共享的A请求刚提交完DB事务还没来得及写RedisB请求就带着同一份数据的新值进来了。于是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价格变80A写Redis缓存变80B写DB价格变120B写Redis缓存变120但分布式环境没有这么听话的时序。实际可能变成A写DB成功内存中价格字段是80B写DB成功DB价格变120B写Redis成功缓存变120A此时才发起Redis SET携带的还是80这个旧值最终结果是数据库里是120缓存里是80。用户刷新页面看到80元下单时却按120元结算。这个案例里的根本问题在于缓存写操作不是读取数据库的最新值再写而是拿着调用方业务代码里残留的旧内存值直接覆盖。竞态条件在代码层面是存在的业务方从DB读到旧值经过一段耗时逻辑处理后写缓存而这个处理窗口内其他请求已经更新了DB。这种情况在缓存更新策略里叫老线程覆盖新数据。要缓解常见做法是写缓存前先查一次DB当前值只有值一致才写。但这样等于把一次缓存更新变成了三次存储交互性能代价极大而且查完后到写入前依然有极小的竞态窗口只是概率被大幅压缩。另一个容易被忽视的时序问题是跨机房或跨集群的时钟偏差。有些团队会尝试用时间戳来解决覆盖问题——给每条数据加一个lastModified时间写入缓存前比较一下新旧时间戳旧的不写。想法没错但分布式系统的机器时钟即使做了NTP同步依然存在毫秒级偏差。在极端情况下慢的那台机器带着过期的业务时间戳却因为系统时钟反而新成功覆盖了快机器的正确数据。用时间戳做并发控制在分布式环境里是出了名的坑Cassandra和旧版Dynamo都在这方面吃过亏。所以双写的时序问题本质上是一个无锁更新问题。两个节点同时持有同一份数据的不同版本各自向缓存提交自己的版本缓存端没有类似数据库MVCC那样的版本裁决机制旧值就可能胜出。要彻底避开通常需要引入版本号、CASCompare And Swap或分布式锁而这已经是另一套设计思路了。一个我在实践中验证过的规律并发覆盖问题的出现频率几乎和业务处理链路长度成正比。链路越短DB和Redis之间的间隔越小覆盖概率越低链路里只要加一次远程调用、一次MQ消息发送、一次外部接口等待窗口就会被拉大问题就会更容易暴露。3. 部分失败的灾难DB写完Redis没写的三种典型情况很多踩过坑的人都会说这样一句话双写模式最怕的不是并发而是写一半失败。这句话精准点出了问题的另一个维度原子性缺失。一个完整双写动作包含两个独立操作任何一个都可能失败。我把实际中遇到的情况归纳成三类每一类在线上都有非常具体的表现。**第一类DB成功Redis失败。**这是最隐蔽的一类因为数据库是权威数据本身没错但缓存旧了。读多写少的场景下这个旧缓存会一直命中直到过期。如果过期时间设置得特别长比如一小时那这一小时内所有读请求都拿到旧值。运营后台看到的数据和用户端看到的数据不一致客服的工单就会堆起来。这类失败的根因通常是Redis端网络超时、连接被拒、或实例发生主从切换时短暂不可用。**第二类Redis成功DB失败。**这类问题更严重因为它产生了缓存里存在、数据库里不存在的幽灵数据。很多业务代码习惯先写Redis后写DB觉得Redis快结果Redis写入成功DB事务因为唯一键冲突、约束校验失败或存储引擎报错而回滚。缓存里留下一条数据库里根本没有的记录。读请求命中后返回给用户一份凭空出现的数据而且这份数据在Redis里还很新鲜过期时间被重新刷新过。等发现时往往已经影响了一大批订单或展示内容。**第三类两边都失败。**这个相对容易感知系统会报错调用方会重试或降级虽然影响业务但至少故障是明账。反而是前两类半成功状态最难处理因为它不报错数据就是不对而且不对得悄无声息。面对这三类问题很多团队的第一反应是加大重试力度。DB失败重试DBRedis失败重试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的逻辑缓存同步是数据基础设施的一部分业务团队只关心DBRedis只是数据的一个订阅视图。这种单一写入源事件分发的模型在架构上比双写干净得多。还有一个务实的补充做法在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接口超时引发的消息悬挂。但这些问题的修复成本远低于在双写模式里反复处理脏数据问题的成本。方向对了具体问题都是可以一个一个解决的。
网站建设高端定制企业官网
RELATED

相关资讯

更多精彩内容,欢迎继续阅读

较早相关资讯

最新相关资讯

Linux内核配置系统全解析:从Kconfig到.config与裁剪实战 2026/9/30 4:44:17

Linux内核配置系统全解析:从Kconfig到.config与裁剪实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

阅读更多 →
新手向Linux安装指南:发行版、启动盘、分区与常见坑 2026/9/30 4:44:16

新手向Linux安装指南:发行版、启动盘、分区与常见坑

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

阅读更多 →
Quill排障清单:录音没声音、权限不生效的6个高频问题逐一解决 2026/9/30 4:44:10

Quill排障清单:录音没声音、权限不生效的6个高频问题逐一解决

Quill排障清单:录音没声音、权限不生效的6个高频问题逐一解决 【免费下载链接】quill Ultra-minimalist macOS recording transcription. 项目地址: https://gitcode.com/gh_mirrors/quill26/quill Quill 是一款极简的 macOS 本地会议录音 转写工具&#x…

阅读更多 →
最近很火的《高性价比人生指南》,我把它做成了微信小程序 2026/9/30 4:44:10

最近很火的《高性价比人生指南》,我把它做成了微信小程序

最近受到关注的开源项目《高性价比人生指南》HowToLiveBetter,我也围绕它做了一个微信小程序,让大家在手机上查阅更方便。 这份指南,具体在讲什么? 它关注的事情很日常:健康、时间、金钱,以及生活里那些平…

阅读更多 →
Altium Designer新建工程全流程:从建项目到元件库与PCB设计技巧 2026/9/30 4:44:10

Altium Designer新建工程全流程:从建项目到元件库与PCB设计技巧

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

阅读更多 →
Model-Optimizer 实战:量化、剪枝与图优化加速模型推理 2026/9/30 4:44:04

Model-Optimizer 实战:量化、剪枝与图优化加速模型推理

1. 从"模型能跑"到"模型跑得省":Model-Optimizer 到底在解决什么做模型部署的人迟早会撞上一堵墙:训练时一切正常,指标也漂亮,可一旦要上线,推理延迟、显存占用、单位算力成本这三座大山就压过来了…

阅读更多 →

今日资讯

本周资讯

本月资讯

看完文章仍有疑问?

联系尧图顾问,获取一对一建站咨询

立即免费咨询 📞 400-888-8888
📞 ✉