MongoDB 踩坑实录②:数据建模和索引没搞对,查询慢了整整 10 倍

系列说明: 本文是「MongoDB 踩坑实录」系列第 2 篇,共 4 篇。上一篇解决了安装连接和安全的问题,这一篇聚焦开发阶段最核心的两件事:数据建模和索引。


MongoDB 跑起来了,认证也配好了,开始写业务代码。

然后发现:查一个列表要 3 秒,详情页要 5 秒,加了索引还是慢。更糟的是,随着数据量增长,某张表突然写入报错------文档超过 16MB 了。

这些问题的根源,99% 都出在数据建模和索引设计上,而不是服务器配置。


坑 1:把 MongoDB 当 MySQL 用,`$lookup` 越写越多

问题描述

从关系型数据库转过来,习惯性地把数据拆成多个集合,然后用 $lookup 做 JOIN:

复制代码
// ❌ 把用户和订单分开存,查询时 $lookup 合并
db.orders.aggregate([
  {
    $lookup: {
      from: "users",
      localField: "userId",
      foreignField: "_id",
      as: "userInfo"
    }
  },
  {
    $lookup: {
      from: "products",
      localField: "items.productId",
      foreignField: "_id",
      as: "productInfo"
    }
  }
]);

根本原因

MongoDB 是文档数据库,$lookup 本质是跨集合扫描------即使有索引,也远不如数据在同一文档里直接读取高效。多个 $lookup 叠加,性能会呈指数级下降。

MongoDB 的设计哲学是:把经常一起访问的数据放在一起(Embed what you access together)

解决方案

判断是「嵌入」还是「引用」,核心只看一件事:这两份数据是不是几乎总是一起读?

场景 推荐方案
子数据数量有限(< 数百条),随父文档一起读 直接嵌入
子数据需要独立查询、独立更新 引用(存 ObjectId)
子数据数量无限增长(评论、日志、消息) 引用 + 分页
多对多关系 双向引用,或在读多写少的一侧冗余

嵌入的正确姿势------在订单里冗余存储用户常用信息,避免 $lookup

复制代码
// ✅ 下单时把需要展示的用户信息一并写入订单文档
{
  _id: ObjectId("..."),
  orderNo: "ORD-20240101-001",
  userId: ObjectId("..."),       // 保留引用,方便跳转用户详情
  userName: "张三",               // 冗余,避免 $lookup
  userPhone: "138****8888",      // 冗余,方便客服查单
  items: [
    {
      productId: ObjectId("..."),
      productName: "AirPods Pro",  // 冗余商品名,即使商品改名也不影响历史订单
      qty: 1,
      price: 1799.00
    }
  ],
  totalAmount: 1799.00,
  status: "paid",
  createdAt: ISODate("2024-01-01T10:00:00Z")
}

「冗余」在关系型数据库里是反模式,在 MongoDB 里是正常设计------两者的哲学不同。


坑 2:嵌入数组无限增长,文档撑到 16MB 上限报错

报错现象

复制代码
MongoServerError: BSONObjectTooLarge: Resulting document after update is larger than 16777216

根本原因

MongoDB 单文档上限是 16MB。把帖子评论、用户日志、消息记录等无限增长的数据直接嵌入数组,总有一天会触顶。

复制代码
// ❌ 把所有评论嵌入文章文档
{
  _id: ObjectId("..."),
  title: "MongoDB 教程",
  content: "...",
  comments: [
    { user: "A", text: "很棒", createdAt: ISODate("...") },
    { user: "B", text: "收藏了", createdAt: ISODate("...") },
    // ... 可能有数千条
  ]
}

解决方案

方案一:引用模式------评论单独一个集合,通过文章 ID 关联(最常用):

复制代码
// comments 集合
{
  _id: ObjectId("..."),
  articleId: ObjectId("..."),   // 关联文章
  userId: ObjectId("..."),
  content: "很棒",
  createdAt: ISODate("2024-01-01T10:00:00Z"),
  likes: 12
}

// 建复合索引,支持按文章分页查评论
db.comments.createIndex({ articleId: 1, createdAt: -1 });

// 查询某文章最新 20 条评论
db.comments.find({ articleId: articleId })
           .sort({ createdAt: -1 })
           .limit(20);

方案二:分桶模式(Bucket Pattern)------适合时序数据,把固定数量的记录打包成一个文档:

