You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Discord机器人等级系统中Mongoose并发写入的数据一致性问题

Discord机器人等级系统并发写入的XP丢失问题

问题背景

我正在用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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.06 05:51:31