
做后端开发最怕遇到的事不是功能上不了线而是数据对不上——MySQL里显示扣款已成功MongoDB里的订单快照却不见了或者反过来MySQL已经回滚MongoDB却多出一条再也删不掉的脏数据。这种问题只要在线上出现一次排查起来就极其费劲因为你根本分不清到底是哪一步先失败的又是从什么时候开始两边不一致的。我接手过一个典型的“接口同时操作MySQL和MongoDB”项目下单接口里核心订单数据落在MySQL订单扩展信息、用户操作埋点、支付回调日志全部写进MongoDB。上线前三个月一直风平浪静直到某次支付网关超时接口抛出业务异常MySQL事务正常回滚但MongoDB里的snapshot早就写进去了。运营拿着对账单来找我说两边数据差了一整批订单那一刻我才意识到跨库事务回滚这件事根本不能靠“运气”和“try-catch兜底”。这篇就把“接口操作MySQL跟MongoDB事务回滚问题”从头到尾拆一遍两套存储的事务边界到底差在哪网上常见的错误解法为什么不能用以及我最终落地的可靠方案和完整代码。适合正在做异构数据存储、又被跨库一致性折磨的后端开发、架构师参考。1. 先把问题拆清楚两套事务到底差在哪1.1 MySQL这边本地事务的边界和Spring的封装MySQL InnoDB的事务是完整的ACID实现核心机制大家都不陌生redo log保证提交后的数据不丢undo log保证回滚时能把数据恢复到原来状态加上MVCC做隔离级别控制事务边界非常清晰。在Spring Boot里我们习惯直接给Service方法加Transactional由DataSourceTransactionManager统一管理。但这里要明确一个关键前提一个事务管理器只能绑定一个DataSource。也就是说Transactional管的是“当前方法内所有走同一个数据源的操作”凡是跨了第二个数据源的写操作全都在事务保护范围之外。很多人的事故就是这么来的——想着给接口方法加个事务注解就能通吃所有存储实际上Spring根本不认识MongoDB的操作它只负责管MySQL。1.2 MongoDB这边原子性不等于事务性MongoDB对“事务”的支持和MySQL完全是两套逻辑。先说单文档操作MongoDB的单文档写入天然是原子的因为一个BSON文档内部本身就是嵌套结构一次insertOne、updateOne、replaceOne写一个文档要么全部成功要么全不成功。这是文档型数据库自带的特性不需要任何配置。但如果是多文档操作比如在一个接口里同时向order_snapshot插入快照、向payment_log插入日志、再更新某个统计文档这就是三个独立写入互相之间没有原子性保证。MongoDB从4.0版本开始支持多文档事务4.2版本扩展到分片集群但有几个现实限制必须运行在副本集或分片集群上单节点部署用不了事务默认60秒超时事务内操作不能太多写冲突检测偏保守高并发下容易直接Abort最关键的MongoDB事务和MySQL事务之间没有任何关联它们各自维护各自的事务状态。大多数真实项目里业务方根本不会给MongoDB写操作套上显式事务——因为没必要单文档原子性已经覆盖了大多数场景。于是接口里的MongoDB写操作就成了“自成一派”的独立提交跟MySQL事务完全脱节。1.3 接口里一起写问题出在“没有统一的提交点”把这两个特点放在一起问题就非常清楚了接口里的逻辑是“先写MySQL再写MongoDB”MySQL那边有事务保护MongoDB那边是即时生效的单文档写入全程不存在一个能让两边同时提交或同时回滚的协调点。用一个最常见的下单场景来看MySQL插入订单主记录状态“待支付”MongoDB写入订单快照和埋点日志支付回调异常业务代码抛出RuntimeExceptionMySQL按Transactional回滚订单记录消失MongoDB的快照和日志已经写入且无法感知MySQL回滚了。最终结果就是MySQL干干净净MongoDB里残留了一堆“幽灵订单”。数据对不上报表错乱运营拿着账单来质问而你只能写脚本手动清理。反过来也一样如果MongoDB写入失败抛异常而MySQL的事务还没提交接口整体报错后MySQL回滚这时候两边倒是能勉强保持一致——但这种“运气”靠不住一旦MySQL先提交、MongoDB后写入失败就又是一轮数据不一致。所以接口同时操作MySQL和MongoDB时事务回滚问题的根因就是缺少一个统一的分布式事务协调机制。2. 试过的错误方案为什么都不能用2.1 先写MongoDB再写MySQL用MySQL兜底有人试图调整顺序先写MongoDB再写MySQL理由是我把风险大、会失败的MySQL放在后面一旦MySQL抛异常我就在catch里把MongoDB的数据删掉。这个思路听起来合理实操起来全是坑。首先是顺序问题MongoDB写入成功之后如果MySQL抛了异常catch块确实会执行但你怎么确定该删哪一条如果你用一个临时ID关联确实能删但这条“删除操作”本身也是不可靠的——MySQL报异常的同时可能MongoDB网络也抖动删除语句失败怎么办更棘手的是如果接口在删除MongoDB之前进程就重启了那条脏数据就永久留下了。其次是重复写入问题接口如果被调用两次比如前端重试、消息重复投递MongoDB里可能已经有一条数据第二次写入又生成一条你的“删除”逻辑删除的是哪一条除非你给每次操作都生成唯一ID并做幂等控制否则补偿逻辑分分钟把自己搞乱。我试过这个方案之后得出的结论是用MySQL的异常去反向补偿MongoDB的写入本质上是在用“另一个不可靠操作”去弥补“一个已经发生的操作”失败链路是链式的风险根本没消除只是转移了。2.2 给MongoDB硬套Transactional还有一种做法更“硬核”给Service方法加Transactional又在里面调MongoTemplate指望Spring把所有操作都放进同一个事务。但前面说了Spring的声明式事务依赖PlatformTransactionManager一个事务管理器对应一个数据源。你配置了DataSourceTransactionManager它只管理MySQLMongoTemplate的操作走的是它自己的MongoTransactionManager两者之间没有关联。即使你把JtaTransactionManager配起来用JTA去统一管MySQL和MongoDB这条路也基本走不通——MySQL的XA支持确实存在但MongoDB对XA事务的配合并不成熟而且XA事务在提交前会持有大量锁长事务直接拖垮并发性能。生产环境里用JTA管MongoDB的成功案例非常少绝大多数团队都踩在配置和调优的坑里出不来。所以Transactional对MongoDB直接写操作默认是不生效的这一点在Spring官方文档和MongoTemplate的源码里都能找到依据。如果你在项目里发现“加上注解后MongoDB好像也回滚了”多半是时序巧合不是事务真生效了。2.3 手动补偿try/catch里删数据靠不住最后就是网上最常见的“手动补偿”示范先写MySQL再写MongoDB把MongoDB包在try里catch到异常之后“删除已写入的MongoDB数据”。这个方案的致命问题有三条补偿操作不具备原子性你在catch里调mongoTemplate.remove()这条remove如果失败异常会吞掉吗如果catch里又抛出异常接口的返回信息会变成另一个错误调用方完全看不懂发生了什么。补偿操作没有幂等保证如果catch里的remove被重复执行第一次删掉了第二次再删可能报“找不到文档”之类的错误或者误删其他同ID数据。补偿操作和MySQL事务的时序错位MySQL事务可能还没提交也可能已经提交你根本不知道当前应该补偿到什么程度。如果MySQL回滚且MongoDB删除成功勉强算一致如果MySQL回滚但MongoDB删除失败又回到幽灵数据如果MySQL提交但MongoDB删除成功MySQL的数据反而变“孤”了。手动补偿不是不能做但它只能作为紧急止损手段不能作为系统设计的一部分。真正要解决问题得从架构层面重新选型。3. 能落地的方案选型强一致与最终一致性怎么选3.1 架构上解耦MongoDB只做异步同步在讨论各种分布式事务方案之前先问一个问题接口里真的需要“同步”写MongoDB吗很多情况下MongoDB里存的只是订单快照、查询副本、埋点日志、操作流水这些数据根本不需要和MySQL严格实时一致。你完全可以MySQL作为唯一事实源MongoDB作为从库通过监听MySQL的binlog异步同步过去。具体做法有两种使用Canal或Debezium等工具监听MySQL binlog把业务表的变更事件投递到MQ消费端再写入MongoDB在业务代码里MySQL事务提交后主动发送一条“变更事件”到MQ消费者负责写MongoDB。这种方案下接口层面只有一个事务MySQLMongoDB写入变成了异步的衍生动作两头不一致的窗口被压缩到秒级甚至毫秒级而且可以配合重试机制做到最终一致。好处非常明显代码清晰、事务边界单一、性能也好——接口不再同步等待MongoDB写入RT直接降下来。代价是MongoDB的数据存在延迟如果你的业务要求“写MySQL后立刻能从MongoDB查到不能有延迟”这个方案就满足不了。3.2 本地消息表接口事务和MongoDB写入解耦如果既要MySQL一致性又不能接受太高的MongoDB延迟但又不想上重型分布式事务框架本地消息表是最接地气的方案。核心思路是在MySQL本地事务里同时写业务表和一张“事件消息表”利用MySQL事务的原子性保证业务数据和待处理事件“要么同时成功要么同时失败”。事务提交后由一个异步任务或MQ消费者读取事件消息去写MongoDB写成功后更新事件状态。这个方案把“跨库一致性”转化成了“单库事务 异步投递 幂等重试”三个环节每个环节都有成熟的保障手段。我会在下一节给出完整实现代码。3.3 分布式事务框架Seata、XA这些要不要上再往上是分布式事务框架主要有三条路线Seata AT模式对MySQL这类关系型数据库支持得很好通过undo_log做反向SQL补偿。但问题是Seata对MongoDB的支持远不如关系型数据库成熟AT模式需要数据库层面支持undo_log机制MongoDB侧很难做到同等的镜像回滚。实际项目中跨MySQLMongoDB的Seata案例少之又少真要硬上需要大量二次开发。TCC / Saga模式核心思想是“业务方自己写正向操作和补偿操作”。比如我们这里的场景就可以理解为“Try阶段写MySQL订单Confirm阶段写MongoDB快照Cancel阶段删除MongoDB快照”。TCC/Saga不依赖底层数据库的事务机制理论上对MongoDB、Redis、ES都适用但实现成本非常高——你需要为每个参与方写Try、Confirm、Cancel三个方法还要处理各种边界情况。团队人力不足的话不建议轻易上。XA / JTA模式前面已经提过MongoDB的XA支持不成熟加上锁持有时间长、扩展性差生产环境极少采用。我的选型结论是如果MongoDB只是查询副本、日志、统计类数据优先用异步同步或本地消息表做最终一致如果业务真的要求强一致建议重新审视架构考虑是否把MongoDB的数据合并到MySQL或者引入更成熟的Saga框架如Seata Saga模式但要预留足够的开发成本。3.4 方案对比与适用场景方案一致性延迟开发成本适用场景异步Binlog同步MongoDB数据有延迟秒级低MongoDB只做查询、报表、日志本地消息表最终一致毫秒到秒级中接口不依赖MongoDB实时返回结果Seata AT模式强一致事务处理增加开销高MySQL为主MongoDB支持有限TCC / Saga强最终一致事务处理增加开销很高每步都有清晰补偿逻辑的复杂链路XA / JTA强一致锁开销大高极少采用不推荐跨MySQLMongoDB4. 实操用本地消息表实现跨库最终一致4.1 表设计与状态机先设计事件消息表这是整个方案的基石。CREATE TABLE event_message ( id bigint(20) NOT NULL AUTO_INCREMENT, event_id varchar(64) NOT NULL COMMENT 全局唯一事件ID, biz_type varchar(32) NOT NULL COMMENT 业务类型如ORDER_CREATE, biz_id varchar(64) NOT NULL COMMENT 业务主键如订单号, payload text NOT NULL COMMENT 需要写入MongoDB的JSON数据, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 0-待处理 1-成功 2-失败待重试 3-终态失败, retry_count int(11) NOT NULL DEFAULT 0 COMMENT 已重试次数, next_retry_time datetime NOT NULL COMMENT 下一次重试时间, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_event_id (event_id), KEY idx_status_next_retry (status, next_retry_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENTMongoDB同步事件消息表;关键设计点event_id全局唯一既是消息表的唯一键也是后续MongoDB文档的_id这样天然就幂等了。状态机不需要太复杂0-待处理事件已入库等待异步任务消费1-成功MongoDB写入成功整个流程结束2-失败待重试MongoDB写入失败等待重试3-终态失败重试次数超过阈值人工介入处理。4.2 接口层代码实现先定义事件实体对应的Mapperpublic interface EventMessageMapper { Insert(INSERT INTO event_message(event_id, biz_type, biz_id, payload, status, retry_count, next_retry_time, create_time, update_time) VALUES(#{eventId}, #{bizType}, #{bizId}, #{payload}, 0, 0, NOW(), NOW(), NOW())) void insertEvent(EventMessage event); Select(SELECT id, event_id, biz_type, biz_id, payload, status, retry_count, next_retry_time, create_time, update_time FROM event_message WHERE status IN (0, 2) AND next_retry_time NOW() ORDER BY id ASC LIMIT #{limit}) ListEventMessage selectPendingEvents(int limit); Update(UPDATE event_message SET status 1, update_time NOW() WHERE event_id #{eventId} AND status ! 1) int markSuccess(String eventId); Update(UPDATE event_message SET status 2, retry_count retry_count 1, next_retry_time #{nextRetryTime}, update_time NOW() WHERE event_id #{eventId}) int markRetry(String eventId); Update(UPDATE event_message SET status 3, update_time NOW() WHERE event_id #{eventId}) int markFailMax(String eventId); }然后是Service层的核心逻辑Service public class OrderServiceImpl implements OrderService { Autowired private OrderMapper orderMapper; Autowired private EventMessageMapper eventMessageMapper; Override Transactional(rollbackFor Exception.class) public void createOrder(OrderCreateDTO dto) { // 1. 生成订单号 String orderId generateOrderId(); // 2. 写MySQL订单表 Order order buildOrder(dto, orderId); orderMapper.insert(order); // 3. 构造MongoDB要存的快照JSON String snapshotJson buildOrderSnapshotJson(order, dto); // 4. 写事件消息表和订单在同一个本地事务里 EventMessage event new EventMessage(); event.setEventId(orderId); // 订单号当作事件ID天然幂等 event.setBizType(ORDER_CREATE); event.setBizId(orderId); event.setPayload(snapshotJson); eventMessageMapper.insertEvent(event); } }注意这里没有直接操作MongoTemplate接口里唯一的数据库写操作就是MySQL而且业务表和事件消息表在同一个Transactional事务里。MySQL订单插入失败事件消息就不会入库事件消息入库说明订单一定插入成功。这就是“单库事务做保障”的核心。4.3 消费端代码与MongoDB幂等写入接口的事务提交后需要有一个异步任务去读取事件消息表把数据写到MongoDB。这部分我用定时任务来示例Component public class MongoSyncTask { Autowired private EventMessageMapper eventMessageMapper; Autowired private MongoTemplate mongoTemplate; private static final int MAX_RETRY 5; Scheduled(fixedDelay 3000) public void syncPendingEvents() { ListEventMessage pendingEvents eventMessageMapper.selectPendingEvents(100); for (EventMessage event : pendingEvents) { processEvent(event); } } private void processEvent(EventMessage event) { try { if (event.getRetryCount() MAX_RETRY) { eventMessageMapper.markFailMax(event.getEventId()); return; } // 把业务JSON转成MongoDB Document Document doc Document.parse(event.getPayload()); // 关键用event_id作为MongoDB的 _id重复写入会报DuplicateKey天然幂等 doc.put(_id, event.getEventId()); mongoTemplate.getCollection(order_snapshot) .insertOne(doc); // 写成功更新状态为成功 eventMessageMapper.markSuccess(event.getEventId()); } catch (Exception e) { log.error(MongoDB同步失败, eventId{}, bizId{}, event.getEventId(), event.getBizId(), e); // 计算下一次重试时间指数退避 LocalDateTime nextRetryTime LocalDateTime.now() .plusSeconds(Math.min(60L * (event.getRetryCount() 1), 1800L)); eventMessageMapper.markRetry(event.getEventId(), nextRetryTime); } } }这里最核心的一行是doc.put(_id, event.getEventId())——利用MongoDB的_id唯一索引保证幂等。假如定时任务第一次写入成功但更新状态失败下次任务再读到这条事件时insertOne会抛DuplicateKeyException表示数据已经存在此时只需要更新状态为成功即可不会重复插入数据。4.4 定时任务、重试与告警上面示例用了Scheduled生产环境更推荐用XXL-Job这类分布式调度平台尤其是接口部署了多个实例时必须保证同一时刻只有一个实例在处理同一条事件。定时调度本身不复杂但有几个细节必须注意重试退避策略不要每次都立刻重试避免MongoDB故障时高频压垮它。我常用指数退避第一次重试等1分钟第二次等2分钟第三次4分钟最大不超过30分钟。失败终态告警当事件状态变成“3-终态失败”时必须有监控告警通知到人。这块可以用简单的定时SQL扫描也可以接到已有的监控平台。清理历史数据status1的已处理事件会不断积累建议按天或按周定期清理数据量大的场景直接按时间分区drop分区比delete高效得多。这个方案的优点是把“跨库事务”降维成了“单库事务异步最终一致”每一环都可以用常规手段保障单库原子性由MySQL保证异步投递靠重试保证幂等靠唯一键保证。缺点是MongoDB的数据存在一定延迟如果你要求“接口返回前MongoDB必须能查到这条数据”那就得在同步链路之外再实现类似“先读MySQL写MongoDB后标记完成”的强一致逻辑。5. 常见问题与排查技巧实录5.1 用jMeter并发测试如何复现回滚问题本地联调时很多人说“我测了没发现问题”那是因为你只测了正常流程。真正的问题往往出现在并发或异常场景下。我复现跨库回滚问题的最快方式是先用jMeter对下单接口做并发压测比如10个线程循环50次同时用故障注入让MySQL在某个环节抛异常比如把订单号生成逻辑临时改成重复、或者让必填参数缺失观察MongoDB里是否多出了没有对应MySQL订单的文档。操作步骤在jMeter里新增“线程组”设置并发数和循环次数添加HTTP请求指向下单接口添加“用户定义的变量”让每次请求带不同参数压测的同时在服务端代码里人为制造MySQL事务回滚场景压测结束后对比MySQL订单表和MongoDB快照集合的数据量、按订单ID核对。如果采用的是“先MySQL后MongoDB”的错误写法这一轮压测下来MongoDB里几乎必定会残留大量幽灵数据。这个复现实验很适合在新方案上线前做回归验证。5.2 本地消息表中消息积压和延迟如何排查消息表方案上线后最常遇到的问题就是事件迟迟没被消费导致MongoDB数据延迟越来越大。排查顺序按这几步走先看selectPendingEvents的next_retry_time如果大量事件的next_retry_time在未来说明重试退避算得太激进把消费频率拉低了再看status3的记录数量如果持续增长说明MongoDB侧一直稳定报错需要看具体异常是网络问题、权限问题还是数据格式问题然后看消费任务本身——定时任务是否被上一轮长任务阻塞如果用Scheduled单线程执行一旦某条MongoDB写入超时整个队列都会被堵住。建议消费端改成线程池并行处理但要注意幂等控制。还有个小坑selectPendingEvents如果一次limit取值太大比如1000条单线程逐条处理很慢MongoDB重试堆积越来越严重。我一般把批处理大小控制在50~100条配合多线程分片处理。5.3 幂等设计和唯一键冲突的边界本地消息表方案的幂等有两层第一层是消息表自身的event_id唯一键第二层是MongoDB文档的_id唯一键。这两层都做好了整个链路才算闭环。实际踩过一个坑为了省事我把event_id直接设成了订单号后来发现同一个订单有两个业务动作——创建订单写快照、支付成功更新快照状态。如果还是用订单号做event_id第二条消息插入时就会触发uk_event_id唯一键冲突。解决方案是给event_id加业务前缀和动作标识比如ORDER_CREATE_20250101120001、ORDER_PAY_20250101120001这样既能区分动作又保证每个动作可重试、可幂等。5.4 一点实操心得坦白说我一开始也是试图用“各种事务注解”去硬解这个问题折腾了快两周最后发现最踏实的路线还是“尽量少跨库”。如果MongoDB在你的系统里只是承担查询、报表、日志这些非实时角色就大胆地把它的写入从接口同步链路里摘出去用异步同步、本地消息表这类最终一致性方案。真正的强一致场景请先反思架构——把本应在一个数据库里的事务拆分到两个存储中本身就是一种架构上的妥协妥协后还想保留“强一致”成本必然翻倍。另外给一个小技巧本地消息表方案验证完成后一定要保留一个“对账定时任务”——每天凌晨扫描MySQL订单表和MongoDB快照集合把两边数据做一次全量对比。一旦发现不一致立刻进入补数流程。对账功能不是可选项是必须项。没有对账的最终一致性方案永远活在“可能不一致但没发现”的侥幸里。这个方案我用了大半年线上再没出现过因为跨库导致的数据对不上。核心心法就一句话能单库就别跨库不能避免就异步化异步之后必须幂等幂等之外要配对账。