复制代码
// 每个桶存 100 条传感器数据
{
  _id: ObjectId("..."),
  sensorId: "sensor_001",
  date: ISODate("2024-01-01"),   // 按天分桶
  count: 100,
  readings: [
    { ts: ISODate("2024-01-01T00:00:00Z"), value: 23.5 },
    { ts: ISODate("2024-01-01T00:01:00Z"), value: 23.6 },
    // ... 最多 100 条
  ],
  minValue: 20.1,
  maxValue: 25.3,
  avgValue: 22.8  // 预计算聚合值
}

分桶模式能大幅减少文档数量,同时利用预计算字段加速聚合查询。


坑 3:用业务字段替换 `_id`,写入越来越慢

问题描述

把手机号、邮箱、业务编号等自定义字段作为 _id

复制代码
// ❌ 用手机号做 _id
{ _id: "13800138000", name: "张三", ... }

// ❌ 用业务流水号做 _id
{ _id: "ORD-2024-00000001", ... }

根本原因

MongoDB 的 _id 默认是 ObjectId(12字节,包含时间戳前缀)。ObjectId 单调递增,写入时只需在 B 树最右侧追加,效率极高。

用随机字符串或业务 ID 替代,写入位置随机分散在 B 树各处,导致频繁的页分裂(Page Split),写入性能随数据量增长持续劣化。

解决方案

保持 _id 为 ObjectId,业务主键另建唯一索引:

复制代码
// ✅ _id 保持自动生成,业务字段单独索引
{
  _id: ObjectId("..."),     // 自动生成
  phone: "13800138000",     // 业务主键
  email: "test@example.com",
  name: "张三"
}

// 给业务字段建唯一索引
db.users.createIndex({ phone: 1 }, { unique: true });
db.users.createIndex({ email: 1 }, { unique: true });

坑 4:查询没走索引,全表扫描拖垮数据库

现象

集合几百万文档,某个接口响应 5-10 秒,并发一高就超时。

根本原因

没有给查询条件字段建索引,触发 COLLSCAN(全集合扫描)。

解决方案

第一步:用 explain() 确认问题

复制代码
db.orders.find({
  userId: ObjectId("..."),
  status: "pending"
}).explain("executionStats");

// 重点看这几个字段:
// winningPlan.stage:
//   "COLLSCAN" → 全表扫描,必须加索引
//   "IXSCAN"   → 走了索引,正常
// totalDocsExamined → 扫描的文档数(和 nReturned 差距越大越浪费)
// executionTimeMillis → 执行耗时(ms)

第二步:针对性建索引

复制代码
// 针对上面的查询,建复合索引
db.orders.createIndex({ userId: 1, status: 1 });

// 如果还有按 createdAt 排序,把排序字段也加进去
db.orders.createIndex({ userId: 1, status: 1, createdAt: -1 });

第三步:再次 explain() 确认 stage 变成 IXSCAN


坑 5:索引建了,但查询没走------索引失效的 4 个场景

索引建好了,但 explain() 还是 COLLSCAN?以下场景会导致索引失效:

场景一:正则查询以通配符开头

复制代码
// ❌ 不走索引------模糊前缀无法利用 B 树
db.products.find({ name: /.*手机/ });

// ✅ 走索引------前缀匹配可以利用 B 树
db.products.find({ name: /^手机/ });

// ✅ 全文搜索场景,建 text 索引
db.products.createIndex({ name: "text" });
db.products.find({ $text: { $search: "手机" } });

场景二:复合索引跳过了前导字段

复制代码
// 索引是 { userId: 1, status: 1, createdAt: -1 }
// ❌ 跳过了 userId,只用 status 查------只能部分利用索引
db.orders.find({ status: "pending" });

// ✅ 包含前导字段,完整利用索引
db.orders.find({ userId: ObjectId("..."), status: "pending" });

场景三:`nin\`、\`ne`、`$not` 通常走不了索引

复制代码
// ❌ 不走索引
db.orders.find({ status: { $nin: ["cancelled", "refunded"] } });

// ✅ 改为正向条件
db.orders.find({ status: { $in: ["pending", "paid", "shipped"] } });

场景四:`$where` 和 JavaScript 表达式

复制代码
// ❌ $where 直接绕过索引,全表扫描
db.users.find({ $where: "this.age > 18" });

// ✅ 改用普通查询操作符
db.users.find({ age: { $gt: 18 } });

坑 6:索引越建越多,写入越来越慢

根本原因

每次插入/更新/删除文档,MongoDB 要同步更新该集合上的所有索引。索引数量越多,写入代价越高------索引是用写入性能换查询性能的。

