《Spring Mongo实战》第14招 事务不是第一选择
## 01、痛点 凌晨两点,报警响起:库存已经扣了,但库存流水没有写,客服对不上账。 我是老李,刚接手一个从 MySQL 迁到 MongoDB 的商城系统。团队原来的 Service 层满是 @Transactional,迁移后几乎原样保留,每个写方法都套上事务。最典型的是扣库存和写库存流水,两个集合操作,大家认为多文档事务正好保证一致。 结果压测时库存集合频繁锁等待,接口响应从几十毫秒涨到两秒,事务回滚率也上升。问题不是事务不可用,而是我们把关系库的用法带进了文档库。官方明确提醒:多文档事务成本更高,不能替代好的 Schema Design。今天就把这件事讲透。 ## 02、是什么 MongoDB 从 4.0 副本集开始支持多文档事务,4.2 分片集群也支持。Spring Data MongoDB 提供 MongoTransactionManager,可以用 @Transactional 注解管理事务。事务覆盖多个文档、多个集合,提供 ACID 保证。 但有个关键区别:单文档操作天然原子。你更新一个文档里的多个字段,即使没有显式事务,也不会出现写一半的状态。这是 MongoDB 存储引擎 WiredTiger 提供的单文档原子性。多文档事务则是另一套机制,需要副本集、协调者、参与者和多数派写入,成本完全不是一回事。 因此,多文档事务是真实可用的工具,但不应该成为默认选择。 ## 03、为什么 为什么从 MySQL 迁移过来后给所有 Service 都加事务是一种危险习惯? 第一,事务模型不同。MySQL 单节点行锁事务比较成熟,很多业务在一个连接里提交几个 update,开销相对固定。MongoDB 多文档事务涉及协调器和多个参与者,需要 prepare、commit、多数派确认,还有全局逻辑时钟和事务元数据。事务越多,协调成本越高。 第二,锁与冲突被放大。多文档事务持有写锁或快照,写冲突会导致事务中止。给所有 Service 加事务,等于把很多本可以单文档原子完成的写操作升级成跨文档事务,放大锁竞争,吞吐下降,尾延迟升高。 第三,事务有边界限制。事务有默认最长运行时间,超过会失败;大批量写入在事务里容易触发压力。长事务还会拖累快照和缓存,影响其他操作。 第四,也是最关键的,它掩盖 Schema 问题。很多从关系库迁过来的系统,仍然拆成大量小集合,然后用事务模拟关系库的 join 和原子性。这样 MongoDB 被用成更慢的“无模式 MySQL”。正确的做法是重新审视文档边界,把本属于一个聚合的数据嵌入到同一个文档,用单文档原子性保证一致性。 官方说得很直接:多文档事务成本更高,不能替代好的 Schema Design。先用单文档原子性,再在真正跨文档一致性场景使用事务。 ## 04、架构 要理解成本,得看多文档事务的架构。 单文档更新:WiredTiger 对单文档修改天然原子,写一次即可。 多文档事务:客户端开始事务后,读写会到达不同 mongod 节点;协调者维护事务参与者列表;提交时先进入 prepare 阶段,每个参与者写入 prepare 记录;协调者发出 commit 决策;各参与者写入 commit 记录,按多数派写关注返回。这中间每一个环节都有网络往返和磁盘写。事务期间的未提交变更需要保存在内存和临时记录中,写冲突时还要回滚。 这也解释了为什么官方反复提醒:事务是保底工具,不是建模方式。一个真正的跨文档一致性场景,比如订单、库存、积分分属不同聚合,才值得用事务。能嵌入文档的,先考虑嵌入;能单文档原子更新的,不要开事务。 ## 05、实战 假设我们要实现“扣库存 + 写库存流水”,并用事务保证一起成功或一起失败。 环境准备:MongoDB 需要副本集。本地可以初始化单节点副本集,连接串带上 replicaSet 参数。Spring Boot 引入 spring-boot-starter-data-mongodb。 配置事务管理器: ```java @Configuration public class MongoTxConfig { @Bean MongoTransactionManager transactionManager(MongoDatabaseFactory factory) { return new MongoTransactionManager(factory); } } ``` 定义两个文档: ```java @Document('product_stock') public class ProductStock { @Id private String id; private String sku; private int qty; // getters/setters } @Document('inventory_flow') public class InventoryFlow { @Id private String id; private String sku; private int qtyDelta; private String reason; private Instant createdAt; // getters/setters } ``` 定义 Repository: ```java public interface ProductStockRepository extends MongoRepository { Optional findBySku(String sku); } public interface InventoryFlowRepository extends MongoRepository { } ``` 核心 Service: ```java @Service public class InventoryService { private final ProductStockRepository stockRepository; private final InventoryFlowRepository flowRepository; public InventoryService(ProductStockRepository stockRepository, InventoryFlowRepository flowRepository) { this.stockRepository = stockRepository; this.flowRepository = flowRepository; } @Transactional public void deductWithFlow(String sku, int qty) { ProductStock stock = stockRepository.findBySku(sku) .orElseThrow(() -> new IllegalStateException('库存不存在')); if (stock.getQty() < qty) { throw new IllegalStateException('库存不足'); } stock.setQty(stock.getQty() - qty); stockRepository.save(stock); InventoryFlow flow = new InventoryFlow(); flow.setSku(sku); flow.setQtyDelta(-qty); flow.setReason('ORDER_DEDUCT'); flow.setCreatedAt(Instant.now()); flowRepository.save(flow); if (qty == 666) { throw new RuntimeException('故意触发回滚'); } } } ``` 验证回滚:调用 deductWithFlow('SKU-1', 666),异常抛出后,去数据库查询。 ```shell db.product_stock.find({sku:'SKU-1'}) db.inventory_flow.find({sku:'SKU-1'}) ``` 库存数量仍然是原值,inventory_flow 集合里没有新增流水。说明扣库存和写流水都回滚了。 如果去掉 @Transactional,只把两个 save 顺序执行,那么第二个失败时第一个已经落库,就会出现开头那种“库存扣了但流水没写”的不一致。这个实验能直观看到多文档事务的价值,也能看到它只应该用在必要场景。 ## 06、洞见 MongoDB 最好的事务优化,可能不是调事务参数,而是重新设计 Document Boundary。 比如扣库存和写流水,如果库存流水可以内嵌进库存文档,保留最近若干条,那么一次 update 就能完成扣减和流水追加: ```javascript db.product_stock.updateOne( { sku: 'SKU-1', qty: { $gte: 2 } }, { $inc: { qty: -2 }, $push: { recentFlows: { $each: [{ at: new Date(), delta: -2, reason: 'ORDER_DEDUCT' }], $slice: -100 } } } ) ``` 这是单文档原子操作,不需要多文档事务。如果全量流水必须独立保存,还可以用 Change Stream 追踪库存文档的 recentFlows 变化,异步归档到流水集合,把强一致事务降级为最终一致。 所以,事务调优只是在既有边界内缓解问题;真正的突破是重新划分边界。一个项目里多文档事务数量多,往往说明文档边界没有设计好。每次想加事务前,先问一句:如果我把这两个集合合成一个文档,是否更自然? ## 07、行动 不要只在嘴上说,先去项目里找出所有事务。 第一步,盘点。搜索 @Transactional 注解、MongoTransactionManager、显式 startSession 的位置,列成清单。 第二步,分类。每个事务涉及哪些集合?维护什么不变量?是强一致还是最终一致? 第三步,消除。能嵌入的集合合并;能用 $inc、$set、$push、$pull、arrayFilters 等单文档原子更新的,改成单文档更新;能容忍最终一致的,用发件箱、补偿或 Change Stream 替代。 第四步,保留。只有真正跨聚合、必须强一致的场景才保留多文档事务。保留时也要控制事务范围,尽量短,避免外部调用。 第五步,度量。记录事务数量、跨文档事务比例、平均事务时长、中止率、锁等待和 P99 延迟。最好把“新增跨文档事务需要评审”写进开发规范。 这一套动作下来,很多项目会发现 80% 的事务其实可以消失。 ## 08、小结 从凌晨报警说起,我们看了 Spring Data MongoDB 多文档事务的用法和回滚验证,也看了它背后的架构成本。多文档事务不是不能用,但如果从 MySQL 迁过来后给所有 Service 都加事务,就很危险。先利用单文档原子性和嵌入模型,再在真正跨文档一致性场景使用事务,这是 MongoDB 官方反复强调的顺序。 ## 09、链接与概念说明 概念说明: - Document Boundary:文档边界,决定一个文档包含哪些字段和子文档。 - Single-Document Atomicity:单文档原子性,MongoDB 单文档操作天然具备。 - Multi-Document Transaction:多文档事务,跨文档、跨集合 ACID。 - Replica Set:副本集,多文档事务必须在副本集或分片集群上运行。 - Write Concern Majority:多数派写关注,事务提交时通常需要。 - WiredTiger:MongoDB 默认存储引擎。 - oplog:操作日志,事务通过 applyOps 等机制复制到从节点。 - Change Stream:变更流,可用于事件驱动和最终一致同步。 链接: - MongoDB 官方文档:Transactions - MongoDB 官方文档:Data Modeling - Spring Data MongoDB 文档:Mongo Transactions - MongoDB Blog:Multi-Document Transactions