系列说明: 本文是「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 倍
