MongoDB如何设计活动报名系统:通过唯一索引与乐观锁防止名额超征

作者:袖梨 2026-06-30
必须为 activity_registration 集合创建复合唯一索引 { user_id: 1, activity_id: 1 },以原子性防止同一用户重复报名同一活动;单字段索引无效,且建索引前须清理历史重复数据,否则 createIndex 会因 E11000 错误失败。

MongoDB 本身不支持行级锁或 SELECT FOR UPDATE,所谓“乐观锁”在报名场景中不能照搬 MySQL 那套逻辑;真正起作用的是唯一索引 + 原子写入 + 应用层重试机制。别指望靠 findAndModify 或版本号字段拦住并发超报——它拦不住,除非你把约束提前压到索引层。

为什么必须用唯一索引防重复报名

活动报名最核心的并发问题不是“抢到最后一个名额”,而是“同一用户报同一活动多次”。MongoDB 没有外键、没有事务回滚(跨文档)、也没有隔离级别控制,唯一能靠得住的数据库级防线就是唯一索引。

  • activity_registration 集合必须建复合唯一索引:{ user_id: 1, activity_id: 1 },而不是只对 user_idactivity_id 单独建索引
  • 如果漏掉这个索引,高并发下插入两条相同 { user_id: 123, activity_id: 456 } 的文档,MongoDB 默认允许,直到应用层查重才发现——此时名额已超,且数据已脏
  • 注意:索引创建前,必须确保集合里没有历史重复数据,否则 createIndex(..., { unique: true }) 会直接失败并报错 E11000 duplicate key error

如何用原子操作校验并扣减剩余名额

“扣减名额”不是先读再改,而是用 updateOne$inc$gte 条件一次性完成判断与更新,避免竞态。MongoDB 的原子性只保证单文档操作,所以名额字段必须存在活动主文档里(activity 集合),不能拆到报名表中计算。

  • 活动文档结构示例:{ _id: ObjectId("..."), name: "春游", capacity: 100, registered_count: 42, status: "published" }
  • 报名时执行:db.activity.updateOne({ _id: activityId, status: "published", registered_count: { $lt: capacity } }, { $inc: { registered_count: 1 } })
  • 检查返回的 result.matchedCount:为 1 表示名额充足且已扣减;为 0 表示已满或状态异常,此时不应插入报名记录
  • 切勿用 find + updateOne 两步走——中间可能被其他请求抢占

报名写入与唯一索引冲突的处理方式

即使做了上述校验,仍可能因网络重试、前端重复提交等导致插入 activity_registration 时触发唯一索引报错。这不是 bug,是预期行为,必须在代码里显式捕获并处理。

  • 错误信息固定为:WriteError: E11000 duplicate key error collection: db.activity_registration index: user_id_1_activity_id_1 dup key: { user_id: 123, activity_id: 456 }
  • 捕获该错误后,应直接返回“您已报名”,而不是抛异常或提示“系统错误”
  • 不要尝试先 findOne 再插入来“规避”错误——这又回到非原子操作的老路,且增加一次查询开销
  • 如果业务允许“取消后重报”,则插入前可加 upsert: true 并设置 status: "registered",但需确保更新操作也带条件(如仅当当前状态非 canceled 时才生效)

分片集群下唯一索引的特殊限制

如果你的 activityactivity_registration 集合启用了分片,唯一索引就不是无脑加了。MongoDB 要求:任何唯一索引,其字段组合必须包含分片键(shard key)作为前缀,否则创建失败。

  • 例如,若 activity_registrationactivity_id 分片,则 { user_id: 1, activity_id: 1 } 可以建唯一索引(因为 activity_id 是前缀);但 { user_id: 1 } 单字段索引会被拒绝
  • 这意味着:如果你计划按用户维度分片,那 user_id 就必须出现在所有唯一索引中——设计初期就得定死分片策略,后期无法变更
  • 线上环境建唯一索引时,务必确认是否在副本集/分片集群上;如果是,必须停写或使用滚动构建(rolling build),否则可能阻塞整个集合的写入

真正难的不是写几行 createIndex,而是在分片、扩容、多服务共写同一个集合时,依然让唯一性约束不被绕过。索引建错一次,修复成本远高于初期多想五分钟。

相关文章

精彩推荐