1. 项目背景
业务场景:本地生活电商的产品经理兴致勃勃地提出新需求——"我们的用户详情页太单薄了,能不能把用户的基本信息、收货地址、最近订单、优惠券、浏览历史都展示出来?"开发打开原型图一看,心里一凉——这在 MySQL 里得 JOIN 5 张表。有人提议:"MongoDB 不是说能嵌入吗?把地址、订单、优惠券、浏览历史都嵌在用户文档里不就行了?"另一个人反对:"开什么玩笑,有的用户订单上千条,全嵌进去文档就爆炸了。"团队争吵半天,又回到最初的问题:到底哪些该嵌入?哪些该引用?怎么划清边界?
痛点:建模决策错误,业务上线后才暴露——嵌入太深导致文档超 16MB 写入报错;嵌入不当导致更新频繁的字段每次都要读写整个大文档;引用不当导致一个简单查询需要 5 次数据库往返;读多写少的数据被引用导致页面加载缓慢;写多读少的数据被嵌入导致每次微小的状态变更触发文档级重写。
2. 项目设计
小胖(挠着头看原型图):大师,我彻底懵了。一个用户详情页要查 6 种数据,MongoDB 就算支持嵌套,到底哪些该嵌进去哪些不该?
大师:好,我们一句话总结 MongoDB 的建模哲学:数据跟着查询走。你的查询需要什么,就把什么放在一起。不要再像关系型数据库那样先画 ER 图再拆表——MongoDB 建模的起点是"这个查询要返回什么 JSON 结构"。
小胖:那订单和订单明细呢?一个订单有多个商品,是把商品明细嵌在订单里?还是拆成 orderItems 集合?
大师:这个问题本身就有分量——订单和订单明细的建模,恰恰能演示嵌入和引用的边界。在你的场景里:订单明细是"跟订单一起读、一起写"的数据,天然适合嵌入。但反过来——如果一个商品被 10 万个订单引用,那商品详情嵌入订单就是灾难。
小白:所以要分清楚"数据的关系类型"?一对一、一对多、多对多——不同关系用不同方式?
大师:对,但 MongoDB 里不能用 MySQL 的思维套。MongoDB 归结为三种模式:
| 关系 | 推荐模式 | 例子 |
|---|---|---|
| 一对一 | 嵌入 | 用户 ↔ 用户画像 |
| 一对少(≤ 几百) | 嵌入 | 订单 ↔ 订单明细 |
| 一对多(> 几百) | 引用 | 类目 ↔ 商品 |
| 一对极多(不定增长) | 引用 + 冗余 | 用户 ↔ 订单 |
| 多对多 | 视情况,通常引用 | 商品 ↔ 标签 |
小胖:等等,"一对少"和"一对多"怎么区分?订单的订单明细——一个订单最多几十个商品,这算少吧?那用户的订单列表——一个人可能有几千个订单,这肯定不能嵌了。我好像有点感觉了。
大师:说对了。嵌入的核心约束是——被嵌入的数据量不是无限增长的。订单明细数量有上限(一个购物车能放多少东西?),用户订单数量没有上限。
技术映射:嵌入模式一次查询就获得完整数据,适合读密集型;引用模式需要二次查询或 $lookup,但写入成本低、数据独立性高。MongoDB 的单文档原子更新也只在文档内部有效——跨文档需要事务,这也是嵌入的优势之一。
小白(追问):那反范式设计呢?我看到一些项目在订单表里冗余了 productName 和 productImage。这不就又回到"数据不一致"的老路上了吗?
大师:这正是 MongoDB 建模最反直觉的一点——冗余不是 bug,是 feature。你这个问题正好引出 MongoDB 一条重要原则:宁可冗余,不做关联。
小胖:啊?这不违反了数据库设计的第三范式吗?
大师:范式是关系型数据库的圣经,但 MongoDB 不是关系型数据库。在关系模型里,冗余会导致更新异常——改一个商品名需要更新 10 万条订单记录,这确实很糟糕。但在 MongoDB 里,历史订单里的商品名本来就不该被现在修改!
小白:什么意思?
大师:你买了一个"iPhone 16 手机壳",一个月后商家改名叫"iPhone 16 保护套高配版"。你的历史订单里显示的应该是你买时的名称"手机壳",而不是现在的"保护套"。所以在订单里冗余 productName 不是问题——那是一个快照,标记了订单创建时刻的商品名。商品的当前名称由商品表管理,订单的快照不需要同步。
技术映射:写数据时对关联数据做快照(Snapshot),是该场景下的最佳实践。快照不可变,无关一致性,但读路径极短。这被称为"Read-optimized Schema"。
小胖:那到底哪些场景应该引用?用户地址是嵌在用户里还是单独存?
大师:四个判别维度:
| 维度 | 嵌入的信号 | 引用的信号 |
|---|---|---|
| 访问模式 | 总是同一个查询一起读 | 各自独立被查询 |
| 变更频率 | 几乎不变,或一起变更 | 独立被频繁修改 |
| 数据规模 | 有上限(几十到几百) | 无上限,可能持续增长 |
| 原子性要求 | 需在同一个事务里一起更新 | 各自的更新彼此独立 |
应对用户地址的决策:用户地址最多 5-10 个,跟用户一起读、几乎不变、不需要独立查询,所以嵌入用户文档。但如果地址需要被"配送系统"独立查询,就可能需要建立单独的地址集合,用户在订单中存储地址快照。
大师(总结):今天讲的三种模式记住了:嵌入(一起读写的放一起)、引用(各自独立的各放各的)、反范式/冗余(宁存快照不做关联)。具体决策没有绝对标准,拿捏"一起读吗?有上限吗?独立变吗?"三个问题就行。
3. 项目实战
3.1 环境准备
docker compose -f mongodb-lab/docker-compose.yml ps
3.2 分步实现
步骤一:嵌入模型——订单 + 订单明细
目标:用嵌入方式设计订单文档,一次性查完整订单。
use local_life
db.orders_model.drop()
// 插入嵌入型的订单
db.orders_model.insertOne({
orderNo: "ORD20260321001",
userId: "USER_001",
status: "已支付",
// 快照:收货地址嵌入在订单内(下单时的地址,未来用户改地址不影响历史订单)
shippingAddress: {
name: "张三",
phone: "13800138000",
province: "广东",
city: "深圳",
detail: "科技园路1号"
},
// 订单明细嵌入在订单内
items: [
{
productId: new ObjectId(),
productName: "无线蓝牙耳机 Pro", // 快照:购买时的商品名
productImage: "/images/earphone.jpg", // 快照:购买时的商品图
price: NumberDecimal("199.00"),
quantity: 1,
subtotal: NumberDecimal("199.00")
},
{
productId: new ObjectId(),
productName: "USB-C 充电线",
productImage: "/images/cable.jpg",
price: NumberDecimal("29.90"),
quantity: 2,
subtotal: NumberDecimal("59.80")
}
],
totalAmount: NumberDecimal("258.80"),
couponId: null,
paidAt: new Date(),
createdAt: new Date(),
updatedAt: new Date()
})
// 查询订单——一次查询获取完整订单数据
const order = db.orders_model.findOne({ orderNo: "ORD20260321001" })
print("订单号:", order.orderNo)
print("收货人:", order.shippingAddress.name)
print("商品数:", order.items.length)
print("总金额:", order.totalAmount)
order.items.forEach(item => {
print(` - ${item.productName} x${item.quantity} ¥${item.price}`)
})
步骤二:引用模型——用户 + 订单(一对多)
目标:用引用方式分离用户和订单,避免用户文档膨胀。
// 用户集合(精简版,只存用户核心信息)
db.users_model.drop()
db.users_model.insertOne({
_id: "USER_001",
name: "张三",
phone: "13800138000",
// 地址列表:最多 5-10 个,嵌入
addresses: [
{ id: "addr1", province: "广东", city: "深圳", detail: "科技园", isDefault: true },
{ id: "addr2", province: "广东", city: "广州", detail: "天河城", isDefault: false }
],
// 最近 3 笔订单 ID(冗余,用于快速展示"最近订单")
recentOrderIds: ["ORD20260321001", "ORD20260320005", "ORD20260319012"],
// 用户统计(冗余,避免每次都去聚合)
stats: {
totalOrders: 156,
totalSpent: NumberDecimal("28500.00")
},
createdAt: new Date()
})
// 订单集合(通过 userId 引用用户)
db.orders_model.insertOne({
orderNo: "ORD20260321002",
userId: "USER_001", // 引用用户
totalAmount: NumberDecimal("399.00"),
items: [
{ productId: new ObjectId(), productName: "机械键盘", price: NumberDecimal("399.00"), quantity: 1 }
],
status: "已完成",
createdAt: new Date()
})
// ---- 查询:用户详情页 ----
// 步骤1:查用户基本信息
const user = db.users_model.findOne({ _id: "USER_001" })
// 步骤2:查最近订单
const recentOrders = db.orders_model.find({
userId: "USER_001"
}).sort({ createdAt: -1 }).limit(3).toArray()
print("用户:", user.name)
print("地址数:", user.addresses.length)
print("统计: 共", user.stats.totalOrders, "单, 累计", user.stats.totalSpent)
print("最近订单:")
recentOrders.forEach(o => print(` ${o.orderNo} ¥${o.totalAmount} ${o.status}`))
步骤三:反范式快照——商品信息冗余在订单中
目标:展示快照模式的正确做法和错误做法的区别。
// 商品主表(独立管理,可随时更新)
db.products_model.drop()
const productId = new ObjectId()
db.products_model.insertOne({
_id: productId,
name: "iPhone 16 手机壳",
price: NumberDecimal("49.00"),
stock: 500,
images: ["/images/case1.jpg", "/images/case2.jpg"],
updatedAt: new Date()
})
// ---- 错误做法:订单存入 productId,查询时 $lookup ----
db.orders_model.insertOne({
orderNo: "ORD_WRONG_01",
userId: "USER_001",
items: [
{ productId: productId, quantity: 2, price: NumberDecimal("49.00") }
// 只有 productId,没有冗余 productName
],
createdAt: new Date(Date.now() - 30 * 24 * 3600000) // 一个月前
})
// 一个月后商品改名
db.products_model.updateOne(
{ _id: productId },
{ $set: { name: "iPhone 16 高级防摔保护壳" } }
)
// 查询"历史订单"——用 $lookup 拿到的产品名
const wrongOrder = db.orders_model.aggregate([
{ $match: { orderNo: "ORD_WRONG_01" } },
{ $unwind: "$items" },
{ $lookup: {
from: "products_model",
localField: "items.productId",
foreignField: "_id",
as: "product"
}},
{ $unwind: "$product" }
]).toArray()
// 订单里显示的是"iPhone 16 高级防摔保护壳"——错误!历史订单被篡改了
print("错误做法:历史订单快照被修改:", wrongOrder[0]?.product?.name)
// ---- 正确做法:下单时写入快照 ----
db.orders_model.insertOne({
orderNo: "ORD_RIGHT_01",
userId: "USER_001",
items: [
{
productId: productId,
productName: "iPhone 16 手机壳", // 下单时的快照
productImage: "/images/case1.jpg", // 下单时的快照
quantity: 2,
price: NumberDecimal("49.00")
}
],
createdAt: new Date(Date.now() - 30 * 24 * 3600000)
})
const rightOrder = db.orders_model.findOne({ orderNo: "ORD_RIGHT_01" })
print("正确做法:历史订单快照不变:", rightOrder.items[0].productName)
步骤四:多对多——商品标签系统
目标:展示多对多关系在 MongoDB 中的建模方式。
db.tag_products.drop()
db.tags_model.drop()
// 标签集合
db.tags_model.insertMany([
{ _id: "tag_recommend", name: "推荐", usageCount: 0 },
{ _id: "tag_new", name: "新品", usageCount: 0 },
{ _id: "tag_hot", name: "热销", usageCount: 0 },
{ _id: "tag_limited", name: "限时", usageCount: 0 }
])
// 商品中嵌入标签 ID(多对多:一个商品多个标签,一个标签多个商品)
db.tag_products.insertMany([
{ name: "商品A", tagIds: ["tag_recommend", "tag_new"], price: 99 },
{ name: "商品B", tagIds: ["tag_hot", "tag_recommend"], price: 199 },
{ name: "商品C", tagIds: ["tag_limited", "tag_new", "tag_hot"], price: 299 }
])
// 查询"推荐"标签的商品
const recommended = db.tag_products.find({
tagIds: "tag_recommend"
}).toArray()
print("推荐标签商品:")
recommended.forEach(p => print(` ${p.name} - 标签:[${p.tagIds}]`))
// 统计每个标签下有多少商品(聚合)
const tagStats = db.tag_products.aggregate([
{ $unwind: "$tagIds" },
{ $group: { _id: "$tagIds", count: { $sum: 1 } } }
]).toArray()
print("\n标签统计:")
tagStats.forEach(t => print(` ${t._id}: ${t.count} 个商品`))
步骤五:文档膨胀监控
目标:学习如何发现和防止文档无限膨胀。
// 模拟文档随时间膨胀的过程
// 创建一个用户文档,每次"登录"时追加一条浏览记录
function simulateGrowth(iterations = 100) {
db.growth_test.drop()
db.growth_test.insertOne({
userId: "GROWTH_USER",
browseHistory: []
})
for (let i = 0; i < iterations; i++) {
const before = db.growth_test.findOne({ userId: "GROWTH_USER" })
const sizeBefore = Object.bsonsize(before)
db.growth_test.updateOne(
{ userId: "GROWTH_USER" },
{
$push: {
browseHistory: {
productId: new ObjectId(),
viewedAt: new Date(),
duration: Math.floor(Math.random() * 120)
}
}
}
)
if (i % 20 === 0) {
const after = db.growth_test.findOne({ userId: "GROWTH_USER" })
print(`迭代 ${i}: BSON 大小 = ${(Object.bsonsize(after) / 1024).toFixed(0)} KB`)
}
}
const final = db.growth_test.findOne({ userId: "GROWTH_USER" })
const finalSize = Object.bsonsize(final)
print(`最终 BSON 大小: ${(finalSize / 1024).toFixed(0)} KB (${finalSize} bytes)`)
print(`16MB 上限: ${16 * 1024 * 1024} bytes`)
print(`占比: ${(finalSize / (16 * 1024 * 1024) * 100).toFixed(2)}%`)
}
simulateGrowth(1000)
// 解决方案:限制嵌入数据的数量
// 只保留最近 100 条浏览记录
db.growth_test.updateOne(
{ userId: "GROWTH_USER" },
{ $push: { browseHistory: { $each: [], $slice: -100 } } }
)
3.3 完整代码清单
| 文件 | 用途 |
|---|---|
mongodb-lab/scripts/ch08-embedding-demo.js |
嵌入模型演示(订单+明细) |
mongodb-lab/scripts/ch08-referencing-demo.js |
引用模型演示(用户+订单) |
mongodb-lab/scripts/ch08-snapshot-demo.js |
反范式快照演示 |
mongodb-lab/scripts/ch08-growth-monitor.js |
文档膨胀监控 |
3.4 测试验证
use local_life
// 1. 嵌入模型:一次查询获取完整订单
const e1 = db.orders_model.findOne({ orderNo: "ORD20260321001" })
print("嵌入模型-查询1次得到订单+明细:", e1.items.length > 0 ? "PASS" : "FAIL")
// 2. 引用模型:两步查询组装用户详情页
const u1 = db.users_model.findOne({ _id: "USER_001" })
const orders1 = db.orders_model.find({ userId: "USER_001" }).toArray()
print("引用模型-用户详情:", u1.name, "订单数:", orders1.length)
// 3. 快照一致性:商品改名不影响历史订单
const right = db.orders_model.findOne({ orderNo: "ORD_RIGHT_01" })
print("快照-历史订单商品名不变:", right.items[0].productName === "iPhone 16 手机壳" ? "PASS" : "FAIL")
// 4. 文档大小检查
const orderSize = Object.bsonsize(e1)
print("订单文档大小:", (orderSize / 1024).toFixed(2), "KB")
print(orderSize < 1024 * 1024 ? "PASS (远小于 1MB)" : "文档偏大,考虑优化")
// 5. 清理
// db.orders_model.dropIndexes()
4. 项目总结
4.1 建模模式对比
| 模式 | 读性能 | 写性能 | 数据一致性 | 存储效率 | 典型场景 |
|---|---|---|---|---|---|
| 嵌入(Embedding) | 极好(1次查询) | 中(整文档重写) | 强(单文档原子性) | 差(数据重复) | 订单+明细 |
| 引用(Referencing) | 差(需多次查询/$lookup) | 好(独立更新) | 弱(需事务保证) | 好(无冗余) | 类目+商品 |
| 反范式快照(Snapshot) | 极好(1次查询) | 好(写时复制) | 不可变(符合业务语义) | 中(可控冗余) | 订单中冗余商品名 |
| 扩展引用(Extended Reference) | 好(常用字段嵌入) | 中 | 中 | 中 | 用户中嵌最近 3 单 ID |
4.2 适用场景
嵌入模式适用:
- 订单系统——订单和明细不可分割,一起读一起写。
- 用户配置——一个用户一份配置,一对一关系。
- 评论系统——文章和评论紧密关联,大多数场景同时出现。
- 传感器读数——每次上报的读数是一组数据,不单独查询。
引用模式适用:
- 社交关系——关注者和被关注者各自独立生命周期。
- 库存管理——商品库存独立于订单,高频独立更新。
- 搜索索引数据——独立于主数据,异步维护。
4.3 注意事项
| 注意事项 | 说明 |
|---|---|
| 嵌入数组的大小 | 用 $slice 限制嵌入数组的元素个数,防止无限增长 |
| 引用字段加索引 | 所有引用字段(如 userId)必须建索引 |
| 快照字段不可变 | 标记清楚哪些字段是快照,避免业务代码尝试"同步更新"快照 |
$lookup 代价 |
尽量在 $lookup 前用 $match 缩小数据集 |
| 文档大小监控 | 用 collStats 定期巡检,发现 .avgObjSize 异常增长及时告警 |
4.4 常见踩坑经验
故障案例一:用户文档无限膨胀
某社交 App 把所有用户动态嵌在用户文档的 feeds 数组里。半年后,活跃用户的文档超过 15MB,每次打开个人主页都超时。根因:$push 无限追加。解决:拆出独立的 feeds 集合,userId 索引查询;用户文档只冗余最近 5 条动态摘要。
故障案例二:快照更新风暴
某电商在订单里冗余了 productName,但业务逻辑里有一段代码定期"同步"订单中的商品名为最新名称——每次商品改名导致数十万条订单被 updateMany,产生海量 Oplog。根因:不理解快照和主数据的关系。解决:删除同步逻辑,历史订单名称不变,这是正确的业务语义。
故障案例三:多对多中间表照搬 MySQL 设计
某团队从 MySQL 迁移 MongoDB,把商品-标签关系拆为一张"商品标签关联表",每条记录 {productId: xx, tagId: xx},查询商品时需要 JOIN 关联表再 JOIN 标签表,三层 $lookup。根因:照搬关系模型。解决:商品文档内嵌 tagIds 数组,查询标签名通过一次 $lookup 或直接嵌入标签名称(少量标签)。
4.5 思考题
- 你有一个"博客文章"集合,每篇文章有标题、正文、作者、评论(列表)、标签(列表)。请分别设计三种数据模型(全嵌入 / 全引用 / 混合),分析各自的优缺点和适用场景。
- MongoDB 的 16MB 文档上限可以配置吗?如果可以,调大它是不是可以缓解嵌入模式下的数据膨胀问题?为什么官方不推荐调大?
(答案将在第 9 章末尾揭晓)
上一章思考题答案:
分片集群中
_id不一定是全局有序的——如果按userId哈希分片,_id在不同分片上可能交错。游标分页的稳定排序必须参考shardKey,或者改用全局有序的字段(如 ObjectId 前 4 字节时间戳,但精度有限)。简言之:分片集群下游标分页不能依赖_id作为唯一稳定的排序兜底。双向游标分页方案:每个游标包含两套值——
after(用于"下一页")和before(用于"上一页"),每页查询时都返回prevCursor和nextCursor。"上一页"用完全相反的条件:$gt替代$lt,排序方向翻转(sort({createdAt:1,_id:1})替代sort({createdAt:-1,_id:-1})),取完结果后再翻转数组顺序。
延伸阅读与资源
python入门:Rquests从菜鸟脚本到企业级SDK的网络实战圣经
Milvus向量数据库实战修炼:从 0 到 1精通向量检索与生产落地
后端工程师的 AI 转型第一课:Ollama 与私有化大模型实战
10倍开发者的 Dify 魔法书:从零构建全栈 AI 应用
后端工程师转型AI第一课-Ollama 与私有化大模型实战

微信公众号: 架构师日常笔记 欢迎关注!
浙公网安备 33010602011771号