MongoDBNotes

第 05 章:Schema 设计

zjc 于 2026-01-05 发布

这是《MongoDB 零基础实战指南》的独立章节版。本章从概念、实操和生产排查三个视角展开,代码块保留了原书可直接运行的版本。 MongoDB Schema 设计不是先画实体关系图,而是先列访问模式:应用如何读、如何写、如何分页、如何统计、是否需要扩展。文档边界通常由读取边界、事务边界和分片边界共同决定。

5.1 设计流程

identify entities
  -> list queries and writes
  -> choose document boundary
  -> design indexes
  -> evaluate growth
  -> review transaction and sharding
  -> validate with real volume

每个核心用例应包含:

项目 示例
查询条件 user_id + created_at
返回字段 订单号、状态、金额
排序 created_at desc
频率
写入路径 创建、支付、发货
数据增长 订单明细有限
一致性要求 状态强一致

5.2 内嵌模式

适合用户资料中的地址:

{
  _id: "u_1001",
  name: "Alice",
  default_address_id: "addr_1",
  addresses: [
    { _id: "addr_1", city: "Shanghai", street: "南京西路 100 号" },
    { _id: "addr_2", city: "Beijing", street: "朝阳路 8 号" }
  ]
}

适用:

  1. 数量有限;
  2. 与主文档一起读取;
  3. 更新频率低;
  4. 不需要独立查询和分页;
  5. 生命周期一致。

5.3 引用模式

订单明细若可能很多,应独立建模:

db.order_items.insertOne({
  order_id: "o_10001",
  sku_id: "sku_10001",
  quantity: 2,
  price: NumberDecimal("199.00")
})

适用:

  1. 子文档无界增长;
  2. 需要独立索引和查询;
  3. 不同方频繁修改;
  4. 数据量大到影响主文档;
  5. 生命周期不同。

5.4 混合模式

订单主表保存摘要,明细独立保存:

{
  _id: "o_10001",
  user_id: "u_1001",
  status: "PAID",
  item_count: 3,
  total_amount: NumberDecimal("597.00"),
  created_at: new Date()
}

列表页只读主表,详情页再读明细。这样能控制文档大小和网络传输。

5.5 一对多设计

场景 建议
用户与地址 内嵌
文章与少量标签 内嵌数组
用户与订单 引用
订单与明细 摘要内嵌或拆分
商品与评价 独立集合并分页
系统与日志 独立集合,按时间分片

不要只看关系基数,还要看读取方式和增长规模。

5.6 多对多设计

商品与分类:

{
  _id: "sku_10001",
  title: "轻薄笔记本",
  category_ids: ["c_notebook", "c_work"]
}

适合商品侧按分类过滤。若还需要从分类查商品数量,可以增加冗余计数或单独维护映射集合:

{
  category_id: "c_notebook",
  sku_id: "sku_10001"
}

多对多通常需要在查询方向、冗余度和一致性之间取舍。

5.7 反范式与冗余

订单明细中冗余商品快照:

{
  order_id: "o_10001",
  sku_id: "sku_10001",
  sku_title: "轻薄笔记本",
  price: NumberDecimal("5999.00")
}

订单必须保存成交时价格和标题快照,不能每次关联最新商品数据。对可接受略旧的数据,可以冗余但需要更新策略和一致性说明。

冗余原则:

  1. 快照型数据适合冗余;
  2. 高频变化数据谨慎冗余;
  3. 必须明确谁负责更新;
  4. 必须有对账或补偿;
  5. 避免多层嵌套冗余。

5.8 状态机设计

状态流转字段:

{
  _id: "o_10001",
  status: "WAIT_PAY",
  version: 3,
  status_history: [
    { status: "CREATED", at: new Date() },
    { status: "RISK_PASSED", at: new Date() }
  ]
}

条件更新:

db.orders.updateOne(
  { _id: "o_10001", status: "WAIT_PAY", version: 3 },
  {
    $set: { status: "PAID" },
    $inc: { version: 1 },
    $push: { status_history: { status: "PAID", at: new Date() } }
  }
)

状态机条件能防止旧请求、重复回调和并发更新覆盖。

5.9 时间与时区

统一规范:

  1. 数据库保存 UTC Date;
  2. 展示层转本地时区;
  3. API 可以同时输出业务时区字符串;
  4. 避免字符串日期;
  5. 范围查询统一 Date 类型;
  6. 审计字段包括 created_atupdated_at

5.10 大字段处理

商品详情 HTML 可能在几百 KB 到几 MB:

products       保存列表字段和摘要
product_detail 保存大字段
object storage 保存图片和附件

好处:

  1. 列表查询更快;
  2. 工作集更稳定;
  3. 更新详情不影响主文档;
  4. 大内容可独立缓存;
  5. 备份和迁移更灵活。

5.11 集合治理

集合命名建议:

users
orders
order_items
product_reviews
audit_login_events

治理要求:

  1. 一个集合职责清晰;
  2. 不要无限创建按用户或按月份的动态集合;
  3. TTL 集合单独规划;
  4. 审计集合与业务集合隔离;
  5. 索引数量受控;
  6. 废弃集合及时归档下线。

5.12 设计评审清单

  1. 前十个查询是否有索引;
  2. 是否存在无界数组;
  3. 文档大小是否可控;
  4. 常见更新是否原子;
  5. 是否需要多文档事务;
  6. 金额和时间的类型是否正确;
  7. 列表页是否只取必要字段;
  8. 是否有分片键候选;
  9. 是否有冷热拆分;
  10. 是否有数据保留和归档策略;
  11. 是否有 validator;
  12. 是否压测过真实容量。

本章小结

MongoDB Schema 设计围绕访问模式展开:一起读的数据可以内嵌,无界增长的数据要拆分,跨实体事实可以冗余快照,状态变更用条件更新和版本控制。设计完成后,用查询、写入、增长、事务和分片五个维度反复评审。

思考题

  1. 什么时候必须把内嵌数组拆成独立集合?
  2. 订单为什么应保存商品价格快照?
  3. 状态机条件更新解决什么并发问题?
  4. 大字段为什么不应放在主文档?
  5. 如何判断一个 Schema 是否支持未来分片?