1. 项目背景

业务场景:本地生活电商的产品经理兴致勃勃地提出新需求——"我们的用户详情页太单薄了,能不能把用户的基本信息、收货地址、最近订单、优惠券、浏览历史都展示出来?"开发打开原型图一看,心里一凉——这在 MySQL 里得 JOIN 5 张表。有人提议:"MongoDB 不是说能嵌入吗?把地址、订单、优惠券、浏览历史都嵌在用户文档里不就行了?"另一个人反对:"开什么玩笑,有的用户订单上千条,全嵌进去文档就爆炸了。"团队争吵半天,又回到最初的问题:到底哪些该嵌入?哪些该引用?怎么划清边界?

痛点:建模决策错误,业务上线后才暴露——嵌入太深导致文档超 16MB 写入报错;嵌入不当导致更新频繁的字段每次都要读写整个大文档;引用不当导致一个简单查询需要 5 次数据库往返;读多写少的数据被引用导致页面加载缓慢;写多读少的数据被嵌入导致每次微小的状态变更触发文档级重写。

2. 项目设计

小胖(挠着头看原型图):大师,我彻底懵了。一个用户详情页要查 6 种数据,MongoDB 就算支持嵌套,到底哪些该嵌进去哪些不该?

大师:好,我们一句话总结 MongoDB 的建模哲学:数据跟着查询走。你的查询需要什么,就把什么放在一起。不要再像关系型数据库那样先画 ER 图再拆表——MongoDB 建模的起点是"这个查询要返回什么 JSON 结构"。

小胖:那订单和订单明细呢?一个订单有多个商品,是把商品明细嵌在订单里?还是拆成 orderItems 集合?

大师:这个问题本身就有分量——订单和订单明细的建模,恰恰能演示嵌入和引用的边界。在你的场景里:订单明细是"跟订单一起读、一起写"的数据,天然适合嵌入。但反过来——如果一个商品被 10 万个订单引用,那商品详情嵌入订单就是灾难。

小白:所以要分清楚"数据的关系类型"?一对一、一对多、多对多——不同关系用不同方式?

大师:对,但 MongoDB 里不能用 MySQL 的思维套。MongoDB 归结为三种模式:

关系 推荐模式 例子
一对一 嵌入 用户 ↔ 用户画像
一对少(≤ 几百) 嵌入 订单 ↔ 订单明细
一对多(> 几百) 引用 类目 ↔ 商品
一对极多(不定增长) 引用 + 冗余 用户 ↔ 订单
多对多 视情况,通常引用 商品 ↔ 标签

小胖:等等,"一对少"和"一对多"怎么区分?订单的订单明细——一个订单最多几十个商品,这算少吧?那用户的订单列表——一个人可能有几千个订单,这肯定不能嵌了。我好像有点感觉了。

大师:说对了。嵌入的核心约束是——被嵌入的数据量不是无限增长的。订单明细数量有上限(一个购物车能放多少东西?),用户订单数量没有上限。

技术映射:嵌入模式一次查询就获得完整数据,适合读密集型;引用模式需要二次查询或 $lookup,但写入成本低、数据独立性高。MongoDB 的单文档原子更新也只在文档内部有效——跨文档需要事务,这也是嵌入的优势之一。

小白(追问):那反范式设计呢?我看到一些项目在订单表里冗余了 productNameproductImage。这不就又回到"数据不一致"的老路上了吗?

大师:这正是 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 适用场景

嵌入模式适用

  1. 订单系统——订单和明细不可分割,一起读一起写。
  2. 用户配置——一个用户一份配置,一对一关系。
  3. 评论系统——文章和评论紧密关联,大多数场景同时出现。
  4. 传感器读数——每次上报的读数是一组数据,不单独查询。

引用模式适用

  1. 社交关系——关注者和被关注者各自独立生命周期。
  2. 库存管理——商品库存独立于订单,高频独立更新。
  3. 搜索索引数据——独立于主数据,异步维护。

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 思考题

  1. 你有一个"博客文章"集合,每篇文章有标题、正文、作者、评论(列表)、标签(列表)。请分别设计三种数据模型(全嵌入 / 全引用 / 混合),分析各自的优缺点和适用场景。
  2. MongoDB 的 16MB 文档上限可以配置吗?如果可以,调大它是不是可以缓解嵌入模式下的数据膨胀问题?为什么官方不推荐调大?

(答案将在第 9 章末尾揭晓)


上一章思考题答案

  1. 分片集群中 _id 不一定是全局有序的——如果按 userId 哈希分片,_id 在不同分片上可能交错。游标分页的稳定排序必须参考 shardKey,或者改用全局有序的字段(如 ObjectId 前 4 字节时间戳,但精度有限)。简言之:分片集群下游标分页不能依赖 _id 作为唯一稳定的排序兜底。

  2. 双向游标分页方案:每个游标包含两套值——after(用于"下一页")和 before(用于"上一页"),每页查询时都返回 prevCursornextCursor。"上一页"用完全相反的条件:$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 与私有化大模型实战

大型语言模型(LLM) vLLM 高性能推理落地实战

Agent开发之LlamaIndex 实战修炼与源码进阶

大语言模型Transformers 实战修炼与源码剖析

posted on 2026-07-21 18:41  一天不进步,就是退步  阅读(5)  评论(0)    收藏  举报