并发写入是数据库应用中最容易踩坑的场景之一。比如电商系统中,两个用户同时抢购最后一件库存商品,如果没有合理的并发控制,可能出现超卖;再比如内容管理系统里,两个人同时编辑同一篇文档,后保存的人会把先保存的人的修改直接覆盖掉。MongoDB作为流行的文档型数据库,提供了两种主流的并发控制手段:一种是4.0版本之后引入的多文档事务,另一种是基于版本号或时间戳的乐观锁。这两种方案各有适用场景,理解它们的原理和差异,是写好MongoDB应用的基础。

一、先理解MongoDB的写入原子性边界
很多人一上来就想用事务,但首先要知道MongoDB在单个文档层面天然就是原子性的。一次对单个文档的更新操作,无论这个文档嵌套了多少层结构,MongoDB都会保证要么全部成功,要么全部失败,中间状态不会对外可见。这一点和关系型数据库的行为类似,但范围仅限于单个文档。
这意味着,如果你的业务可以通过合理的文档设计把强关联的数据放进同一个文档里,很多看似需要事务的场景其实并不需要事务。例如把订单的明细直接作为数组内嵌在订单文档中,那么更新订单状态和修改明细就可以在一次update操作中完成,天然原子。
但一旦数据分散在多个文档甚至多个集合中,比如下单要扣减库存集合中的数量、又要在订单集合中插入记录、还要更新用户的积分,单文档原子性就无能为力了。这时候才需要引入多文档事务或应用层的并发控制。判断标准很简单:一次业务操作是否必须同时修改多个文档且要求一致。
二、多文档事务的使用方法与限制
MongoDB从4.0开始支持副本集内的多文档事务,4.2开始支持分片集群上的事务。使用事务的基本流程是:开启会话,在会话中启动事务,执行若干读写操作,最后提交或回滚。下面是Node.js驱动的示例:
const session = client.startSession();
try {
session.startTransaction({
readConcern: { level: 'snapshot' },
writeConcern: { w: 'majority' }
});
const db = client.db('shop');
// 扣减库存,条件是库存足够
const inv = await db.collection('inventory').findOneAndUpdate(
{ sku: 'A001', qty: { $gte: 1 } },
{ $inc: { qty: -1 } },
{ session }
);
if (!inv) {
throw new Error('库存不足');
}
// 创建订单
await db.collection('orders').insertOne(
{ sku: 'A001', user: 'u1001', createdAt: new Date() },
{ session }
);
await session.commitTransaction();
console.log('事务提交成功');
} catch (err) {
await session.abortTransaction();
console.error('事务回滚:', err.message);
} finally {
await session.endSession();
}
这段代码演示了一个典型的转账式场景:两个文档的修改要么都生效,要么都不生效。注意扣库存的更新条件里带了qty大于等于1的判断,这本身就是一种内置的并发保护,防止库存被扣成负数。
使用事务有几个必须注意的限制。第一,事务默认要求在60秒内完成,超时会被自动中止,长事务会持有锁并占用WiredTiger缓存,严重影响性能。第二,事务内的操作数量不宜过多,官方建议单个事务修改的文档数控制在1000以内。第三,事务只能作用于副本集或分片集群,单机模式(没有配置副本集的standalone实例)不支持事务,本地开发时经常有人在这里报错。第四,未提交事务的修改对其他会话不可见,但事务写入的数据会占用存储,异常情况下可能出现大量临时文件。
另外要理解,MongoDB的事务实现基于快照隔离,通过乐观并发控制在提交时检测写冲突。如果两个事务修改了同一个文档,后提交的那个会收到写冲突异常,应用层需要捕获异常并决定重试。MongoDB官方建议把整个事务逻辑包在重试循环里,驱动通常提供了withTransaction辅助函数来简化这件事。
三、用版本号实现乐观锁并发控制
乐观锁的核心思想是:假设冲突很少发生,操作前不加锁,而是在更新时校验数据有没有被别人改过。最常见的实现方式是给文档加一个version字段,每次读取时记下版本号,更新时把版本号作为更新条件的一部分,同时把版本号加一。如果版本号对不上,说明数据已经被其他操作修改,本次更新失败。
下面是一个完整的示例,模拟两个客户端并发编辑同一篇文档:
async function saveArticle(db, articleId, userId, newContent) {
const col = db.collection('articles');
const MAX_RETRY = 3;
for (let i = 0; i < MAX_RETRY; i++) {
// 读取当前文档和版本号
const doc = await col.findOne({ _id: articleId });
if (!doc) throw new Error('文档不存在');
// 带版本号条件更新,同时版本号加一
const res = await col.updateOne(
{ _id: articleId, version: doc.version },
{
$set: { content: newContent, updatedAt: new Date() },
$inc: { version: 1 }
}
);
if (res.modifiedCount === 1) {
return { ok: true, version: doc.version + 1 };
}
// 版本号不匹配,说明有并发修改,重试或返回冲突
console.log(`用户${userId}遇到写冲突,第${i + 1}次重试`);
}
return { ok: false, reason: 'conflict' };
}
关键点在updateOne的过滤条件里同时包含_id和version。modifiedCount为1说明更新成功,为0则说明在这段时间内有其他人已经修改过该文档,当前读取的数据已经过期。这时可以选择重新读取数据、合并修改后重试,也可以直接把冲突抛给前端,让用户决定是否覆盖。
乐观锁的优点非常明显:不需要长时间持有数据库锁,性能开销小,特别适合读多写少的场景。缺点是在写冲突频繁的环境下,重试次数会上升,甚至出现一直失败的活锁问题,所以示例中设置了最大重试次数。另外要注意,如果业务需要检测文档内部某个数组或子文档的变化,单靠一个全局version字段粒度可能不够,可以为热点子结构单独设计版本字段。
除了版本号,还可以用updatedAt时间戳做乐观锁,原理相同。但时间戳存在时钟精度问题,高并发下同一毫秒内的两次修改可能无法区分,因此生产环境更推荐单调递增的数字版本号。
四、事务与乐观锁如何选择
这两种方案并不互斥,而是解决不同维度的问题。事务解决的是多个文档修改的原子性和隔离性,即要么全成功要么全回滚;乐观锁解决的是检测并防止并发修改造成的数据覆盖。很多实际系统会把两者结合起来用:在事务内部使用带版本号的更新条件,既保证原子提交,又能在冲突时提前失败。
选择时可以参考几个维度。如果操作涉及多个集合或多个文档的一致性修改,必须用事务;如果只是防止同一文档被并发覆盖,乐观锁就够了,成本更低。如果冲突概率低,乐观锁的重试代价小;如果冲突概率高,可以考虑悲观思路,比如借助findAndModify的原子性,或者用Redis分布式锁排队。对延迟敏感的核心链路,能不用大事务就不用,把长流程拆解成多个小的幂等操作配合补偿逻辑,往往比强事务更可靠。
还有一个容易忽视的实践建议:无论用哪种方案,都要给关键操作设计幂等性。事务提交后如果应用崩溃,客户端可能不知道结果,重试时幂等键可以避免重复扣减或重复下单。把乐观锁版本号、事务重试和幂等设计组合起来,才是应对并发问题的完整答案。
五、常见踩坑点总结
第一,在standalone实例上调用startTransaction直接报错,本地开发需要用单节点副本集启动。第二,事务中混合使用不同readConcern级别可能导致行为不符合预期,建议统一使用snapshot级别。第三,乐观锁更新后忘记检查modifiedCount,导致冲突被静默吞掉,数据被覆盖的问题依然存在。第四,在事务里做外部调用(比如发HTTP请求)导致事务长时间挂起超时,外部交互应该放到事务提交之后。第五,分片集群上事务要求所有操作的路由正确,事务内不能创建集合(4.4之前),提前建好集合可以省去很多麻烦。
总结一下:单文档能解决的用原子操作,多文档一致性用事务,防覆盖用乐观锁,高冲突场景考虑排队或拆分流程。理解每种工具的边界,比死记语法重要得多。希望这些内容能帮助你在自己的项目中搭建出可靠的并发控制方案。