这是《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 号" }
]
}
适用:
- 数量有限;
- 与主文档一起读取;
- 更新频率低;
- 不需要独立查询和分页;
- 生命周期一致。
5.3 引用模式
订单明细若可能很多,应独立建模:
db.order_items.insertOne({
order_id: "o_10001",
sku_id: "sku_10001",
quantity: 2,
price: NumberDecimal("199.00")
})
适用:
- 子文档无界增长;
- 需要独立索引和查询;
- 不同方频繁修改;
- 数据量大到影响主文档;
- 生命周期不同。
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")
}
订单必须保存成交时价格和标题快照,不能每次关联最新商品数据。对可接受略旧的数据,可以冗余但需要更新策略和一致性说明。
冗余原则:
- 快照型数据适合冗余;
- 高频变化数据谨慎冗余;
- 必须明确谁负责更新;
- 必须有对账或补偿;
- 避免多层嵌套冗余。
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 时间与时区
统一规范:
- 数据库保存 UTC Date;
- 展示层转本地时区;
- API 可以同时输出业务时区字符串;
- 避免字符串日期;
- 范围查询统一 Date 类型;
- 审计字段包括
created_at和updated_at。
5.10 大字段处理
商品详情 HTML 可能在几百 KB 到几 MB:
products 保存列表字段和摘要
product_detail 保存大字段
object storage 保存图片和附件
好处:
- 列表查询更快;
- 工作集更稳定;
- 更新详情不影响主文档;
- 大内容可独立缓存;
- 备份和迁移更灵活。
5.11 集合治理
集合命名建议:
users
orders
order_items
product_reviews
audit_login_events
治理要求:
- 一个集合职责清晰;
- 不要无限创建按用户或按月份的动态集合;
- TTL 集合单独规划;
- 审计集合与业务集合隔离;
- 索引数量受控;
- 废弃集合及时归档下线。
5.12 设计评审清单
- 前十个查询是否有索引;
- 是否存在无界数组;
- 文档大小是否可控;
- 常见更新是否原子;
- 是否需要多文档事务;
- 金额和时间的类型是否正确;
- 列表页是否只取必要字段;
- 是否有分片键候选;
- 是否有冷热拆分;
- 是否有数据保留和归档策略;
- 是否有 validator;
- 是否压测过真实容量。
本章小结
MongoDB Schema 设计围绕访问模式展开:一起读的数据可以内嵌,无界增长的数据要拆分,跨实体事实可以冗余快照,状态变更用条件更新和版本控制。设计完成后,用查询、写入、增长、事务和分片五个维度反复评审。
思考题
- 什么时候必须把内嵌数组拆成独立集合?
- 订单为什么应保存商品价格快照?
- 状态机条件更新解决什么并发问题?
- 大字段为什么不应放在主文档?
- 如何判断一个 Schema 是否支持未来分片?