
先写DB还是先写Redis这个问题我在不同公司的技术讨论群里都见过每次都有人争论得面红耳赤。乍一看这只是个顺序问题但往深了挖它牵扯出的是缓存一致性、分布式事务、异常补偿这一整套架构设计思维。今天就用实际场景把这个双写模式的坑一条条拆开讲透看完你应该能搞清楚为什么“先DB后Redis”在分布式环境下容易出事以及我们到底该怎么绕过去。1. 内容整体设计与思路拆解1.1 双写模式的本质与适用场景先聊聊双写到底是什么。双写指的是同一条数据既写到主存储一般是数据库比如MySQL、PostgreSQL又写到缓存最常见的是Redis两份存储都要保证有数据。日常业务里最典型的就是“读多写少”的场景——商品详情、用户资料、配置信息热点数据读压力很大数据库扛不住就靠Redis在前面缓冲一下。我们先把两种写入时序说清楚先写Redis再写DB先写DB再写Redis也就是标题里说的这个模式这两种方案在低并发、低要求的环境里短期内看起来都能跑。但分布式系统最怕的就是“看起来能跑”因为异常总发生在你想不到的时刻。尤其在微服务架构普及之后一个插入操作背后可能有好几个服务在协同缓存的一致性一旦出问题用户看到的数据就可能“昨天是A今天是B明天又是A”这种诡异问题最难排查。1.2 为什么“先DB后Redis”成为默认选择很多团队在选型时都会默认先写DB后写Redis理由听起来很充分DB是数据的最终归属先保证数据落库再刷新缓存逻辑上很“顺”——毕竟缓存是结果DB是源头先源头后结果嘛。但这套逻辑有一个隐藏前提系统是串行的写入后立刻读到的一定是同一个线程的同一份数据。这在小流量单机应用里确实成立但一旦上了分布式、上了集群这个前提就不存在了。大量线程同时在操作同一份数据CPU调度的顺序、网络IO的延迟、事务提交的时机都可能让最终的结果和你预想的完全不同。我不是说这个模式一无是处在缓存允许短暂不一致、业务上对实时性要求不高的场景里它依然可用。但它绝对不等于“分布式下的安全默认选项”这个认知误区才是真正致命的。2. 核心细节解析与实操要点2.1 双写时序对一致性的影响链路为了把问题说透我画一条时间线来推演典型的失败场景。假设现在一个用户触发插入操作ID 1001要写入一份订单数据。代码逻辑是// 伪代码先写DB insertOrder(order); // 再写Redis redis.set(order: order.getId(), orderJson);看起来干净利落。但分布式环境下这个操作从来不是孤立的同一时刻可能有另一个线程在读取这份订单。假设读请求先于写请求发起但网络调度导致它晚于写请求到达Redis读到的就是旧数据或空数据。用户看到“订单不存在”实际订单已经落库了。这个问题的本质就是写时序和读时序在分布式环境下发生了交叉而我们完全没有做任何防护。单机情况下线程锁和事务隔离级别还能约束一下分布式环境下节点之间没有任何强同步机制这是并发控制的第一课但很多团队在写双写代码时根本不记得这回事。2.2 先写DB后写Redis的三个致命缺陷第一个致命缺陷是崩溃窗口。应用服务器在写完DB之后、写Redis之前的那一刻挂了Redis里要么没有这条数据要么还是旧值。缓存和数据库已经不一致了而且这种不一致是永久性的除非有额外的补偿任务否则它会一直存在。用户下一次读缓存要么读到缺失、要么读到脏数据而“缓存没数据”还能通过回源DB来解决“缓存有脏数据”才是最麻烦的。第二个致命缺陷是并发覆盖。两个线程同时修改同一条记录线程A先写DB线程B后写DB最后B成功。但Redis的写入顺序可能完全反过来——A先写RedisB后写Redis缓存里留下来的反而是A修改前的数据。这就出现了一个极其经典的场景DB里是最新值Redis里却是旧值而且整个系统没有任何日志能告诉你为什么。这种问题在秒杀场景、库存扣减场景里格外致命直接导致超卖。第三个致命缺陷是事务边界问题。写入DB的操作往往是带事务的事务没提交前数据是不可见的。如果代码在事务提交之前就去写了Redis而事务后续回滚了Redis里却留着一条“幻觉数据”——DB里根本没有这条记录缓存里却存得有。这个问题尤其在ORM框架下容易踩坑因为事务是隐式的你不一定知道自己到底处在事务边界内还是边界外。2.3 为什么单机环境下这种模式看起来没问题我接触过很多开发者在单机环境里写双写跑了很久都没出过问题于是觉得“这套方案挺稳的”。其实不是方案稳是运气好。单机部署时并发量有限Redis和DB都在同一台机器连接延迟很低写操作之间的窗口极窄碰撞概率自然低。但分布式环境一上所有被单机掩盖的问题就像潮水一样涌出来。网络分区会导致Redis短暂不可用线程调度会让写序错乱集群会让热点数据被多个节点同时操作。一个低概率事件在单机下可能几个月都碰不到一次在分布式下可能一天就能碰到十几次。我见过一个线上事故双写模式下数据库连接池满了大量线程阻塞在DB写入这一步等DB恢复后这些线程又一起涌向Redis把Redis的连接也打爆了。最终的结果是缓存大面积穿透、数据库被海量回源请求压垮整个链路瘫痪了半小时。这已经不是一致性问题了双写的时序缺陷间接引爆了可用性问题两者是关联的。3. 实操过程与核心环节实现3.1 双写时序问题的压测复现方法如果你现在正在用“先DB后Redis”模式我建议你先别急着改方案而是自己动手复现一下这个问题建立体感后续做架构决策才会更敏锐。复现环境其实不复杂一台装了MySQL、Redis、一个SpringBoot应用的机器就够了。关键是要模拟并发碰撞的场景PostMapping(/insert) public Result insert(RequestBody Order order) { // 模拟并发地写入同一个ID但不同内容 orderService.insertDB(order); redisService.set(order: order.getId(), JSON.toJSONString(order), 300); return Result.success(); } GetMapping(/get) public Result get(RequestParam Long id) { String cache redisService.get(order: id); if (cache ! null) { return Result.success(JSON.parseObject(cache)); } Order order orderMapper.selectById(id); redisService.set(order: id, JSON.toJSONString(order), 300); return Result.success(order); }然后用一个压测工具我用的是JMeter和wrk同时发起两个请求请求A把订单ID1001的金额改成100请求B改成200。你反复压个几十秒然后去DB和Redis里分别查一下这个订单的金额。你大概率会看到DB里是200Redis里是100的情况。这就是双写并发覆盖的实锤。如果你在代码里故意加一个sleep模拟网络抖动比如在DB写完之后sleep 500毫秒再写Redis这个问题会暴露得更明显。3.2 复现时的几个小细节一个细节是你的应用和Redis之间如果走的是局域网延迟太低问题反而不容易复现。建议在Redis客户端连接池里把超时时间调大一点或者干脆用一台远程的Redis实例加大网络延迟让写Redis这一步更容易“掉链子”。另外一个细节是压测前先在产品环境或者压测环境里多跑几轮热数据让Redis里已有旧缓存。这样碰撞发生时你还能区分是“缓存缺失回源”还是“缓存脏数据”两种现象这对后续排查很有帮助。还有人会问为什么需要在代码里sleep来复现因为这本来就需要制造一个典型的时序窗口。在生产环境里这个窗口是线程调度和网络延迟自然形成的你只是看不见也摸不着。压测时人为拉大窗口能让你更可控地观察到问题。3.3 先DB后Redis在工具层面的现象观察如果你想更直观地看到不一致现象可以使用DBeaver或者Navicat直连数据库查看最终数据同时用Redis Desktop Manager或者Another Redis Desktop Manager查看缓存的值。两边一对比差异一目了然。实际操作的时候我习惯先开一个Redis的MONITOR命令窗口实时监听写Redis的操作然后再发起压测。MONITOR会打印出所有Redis命令以及它们进来的顺序跟DB里的最终数据一核对就能清楚看到乱序发生在哪里。这个排查思路在公司内部的故障复盘里屡试不爽。4. 双写时序问题的主流修正方案4.1 换顺序先写Redis后写DB其实问题更严重有人看完上面的分析会想那我改一下顺序先写Redis再写DB不就行了答案是不行甚至问题更严重。先写Redis后写DB崩溃窗口就变成了“写Redis成功、写DB失败”。此时缓存里有一条数据库中根本不存在的记录而且这条记录还带有效期TTL之内用户会一直读到假数据。更麻烦的是后续业务系统可能基于这条假数据去做查询、统计、甚至触发下游消息污染范围更广。数据库是几乎所有业务的最终依据缓存只是加速层。优先保证数据库的正确性再考虑缓存这个排序本身没错。错的是我们把“写缓存”等同于“保护缓存正确性”。4.2 公认基础方案删除缓存而非更新缓存既然先写缓存容易出问题那不如换个思路写入时干脆不更新缓存而是删掉缓存。删除缓存的操作有一个天然优势——删掉一条不存在的键是幂等的不发一次失败也不会留下“错误的新数据”。下一次读请求打过来发现缓存没有数据就会回源数据库拿到最新值再写回缓存。这样即使发生崩溃最坏的后果只是缓存多穿透一次数据正确性不会受损。操作顺序也调整一下先写DB提交事务成功后再删除Redis里的对应键。这里有一个细节要注意删除操作必须放在事务提交之后。如果事务还没提交就删了而事务后续回滚了其它线程回源数据库时读到的还是旧值可能把旧值重新写回缓存又会造成不一致。这个方案依然不是完美无缺的想要效果更好还需要配合延迟双删——删除缓存后再延迟几十毫秒或几百毫秒做第二次删除这个时间窗口用来规避并发读请求在第一次删除后一瞬间写回了旧缓存的可能性。但延迟双删只是个折中手法不能彻底消除窗口只能缩短。4.3 更可靠的方案订阅数据库变更日志如果你对一致性要求比较高而且团队有配置运维的能力可以考虑订阅数据库的binlog或者WAL日志来实现缓存同步。比如用Canal订阅MySQL的binlog一旦捕获到数据变更异步解析变更事件然后去更新或删除Redis中的对应键。这套方案的好处是代码跟业务解耦App只负责写库不管缓存同步Redis的操作完全由日志解析器接管即使失败也有日志可以重放天然的可靠性兜底。坏处也很明显系统复杂度上来了需要额外部署Canal和对应的消费服务排查链路也因为多了一个中间节点而变长。所以在团队规模不大、对一致性要求没那么极端的场景下我更推荐的还是“先写DB提交事务成功后再删缓存”这个方案它足够简单也规避了大部分问题。binlog订阅方案适合数据量已经大到需要专门治理、团队也有足够人力去Hold的规模。4.4 双写一致性最终兜底补偿任务无论你用上述哪种方案我还要建议上一道保底工序——定期对账。写一个定时任务每隔一段时间扫描最近变更的数据比对DB和Redis的值发现不一致就用DB里的权威数据去覆盖缓存。这种对账任务在负责的项目里是必须的。因为即使你再小心缓存写入也可能会因为Redis持久化失败、网络闪断等各种底层原因静默失败。而定时补偿机制能把这些零散的异常都捞回来尽量减少不一致的存活时间。注意对账全表扫描代价太高一定得设计好增量条件比如只检查最近5分钟更新过的数据。不然上线前没感觉上线后才发现批处理任务把自己DB和Redis的负载全拖崩了那就麻烦了。5. 常见问题与排查技巧实录5.1 问题现象速查表日常工单里最容易出现的几种疑难问题在此整理成一张排查表方便大家照着定位现象可能原因优先排查点缓存里读到旧数据DB是新数据双写乱序或失败更新缓存逻辑未处理查询Redis的写入日志比对DB最新值缓存里有数据DB里没有事务回滚后未同步删除缓存检查事务边界确认提交后才操作缓存读请求直接穿透到DB缓存长期为空缓存删除后并发回源未写回检查回源逻辑是否加了并发控制或锁缓存数据是正确的但偶发抖动网络抖动或Redis连接池回收导致看Redis日志检查连接池配置不同节点读到不一致的数据Redis集群分片热点设计因素检查是否使用了合适的哈希策略与一致性算法5.2 我踩过的三个坑第一个坑是事务里写Redis。当时用Spring的Transactional一个方法里既操作了数据库又操作了Redis代码顺序是先写DB后写Redis。正好那个方法里有一个业务规则校验不通过事务回滚了但Redis里的缓存却已经写进去了。每次回滚后缓存里就残留一条DB里不存在的记录用户点击详情页时频繁看到错误档案。排查了一个多小时才定位到事务边界问题。从那之后我给自己立了一条规矩缓存操作永远放在事务外面要么显式地拆成两个方法要么借助事务同步器在commit后执行。第二个坑是在并发量上去后删缓存操作太“爽快”造成了缓存雪崩。当时用的是先写DB后删缓存的方案凌晨跑批业务一个批次更新了几十万条数据一个循环下来全删了缓存瞬间空了随后所有请求全部打到数据库上差点把库打挂。后来加了分批删除和延迟双删的节奏控制并且在删缓存之前先判断一下更新时间如果数据根本没变就不删。第三个坑是毫秒级的sleep没生效。当时在测试环境复现并发覆盖问题为了保证必现写了一个sleep(500)结果发现Redis写入顺序依然没乱一直以为是代码有问题。后来发现测试环境用的是嵌入式RedisRedis单线程处理请求命令自然按到达顺序执行不产生交叉。所以复现问题一定要用真实独立的Redis实例至少是一个独立的进程。5.3 排查工具与手段整合排查双写问题时我常用的工具组合是Redis的MONITOR命令加DBeaver看库再加一份业务日志。最关键的是所有关键操作都得有日志包括数据库写入、Redis操作、缓存删除、回源动作。没有日志这种不确定性问题的定位难度会翻好几倍。如果需要看更细的操作时序可以用Arthas在应用里trace关键方法把每个步骤的耗时打出来。这样能直接看到是不是某个步骤耗时异常导致了时序错乱。线上问题只要发生过一次后续都要有完整的日志链条来支撑复盘。6. 项目扩展思考与设计建议6.1 从双写看分布式的一致性与事务边界聊完缓存双写你会发现这其实是分布式事务的一个缩影。微软的Dan Pritchett早年总结过BASE模型和最终一致性理论但那不是最核心的最核心的是我们必须在设计阶段就识别出目标哪些数据允许暂时不一致哪些不能。订单状态允许缓存延迟几秒钟但库存扣减、支付倒扣这类操作绝对不能出现脏缓存。如果你能把“先DB后删缓存”这套思路吃透再去看分布式事务、消息队列的最终一致性方案整个框架都会清晰很多。它们本质上都在处理同一个问题多个存储系统之间如何通过“异步补偿”和“幂等保护”来达到最终一致。6.2 最终的架构选型建议我的经验总结是这样的如果你在做一个内部的、低并发的管理系统或者缓存数据本身允许几十秒的延迟那“先写DB再删缓存”就够了。如果你在做一个高并发的面向用户的业务我建议你直接上延迟双删并在底层加上对账任务。如果业务体量到了要专门治理缓存一致性的程度再考虑引入binlog订阅和对应的中间件。不要一开始就上重型方案过度设计带来的运维负担往往比它要解决的一致性问题更难搞。6.3 个人实操中的几条心得第一任何读写方案都一定要考虑异常分支。写DB成功的下一步如果挂了怎么办需要在设计阶段就想清楚。第二日志和监控必须前置不用等问题出现了再来补。第三不要迷信权威顺序自己用压测工具反复验证方案跑通了一条链路你对它的信任度才算是真建立起来了。“先写DB再写Redis”不适合做分布式环境下的默认方案关键原因不是顺序本身而是这个顺序没有兜底机制。没有补偿、没有事务边界约束、没有日志风险点控制任何一个环节出问题都会引发连锁反应。把方案换成“先写DB事务提交成功后再删缓存”再配合延迟双删和定期对账你会少加很多个凌晨三点的班。