
本文介绍在 mongoose 应用中避免多请求并发修改同一文档时发生数据丢失的实用方案,重点讲解基于应用层的轻量级文档锁机制及其安全实现。
本文介绍在 mongoose 应用中避免多请求并发修改同一文档时发生数据丢失的实用方案,重点讲解基于应用层的轻量级文档锁机制及其安全实现。
在使用 MongoDB 和 Mongoose 构建高并发 Web 应用时,一个常见但危险的场景是:多个请求同时读取同一文档、各自修改不同字段、再分别保存——后保存的请求会完全覆盖前序请求的变更,造成静默数据丢失。你提供的示例代码 readInfo 就属于典型情况:两次异步请求读取同一 Doc,各自向嵌套数组 read 中添加用户 ID,但若无协调机制,第二次 .save() 将抹去第一次的修改。
MongoDB 本身不提供行级或文档级悲观锁(如 PostgreSQL 的 SELECT ... FOR UPDATE),其原子操作(如 $push、$inc)虽能解决部分问题,但对复杂嵌套结构(如 infos[key][skey][i].read.push(...))难以直接原子化。因此,需在应用层构建可靠的并发控制策略。
✅ 推荐方案:应用层文档锁(Application-Level Document Locking)
核心思想是:对目标文档 ID 实施互斥访问控制,确保同一时刻仅一个请求可执行读-改-存流程。以下是一个生产就绪的轻量级实现:
import { Types } from 'mongoose';
export class DocumentLock {
private lockedIds = new Set<string>();
private pendingWaits = new Map<string array> void>>();
private timeoutMs = 10_000; // 默认超时 10 秒
private pollIntervalMs = 100; // 轮询间隔
/**
* 尝试获取文档锁,失败则等待直至成功或超时
*/
async acquire(_id: Types.ObjectId | string): Promise<void> {
const idStr = _id.toString();
// 快速尝试获取锁
if (this.tryLock(idStr)) return;
// 进入等待队列
return new Promise((resolve, reject) => {
const startTime = Date.now();
const timer = setInterval(() => {
if (Date.now() - startTime > this.timeoutMs) {
clearInterval(timer);
reject(new Error(`Lock acquisition timeout for document ${idStr}`));
return;
}
if (this.tryLock(idStr)) {
clearInterval(timer);
resolve();
}
}, this.pollIntervalMs);
});
}
/**
* 释放文档锁
*/
release(_id: Types.ObjectId | string): void {
this.lockedIds.delete(_id.toString());
}
private tryLock(idStr: string): boolean {
if (this.lockedIds.has(idStr)) return false;
this.lockedIds.add(idStr);
return true;
}
}
// 全局单例(根据部署方式调整作用域)
export const docLock = new DocumentLock();</void></string></string>
? 在路由中安全集成
将锁机制无缝嵌入你的 readInfo 处理逻辑:
exports.readInfo = async (req, res) => {
const user = req.user;
const data = req.data;
const docId = new Types.ObjectId(data._id);
try {
// ⚠️ 关键:获取文档锁(阻塞直到可用)
await docLock.acquire(docId);
// 此刻可安全读-改-存
const doc = await Doc.findOne({ _id: docId });
if (!doc) throw new Error('Document not found');
// 安全修改嵌套结构
const targetArray = doc.infos?.[data.key]?.[data.skey]?.[data.i]?.read;
if (!Array.isArray(targetArray)) {
throw new Error('Invalid path or structure');
}
if (!targetArray.includes(user._id.toString())) {
targetArray.push(user._id.toString());
doc.markModified(`infos.${data.key}.${data.skey}.${data.i}.read`);
}
await doc.save();
res.status(200).end('success');
} catch (err) {
console.error('Lock or save failed:', err);
res.status(500).json({ error: 'Operation failed' });
} finally {
// ✅ 必须释放锁(即使出错也要释放!)
docLock.release(docId);
}
};
⚠️ 重要注意事项
-
务必在
finally块中释放锁,防止死锁; - 此锁为进程内内存锁,适用于单实例 Node.js 服务;若部署多实例(如 PM2 cluster 或 Kubernetes 多 Pod),需升级为分布式锁(如 Redis + Redlock);
- 锁粒度应尽量细(按文档 ID),避免全局锁影响吞吐;
- 对纯原子操作(如计数器增减),优先使用 MongoDB 原生更新器(
$inc,$push)而非锁; - 配合数据库唯一索引、版本号(
versionKey)或乐观锁($setOnInsert+ 条件更新)可进一步增强健壮性。
通过该方案,你不仅能彻底规避“后写覆盖前写”的竞态问题,还能保持代码清晰、可测试,并为后续扩展分布式锁打下基础。











