Discord机器人等级系统中Mongoose并发写入的数据一致性问题
问题背景
我正在用discord.js和TypeScript开发Discord机器人,搭建用户发送消息即可获得XP的等级系统,采用Mongoose与MongoDB数据库交互。当前使用的Schema会生成包含公会ID及公会内各用户等级信息数组的文档,但担心当机器人同时对同一公会文档发起多次写入操作时,会出现XP丢失的意外情况。
我的Schema代码
import { Schema, model } from "mongoose"; interface IGuildProfiles { guildid: number; levels: { userid: number; totalXp: number; level: number; levelXp: number; levelXpNeeded: number; obtained: { level: number; date: number }[]; }[]; } const guildProfiles = new Schema<IGuildProfiles>( { guildid: {type: Number, required: true}, levels: [ { userid: {type: Number, required: true}, totalXp: {type: Number, required: true}, level: {type: Number, required: true}, levelXp: {type: Number, required: true}, levelXpNeeded: {type: Number, required: true}, obtained: [{ level: {type: Number, required: true}, date: {type: Number, required: true} }], }, ], }, { timestamps: true } ); export default model("GuildProfiles", guildProfiles);
并发写入的担忧场景
// 第一次写入(与其他写入几乎同时) const data = getDataSomeHow() // {id: 123, example:[]} writeDataSomeHow() // 写入 {id:123, example: [{exampleString:"THIS IS THE FIRST WRITE"}]} // 第二次写入(与其他写入几乎同时) const data = getDataSomeHow() // {id: 123, example:[]} writeDataSomeHow() // 写入 {id:123, example: [{exampleString:"THIS IS THE SECOND WRITE"}]} // 第三次写入(与其他写入几乎同时) const data = getDataSomeHow() // {id: 123, example:[]} writeDataSomeHow() // 写入 {id:123, example: [{exampleString:"THIS IS THE THIRD WRITE"}]} // 检查写入后的数据 const data = getDataSomeHow() /* 我担心因并发问题得到的数据: {id:123, example: [{exampleString:"THIS IS THE THIRD WRITE"}]} 我期望得到的数据: {id:123, example: [{exampleString:"THIS IS THE FIRST WRITE"}, {exampleString:"THIS IS THE SECOND WRITE"}, {exampleString:"THIS IS THE THIRD WRITE"}]} */
问题解答
这种情况是否必然发生?
不是必然发生,但出现概率很高。这是典型的「读取-修改-写入」竞态条件:多个请求同时读取到旧的文档数据,各自修改后写回数据库,后执行的写入会完全覆盖前一次的修改,导致中间的XP增量丢失。
如何避免?
1. 使用MongoDB原子更新操作(推荐)
不要先读取文档、在客户端修改后再写回,而是直接用Mongoose的updateOne/findOneAndUpdate配合MongoDB原子操作符(如$inc、$push、$set),让数据库直接处理修改逻辑,保证操作的原子性,从根源上避免竞态条件。
示例:给指定用户增加XP的原子操作
// 找到对应公会文档,定位到目标用户的等级条目,原子增加XP await GuildProfiles.updateOne( { guildid: guildId, "levels.userid": userId }, { $inc: { "levels.$.totalXp": addedXp, // 增加总XP "levels.$.levelXp": addedXp // 增加当前等级进度XP } }, { upsert: false } // 如果用户不存在,可先执行插入逻辑,或调整为upsert配合合适的条件 );
如果需要处理用户等级条目不存在的情况,可以结合$addToSet和数组过滤器:
await GuildProfiles.findOneAndUpdate( { guildid: guildId }, { // 若用户条目不存在则插入 $addToSet: { levels: { userid: userId, totalXp: addedXp, level: 0, levelXp: addedXp, levelXpNeeded: calculateXpNeeded(0), // 自定义计算升级所需XP的函数 obtained: [] } }, // 对已存在的用户条目增加XP $inc: { "levels.$[elem].totalXp": addedXp, "levels.$[elem].levelXp": addedXp } }, { arrayFilters: [{ "elem.userid": userId }], upsert: true, // 若公会文档不存在则创建 new: true } );
2. 使用乐观锁
在Schema中添加version字段,每次更新时校验版本号,只有版本匹配才允许更新,更新后版本号自增。若更新失败(说明有其他请求先修改了文档),则重试操作。
示例:
首先修改Schema,添加版本字段:
const guildProfiles = new Schema<IGuildProfiles>( { guildid: {type: Number, required: true}, version: {type: Number, default: 0}, // 新增乐观锁版本字段 levels: [/* 原等级结构 */], }, { timestamps: true } );
然后实现带重试的更新逻辑:
let updated = false; while (!updated) { const doc = await GuildProfiles.findOne({ guildid: guildId }); if (!doc) { // 公会文档不存在,创建新文档 await GuildProfiles.create({ guildid: guildId, levels: [{ userid: userId, totalXp: addedXp, level: 0, levelXp: addedXp, levelXpNeeded: calculateXpNeeded(0), obtained: [] }] }); updated = true; break; } // 修改客户端的文档数据 const userLevel = doc.levels.find(l => l.userid === userId); if (userLevel) { userLevel.totalXp += addedXp; userLevel.levelXp += addedXp; } else { doc.levels.push({ userid: userId, totalXp: addedXp, level: 0, levelXp: addedXp, levelXpNeeded: calculateXpNeeded(0), obtained: [] }); } // 尝试更新,匹配当前版本号 const result = await GuildProfiles.updateOne( { guildid: guildId, version: doc.version }, { $set: doc, $inc: { version: 1 } } ); updated = result.modifiedCount > 0; }
这种方式适合复杂修改场景,但需要处理重试逻辑,性能不如原子操作。
3. 调整数据模型(可选)
当前模型是一个公会文档包含所有用户的等级信息,当公会用户较多时,文档体积大,并发冲突概率高。可以将每个用户的等级信息拆分为独立文档,减少并发冲突的可能性。
示例新Schema:
import { Schema, model } from "mongoose"; interface IUserLevel { guildid: number; userid: number; totalXp: number; level: number; levelXp: number; levelXpNeeded: number; obtained: { level: number; date: number }[]; } const userLevelSchema = new Schema<IUserLevel>({ guildid: { type: Number, required: true }, userid: { type: Number, required: true }, totalXp: { type: Number, required: true }, level: { type: Number, required: true }, levelXp: { type: Number, required: true }, levelXpNeeded: { type: Number, required: true }, obtained: [{ level: { type: Number, required: true }, date: { type: Number, required: true } }] }, { timestamps: true }); // 添加联合唯一索引,确保同一公会内用户唯一 userLevelSchema.index({ guildid: 1, userid: 1 }, { unique: true }); export default model("UserLevel", userLevelSchema);
这样更新单个用户XP时,直接操作该用户的独立文档,原子性更好,也避免了大文档的并发问题。
内容的提问来源于stack exchange,提问作者Mood

