先写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先写Redis,B后写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把订单ID=1001的金额改成100,请求B改成200。你反复压个几十秒,然后去DB和Redis里分别查一下这个订单的金额。
你大概率会看到DB里是200,Redis里是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写入顺序依然没乱,一直以为是代码有问题。后来发现测试环境用的是嵌入式Redis,Redis单线程处理请求,命令自然按到达顺序执行,不产生交叉。所以复现问题一定要用真实独立的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,事务提交成功后再删缓存”,再配合延迟双删和定期对账,你会少加很多个凌晨三点的班。