第3章 数据建模与 Schema 设计
🎯 本章学习目标
- 完成核心思维转换:MySQL 按「数据是什么」拆表(范式),MongoDB 按「数据怎么用」聚合(查询模式驱动)。
- 掌握建模第一问:内嵌(embed)还是引用(reference),能用决策表对常见关系(1:1、1:N 少、1:N 多、N:N)选型。
- 认识 16MB 文档上限与「数组无限增长」反模式,掌握分桶(bucket)与属性(attribute)模式。
- 会用
$jsonSchema给集合加校验,把 MySQL 的 schema 安全感找回来。 - 拿到一张「MySQL 表 → MongoDB 集合」的翻译路线图,能为手上的项目做建模评审。
这一章没有新语法,全是设计——它是 MongoDB 使用成败的分水岭章节。建模错了,索引(第 4 章)和聚合(第 5 章)都救不回来。文中示例仍按 mongosh / Java 双外壳成对给出,方便把每个设计决策真的动手建一遍。
3.1 思维重装:为什么「拆表习惯」在 MongoDB 会受伤
MySQL 训练出的直觉:数据冗余是罪,必须范式化拆到最小,用时再 JOIN 拼回来。
文档数据库的直觉:读路径上需要的数据,尽量在写路径上就放在一起。理由有三:
- JOIN 成本高:MongoDB 的
$lookup能做联表,但它不是引擎一等公民,性能与分布式语义都远不如「一次读一个文档」; - 单文档原子性:一个文档内的修改天然原子、不需要事务(第 6 章)——把强关联数据放进一个文档,一致性白拿;
- 一次 IO 一块数据:应用加载「订单+全部明细」时,读一个文档 vs 读 1 条订单 + N 条明细再拼装,延迟差一个量级。
但也别走极端:内嵌不是万能胶
内嵌的代价是数据复制(同一用户信息散在 N 个文档里,更新要 N 处)与文档膨胀(无限增长的数组会撑爆 16MB)。建模的本质是权衡,下面给出可执行的决策方法。
3.2 第一问:内嵌还是引用?
3.2.1 决策表(按关系基数)
| 关系 | 典型例子 | 推荐 | 理由 |
|---|---|---|---|
| 1:1 | 用户 ↔ 详情/配置 | 内嵌 | 永远一起读,拆开纯增 IO |
| 1:N 且 N 小而有界(< 几百) | 文章 ↔ 评论(分页展示、限量审核)、订单 ↔ 明细行 | 内嵌 | 「订单 3 行明细」拆两张表是 MySQL 的条件反射,在这里是错的 |
| 1:N 且 N 大或无界 | 用户 ↔ 发帖、设备 ↔ 采集点、商品 ↔ 浏览记录 | 引用(独立集合) | 16MB 上限 + 数组越大更新越慢(文档搬迁) |
| 文档常被「部分查询」 | 大文本 content 只列表页不要正文 | 拆分/引用 或延迟关联 | 避免每次查询拖全档 |
| 实体本身有独立生命周期 | 商品、用户、优惠券 | 引用 | 会被单独查询/修改的实体值得独立集合 |
| 需要被多个集合「各存一份语义」 | 订单里的商品价格快照 | 内嵌(快照化) | 见 3.2.3,订单场景反而要故意冗余 |
3.2.2 两种写法对照(Java)
内嵌式——文章带着评论,一读全得:
// mongosh:插入带内嵌评论数组的文档
db.posts.insertOne({
title: "MongoDB 建模",
author: { id: ObjectId("..."), name: "tom" }, // 少量作者信息快照
comments: [
{ user: "amy", text: "写得真好", time: new Date() },
{ user: "bo", text: "受教了", time: new Date() }
],
commentCount: 2
});
// 追加一条评论:单文档原子,无需事务
db.posts.updateOne({ _id: postId }, {
$push: { comments: newComment },
$inc: { commentCount: 1 }
});// Java Driver 对照
Document post = new Document("title", "MongoDB 建模")
.append("author", new Document("id", authorOid).append("name", "tom")) // 少量作者信息快照
.append("comments", List.of(
new Document("user", "amy").append("text", "写得真好").append("time", new Date()),
new Document("user", "bo").append("text", "受教了").append("time", new Date())))
.append("commentCount", 2);
posts.insertOne(post);
// 追加一条评论:单文档原子,无需事务
posts.updateOne(Filters.eq("_id", postId),
new Document("$push", new Document("comments", newComment))
.append("$inc", new Document("commentCount", 1)));引用式——用户与发帖,一边独立生长:
// mongosh:帖子只存 authorId(≈ 外键,但无约束)
db.users.insertOne({ username: "tom", email: "tom@yjoffer.com" });
db.posts.insertOne({
title: "新文章",
authorId: authorOid,
tags: ["mongodb"]
});
// 「查文章带作者名」:应用层拿 authorId 再查($lookup 服务端联法见第 5 章)
db.users.find({ _id: { $in: [authorOid] } }, { username: 1 });// Java Driver 对照
Document author = new Document("username", "tom").append("email", "tom@yjoffer.com");
posts.insertOne(new Document("title", "新文章")
.append("authorId", authorId) // 只存 ObjectId 引用(≈ 外键,但无约束)
.append("tags", List.of("mongodb")));
// 「查文章带作者名」这种需求:先查文章,再批量按 id 捞作者 —— 应用层 JOIN
List<ObjectId> authorIds = ...; // 从文章收集
List<Document> authors = users.find(Filters.in("_id", authorIds))
.projection(Projections.include("username")).into(new ArrayList<>());
// 或者用 $lookup 让服务端联(第 5 章),但热点路径更推荐反范式快照或缓存3.2.3 MySQL 外键的替代语义
MongoDB 没有外键约束:authorId 指向的用户不存在,写照样成功(孤儿文档要应用层/定时任务自查)。三种找回约束感的方式:
$jsonSchema校验字段存在与类型(见 3.5),但校验不了引用完整性;- 应用层规范:写入前校验引用 id 存在,删除时级联清理由代码/作业保证;
- 关键单据类数据直接快照化内嵌——订单里存下单时刻的商品名和价格(对照 MySQL 订单表也会冗余存快照价,这里只是把冗余变成默认姿势)。
3.3 第二问:会长大吗?——16MB 与增长模式
单个文档上限 16MB(对照 MySQL:TEXT 列 64KB / LONGTEXT 4GB,列级限制在这里变成整档限制)。由此推出两条铁律:
- 无界增长的数据必须引用化或分桶;
- 大二进制(图片/视频)不进文档——放 GridFS 或对象存储,文档只存引用。
3.3.1 分桶(Bucket)模式:one-to-many 的中间答案
设备每分钟上报一条温度:全内嵌会爆,全引用查询碎。折中——按固定时间窗打包进数组:
// mongosh:一个文档装一天的 5 分钟粒度数据(12KB 级,远小于 16MB)
db.readings.updateOne(
{ deviceId: devId, date: "2026-10-02", startHour: 10 },
{ $push: { points: { t: 36.5, m: 50 } }, // m = 桶内第几分钟
$inc: { count: 1 } },
{ upsert: true } // 桶不存在就开新桶
);// Java Driver 对照
// readings 集合,一个文档装一天的 5 分钟粒度数据
Document bucket = new Document("deviceId", devId)
.append("date", LocalDate.now().toString())
.append("startHour", 10)
.append("points", IntStream.range(0, 12).mapToObj(i ->
new Document("t", baseTemp + i * 0.1).append("m", i * 5)).toList())
.append("count", 12);
readings.updateOne(
Filters.eq("deviceId", devId).and(Filters.eq("date", today), Filters.eq("startHour", 10)),
new Document("$push", new Document("points", point)).append("$inc", new Document("count", 1)),
new UpdateOptions().upsert(true)); // 桶不存在就开新桶对照 MySQL:这就是「按天/按小时分表」的文档版,只是分桶发生在文档内的数组上。
3.3.2 属性(Attribute)模式:字段名是动态的
IoT 采集器有几千个测点,做成几千个字段 → {"sensors": { "temp_1": 36.5, "temp_2": 36.7, ... }},只查询关心的测点投影(对照 MySQL 只能用 EAV 表或 JSON 列的别扭方案)。
// mongosh:按动态字段名写入(点号路径)并只读关心的测点
db.collectors.updateOne({ deviceId: devId }, { $set: { "sensors.temp_1": 36.5, "sensors.temp_2": 36.7 } });
db.collectors.find({}, { "sensors.temp_1": 1 });// Java Driver 对照
collectors.updateOne(Filters.eq("deviceId", devId),
new Document("$set", new Document("sensors.temp_1", 36.5).append("sensors.temp_2", 36.7)));
collectors.find().projection(Projections.include("sensors.temp_1"));3.4 第三问:怎么被查询?——查询模式驱动建模
建模评审时,先把系统的查询列出来,再对着查询设计文档形状:
| 查询清单(例子) | 建模含义 |
|---|---|
| 「商品详情页:商品+分类名+SKU 列表」 | 三者一起读 → 分类名快照内嵌、SKU 内嵌(一般 < 百级) |
| 「库存独立高频扣减」 | 库存虽属于商品,但高频单独写 → 拆独立集合,防热点文档反复搬迁 |
| 「用户首页:资料+最近 20 条动态」 | 动态引用集合 + 按用户索引,别把动态内嵌进用户 |
| 「统计全站商品销量」 | 明细量大 → 预聚合表思想($group + 物化视图/定时任务) |
高频写 + 高频读同一文档 ≠ 好模型
内嵌让「读一整块」快,但任何一处小更新都要重写整个文档(WiredTiger 文档级搬移)。把「高频单独改的部分」拆出去(如库存、计数)与「一起读的部分」分开(商品详情),是建模的第二层权衡。
3.5 找回 schema 安全感:$jsonSchema 校验
生产团队不接受「随便写」怎么办?给集合加校验器——MySQL 的列定义/约束在文档世界的对应物:
// mongosh 对照:同一个校验器,就是 createCollection 的一个参数
db.createCollection("t_user_validated", {
validator: {
$jsonSchema: {
bsonType: "object",
required: ["username", "age", "status"],
properties: {
username: { bsonType: "string", description: "必填用户名" },
age: { bsonType: "int", minimum: 0, maximum: 150 },
status: { enum: [0, 1, 2] },
tags: { bsonType: "array", items: { bsonType: "string" } }
}
}
},
validationLevel: "strict", // 全部写入都校验
validationAction: "error" // 违规则拒写
});
// 给已存在的集合加校验(对照 ALTER TABLE ADD CONSTRAINT)
db.runCommand({ collMod: "t_user", validator: { $jsonSchema: { /* 同上 */ } } });
// 试写一条非法文档:age 是字符串 → 被拒
db.t_user_validated.insertOne({ username: "x", age: "25", status: 1 }); // DocumentValidationFailure// Java Driver 对照
Document validator = new Document("$jsonSchema", new Document("bsonType", "object"))
.append("required", List.of("username", "age", "status"))
.append("properties", new Document(
"username", new Document("bsonType", "string").append("description", "必填用户名"),
"age", new Document("bsonType", "int").append("minimum", 0).append("maximum", 150),
"status", new Document("enum", List.of(0, 1, 2)),
"tags", new Document("bsonType", "array").append("items",
new Document("bsonType", "string")))));
db.createCollection("t_user_validated", new CreateCollectionOptions().validationOptions(
new ValidationOptions().validator(validator)
.validationAction(ValidationAction.ERROR) // 对照约束:违规则拒写
.validationLevel(ValidationLevel.STRICT)));- 写非法文档直接抛
MongoWriteException; - 校验只约束写入,不自动清洗存量(同 MySQL 加约束要先清数据);
MODERATE级别只校验$set改新结构的写入,老格式更新字段不拦——给存量迁移留通道。
3.6 从 MySQL 表结构翻译过来:实操路线图
手上有一个 MySQL 库要迁 MongoDB,按这五步走(第 8 章再讲数据搬迁工具):
- 列出读路径:每个页面/接口读哪些表?——把「一起读」的表圈成一组;
- 圈 1:1 / 1:N少:组内先做内嵌合并(用户+详情、订单+明细行);
- 识别无界关系:一对多且可能上千 → 引用集合 + 外键字段(如用户→发帖);
- 识别高频单独写的列:拆出去独立集合(库存、状态机字段);
- 定主键策略:沿用业务主键?还是 ObjectId?被分片场景推荐「含哈希的复合键」(第 8 章分片键);对照 AUTO_INCREMENT 的全局唯一诉求,分布式下也可用雪花 ID 字符串存
_id(和本站达梦教程迁移章节的理念一致:应用发号最稳)。
MySQL: t_user(1) ─┬─ t_user_profile(1:1)
├─ t_address(1:N 少)
└─ t_order(1:N 多) ── t_order_item(1:N 少)
MongoDB 翻译后:
users 集合 —— profile 字段内嵌,addresses 数组内嵌
orders 集合 —— items 数组内嵌;user_id 引用;按 user_id+shardKey 设计3.7 本章小结 + 练习
- 建模三问:一起读吗?(内嵌/引用)会长大吗?(16MB/分桶)怎么写?(拆热点)——先问使用,再定形状。
- 无外键约束:引用完整性靠应用规范与快照内嵌补偿;关键单据要「下单时刻」的冗余快照。
$jsonSchema= 文档世界的 DDL:必填/类型/范围/枚举都能找回,存量数据不自动清洗。- MySQL 翻译五步:读路径 → 合并 → 拆无界 → 拆热点 → 定主键。
✍️ 练习(动手建模)
- 把站内 MySQL 课里的「商品-分类-SKU-库存」四表模型翻译成 MongoDB 文档设计,画出你的内嵌/引用决策,并写明每个决策对应的查询清单。
- 用 Java 实现「用户点赞商品」:点赞数用内嵌字段
$inc,点赞人列表超过 1 万时你会怎么改设计?写出两版方案对比。 - 给订单集合写一份
$jsonSchema:必填单号/金额/明细数组,金额枚举币种,明细行含 skuId、数量>0、快照价格。 - (思考题)「文章+评论」内嵌到 500 条时性能开始劣化,你会:a) 继续内嵌到 16MB,b) 分桶,c) 评论独立集合引用——按「列表页只显示前 10 评论 + 分页加载其余」的查询模式给出你的选择和理由。
📖 参考答案
第 1 题:商品-分类-SKU-库存四表翻译
第 1 步,列出输入(MySQL 四表的典型行):
-- t_category: (id=7, name='图书')
-- t_product: (id=1, name='Java实战', category_id=7)
-- t_sku: (product_id=1, spec='平装版', price=99.00), (product_id=1, spec='电子书', price=49.00)
-- t_stock: (product_id=1, warehouse='HZ', qty=100)第 2 步,先写查询清单再定建模(这是本章核心方法论,不是拍脑袋内嵌):
| 查询 | 频率 | 决策含义 |
|---|---|---|
| 详情页:商品+分类名+SKU 列表一起读 | 高频 | 三者「一起读」→ 合并 |
| 下单扣减某 SKU 某仓库存 | 极高并 | 库存单独写 → 拆集合防热点文档搬迁 |
| 分类页:按分类拉商品列表 | 中频 | 分类名已快照内嵌,商品上 category 字段建索引即可 |
第 3 步,输出设计(商品文档 + 独立库存集合):
// t_product:分类名快照内嵌 + SKU 内嵌数组(数量有界,几十级)
{ _id: 1, name: "Java实战", category: 7, categoryName: "图书", // categoryName 冗余快照,改名需刷数据(权衡!)
skus: [ { spec: "平装版", price: NumberDecimal("99.00") },
{ spec: "电子书", price: NumberDecimal("49.00") } ] }
// t_stock:独立集合,「商品×仓」一行,$inc 原子扣减不在商品文档上制造写热点
db.t_stock.updateOne({ productId: 1, warehouse: "HZ" }, { $inc: { qty: -2 } })第 4 步,自检:商品文档会不会无界增长?SKU 有上限吗?(超过百级就要拆独立集合)——把答案写进设计文档。
第 2 题:点赞的两版设计
v1(万级以内,内嵌双写:计数 + 点赞人列表):
products.updateOne(Filters.eq("_id", pid),
new Document("$inc", new Document("likeCount", 1))
.append("$addToSet", new Document("likers", uid))); // addToSet 天然防重复点赞
// 重复点:likeCount 不动?——错!$inc 无条件 +1。防重要把「是否点过」放进条件:
products.updateOne(Filters.and(Filters.eq("_id", pid), Filters.ne("likers", uid)),
new Document("$inc", new Document("likeCount", 1))
.append("$addToSet", new Document("likers", uid)));
// matchedCount=0 即「已经点过」,这就是用条件防重的三板斧姿势(第 6 章)v2(超 1 万——内嵌数组逼近 16MB 且文档搬移成本剧增,拆引用):
// 点赞关系拆独立集合,唯一索引兜底防重
likes.createIndex(new Document("postId",1).append("uid",1), new IndexOptions().unique(true));
likes.insertOne(new Document("postId", pid).append("uid", uid).append("time", new Date())); // 重复插报 DuplicateKey
products.updateOne(Filters.eq("_id", pid), new Document("$inc", new Document("likeCount", 1)));
// 「我点过没有」:likes.find({postId:pid, uid:me}) 走唯一索引,O(logN)
// 「前 50 个点赞头像」:likes.find(...).limit(50) 分页取 uid 再批量捞用户(应用层二查)对比结论:计数留在商品文档($inc 单文档原子),明细列表拆集合——列表无界增长拆出去,计数永远 O(1) 读。
第 3 题:订单集合的 $jsonSchema
第 1 步,建校验集合:
db.createCollection("t_order_validated", {
validator: { $jsonSchema: {
bsonType: "object",
required: ["orderNo", "amount", "currency", "items"],
properties: {
orderNo: { bsonType: "string", pattern: "^[A-Z]{2}\\d{10}$", description: "两位大写字母+10位数字" },
amount: { bsonType: "decimal", minimum: 0 },
currency: { enum: ["CNY", "USD", "EUR"] },
items: {
bsonType: "array", minItems: 1,
items: { // 数组元素自身的校验
bsonType: "object",
required: ["skuId", "qty", "snapshotPrice"],
properties: {
skuId: { bsonType: "string" },
qty: { bsonType: "int", minimum: 1 }, // 数量 > 0
snapshotPrice: { bsonType: "decimal" } // 下单时刻快照价(3.2.3 的冗余快照)
}
}
}
}
}},
validationLevel: "strict", validationAction: "error"
});第 2 步,合法/非法各试一条,验证拦截:
// ✅ 通过
db.t_order_validated.insertOne({ orderNo: "AB2026100001",
amount: NumberDecimal("128.00"), currency: "CNY",
items: [{ skuId: "S001", qty: 2, snapshotPrice: NumberDecimal("64.00") }] });
// ❌ 被拒:qty=0 违反 minimum:1;currency="JPY" 不在 enum;输出报错信息:
// DocumentValidationFailure: Document does not match validator...
// "items.0.qty" : "0 does not satisfy \"minimum\""
db.t_order_validated.insertOne({ orderNo: "AB2026100002",
amount: NumberDecimal("50.00"), currency: "JPY",
items: [{ skuId: "S002", qty: 0, snapshotPrice: NumberDecimal("50.00") }] });第 3 步,Java 侧同构:db.createCollection("t_order_validated", new CreateCollectionOptions().validationOptions(new ValidationOptions().validator(validatorDoc))),非法写入捕获 MongoWriteException 打印 getCodeName() 验证是 DocumentValidationFailure。
第 4 题(思考题)参考思路
查询模式是「列表页只显示前 10 条评论 + 分页加载其余」——推荐 b+c 混合:
- 评论独立集合(c)作为全量存储:
{postId, uid, text, time}+(postId, time)索引,分页加载走它,天然无 16MB/性能天花板; - 文章文档内嵌「最新 10 条快照」(b 的变体):列表页高频「带首屏评论」的读一次拿完,新评论到达时
$push + $slice: 10维持窗口:
db.posts.updateOne({ _id: pid }, {
$push: { topComments: { $each: [newComment], $slice: -10, $sort: { time: -1 } } },
$inc: { commentCount: 1 } })- 为什么不选 a:内嵌到接近 16MB 时每次更新都在搬大文档,性能雪崩;为什么不纯 b:分桶适合「全量都要在文档里」的时序数据,这里只有前 10 条需要陪伴文章,引用集合更干净。本质还是那三问:一起读吗?会长大吗?怎么写?
下一章:索引——你的 MySQL 最左前缀经验在这里大部分能直接提款,但有几个关键差异。
