资讯动态

跨存储事务一致性:MySQL+MongoDB回滚问题与本地消息表方案

发布时间:2026/9/16 8:41:13 来源:尧图企业网站定制
1. 一次由事务回滚引发的线上脏数据事故让我重新审视跨存储一致性先交代一下事故背景。我负责的项目里用户下单后需要同时在 MySQL 里扣减账户余额、在 MongoDB 里写入一条积分流水。接口本身逻辑很简单就是先查余额、扣款、再插一条 MongoDB 文档。上线前测试环境一切正常结果某天线上用户反馈余额扣了积分却没到账。我的第一反应是查代码逻辑反复看也没发现问题。最后去翻 MongoDB 里的数据发现积分流水压根没写进去。再查 MySQL余额扣款记录居然被回滚了。我当时很疑惑代码里明明是先扣 MySQL 再写 MongoDBMySQL 都回滚了怎么 MongoDB 的数据反而没回滚这不是同一段代码里的两个操作吗问题就出在同一个事务这四个字上。接口同时操作 MySQL 和 MongoDB看起来是在一个事务里实际上 MySQL 的事务管理器根本管不到 MongoDB 的写操作。MySQL 回滚自己的数据没问题但 MongoDB 那边已经执行成功的写入没有任何机制能跟着一起撤销。这就是典型的跨存储事务一致性问题也是很多刚接触接口开发的同学最容易踩的坑。这篇文章不是教你怎么写 CRUD而是讲清楚一个真实存在的问题当接口内部同时操作 MySQL 和 MongoDB 时事务回滚到底怎么设计才靠谱。我会从底层原理讲到可行方案再给出代码实现和踩坑经验。适合正在做接口层开发、被跨库数据一致性问题困扰的开发者阅读也适合团队里写业务代码但很少深究事务机制的 Java 工程师。开头先说一个最重要的认知MySQL 和 MongoDB 是两个完全独立的存储引擎它们在事务机制上没有一丁点关系。你把两段写操作放在同一个方法里它们各自的事务也完全是两套体系。方法异常就一起回滚这件事在单库里成立在双库里根本没有天然保障。2. MySQL 能回滚背后的机制是什么想理解为什么 MongoDB 不回滚先得搞清楚MySQL 凭什么能回滚。MySQL 的 InnoDB 引擎通过undo log回滚日志实现了原子回滚能力。每当你执行一条 UPDATE 或 INSERTInnoDB 会把被修改行的旧值写入 undo log同时记录一个事务 ID。如果事务中途抛异常、或者主动 ROLLBACKInnoDB 会根据 undo log 里的旧值逐行恢复数据。举一个具体例子START TRANSACTION; UPDATE account SET balance balance - 100 WHERE user_id 10001; INSERT INTO points_record (user_id, points) VALUES (10001, 100); ROLLBACK;当 ROLLBACK 执行时InnoDB 会做两件事第一把 account 表里 user_id10001 的 balance 恢复为原始值第二undo 掉刚才那个 INSERT让 points_record 里不留任何痕迹。整个过程基于一个核心前提——两个数据操作都在同一个 MySQL 连接、同一个事务上下文里。MongoDB 在 4.0 版本之后也支持多文档事务但它有几个硬条件必须运行在副本集模式下、事务期间会话内所有操作要显式传入 session、事务有默认超时时间。更要命的是如果你的 MongoDB 部署在单机或者测试环境,即使写了 startTransaction 也不会生效MongoDB 早期版本甚至直接忽略事务指令。所以当你用 Spring 的 Transactional 注解去包一个既有 MySQL 又有 MongoDB 的方法时实际发生的是Spring 开启 MySQL 事务绑定到数据源连接 → 执行 update 扣减余额MySQL 事务内 → 执行 mongodbTemplate.insertMongoDB 单文档原子写入无事务 → 方法抛异常比如 check 失败 → Spring 回滚 MySQL 事务余额恢复 → 但 MongoDB 那条文档已经写进去了说白了MySQL 的回滚是有账本可查的MongoDB 的写入是写进去就没了后路。两个系统各有各的记账方式Spring 不可能用一个事务管理器同时指挥两个账本。这是本质原因。3. Spring 的一个 Transactional为什么管不住两个库很多新手会把 Spring 的 Transactional 理解成万能药仿佛给方法加个注解方法里所有存储操作都能被魔法般管理起来。真实情况完全不是这样。Spring 的事务管理核心是PlatformTransactionManager接口最常见的是DataSourceTransactionManager。它的工作方式非常简单粗暴从一个DataSource获取一个数据库连接把连接绑定到当前线程的 ThreadLocal 上事务开始、提交、回滚全都针对这一个连接操作。这就是问题的根源。当你配置了 MySQL 的数据源Spring 只会为一个数据源创建事务管理器。代码里即使注入了 MongoTemplateMongoTemplate 本质上是独立连接 MongoDB 的客户端它和 MySQL 的事务管理完全无关。看一段业务代码大家就明白了Service public class OrderService { Transactional(rollbackFor Exception.class) public void createOrder(OrderRequest request) { // 这个写入受 MySQL 事务控制 accountMapper.deductBalance(request.getUserId(), request.getAmount()); // 这个写入跟 MySQL 事务没有半点关系 mongoTemplate.save(PointsRecord.builder() .userId(request.getUserId()) .points(request.getAmount()) .build()); } }这段代码里accountMapper.deductBalance是 MySQL 操作事务生效mongoTemplate.save是 MongoDB 操作事务不生效。如果在 save 之后的代码抛了 RuntimeExceptionMySQL 会回滚MongoDB 不会回滚。我把常见的误解列一下常见误解实际情况Transactional 能管理所有数据库只能管理一个 DataSource 的本地事务方法抛异常两个库都会回滚MongoTemplate 的写入不在事务范围内再配置一个 MongoDB 事务管理器就行两个事务管理器互不感知无法协同提交/回滚加上 JTA 分布式事务就能解决JTA 需要每个数据库都支持 XA 协议MongoDB 不是 XA 参与者我还遇到过一类隐蔽的问题Spring 对 Transactional 的异常回滚默认只针对 RuntimeException 和 Error如果你 catch 了异常而没有重新抛出MySQL 也会静默提交半成品数据。在双存储场景里这种情况更容易被忽略——你以为是事务保护了数据实际上 MySQL 提交了、MongoDB 也写了两边的数据还可能不一致。所以不要再试图用一个注解管两个库的思路了这条路走不通必须换设计思路。4. 事务补偿的三种主流方案对比最终一致性比强一致更现实既然单靠一个本地事务解决不了跨存储一致性问题那现实局面下有哪些方案我把自己实际调研和实践过的方案列出来从简单到复杂排一遍。4.1 本地消息表方案核心思路是把 MongoDB 要执行的写操作先以消息的形式记录在 MySQL 的同一事务里。业务数据写成功、消息也写成功这个本地事务一起提交然后由独立任务异步处理消息把数据同步到 MongoDB。如果 MongoDB 写入失败任务会不断重试直到成功为止。这个方案的优点是非常容易实现不需要引入额外的中间件也不要求 MongoDB 支持事务。缺点是引入了异步MongoDB 的数据更新有一点点延迟但绝大多数业务场景都接受这种最终一致性。4.2 事务性消息 / Outbox 模式Outbox 模式和本地消息表本质是一个思路差别在于它会用 CDCChange Data Capture变更数据捕获组件监听 MySQL 的 binlog把消息表的数据变化转发给消息队列再由消费者写入 MongoDB。这个方案更可靠、也更复杂适合已经上了 MQ 和 CDC 技术栈的团队小项目没必要这么搞。4.3 TCC 模式TCCTry-Confirm-Cancel是分布式事务里的强一致方案。拿积分流水场景举例Try 阶段先把用户积分冻结Confirm 阶段真正加积分Cancel 阶段把冻结的积分释放。这个过程要求 MongoDB 侧也配合实现类似的业务状态字段实现成本比本地消息表高一个量级。从我的实践经验看大多数接口场景不需要 TCC。锁定资源、冻结状态、补偿接口这些设计会让业务代码膨胀好几倍维护成本直线上升。强一致是听起来很爽、做起来很痛的需求绝大多数业务场景最终一致性就够用了。5. 我实际采用的方案MySQL 本地消息表 定时补偿 MongoDB 幂等设计说回我自己的项目最终采用的是本地消息表 定时补偿 MongoDB 幂等这套组合。这也是在没有额外引入 MQ 的情况下最稳妥、最容易维护的一个折中方案。下面把实现细节完整写出来大家可以直接参考。5.1 MySQL 侧业务表和消息表必须同生共死首先在 MySQL 里新建一张消息表 transaction_event记录所有需要同步到 MongoDB 的操作CREATE TABLE transaction_event ( id BIGINT PRIMARY KEY AUTO_INCREMENT, event_id VARCHAR(64) NOT NULL COMMENT 业务唯一事件ID, biz_type VARCHAR(32) NOT NULL COMMENT 业务类型POINTS/REFUND, biz_id VARCHAR(64) NOT NULL COMMENT 业务主键, payload JSON NOT NULL COMMENT 要写入MongoDB的完整数据, status TINYINT NOT NULL DEFAULT 0 COMMENT 0-待处理 1-已处理 2-失败 3-已放弃, retry_count INT 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, UNIQUE KEY uk_event_id (event_id) );事件表的关键点是把 event_id 设计成唯一键这是幂等的基础。业务代码里生成一个全局唯一 ID比如 UUID 或雪花 ID作为 event_id在写入业务表的同时插入消息表。然后看业务代码Service public class OrderService { Autowired private AccountMapper accountMapper; Autowired private TransactionEventMapper eventMapper; Autowired private MongoTemplate mongoTemplate; Transactional(rollbackFor Exception.class) public void createOrder(OrderRequest request) { // 1. 扣减余额MySQL accountMapper.deductBalance(request.getUserId(), request.getAmount()); // 2. 使用雪花算法生成唯一事件ID String eventId IdGenerator.generate(); // 3. 组装要写入MongoDB的流水数据 PointsRecord record PointsRecord.of(request.getUserId(), request.getAmount(), eventId); // 4. 写本地事件表payload是JSON格式 TransactionEvent event new TransactionEvent(); event.setEventId(eventId); event.setBizType(POINTS); event.setBizId(String.valueOf(request.getUserId())); event.setPayload(JSON.toJSONString(record)); event.setStatus(0); event.setRetryCount(0); event.setNextRetryTime(new Date()); eventMapper.insert(event); // 5. 整个方法结束时MySQL 事务会同时提交两条记录 // 余额扣减 事件记录。这里不直接调用MongoDB。 } }注意第十行的 Transactional 只管理 MySQL 事务。这个事务里做了两件事扣余额 写事件记录。这两条记录要么一起提交要么一起回滚。如果扣余额之后、插入事件之前出了问题MySQL 直接回滚什么都不会留下。这一步就保证了 业务数据一定有对应的事件记录 后续的同步有了依据。5.2 MongoDB 侧同步任务幂等写入绝不重复事件记录有了谁来消费我写了一个定时任务每隔几秒扫描状态为待处理的事件把 payload 里的数据写入 MongoDBComponent public class EventSyncJob { Autowired private TransactionEventMapper eventMapper; Autowired private MongoTemplate mongoTemplate; Scheduled(fixedDelay 5000) public void syncEvents() { // 扫描待处理事件最多取100条 ListTransactionEvent pendingEvents eventMapper.selectPendingEvents(100); for (TransactionEvent event : pendingEvents) { syncEventWithRetry(event); } } private void syncEventWithRetry(TransactionEvent event) { try { PointsRecord record JSON.parseObject(event.getPayload(), PointsRecord.class); // 使用eventId作为MongoDB文档的唯一键 Query query new Query(Criteria.where(eventId).is(record.getEventId())); Update update new Update() .set(userId, record.getUserId()) .set(points, record.getPoints()) .set(eventId, record.getEventId()) .set(createTime, record.getCreateTime()); // upsert存在就更新不存在就插入天然幂等 mongoTemplate.upsert(query, update, points_record); // 同步成功后更新事件状态 eventMapper.markProcessed(event.getId()); } catch (Exception e) { // 记录失败次数设置下次重试时间 int nextRetryCount event.getRetryCount() 1; Date nextRetryTime calculateNextRetryTime(nextRetryCount); eventMapper.markFailed(event.getId(), nextRetryCount, nextRetryTime); log.error(sync event to mongodb failed, eventId{}, event.getEventId(), e); } } }这里最关键的设计是 MongoDB 侧的幂等写入。我用mongoTemplate.upsert而不是mongoTemplate.insert并且以 eventId 作为查询条件。这样的话即使同一个事件被定时任务重复扫描十遍MongoDB 里也只会有一条记录不会因为重试而导致数据重复。很多人在这一步会踩坑不加 eventId 唯一条件直接按业务主键判断是否存在结果并发场景下多个实例同时执行同步任务出现重复文档。用 upsert 业务唯一键是最简单的解法。5.3 回滚场景不再靠事务魔法而是靠补偿逻辑现在回到最开始的场景如果同步 MongoDB 的时候失败了怎么办不需要立刻回滚 MySQL定时任务会不断重试。如果重试了几次还是失败说明 MongoDB 可能挂了此时不应该无限重试而是进入告警流程。我在实现里给事件表加了一个重试上限重试超过 10 次后状态标记为失败定时任务会跳过由另一套监控脚本查询失败事件并发送告警人工介入修复数据。这里的人工修复不是重新执行 MongoDB 写入这么简单而是要核对 MySQL 数据与 MongoDB 数据之间的偏差做一次完整的对账修改。对账是另一个话题这里简单说一句线上系统复杂度越高越需要一张数据核对任务表定时把 MySQL 和 MongoDB 的数据做对比不一致的记录单独标记出来处理。别等用户投诉了才发现问题。那事务回滚这个关键词怎么体现体现在业务异常时MySQL 业务表 事件表一起回滚MongoDB 那边通过定时任务先查一遍有没有多余的写入有则删除。我之前在补偿逻辑里加了一个反向操作业务已经回滚但 MongoDB 写入成功时可以通过事件表里额外记录的反向操作来清理。不过这个场景比较少大家先理解主流程的可靠性就够用了。6. 接口幂等性设计与重试机制事务补偿的隐藏支撑点事务补偿方案离不开接口幂等性设计。如果接口被客户端调用了两次会产生什么后果第一次请求扣费 100事件表插入一条记录。 第二次请求再扣费 100事件表再插入一条记录。最终 MongoDB 里会多出两条积分流水用户凭空多赚了 100 积分。这不是分发事件的问题而是整个业务链路的幂等没有设计好。接口幂等设计要落在两个层面。第一个层面是入口幂等同一个业务请求带上全局唯一请求号服务端在入口处检查这个对象是否已处理过。具体实现可以在 MySQL 事件表里加一个字段也可以单独建一张 request_record 表。第二个层面是数据写入幂等也就是上面提到的 eventId 唯一索引、MongoDB upsert 模式。实际项目里我会把幂等设计得更靠前一些public void createOrder(OrderRequest request) { // 请求ID String requestId request.getRequestId(); // 先查事件表如果已存在同requestId的记录直接返回 if (eventMapper.exists(requestId)) { log.info(duplicate request, requestId{}, requestId); return; } // 然后执行扣款 写事件表在同一本地事务内 ... }这里要注意一个并发问题数据库表上的唯一索引必须建立而且代码层不能只做先查再插要依赖数据库的唯一约束来兜底。我之前遇到过双实例同时处理同一个请求ID的场景两层判断都不生效最后靠唯一索引挡住了重复数据。重试机制也要做精细一点。事件表的 retry_count 和 next_retry_time 两个字段不只是状态标记还要承担退避算法的执行责任。我常用的策略是第1次重试等待30秒第2次1分钟第3次5分钟第4次30分钟第5次及以后每2小时重试一次。这样给 MongoDB 预留恢复时间也避免每5秒高频打不可用的数据库。7. 这套方案在生产环境实测还藏着几个不容忽视的坑落地这套方案不代表就万事大吉了。生产环境跑了大几个月我积累了一些实际踩坑经验分享出来帮大家避雷。7.1 MongoDB 的 upsert 有性能死角mongoTemplate.upsert按理说做的是存在则更新、不存在则插入听起来很完美但它的底层仍然是先查一遍索引、再决定是 update 还是 insert。如果你的文档查询条件没有命中索引或者集合数据量特别大这个操作会有明显的性能问题。我遇到过一版问题积分流水表每个月新增几百万条数据查询条件只带了一个业务主键没有建索引结果定时任务每次同步都要全表扫描接口整体响应时间飙升。后来在 MongoDB 集合上加了一个复合索引db.points_record.createIndex({ eventId: 1 }, { unique: true })加了唯一索引之后upsert 的操作效率大幅提升而且唯一索引本身还防止了重复插入一石二鸟。7.2 定时任务的调度要防止多实例并发冲突如果你的服务是多实例部署的大概率是那定时任务也要考虑并发问题。假设两台服务器的定时任务同时扫描事件表都拿到了同一条 event_id 记录都执行了 upsert都尝试 markProcessed这就是并发冲突。解决办法很简单写更新 SQL 时带上状态条件UPDATE transaction_event SET status 1, update_time NOW() WHERE id #{id} AND status 0这条语句利用数据库的行锁保证只有一个实例能把状态从0改成1。另一个实例执行后受影响行数为0就知道这条记录被别人处理了跳过即可。如果不做这层控制定时任务会出现大量重复消费、重复执行的问题。7.3 MongoDB 不可用时的雪崩风险还有一个特别容易被忽略的场景MongoDB 挂掉以后定时任务扫描事件表每次都会失败重试计数不断增加事件表里堆积大量未处理记录。此时如果业务还在正常写入订单事件表的数据量会飞速膨胀数据库磁盘告警、MySQL 慢查询、接口响应变慢会接踵而来。我的应对策略是在定时任务里加一个熔断开关如果连续 20 次同步都失败任务自动停止不再扫描事件表同时触发告警。等 MongoDB 恢复后人工或自动恢复任务。这个20次不是拍脑袋定的要结合你的定时任务频率保证在 MongoDB 故障 2 分钟后能够触发熔断。以下是熔断控制的核心伪代码Scheduled(fixedDelay 5000) public void syncEvents() { if (breaker.isOpen()) { log.warn(mongodb sync breaker is open, skip this round); return; } try { ListTransactionEvent pendingEvents eventMapper.selectPendingEvents(100); for (TransactionEvent event : pendingEvents) { syncEventWithRetry(event); } breaker.recordSuccess(); // 全部成功记录成功 } catch (Exception e) { breaker.recordFailure(); // 连续失败超过阈值则打开熔断 } }这个熔断逻辑参考了 Hystrix 的做法写得简洁但很实用。没有它故障期间的定时任务会把 MySQL 拖垮这是真实会发生的雪崩。7.4 别忘了给事件表加上对账机制很多同学实现到定时任务就收工了其实还差最后一个环节——对账。本地消息表加定时同步可以保证大部分情况下两边数据一致但总有极端情况比如网络分区、消息表数据被误删、MongoDB 集群脑裂。这些情况需要一套对账任务来兜底。我在每个包含跨库操作的接口里都会在响应前把业务数据的关键字段快照存到 MongoDB 的 audit_log 集合里。对账任务每天晚上扫描前一天的业务数据快照和 MongoDB 中的实际数据做字段比对不一致的就生成差异报告。这一步成本不高但非常值得让跨存储一致性从尽力而为变成了可观测、可追踪。8. 我这段时间的实操体会项目上线后我还专门做过一次故障注入演练模拟 MongoDB 宕机、模拟 MySQL 扣款成功但事件表插入失败、模拟定时任务重复执行。整套方案在极端场景下表现稳定唯一一次出问题是在 MongoDB 恢复的瞬间大量堆积的同步任务并发查询旧数据把 MongoDB 的 CPU 打满后来给同步任务加了限流就解决了。如果你负责的项目规模不大、没有专门的运维团队本地消息表这套方案是你最实用的选择。不需要搭 Kafka不需要维护 Flink就一张 MySQL 事件表、一个定时任务、加上 MongoDB 的唯一索引和 upsert就能保证接口操作 MySQL 和 MongoDB 之间的数据最终一致。记住几条要点不要把两个存储引擎的写入放在一个本地事务的保护伞下那一定是假的保护伞业务数据表与事件表写在一个 MySQL 事务里是这套方案的核心基石MongoDB 侧必须做幂等设计eventId 唯一键是关键重试要有上限、要退避、要熔断不能无限打日志补一个对账任务才能真正睡得着觉我在之后的新项目里凡是遇到接口操作多个存储的场景都会先画一张数据流图把所有写操作标出来然后问自己三个问题哪里是事务保护范围哪里是异步补偿范围哪里是幂等边界想清楚这三个问题代码设计基本不会出大偏差。这套方案还有一个好处是迁移成本低。后续如果项目引入消息队列本地消息表的事件推送只需要加一个 MQ producer把事件发送到 Kafka 或 RocketMQ消费者去写 MongoDB链路顺滑升级不需要改动已有的 MySQL 事务逻辑。也就是说你现在实施的数据补丁不是白做而是为未来扩展留好了接口。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价