MongoDB事务锁能否解决分布式API并发竞态问题
MongoDB分布式场景下
/join_game与/close_game竞态问题解决方案 关于MongoDB事务的行为澄清
- WiredTiger存储引擎默认采用MVCC+文档级锁机制,不存在你担心的“事务持有全局完全锁阻塞所有请求”的问题:
- 事务只会对当前操作涉及的目标文档加粒度极细的意向锁,不会锁整个集合或数据库,其他无关文档的读写完全不受影响
- 两个事务同时修改同一个文档时,后发起的请求不会无限阻塞,等待时长超过
maxTransactionLockRequestTimeoutMillis配置(默认5ms)后会直接抛出锁冲突错误,不会引发死锁或请求堆积 - 这类锁冲突属于可重试的瞬时错误,捕获后做1-2次指数退避重试即可,不会引发非预期业务错误
- 多文档事务确实能覆盖该场景的原子性需求,但这个场景完全不需要上事务,用MongoDB原生的单文档原子更新就能以更低的性能开销彻底解决竞态,不需要额外引入分布式队列、分布式锁等重组件。
最优方案:利用单文档原子更新消除竞态窗口
你之前的逻辑存在竞态的核心原因是:把「校验对局状态」和「更新对局参与者/状态」拆成了两个独立的数据库操作,两个操作之间存在时间差,给了并发请求修改状态的空间。
MongoDB单文档的读+匹配+写操作是天然原子的,只要把状态校验逻辑直接写到更新语句的匹配条件里,就能彻底消灭这个时间差,不管部署多少个服务实例,并发请求都不会出现状态判断和实际写入不一致的问题。
/join_game 接口改造逻辑
不要先查询Games集合判断状态再做更新,直接用findOneAndUpdate完成原子的状态校验+参与者添加:
// 原子操作:仅当对局处于开放状态时,才将用户加入参与者列表 const joinedGame = await db.collection("Games").findOneAndUpdate( { _id: targetGameId, status: "open", // 状态校验直接写在更新匹配条件中 "participants.userId": { $ne: currentUserId } // 顺便做重复加入校验 }, { $push: { participants: { userId: currentUserId, joinAt: new Date() } } }, { returnDocument: "after" } ) // 匹配结果为空 = 对局已关闭/不存在/用户已加入,直接返回失败 if (!joinedGame) { return res.status(403).send("当前对局不可加入") } // 走到此处说明加入对局的核心逻辑已经原子生效,再更新用户侧统计即可 // 如果需要保证这一步和前面的对局更新强一致,给这两步套多文档事务即可 await db.collection("Users").updateOne( { _id: currentUserId }, { $inc: { joinedCount: 1 } } )
/close_game 定时任务接口改造逻辑
同样用原子更新做状态判断,避免重复关闭、以及关闭后仍有用户加入的问题:
// 原子操作:仅当对局处于开放状态时,才标记为关闭 const closedGame = await db.collection("Games").findOneAndUpdate( { _id: targetGameId, status: "open" }, { $set: { status: "closed", closedAt: new Date() } }, { returnDocument: "after" } ) // 匹配结果为空 = 对局已经被关闭过,直接跳过后续逻辑 if (!closedGame) return // 基于关闭时拿到的最新参与者列表判定获胜者,更新用户获胜统计 const winnerId = calcWinner(closedGame.participants) await db.collection("Users").updateOne( { _id: winnerId }, { $inc: { winCount: 1 } } )
方案可靠性说明
- 针对同一个
Games文档的并发更新,MongoDB会串行执行,不存在并行修改的可能:- 如果关闭请求先执行完成,对局状态被改为
closed,后续的加入请求匹配条件status: "open"不满足,更新直接返回空,不会出现关闭后仍有用户加入的问题 - 如果加入请求先执行完成,用户被加入参与者列表,后续关闭请求执行时依然能匹配到开放状态的对局,拿到的参与者列表包含刚加入的用户,后续结算逻辑不会漏掉该用户,不会出现数据不一致
- 如果关闭请求先执行完成,对局状态被改为
- 该方案完全不依赖服务侧的内存状态,不管你扩容多少个服务实例、容器,所有并发控制逻辑都在MongoDB侧完成,没有分布式组件的维护成本,性能远高于分布式队列、分布式锁方案。
- 如果业务要求「加入对局时更新Games文档」和「更新Users统计」必须同时成功或同时失败,给这两个操作套一层多文档事务即可,核心的竞态防护逻辑不需要改动,遇到锁冲突做简单重试即可。
内容的提问来源于stack exchange,提问作者Dave
相关产品推荐
相关产品推荐

