MongoDB事务必须在副本集或分片集群中使用,单节点standalone模式不支持;扣减库存需在事务中用findOneAndUpdate原子操作校验并更新,避免超卖。

事务必须开启 replica set 或 sharded cluster 才能用
MongoDB 的 session.startTransaction() 在单节点 standalone 模式下直接报错:Transaction numbers are only allowed on a replica set member or mongos。这不是配置问题,是硬性限制——哪怕你只是本地开发,也得用 mongod --replSet rs0 启动,并执行 rs.initiate() 初始化副本集。Sharded cluster 同理,mongos 是事务入口,分片本身不支持独立事务。
常见踩坑点:
- 误以为加了
session就能事务,结果commitTransaction()抛出未初始化副本集错误 - Docker 里跑单节点 MongoDB 镜像,默认就是 standalone,没配
--replSet参数就必然失败 - Spring Data MongoDB 的
MongoTemplate.executeInTransaction()底层仍依赖服务端副本集能力,不解决部署层问题
扣减库存必须用 findAndModify + 条件更新,不能先查后改
事务不是万能锁。在事务内写 collection.findOne() 再 collection.updateOne(),依然可能被并发请求穿插执行——因为读操作默认不加文档级锁,MongoDB 的事务隔离只保证「写写冲突检测」,不提供「读写阻塞」。
正确做法是在事务中用原子操作一步完成判断与更新:
session.startTransaction();
try {
const result = await inventoryCollection.findOneAndUpdate(
{ _id: "item_123", stock: { $gte: 1 } }, // 关键:条件包含库存余量
{ $inc: { stock: -1 } },
{ session, returnDocument: "after" }
);
if (!result.value) throw new Error("stock insufficient");
await session.commitTransaction();
} catch (e) {
await session.abortTransaction();
throw e;
}
注意点:
-
findOneAndUpdate的查询条件必须包含stock: { $gte: X },否则事务只能靠写冲突回滚,无法预防逻辑超卖 - 不要用
updateOne({ _id }, { $inc: { stock: -1 } })单独更新,它不校验当前值,事务提交时才发现 stock 变负,但此时业务逻辑已按“扣减成功”走了 - 返回的
result.value是更新后的文档,可直接用于后续校验(比如检查是否为 null)
事务超时和长时间运行会引发自动 abort
MongoDB 默认事务最长存活 60 秒(transactionLifetimeLimitSeconds),超过即自动 abortTransaction。而库存扣减本该是毫秒级操作,一旦卡在外部 HTTP 调用、日志写入或复杂计算里,事务就会静默失败。
实操建议:
- 把所有非数据库操作(如调用支付网关、发 MQ 消息)移到
commitTransaction()之后,事务内只做findOneAndUpdate和必要字段校验 - 监控
db.currentOp({ "secs_running": { $gt: 30 } }),及时发现长事务 - 应用层设置比服务端更短的超时(比如 5 秒),主动抛异常并清理资源,避免依赖服务端自动 abort
高并发下事务冲突会导致频繁重试,必须设计幂等回退
当多个事务同时尝试扣减同一商品库存,MongoDB 会在 commitTransaction() 阶段检测写冲突(WriteConflict),抛出 TransientTransactionError。这不是失败,而是提示你重试——但业务代码若没捕获并重试,就会直接返回“库存不足”给用户,实际库存还充足。
简单重试逻辑示例:
let attempt = 0;
const maxAttempts = 3;
while (attempt {
const result = await inventoryCollection.findOneAndUpdate(
{ _id: "item_123", stock: { $gte: 1 } },
{ $inc: { stock: -1 } },
{ session }
);
if (!result.value) throw new Error("out of stock");
});
break; // 成功退出循环
} catch (e) {
if (e.errorLabels?.includes("TransientTransactionError") && attempt setTimeout(r, 10 * attempt)); // 指数退避
continue;
}
throw e;
} finally {
await session.endSession();
}
}
关键细节:
- 每次重试必须新建
session,复用旧 session 会报InvalidSession - 退避时间要递增,避免所有请求在同一毫秒重试,形成“重试风暴”
- 重试上限设为 3–5 次足够,再高大概率是架构问题(比如热点商品没做分桶)
事务真正起作用的地方,从来不在语法是否写对,而在于你有没有把库存校验压进原子操作里、有没有让事务足够短、以及敢不敢在写冲突时多试几次。这些地方一松动,超卖就藏不住。










