3. 企业文档数据建模实战
企业文档数据平台建设中最容易踩坑的环节,不是选型,而是建模。关系模型经过几十年沉淀,已经形成“先定表结构、再写 SQL”的思维惯性;而 MongoDB 的文档模型要求我们反过来思考:先确定查询模式与写入模式,再决定文档边界。本节围绕四个相互关联的实战问题展开:关系模型如何迁移、聚合管道如何围绕访问模式设计、版本化文档如何落地、模式演进如何在不破坏线上服务的前提下推进。
一、从关系模型迁移:先拆“访问模式”,再定文档边界
迁移不是把每张表机械映射成一个集合。以典型的订单场景为例,关系库中通常有 orders、order_items、products、customers 四张表,通过外键关联。迁移到 MongoDB 时,第一步是列出核心访问模式:
- 按订单号查询订单详情(含明细行、商品快照、收货地址)——读多写少,要求单次查询返回完整订单;
- 按客户查询近 90 天订单列表——需要按客户维度聚合;
- 商品价格与名称会变动,但订单中的商品信息必须保留下单时刻的快照;
- 订单状态会经历“待支付→已支付→已发货→已完成/已取消”的状态流转。
基于这些访问模式,推荐采用“订单为主文档、明细内嵌、商品快照内嵌、客户信息部分冗余”的混合模型。订单明细作为数组内嵌,是因为它总是与订单一起被读取,且单笔订单明细数量可控(示例中假设不超过 200 行)。商品不采用引用,而是内嵌下单时的名称、单价、规格快照,避免商品改价后历史订单失真。客户信息只冗余 customerId、name、phone 等展示字段,完整客户档案仍保留在 customers 集合中,通过 customerId 关联。
// orders 集合示例文档(教学示例,非实测数据)
{
_id: ObjectId("..."),
orderNo: "SO20240517001",
customerId: "C10086",
customerSnapshot: { name: "张伟", phone: "138****0000" },
status: "PAID",
items: [
{ sku: "SKU-A01", name: "机械键盘", price: 399.00, qty: 1, snapshotAt: ISODate("2024-05-17T10:00:00Z") },
{ sku: "SKU-B07", name: "显示器支架", price: 159.00, qty: 2, snapshotAt: ISODate("2024-05-17T10:00:00Z") }
],
amount: { total: 717.00, currency: "CNY" },
address: { province: "广东", city: "深圳", detail: "科技园路 1 号" },
createdAt: ISODate("2024-05-17T10:00:00Z"),
updatedAt: ISODate("2024-05-17T10:05:00Z")
}
这里有一个关键判断:内嵌还是引用,取决于“是否总是一起读”和“数组是否会无界增长”。订单明细有界,内嵌;客户订单历史无界,必须放在独立集合或使用 $lookup 按需关联。若把客户所有订单内嵌到客户文档,会迅速触及 16MB 文档上限,且写入冲突严重。
二、聚合管道设计:围绕“查询形状”组织阶段
聚合管道的设计原则是:尽早过滤、尽早投影、把最重的操作放在数据量最小之后。以“统计 2024 年 5 月各城市已支付订单的销售额 Top 10”为例,管道应按如下顺序组织:
db.orders.aggregate([
{ $match: { status: "PAID",
createdAt: { $gte: ISODate("2024-05-01"), $lt: ISODate("2024-06-01") } } },
{ $project: { city: "$address.city", total: "$amount.total" } },
{ $group: { _id: "$city", sales: { $sum: "$total" }, orders: { $sum: 1 } } },
{ $sort: { sales: -1 } },
{ $limit: 10 }
])
第一阶段 $match 必须放在最前,才能利用 status 与 createdAt 的复合索引;$project 提前裁剪字段,减少后续阶段的内存占用;$group 之后再 $sort 与 $limit,避免对全量数据排序。若把 $sort 放在 $group 之前,不仅语义错误,还会造成大量无效排序。对于需要关联客户档案的场景,$lookup 应尽量放在 $match 缩小结果集之后,并配合 localField/foreignField 上的索引。
explain("executionStats") 中的 totalDocsExamined 与 nReturned 的比值。若比值远大于 1,说明过滤没有走索引,应优先调整 $match 位置或补充索引。三、版本化文档:用“版本字段 + 快照”保留历史
企业文档常需要审计与回溯,例如合同、配置、商品描述。版本化有两种常见策略:内嵌版本数组与独立版本集合。内嵌适合版本数量少、单版本体积小的场景;独立集合适合版本多、需要按版本号单独查询的场景。以下采用独立集合方案,主文档保留当前版本指针:
// contracts 主文档
{ _id: "CT-2024-001", title: "采购合同", currentVersion: 3, status: "ACTIVE" }
// contract_versions 版本集合
{ contractId: "CT-2024-001", version: 3, content: {...},
changedBy: "u1001", changedAt: ISODate("2024-05-17T09:00:00Z"),
changeNote: "调整付款条款" }
写入新版本时,使用事务或“先插入版本、再更新主文档指针”的两步操作,保证 currentVersion 始终指向已存在的版本。查询历史时按 contractId + version 建复合索引,按时间倒序读取即可。若业务要求“任意时刻可还原”,还需在版本文档中保存完整内容快照,而不是只存差异。
四、模式演进策略:兼容旧文档,渐进式迁移
MongoDB 是无模式(schema-less)的,但企业应用必须有受控模式。推荐使用 schemaVersion 字段标记文档结构版本,并遵循“向后兼容、读时兼容、写时升级”的策略:
- 新增字段时,旧文档缺失该字段,读取代码提供默认值,不阻塞线上;
- 字段重命名或类型变更时,先双写新旧字段,再逐步迁移读路径,最后清理旧字段;
- 批量迁移使用后台任务按
schemaVersion分批处理,每批限制数量并记录进度; - 迁移期间保持新旧两种结构都能被正确解析,避免出现“半迁移”状态导致查询遗漏。
下面这张图概括了本节四个环节之间的数据流与决策关系:从关系模型出发,经过访问模式分析确定文档边界,再通过聚合管道支撑查询,版本化保留历史,模式演进保证长期可维护。
把上述四个环节串起来看,迁移解决“数据怎么放”,聚合解决“数据怎么查”,版本化解决“历史怎么留”,模式演进解决“结构怎么变”。四者共同构成企业文档数据建模的闭环。实际落地时,建议先用真实查询样本验证文档边界,再用 explain 验证聚合管道,最后用 schemaVersion 与迁移脚本保证长期可维护性。