系列说明: 本文是「MongoDB 踩坑实录」系列第 3 篇,共 4 篇。前两篇覆盖了安装安全和建模索引,这一篇进入开发阶段最容易引发线上事故的 3 个核心领域:写操作、事务和聚合管道。
第一篇的问题让你上不了线,第二篇的问题让你查询慢到崩溃。
这一篇的问题不一样------写操作踩坑,数据直接丢了 ;事务踩坑,钱账对不上 ;聚合踩坑,报表跑不出来。
而且这些坑往往不会立刻报错,等你发现的时候已经酿成事故了。
写操作篇
坑 1:`update` 忘写 `$set`,整个文档被替换
报错现象
不报错。文档里其他字段全部消失。
// ❌ 这是最坑的低级错误,没有任何报错提示
db.users.updateOne(
{ _id: ObjectId("507f1f77bcf86cd799439011") },
{ age: 18 } // 忘了 $set
);
// 执行前:{ _id: ..., name: "张三", phone: "138...", email: "...", age: 17 }
// 执行后:{ _id: ..., age: 18 }
// name、phone、email 全没了!
根本原因
MongoDB 的 updateOne 有两种用法:
- 传入更新操作符 (
$set、$inc等)→ 只更新指定字段 - 传入普通文档 → 直接替换整个文档(只保留
_id)
两种写法都合法,MongoDB 通过是否有 $ 前缀来区分------所以忘写 $set 不会报错。
解决方案
// ✅ 正确:只更新 age 字段
db.users.updateOne(
{ _id: ObjectId("...") },
{ $set: { age: 18 } }
);
// 常用更新操作符速查
// $set → 设置字段值(最常用)
// $unset → 删除字段
// $inc → 数值加减(原子操作)
// $mul → 数值乘法
// $push → 数组末尾追加元素
// $addToSet → 数组追加(自动去重)
// $pull → 从数组删除满足条件的元素
// $pop → 删除数组首尾元素
// $rename → 重命名字段
// $currentDate → 设置为当前时间
如果确实需要替换整个文档,用 replaceOne 而非 updateOne,语义更清晰:
// 明确表达替换意图
db.users.replaceOne(
{ _id: ObjectId("...") },
{ name: "张三", age: 18 } // 这里就是要替换整个文档
);
坑 2:并发更新导致数据丢失(经典的读-改-写问题)
问题描述
库存系统的经典 Bug:
// 两个请求同时处理,时序如下:
// T1: 请求A 读取库存 stock = 10
// T2: 请求B 读取库存 stock = 10(A 还没写回去)
// T3: 请求A 计算 10 - 1 = 9,写入 stock = 9
// T4: 请求B 计算 10 - 1 = 9,写入 stock = 9
// 结果:卖出了 2 件商品,库存只减了 1
// ❌ 先读后写,并发不安全
const product = await db.collection("products").findOne({ _id: productId });
const newStock = product.stock - quantity;
await db.collection("products").updateOne(
{ _id: productId },
{ $set: { stock: newStock } }
);
根本原因
「先读后写」的两步操作不是原子的,并发时会产生竞态条件。
解决方案
用 $inc 在服务端完成运算,让整个操作变成原子的:
// ✅ 原子操作:条件更新 + 服务端运算
const result = await db.collection("products").updateOne(
{
_id: productId,
stock: { $gte: quantity } // 条件:库存必须充足
},
{
$inc: { stock: -quantity } // 服务端原子减法
}
);
// matchedCount === 0 说明库存不足(条件不满足)
if (result.matchedCount === 0) {
throw new Error("库存不足,下单失败");
}
这个写法无论多少并发请求,库存不会超卖,也不会有数据丢失。
坑 3:高并发 `upsert` 产生重复文档
问题描述
// ❌ 高并发下,两个请求同时判断"文档不存在",同时插入,产生重复
await db.collection("userProfiles").updateOne(
{ userId: "user_123" },
{ $set: { lastLoginAt: new Date(), loginCount: 1 } },
{ upsert: true }
);
// 结果:userId=user_123 的文档出现了 2 条
根本原因
upsert 的「查找是否存在」和「插入」之间有一个极短的窗口,并发请求可能同时通过查找阶段。
解决方案
给 upsert 的查询字段建唯一索引,让数据库在冲突时报错而非插入:
// 建唯一索引
db.userProfiles.createIndex({ userId: 1 }, { unique: true });
// 应用层捕获重复键冲突(错误码 11000)并重试
async function upsertUserProfile(userId, data) {
try {
await db.collection("userProfiles").updateOne(
{ userId },
{ $set: data, $inc: { loginCount: 1 } },
{ upsert: true }
);
} catch (err) {
if (err.code === 11000) {
// 说明另一个并发请求刚刚插入成功,改为普通更新即可
await db.collection("userProfiles").updateOne(
{ userId },
{ $set: data, $inc: { loginCount: 1 } }
);
} else {
throw err;
}
}
}
事务篇
坑 4:单机 `mongod` 用不了多文档事务
报错现象
MongoServerError: Transaction numbers are only allowed on a replica set member or mongos
根本原因
MongoDB 的多文档 ACID 事务(4.0+)只能在副本集 或分片集群 上使用,单机 mongod 不支持。
开发环境搭一个单节点副本集(伪副本集),用最小代价支持事务:
解决方案
方式一:修改 mongod.conf + 初始化
# /etc/mongod.conf 添加以下配置
replication:
replSetName: "rs0"
sudo systemctl restart mongod
# 进入 mongosh 初始化副本集
mongosh --eval 'rs.initiate({
_id: "rs0",
members: [{ _id: 0, host: "127.0.0.1:27017" }]
})'
方式二:Docker 一行命令
docker run -d \
--name mongodb \
-p 27017:27017 \
-v /data/mongodb:/data/db \
mongo:7.0 --replSet rs0
# 等待 2 秒后初始化
sleep 2 && docker exec mongodb mongosh --eval 'rs.initiate()'
坑 5:事务内做了外部调用,导致超时或状态不一致
问题描述
// ❌ 把第三方支付 API 调用放进事务里
await session.withTransaction(async () => {
await db.collection("orders").insertOne({ ...orderData }, { session });
// 调用支付网关,可能超时 3-5 秒
const payResult = await paymentGateway.charge({ amount: 99.9 }); // ❌
await db.collection("payments").insertOne({ ...payResult }, { session });
});
根本原因
MongoDB 事务默认超时 60 秒 ,但更大的问题是一致性语义:如果支付成功了但数据库回滚了,或者数据库写成功了但支付失败了,状态就不一致了。而且支付这类操作往往不可回滚。
解决方案
事务内只放数据库操作,外部调用放事务外:
// ✅ 正确的流程设计
async function createOrder(userId, productId, amount) {
// Step 1:事务外调用支付(支付本身就是外部原子操作)
const payResult = await paymentGateway.charge({ amount });
if (!payResult.success) {
throw new Error("支付失败:" + payResult.message);
}
// Step 2:支付成功后,事务内写数据库
const session = client.startSession();
try {
await session.withTransaction(async () => {
// 减库存
const stockResult = await db.collection("products").updateOne(
{ _id: productId, stock: { $gte: 1 } },
{ $inc: { stock: -1 } },
{ session }
);
if (stockResult.matchedCount === 0) {
throw new Error("库存不足"); // 自动触发事务回滚
}
// 创建订单
await db.collection("orders").insertOne(
{ userId, productId, amount, paymentId: payResult.transactionId, status: "paid" },
{ session }
);
});
} finally {
await session.endSession();
}
}
Node.js 驱动完整事务写法(带重试):
const session = client.startSession();
try {
await session.withTransaction(
async () => {
// 所有数据库操作都传入 { session }
await db.collection("accounts").updateOne(
{ _id: fromId, balance: { $gte: amount } },
{ $inc: { balance: -amount } },
{ session }
);
await db.collection("accounts").updateOne(
{ _id: toId },
{ $inc: { balance: amount } },
{ session }
);
},
{
// 事务重试配置
readConcern: { level: "snapshot" },
writeConcern: { w: "majority" },
maxCommitTimeMS: 5000 // 最长提交等待时间
}
);
} finally {
await session.endSession();
}
坑 6:WriteConflict 错误------两个事务抢同一文档
报错现象
MongoServerError: WriteConflict error: this operation conflicted with another operation.
根本原因
两个事务同时修改同一份文档,MongoDB 使用乐观锁,后提交的事务检测到冲突后自动中止。
解决方案
withTransaction API 会自动在 WriteConflict 时重试整个事务函数,无需手动处理。
但如果用的是手动事务(session.startTransaction() / commitTransaction()),需要自己实现重试:
async function runWithRetry(txnFunc, session, maxRetries = 3) {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
session.startTransaction();
await txnFunc(session);
await session.commitTransaction();
return;
} catch (err) {
await session.abortTransaction();
// WriteConflict 或 TransientTransactionError 可以重试
const isRetryable = err.hasErrorLabel?.("TransientTransactionError");
if (isRetryable && attempt < maxRetries) {
await new Promise(r => setTimeout(r, 100 * attempt)); // 指数退避
continue;
}
throw err;
}
}
}
聚合管道篇
坑 7:`$match` 位置放错,聚合性能差 10 倍
问题描述
// ❌ $match 放在 $lookup 后面------先 JOIN 几百万条数据,再过滤
db.orders.aggregate([
{
$lookup: {
from: "users",
localField: "userId",
foreignField: "_id",
as: "user"
}
},
{ $unwind: "$user" },
{ $match: { "user.city": "北京", status: "paid" } } // 太晚了
]);
根本原因
聚合管道逐阶段处理数据,$lookup 会对当前管道里的每一条 文档做关联查询。$match 越早,后续阶段需要处理的数据越少。
解决方案
// ✅ 先过滤,再 JOIN,再二次过滤
db.orders.aggregate([
// Stage 1:在 orders 集合上先过滤(走索引,速度最快)
{ $match: { status: "paid", createdAt: { $gte: ISODate("2024-01-01") } } },
// Stage 2:只对过滤后的文档做 $lookup
{
$lookup: {
from: "users",
localField: "userId",
foreignField: "_id",
as: "user",
// pipeline 写法可以在 $lookup 内部加条件(MongoDB 3.6+)
pipeline: [
{ $match: { city: "北京" } }, // JOIN 时顺便过滤
{ $project: { name: 1, city: 1 } } // 只取需要的字段
]
}
},
// Stage 3:过滤掉没有匹配用户的文档(city 不是北京的)
{ $match: { user: { $ne: [] } } },
// Stage 4:最后投影输出字段
{ $project: { orderNo: 1, totalAmount: 1, "user.name": 1, "user.city": 1 } }
]);
聚合管道优化口诀: 先 $match,再 $project,$sort 紧跟 $limit。
坑 8:`group\` / \`sort` 超内存限制,管道中断报错
报错现象
MongoServerError: Exceeded memory limit for $group stage, used: 104MB, limit: 104MB.
# 或
MongoServerError: Sort exceeded memory limit of 104857600 bytes
根本原因
聚合管道默认每个阶段内存上限 100MB 。数据量大时,$group(按字段分组聚合)、$sort(未命中索引的排序)很容易超限。
解决方案
// 方案一:开启磁盘溢出(allowDiskUse)
db.orders.aggregate(
[
{ $match: { createdAt: { $gte: ISODate("2024-01-01") } } },
{ $group: { _id: "$userId", totalSpent: { $sum: "$amount" }, count: { $sum: 1 } } },
{ $sort: { totalSpent: -1 } },
{ $limit: 100 }
],
{ allowDiskUse: true } // 超内存时溢出到磁盘
);
// 方案二:$sort + $limit 提前下推,减少进入 $group 的数据量
// 如果业务逻辑允许先过滤再聚合,优先用这个方案
// 方案三:预聚合(定时任务预先计算并存储聚合结果)
// 适合报表、统计类场景------把每日/每周的聚合结果存到单独集合
// 查询时直接读聚合结果集合,避免实时大量运算
allowDiskUse会大幅降低聚合性能,是兜底方案。根本解法是优化管道结构,尽早过滤数据量。
坑 9:`$unwind` 展开数组后文档数量暴增,撑爆内存
问题描述
// 每个订单有 items 数组,平均 10 个元素
// $unwind 后文档数量变成原来的 10 倍
db.orders.aggregate([
{ $unwind: "$items" },
{ $group: { _id: "$items.productId", totalQty: { $sum: "$items.qty" } } }
]);
// 100 万订单 → $unwind 后变 1000 万条 → 内存超限
解决方案
// ✅ 善用 $unwind 的 preserveNullAndEmptyArrays 选项
// 并且尽量在 $unwind 前先 $match 缩小范围
db.orders.aggregate([
// 先过滤时间范围,减少 $unwind 的数据量
{ $match: { createdAt: { $gte: ISODate("2024-01-01") }, status: "paid" } },
// 展开前先投影,减少每条文档的体积
{ $project: { items: 1 } },
// 展开
{ $unwind: "$items" },
// 聚合
{ $group: { _id: "$items.productId", totalQty: { $sum: "$items.qty" } } },
{ $sort: { totalQty: -1 } },
{ $limit: 50 }
], { allowDiskUse: true });
// 更优解:如果 items 数据量很大,考虑用 $reduce 代替 $unwind+$group
// $reduce 在文档内部完成运算,不需要展开
db.orders.aggregate([
{ $match: { ... } },
{
$project: {
itemCount: { $size: "$items" },
totalQty: { $reduce: {
input: "$items",
initialValue: 0,
in: { $add: ["$$value", "$$this.qty"] }
}}
}
}
]);
本篇小结
| 坑 | 严重程度 | 一句话根因 | 关键解法 |
|---|---|---|---|
忘写 $set 替换文档 |
🔴 高 | update 两种语义混淆 | 始终用 ![]() |
| 并发写入数据丢失 | 🔴 高 | 先读后写非原子 | 改用 set,替换场景用replaceOne∣∣并发写入数据丢失∣🔴高∣先读后写非原子∣改用inc 等原子操作符 |
| upsert 重复文档 | 🟠 中 | 查找和插入之间有竞态窗口 | 建唯一索引 + 捕获 11000 重试 |
| 单机无法用事务 | 🟠 中 | 事务需要副本集 | 开发环境启单节点副本集 |
| 事务内做外部调用 | 🔴 高 | 超时或状态不一致 | 外部调用放事务外 |
| WriteConflict | 🟡 低 | 两事务并发修改同文档 | withTransaction 自动重试 |
$match 位置错误 |
🟠 中 | 先 JOIN 再过滤,数据量巨大 | $match 永远放管道最前 |
| 聚合超内存 | 🟠 中 | $group/$sort 100MB 上限 |
allowDiskUse + 预聚合 |
$unwind 数据量暴增 |
🟠 中 | 数组展开倍增文档数 | 先 $match 缩量 + $reduce 替代 |
预告
下一篇(终篇):「MongoDB 踩坑实录④:副本集和生产运维------这些坑会让你凌晨三点爬起来排障」
覆盖内容:
- 副本集 Secondary 一直 RECOVERING,怎么救
- 应用写死 Primary IP,选举后全量请求失败
- MongoDB 把服务器内存吃满触发 OOM
- 慢查询日志没开,生产问题排查无从下手
- 删数据后磁盘空间不释放
- 分片键选错,热点分片让集群扩容毫无意义