解决方案

定期用 $indexStats 找出僵尸索引:

复制代码
// 查看所有索引的使用次数统计
db.orders.aggregate([{ $indexStats: {} }]);

// 找出从未被使用的索引(accesses.ops === 0)
// 注意:服务器重启后统计清零,需观察一段时间再决定是否删除
db.orders.dropIndex("废弃索引名");

复合索引替代多个单字段索引:

复制代码
// ❌ 同时维护 3 个索引
db.orders.createIndex({ userId: 1 });
db.orders.createIndex({ status: 1 });
db.orders.createIndex({ createdAt: -1 });

// ✅ 1 个复合索引通常可以覆盖多种查询模式
db.orders.createIndex({ userId: 1, status: 1, createdAt: -1 });

建索引的黄金标准:

  • 单集合索引原则上不超过 10 个

  • 低基数字段(性别、状态只有 2-5 个值)单独建索引收益极低

  • 优先建覆盖索引(查询只读索引,不读原文档):

    // 只查 userId 和 totalAmount,把这两个字段都放进索引
    db.orders.createIndex({ userId: 1, status: 1, totalAmount: 1 });

    // 查询时用 projection 只取索引里有的字段------全程不读文档,速度最快
    db.orders.find(
    { userId: ObjectId("..."), status: "paid" },
    { totalAmount: 1, _id: 0 } // projection
    );


复合索引设计:ESR 原则

记住这个口诀,复合索引字段顺序就不会错:

复制代码
E(Equality)    → 等值查询的字段放最前
S(Sort)        → 排序字段放中间
R(Range)       → 范围查询字段放最后

示例:

复制代码
// 查询:find({ userId: xxx, status: "paid" }).sort({ createdAt: -1 })
// E: userId, status(等值)
// S: createdAt(排序)
// R: 本例没有范围查询字段

db.orders.createIndex({ userId: 1, status: 1, createdAt: -1 });
//                       E           E           S

// 查询:find({ category: "phone", price: { $gte: 1000, $lte: 5000 } }).sort({ sales: -1 })
// E: category
// S: sales
// R: price

db.products.createIndex({ category: 1, sales: -1, price: 1 });
//                         E            S           R

本篇小结

一句话根因 关键解法
过度 $lookup 用关系型思维设计文档模型 以访问模式为核心,嵌入常用数据
文档 16MB 超限 无限增长的数据嵌入数组 引用模式 / 分桶模式
_id 用业务字段 随机 ID 导致 B 树页分裂 保持 ObjectId,业务键建唯一索引
全表扫描 查询字段无索引 explain() 诊断 + 针对性建索引
索引失效 正则前缀/跳字段/负向条件 避开失效场景,改写查询条件
索引太多写入慢 写入要维护所有索引 定期清理僵尸索引,用复合索引合并

预告

下一篇:「MongoDB 踩坑实录③:写操作、事务、聚合------这些坑踩一个就是线上事故」

覆盖内容:

  • update 忘写 $set,整个文档被替换(最坑的低级错误)
  • 并发写入导致数据丢失的根本原因和解法
  • 单机环境用不了事务的解决方案
  • 聚合管道 $match 位置放错,性能差 10 倍
相关推荐
KaMeidebaby1 小时前
卡梅德生物技术快报|单克隆抗体人源化 PEG 修饰质控方法体系构建与验证
服务器·前端·数据库·人工智能·算法·百度·新浪微博
2401_824697661 小时前
mysql添加索引导致插入变慢怎么办_索引优化与异步处理方案
jvm·数据库·python
2401_824697661 小时前
Go语言如何写负载均衡器_Go语言负载均衡器实战教程【完整】
jvm·数据库·python
m0_733565461 小时前
CSS如何快速微调项目的间距大小_使用CSS变量批量修改值
jvm·数据库·python
Languorous.1 小时前
MySQL聚合查询:COUNT、SUM、AVG用法,实战案例演示
android·数据库
woxihuan1234561 小时前
如何为禁用按钮添加点击提示信息
jvm·数据库·python
ㄟ留恋さ寂寞1 小时前
Golang怎么限制请求Body大小_Golang如何防止客户端发送过大的请求体【避坑】
jvm·数据库·python
老纪2 小时前
CSS Flex布局中如何实现导航栏与Logo的左右分布_利用justify-content- space-between
jvm·数据库·python
会编程的土豆2 小时前
Go ini 配置加载:`ini.MapTo` 详细解析
开发语言·数据库·golang