
本文介绍在 mongoose 应用中避免多请求并发修改同一文档导致数据丢失的实用方案,涵盖原子操作优化、版本控制(optimistic concurrency)及轻量级应用层文档锁的实现与使用。
本文介绍在 mongoose 应用中避免多请求并发修改同一文档导致数据丢失的实用方案,涵盖原子操作优化、版本控制(optimistic concurrency)及轻量级应用层文档锁的实现与使用。
在高并发 Web 应用中,多个请求同时读取、修改并保存同一 MongoDB 文档时,极易发生“后写覆盖前写”的竞态问题(Race Condition)。例如:两个请求分别读取用户文档,各自添加一条阅读记录,再调用 doc.save() —— 先保存的变更会被后保存的完全覆盖,造成数据丢失。
✅ 推荐方案:优先使用 MongoDB 原生原子操作
对于简单字段更新(如数组 push、计数器自增),应避免“读-改-写”模式,改用 findOneAndUpdate 等原子操作:
exports.readInfo = async (req, res) => {
const { _id, key, skey, i } = req.data;
const userId = req.user._id.toString();
const result = await Doc.findOneAndUpdate(
{ _id: new mongoose.Types.ObjectId(_id) },
{ $addToSet: { [`infos.${key}.${skey}.${i}.read`]: userId } },
{ new: true } // 返回更新后的文档
);
if (!result) throw new Error('Document not found');
res.end('success');
};
✅ 优势:无需加锁、无竞态、高性能;
⚠️ 注意:$addToSet 可防重复插入,若需精确控制顺序或复杂嵌套逻辑,则需更健壮方案。
?️ 进阶方案:应用层文档锁(适用于复杂业务逻辑)
当业务涉及多步计算、条件判断或跨字段协同更新(如库存扣减 + 订单状态变更)时,原子操作难以覆盖。此时可在应用层实现轻量级文档锁机制——非阻塞式等待 + 超时熔断,避免数据库级锁带来的性能瓶颈。
以下为优化后的 Locking 类(修复原示例中的内存泄漏与并发缺陷):
import { Types } from 'mongoose';
export class DocumentLocker {
private locked = new Set<string>();
private waiters = new Map<string array> void>>();
private timeouts = new Map<string nodejs.timeout>();
constructor(private timeoutMs = 60_000) {}
acquire(id: string): Promise<void> {
if (this.locked.has(id)) {
return new Promise((resolve, reject) => {
const waiter = () => resolve();
const waiters = this.waiters.get(id) || [];
waiters.push(waiter);
this.waiters.set(id, waiters);
// 自动超时清理
const timeout = setTimeout(() => {
this.waiters.delete(id);
this.timeouts.delete(id);
reject(new Error(`Lock acquisition timeout for ${id}`));
}, this.timeoutMs);
this.timeouts.set(id, timeout);
});
}
this.locked.add(id);
return Promise.resolve();
}
release(id: string): void {
this.locked.delete(id);
const waiters = this.waiters.get(id);
if (waiters?.length) {
const next = waiters.shift();
if (next) next();
if (waiters.length === 0) this.waiters.delete(id);
}
const timeout = this.timeouts.get(id);
if (timeout) {
clearTimeout(timeout);
this.timeouts.delete(id);
}
}
}
// 使用示例
const locker = new DocumentLocker(30_000); // 30秒超时
exports.readInfo = async (req, res) => {
const { _id } = req.data;
const idStr = new mongoose.Types.ObjectId(_id).toString();
try {
await locker.acquire(idStr);
const doc = await Doc.findById(_id);
if (!doc) throw new Error('Document not found');
// 执行任意复杂业务逻辑(读-改-写)
doc.infos[req.data.key][req.data.skey][req.data.i].read.push(req.user._id.toString());
doc.markModified(`infos.${req.data.key}.${req.data.skey}.${req.data.i}`);
await doc.save();
res.end('success');
} finally {
locker.release(idStr);
}
};</void></string></string></string>
⚠️ 关键注意事项
-
不适用于分布式部署:上述
DocumentLocker基于内存Set/Map,仅限单进程 Node.js 实例。生产环境需升级为 Redis 分布式锁(如Redlock); -
锁粒度要细:按
_id锁定文档,而非集合或全局,避免串行化瓶颈; -
务必
try/finally释放锁:防止异常导致死锁; - 避免长事务:锁持有时间越短越好,复杂计算尽量前置或异步化;
- 监控与告警:记录锁等待时长、超时频次,及时发现热点文档。
✅ 总结
| 方案 | 适用场景 | 并发安全 | 性能 | 复杂度 |
|---|---|---|---|---|
findOneAndUpdate + 原子操作 |
字段级简单更新 | ✅ | ⚡️ 极高 | ? 低 |
| 应用层内存锁 | 单实例、中等并发、复杂逻辑 | ✅ | ? 中等(锁等待) | ? 中 |
| Redis 分布式锁 | 多实例、高并发、强一致性要求 | ✅ | ? 中等(网络开销) | ? 高 |
最佳实践路径:优先设计原子化更新 → 若不可行,引入轻量锁 → 分布式场景务必切换至 Redis 锁。切勿依赖 doc.save() 的默认行为处理并发写入。











