MongoDB 踩坑实录③:写操作、事务、聚合,踩一个就是线上事故

系列说明: 本文是「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
  • 慢查询日志没开,生产问题排查无从下手
  • 删数据后磁盘空间不释放
  • 分片键选错,热点分片让集群扩容毫无意义
相关推荐
星梦清河1 小时前
微服务-Redis高级
数据库·redis·缓存
zhangchengjava1 小时前
Redis 连接问题完整解决报告
数据库·redis·缓存
high20111 小时前
【架构】-- Mysql delete vs truncate 深度解析
数据库·mysql·架构
l1t2 小时前
DeepSeek总结的DuckDB CLAUDE.md
数据库·人工智能
蜜獾云2 小时前
Redis常用集群以及性能压测实战
数据库·redis·缓存
fengxin_rou2 小时前
【Redis 位图分片计数详解】:原理、实战架构与避坑最佳实践
数据库·redis·架构·bitmap
ZC跨境爬虫2 小时前
跟着 MDN 学 HTML day_63:(Web 中矢量图形的完整指南)
前端·javascript·数据库·ui·html
历程里程碑2 小时前
53 多路转接select
linux·开发语言·数据结构·数据库·c++·sql·排序算法
闪电悠米2 小时前
黑马点评短信登录02_redis_token_login
数据库·redis·firefox