起因
第一次用 MongoDB 是从关系数据库转过来:把 PG 里的 users / posts /
comments 三张表照搬成三个 collection,每篇 post 存 user_id 引用。
查询时 application 端做 3 次 query 拼起来。
最后发现这就是把 MongoDB 当 NoSQL "假 PG" 用,丧失了文档数据库的优势
(也没拿到关系数据库的事务)。
MongoDB 的正确姿势是设计 schema 时考虑访问模式(access pattern):
经常一起读的数据 embed 在一起。
决策框架
按几个维度选 embed vs reference:
1. 一对一 / 一对多 / 多对多
- 1:1 或 1:few(< 几十)→ embed
- 1:many(百级 +)→ reference(避免文档太大)
- many:many → reference
2. 是否一起读 / 一起写
- 经常一起读 → embed
- 独立访问、生命周期不同 → reference
3. 是否会增长
- 有界(如 user.address 通常 1-3 条)→ embed
- 无界(comments 可能千万级)→ reference
4. 写入频率
- 子数据频繁更新(counter / status) → reference 避免文档反复重写
- 子数据基本不变 → embed
实战例子:博客系统
模式 A:扁平 reference(关系数据库 mindset)
// users
{ _id: ObjectId("..."), name: "Alice", email: "..." }
// posts
{ _id: ObjectId("..."), title: "...", body: "...", author_id: ObjectId("...") }
// comments
{ _id: ObjectId("..."), post_id: ObjectId("..."), author_id: ObjectId("..."), body: "..." }
读一篇 post + 它的评论 + 作者:3 次 query + application 拼接。
不是 MongoDB 的优势用法。
模式 B:embed comments 进 post
{
_id: ObjectId("..."),
title: "...",
body: "...",
author: { _id: ObjectId("..."), name: "Alice" }, // embed 作者基本信息
comments: [
{
_id: ObjectId("..."),
author: { _id: ObjectId("..."), name: "Bob" },
body: "...",
created_at: ISODate(),
},
// ...
],
created_at: ISODate(),
}
一次 db.posts.findOne({_id}) 拿全部数据。
适合:
- 每篇 post 评论数有限(< 几百)
- 评论展示几乎总是跟 post 一起读
- 评论不会被独立查询(如"列出某用户所有评论"少见)
不适合:
- 评论可能上千(文档 > 16MB 限制)
- 评论独立修改频繁(每次更新整个 post 文档)
- 需要按评论独立查询
模式 C:分桶(bucket pattern)
如果评论会增长但仍想 embed-like 局部性:
// post 主体
{ _id, title, body, comment_count: 42 }
// comments 分桶
{
_id: ObjectId(),
post_id: ObjectId("..."),
bucket_index: 0, // 第 0 桶
comments: [
{ author, body, created_at }, // 桶内最多 100 条
// ...
]
}
按时间 / id 范围把评论分批。读最近评论 = 读最后一个 bucket(小文档)。
全量评论 = 多次 query 但每次都是几 KB 而非几 MB。
适合 IoT 时序数据、聊天历史、活动 feed 等。
模式 D:用 sub-collection + 反规范化字段
// posts (主信息 + 反规范化字段)
{
_id, title, body, author_id, author_name, // 作者名嵌进来避免 join
comment_count: 42, last_comment_at: ISODate(),
}
// comments(独立 collection)
{ _id, post_id, author_id, author_name, body, created_at }
author_name 在 post 里冗余存——读 post 列表不需要 join users。
用户改名时要更新所有引用(cost 低频)。
last_comment_at / comment_count 在 post 上维护,列表页排序方便。
这是大多数博客 / 社交 app 的 sweet spot。
实战例子:电商订单
// orders(embed product 当时的 snapshot)
{
_id,
user_id: ObjectId(),
items: [
{
product_id: ObjectId(),
// 下面是下单时 snapshot,product 涨价也不变
sku: "ABC-123",
name: "Widget",
price: 99.50,
quantity: 2,
}
],
total: 199.00,
status: "shipped",
shipping_address: { ... }, // embed
payment: { method, txn_id }, // embed
created_at, updated_at,
}
为什么 product 信息要 embed snapshot?产品涨价 / 改名 / 下架后,
历史订单还能显示下单时的价格 / 描述。这是文档数据库优势:把"事实"
冻结在事件发生时。
索引
// 复合索引:常按 user 查最新订单
db.orders.createIndex({ user_id: 1, created_at: -1 })
// 部分索引:只索引未完成订单
db.orders.createIndex(
{ user_id: 1, created_at: -1 },
{ partialFilterExpression: { status: { $in: ["pending", "processing"] } } }
)
// 全文搜索
db.posts.createIndex({ title: "text", body: "text" })
db.posts.find({ $text: { $search: "mongodb schema" } })
explain('executionStats') 看 query 走没走索引:
db.orders.find({user_id: ObjectId("...")}).sort({created_at: -1}).limit(20).explain('executionStats')
// IXSCAN + totalKeysExamined ~= nReturned = 健康
// COLLSCAN = 缺索引
事务
MongoDB 4.0+ 支持多文档事务:
const session = client.startSession()
try {
session.startTransaction()
await db.collection('orders').insertOne(order, { session })
await db.collection('inventory').updateOne(
{ _id: productId },
{ $inc: { stock: -quantity }},
{ session }
)
await session.commitTransaction()
} catch (e) {
await session.abortTransaction()
throw e
} finally {
session.endSession()
}
但好的 schema 设计应该让大多数操作不需要跨文档事务。
embed 把"原子操作"装进一个文档里,单文档写自然原子。
Aggregation pipeline
复杂查询用 aggregation:
db.orders.aggregate([
{ $match: { status: "shipped" } },
{ $unwind: "$items" },
{ $group: {
_id: "$items.sku",
revenue: { $sum: { $multiply: ["$items.price", "$items.quantity"] }},
count: { $sum: "$items.quantity" }
}},
{ $sort: { revenue: -1 } },
{ $limit: 10 },
])
像 SQL GROUP BY 但 stages 串成 pipeline。比 application 端聚合快 10-100x
(DB 内执行,结果集小)。
何时不用 MongoDB
- 强事务 + 多表 join + 复杂报表 → PostgreSQL
- 关系紧密 + schema 严格 → 关系库
- 极大写吞吐(百万 ops/s)+ key-value → DynamoDB / Cassandra
MongoDB 适合:用户 profile / 内容 / 半结构化 / 易变 schema /
fast-iterating 产品。
效果
我们的内容系统迁移到合理设计:
- 首页列表 query:3 次 SQL JOIN(PG)→ 1 次 find(Mongo),延迟从
80ms → 12ms - 文档存 markdown 源 + render 后 HTML + metadata 一起,全 cache 友好
- schema 增加新字段无需 migration(直接写新 doc)
踩过的坑
-
文档 > 16MB:MongoDB 单文档硬上限。无限增长的 array 早晚踩。
-
数组上的
$push+$slice:要限大小:
js db.posts.updateOne( { _id: postId }, { $push: { comments: { $each: [newComment], $slice: -100 } } } )
保留最近 100 条评论,老的滚出。 -
null 和缺字段语义混淆:
js { foo: null } // 字段存在,值为 null { /* no foo */ } // 字段缺失
find({foo: null})同时匹配两种。要区分用{foo: {$exists: true}}。 -
ObjectId vs string:把 string
_id传给find({_id: id})
匹配不到 ObjectId。永远new ObjectId(idStr)转换。 -
schema 后期变更:MongoDB 不强制 schema,但 application 假设
字段存在。改 schema 需要 migrate 旧文档(updateMany加默认值)。
建议生产用 schema validation:
js db.createCollection("orders", { validator: { $jsonSchema: { ... } } })
登录后参与评论。