MongoDB(Mongoose)中用事务实现单链表的问题排查
我来帮你搞定这个问题——你现在遇到的核心问题是MongoDB事务的读已提交隔离级别,加上没有用原子性操作维护链表的尾节点,导致并发请求时多个事务都读到了相同的旧数据,最终多个新文档的previous都变成了null。
问题出在哪?
你的原代码虽然把findOne和create放进了事务,但事务只保证这两个操作的原子性(要么全成功要么全失败),但不会自动帮你锁住查询的文档或者阻止并发事务读取未提交的数据。在MongoDB的默认隔离级别下,每个事务只能看到已经提交的数据,所以当第一个事务还没提交新创建的文档时,后面的事务执行findOne时,拿到的还是之前的最新节点(甚至是null),自然就会出现多个新节点都指向同一个previous的情况。
靠谱的解决方案
要解决这个问题,我们需要用原子性的读写操作来替代分开的查询和创建,确保同一时间只有一个请求能获取到当前的尾节点并创建新节点。下面给你几个可行的方案:
方案1:维护一个尾节点记录(最推荐)
我们可以单独建一个集合,比如messageRoots,用来记录每个root对应的最新消息节点。然后用findOneAndUpdate做原子性的查询+更新,这样就能确保并发请求不会拿到相同的尾节点:
const session = await mongoose.startSession(); session.startTransaction(); try { // 先生成一个新的ObjectId,用来占位即将创建的消息 const newMsgId = new mongoose.Types.ObjectId(); // 原子性获取当前尾节点,并把尾节点更新为新的ObjectId const rootRecord = await MessageRoot.findOneAndUpdate( { root: root }, { $set: { latest: newMsgId } }, { session, new: true, // 返回更新后的文档 upsert: true // 如果这个root的记录不存在,就自动创建 } ); // 创建新消息,previous指向更新前的尾节点(也就是真正的上一个消息) const newMessage = await Message.create( { _id: newMsgId, time: new Date(), root: root, previous: rootRecord.latest, content: content, from: from // 你原数据里有这个字段,记得补上 }, { session } ); await session.commitTransaction(); } catch (err) { await session.abortTransaction(); throw err; } finally { session.endSession(); }
方案2:用乐观锁实现
如果不想额外维护集合,可以给Message文档加一个version字段,通过版本号来控制并发更新,冲突时重试:
const session = await mongoose.startSession(); session.startTransaction(); try { while (true) { // 获取当前最新的消息 const latestMsg = await Message.findOne({ root: root }) .sort({ time: -1 }) .session(session); const prevId = latestMsg?._id || null; const currentVersion = latestMsg?.version || 0; // 创建新消息 const newMsg = new Message({ time: new Date(), root: root, previous: prevId, content: content, from: from, version: 0 }); await newMsg.save({ session }); // 如果有上一个消息,尝试更新它的next字段(如果需要双向链表),同时校验版本号 if (latestMsg) { const updateResult = await Message.updateOne( { _id: latestMsg._id, version: currentVersion }, { $set: { next: newMsg._id, version: currentVersion + 1 } }, { session } ); // 如果更新失败,说明有其他事务已经修改了这个节点,回滚重试 if (updateResult.modifiedCount === 0) { await session.abortTransaction(); session.endSession(); // 重新开启事务循环 session = await mongoose.startSession(); session.startTransaction(); continue; } } await session.commitTransaction(); break; } } catch (err) { await session.abortTransaction(); throw err; } finally { session.endSession(); }
方案3:使用悲观写锁
如果你用的是MongoDB 4.0+,可以在查询时加上悲观写锁,强制锁住查询到的最新文档,这样后续事务必须等当前事务提交后才能读取:
const session = await mongoose.startSession(); session.startTransaction(); try { // 用悲观写锁锁住当前最新的消息,阻止其他事务读取 const latestMsg = await Message.findOne({ root: root }) .sort({ time: -1 }) .session(session) .setOptions({ lock: 'pessimistic_write' }); // 创建新消息,previous指向锁住的节点 const newMessage = await Message.create( { time: new Date(), root: root, previous: latestMsg?._id || null, content: content, from: from }, { session } ); await session.commitTransaction(); } catch (err) { await session.abortTransaction(); throw err; } finally { session.endSession(); }
总结
你的原代码没有解决并发问题的核心原因是:事务只保证了操作的原子性,但没有处理并发场景下的读一致性。上面的几个方案都通过原子操作或者锁机制,确保了每次只有一个请求能获取到当前的尾节点,从而保证链表的一致性。
内容的提问来源于stack exchange,提问作者user3210986

