1. 背景
在使用 Mongoose 进行数据库操作时,store.save()
是最常见的文档更新方式之一。然而,在高并发环境 下,store.save()
可能会导致 No matching document found for id
错误,并导致某些更新丢失或失败。
本篇文章将深入剖析 store.save()
在高并发下的问题、原理,以及如何正确地避免这个坑。
2. store.save()
在高并发下的问题
2.1 问题现象
当多个请求并发执行 store.save()
时,可能会报如下错误:
bash
No matching document found for id "66d1dcc7c685ab6bb92a3098" version 512381
这个错误意味着 Mongoose 找不到匹配的文档 ,通常是因为文档的 __v
版本号发生了变化。
2.2 代码示例
假设有如下代码:
csharp
async getDelayHours(){
const store = await this.ctx.model.Store.findOne({});
const delayHours = store.postTaskRunAtDelayHours.shift();
store.postTaskRunAtDelayHours.push(delayHours);
await store.save();
return delayHours;
}
多个并发请求调用 getDelayHours()
时,所有请求都获取了相同的 store
,并且版本号 __v
也是一样的。
当第一个请求执行 store.save()
时,它会成功,并且 __v
版本号增加 。但其他请求的 __v
版本号已过时,导致 save()
失败,触发 No matching document found for id
错误。
3. 原理解析
3.1 __v
版本字段的背景知识
在 Mongoose 中,每个文档默认都有一个 __v
字段,这是 Mongoose 内部的版本控制字段 。当 save()
发生时,Mongoose 会自动递增 __v
,以确保数据的 乐观锁(Optimistic Locking) 机制。
__v
主要用于:
- 防止并发冲突:当多个请求尝试修改同一文档时,确保只有最新的修改能成功。
- 确保数据一致性:避免旧数据覆盖新数据,减少数据丢失风险。
如果不想使用 __v
,可以在 Schema 配置中关闭:
php
const schema = new mongoose.Schema({
name: String
}, { versionKey: false });
但通常不建议关闭 __v
,因为它在并发控制方面起到了重要作用。
3.2 store.save()
的工作方式
store.save()
的底层逻辑如下:
-
findOne()
读取文档 (此时__v = X
)。 -
本地修改文档的某些字段。
-
调用
save()
时,Mongoose 生成update
语句 ,并携带__v = X
作为条件:phpdb.store.updateOne( { _id: ObjectId("66d1dcc7c685ab6bb92a3098"), __v: X }, { $set: { postTaskRunAtDelayHours: [...], __v: X+1 } } );
-
如果
__v
仍然是X
,更新成功。 -
如果
__v
已被其他请求改为X+1
,当前save()
失败。
3.3 为什么 __v
版本冲突会导致 store.save()
失败?
因为 Mongoose 采用乐观锁(Optimistic Locking) ,即:
- 任何修改操作都会基于
__v
版本号。 - 如果
__v
版本号不匹配,save()
失败,防止并发写入时数据错乱。
但这样会导致高并发环境下 save()
失败率很高。
4. 最佳实践:如何避免 store.save()
的并发问题?
4.1 使用 findOneAndUpdate()
进行原子操作(推荐 ✅)
与 store.save()
相比,findOneAndUpdate()
直接在数据库中执行更新 ,避免 __v
版本冲突。
优化代码
csharp
async getDelayHours() {
const store = await this.ctx.model.Store.findOneAndUpdate(
{},
{ $pop: { postTaskRunAtDelayHours: -1 } },
{ new: true }
);
if (!store) throw new Error("Store not found");
const delayHours = store.postTaskRunAtDelayHours[store.postTaskRunAtDelayHours.length - 1];
return delayHours;
}
✅ 优点:
- 避免
store.save()
带来的__v
版本冲突。 - 原子操作 ,保证多个请求不会取到相同的
delayHours
。 - 性能更优 ,减少
findOne() + 修改 + save()
的查询开销。
4.2 事务(Transactions)+ session
机制
如果你的 MongoDB 支持事务(Transactions) ,可以使用 session
保证数据一致性。
ini
async getDelayHours() {
const session = await this.ctx.model.Store.startSession();
session.startTransaction();
try {
const store = await this.ctx.model.Store.findOne({}).session(session);
const delayHours = store.postTaskRunAtDelayHours.shift();
store.postTaskRunAtDelayHours.push(delayHours);
await store.save({ session });
await session.commitTransaction();
session.endSession();
return delayHours;
} catch (error) {
await session.abortTransaction();
session.endSession();
throw error;
}
}
✅ 优点:
- 事务保证数据一致性,防止并发操作导致数据丢失。
- 适用于修改多个文档的情况。
⚠️ 注意:MongoDB 事务需要 Replica Set,单机模式下可能不支持。
5. 结论
方法 | 并发安全 | 性能 | 适用场景 |
---|---|---|---|
store.save() |
❌ 有 __v 版本冲突 |
慢 | 低并发 |
findOneAndUpdate() |
✅ 原子操作 | 快 | 高并发 & 单字段更新 |
session + save() |
✅ 事务保证 | 中 | 需要事务支持 |
最终建议:
- 高并发环境下,避免
store.save()
,推荐findOneAndUpdate()
! - 如果有事务需求,可使用
session
机制。
🚀 希望这篇文章能帮助你优化 Mongoose 并发处理,让你的数据库操作更稳定高效!