
本文介绍在高并发场景下防止 mongoose 文档被意外覆盖的实用方案,重点讲解基于应用层的轻量级文档锁机制,结合代码示例说明如何安全地实现多请求对同一文档的顺序化写入。
本文介绍在高并发场景下防止 mongoose 文档被意外覆盖的实用方案,重点讲解基于应用层的轻量级文档锁机制,结合代码示例说明如何安全地实现多请求对同一文档的顺序化写入。
在使用 Mongoose 操作 MongoDB 时,若多个异步请求同时读取、修改并保存同一文档(如用户标记已读、更新嵌套字段等),极易发生“后写覆盖前写”的竞态条件(Race Condition)。典型场景如下:
- 请求 A 读取文档,修改
infos.key.skey.i.read数组; - 请求 B 同时读取相同文档,修改另一处字段;
- A 先保存,B 后保存 → A 的变更被彻底丢弃。
你提供的 readInfo 示例正是此类问题的典型体现:两次 findOne() + save() 之间无任何同步保障,纯靠时间差决定结果,可靠性为零。
✅ 推荐方案:应用层文档级乐观锁 + 队列化写入
MongoDB 原生不支持行级锁(如 PostgreSQL 的 SELECT ... FOR UPDATE),因此需在应用层构建轻量、可扩展的锁机制。以下是一个生产可用的 Locking 类实现,专为 Node.js + Mongoose 环境设计:
import { Types } from 'mongoose';
export class DocumentLock {
private lockedIds = new Set<string>();
private pendingWaits = new Map<string array>>();
private cleanupIntervals: NodeJS.Timeout[] = [];
constructor(
private readonly timeoutMs = 60_000, // 默认超时 60s
private readonly pollIntervalMs = 100 // 每 100ms 检查一次
) {}
/**
* 尝试加锁指定文档 ID(非阻塞)
* @returns true 表示成功获取锁,false 表示已被占用
*/
tryLock(id: string): boolean {
if (this.lockedIds.has(id)) return false;
this.lockedIds.add(id);
return true;
}
/**
* 释放文档锁
*/
unlock(id: string): void {
this.lockedIds.delete(id);
}
/**
* 阻塞式等待锁释放(带超时)
* @returns Promise<void> 成功获得锁后 resolve,超时则 reject
*/
async waitForLock(id: string): Promise<void> {
const startTime = Date.now();
return new Promise((resolve, reject) => {
const interval = setInterval(() => {
if (Date.now() - startTime > this.timeoutMs) {
clearInterval(interval);
reject(new Error(`Document lock timeout for ${id}`));
return;
}
if (!this.lockedIds.has(id)) {
clearInterval(interval);
this.lockedIds.add(id); // 立即抢占
resolve();
}
}, this.pollIntervalMs);
});
}
}
// 全局单例(按需可改为 per-request 或 per-process 实例)
export const docLock = new DocumentLock();</void></void></string></string>
✅ 在业务逻辑中安全使用锁
将上述 docLock 集成到你的路由处理函数中,确保对同一 _id 的写操作串行化:
exports.readInfo = async (req, res) => {
const user = req.user;
const { _id, key, skey, i } = req.data;
try {
// 步骤1:等待获取文档锁(自动重试直到成功或超时)
await docLock.waitForLock(_id);
// 步骤2:此时可安全执行读-改-写(无并发干扰)
const doc = await Doc.findById(_id).lean().exec(); // lean() 提升性能
if (!doc) throw new Error('Document not found');
// 安全修改嵌套结构(注意深拷贝或路径校验)
const target = doc.infos?.[key]?.[skey]?.[i];
if (!target || !Array.isArray(target.read)) {
throw new Error('Invalid document structure');
}
if (!target.read.includes(user._id.toString())) {
target.read.push(user._id.toString());
}
// 步骤3:原子性保存(推荐使用 findByIdAndUpdate 避免全量序列化)
await Doc.findByIdAndUpdate(
_id,
{ $addToSet: { [`infos.${key}.${skey}.${i}.read`]: user._id.toString() } },
{ new: true, runValidators: true }
);
res.end('success');
} catch (err) {
console.error('Lock or update failed:', err);
res.status(500).end('Internal error');
} finally {
// 务必释放锁(即使出错也要保证)
docLock.unlock(_id);
}
};
⚠️ 关键注意事项
-
不要依赖内存锁跨进程:当前
DocumentLock是单进程内存锁。若部署多实例(如 PM2 cluster / Kubernetes 多 Pod),必须升级为分布式锁(如 Redis + Redlock 或 MongoDB TTL collection 实现)。 - 避免长时持锁:锁应仅包裹最小必要操作(如 DB 查询 + 更新),禁止在锁内执行 HTTP 请求、文件 I/O 等耗时操作。
-
优先使用原子操作:对简单字段更新(如计数器、数组追加),优先用
$inc、$addToSet、$push等 MongoDB 原子操作,比“读-改-写”更高效且天然线程安全。 -
配合版本控制(可选):对关键业务,可在 Schema 中添加
version: { type: Number, default: 0 }字段,每次更新时校验并递增,实现乐观锁({ _id, version: expected }查询 +version: expected + 1更新)。
通过应用层文档锁 + MongoDB 原子操作组合,你能在不引入复杂中间件的前提下,显著提升并发写入的安全性与数据一致性。











