Chapter 01
文档模型与 BSON
起点页给了一句话本质:"schema 是访问模式的函数"。这章把这句话落到具体形状上——文档、集合、BSON、_id、16MB 上限——并讲清这一条如何反转关系型工程师的范式化本能。
本章你将建立的 schema
- 文档 / 集合 / BSON 是什么,和"行 / 表 / JSON"差在哪个机制点上。
_id的三条硬规则:强制、不可变、自动唯一索引;ObjectId 为什么能客户端生成。- 16MB 文档上限不是限制,是建模信号——它在告诉你访问模式何时错了。
- "为访问模式建模"如何把建模方向从"结构 → 查询"翻转成"查询 → 结构"。
1.1文档与集合:BSON,不是 JSON
文档是一条 BSON 记录,集合是一组文档;集合不强制所有文档同构。
关系型把一个实体拆到多张表,靠外键和 JOIN 在查询时重新拼装。文档模型允许把"总是一起被使用的数据"放进一条记录,读取时不必重组。代价转移了——不是消失了——后面几节会逐一还账。
底层机制(比文档深一层):文档不是以文本 JSON 存储,而是以 BSON(Binary JSON)存储。差别是机制性的:BSON 给每个字段带上类型标记和长度前缀,所以引擎能在不解析整个文档的前提下跳过某个字段、直接定位到一个嵌套路径。这也是为什么 BSON 能表达 JSON 没有的类型——ObjectId、Decimal128(精确十进制,给钱用)、Date(64 位毫秒)、Binary、区分 32/64 位整数。它的代价同样是机制性的:BSON 每个文档都各自存一份字段名(没有表头共享字段名),所以同样的数据,文档存储通常比规范化的行占更多空间,短字段名能省下可观的量。
文档像一份排好版的"订单详情打印页"——所有相关信息已经摆在一页上。关系型像把同样信息拆进档案柜的不同抽屉,看一次详情要开好几个抽屉。类比失效处:文档不是冻结的纸,你可以原子地只更新其中一个字段($set 单字段),不必重写整页。
下面是同一个"订单详情"在两种模型里的样子。先看摆放,再看读取代价。
// 一条文档就装下了关系型里要跨 3 张表的数据
db.orders.insertOne({
_id: ObjectId("665f1a2b3c4d5e6f70819200"),
total: 248.00, // Decimal 应该用 NumberDecimal,演示从简
status: "paid",
customer: { name: "Lin", tier: "gold" }, // 内嵌子文档
items: [ // 内嵌数组
{ sku: "A-12", qty: 1, price: 199.0 },
{ sku: "B-07", qty: 1, price: 49.0 }
]
})
// 读详情:一次按 _id 命中,无 JOIN
db.orders.findOne({ _id: ObjectId("665f1a2b3c4d5e6f70819200") })
如果把"该用户的全部历史订单"也嵌进 customer 里,随着用户不断下单,这个文档会发生什么?
展开答案(先停 10 秒)
文档会无界增长,逐步逼近 16MB 上限;更糟的是,每次读这个用户(哪怕只想看名字)都会把全部历史订单一起载入内存。这正是经典反模式——嵌入只适合有界的、总是一起读的数据。历史订单数量无界、且常被独立查询,应当引用而非嵌入(§1.4 给判据,03 章给完整决策)。
1.2_id 与 ObjectId
每个文档必须有唯一且不可变的 _id;省略时引擎自动生成一个 12 字节 ObjectId。
_id 是主键,且自动带一个唯一索引(你删不掉这个索引)。它要解决的问题和关系型主键一样——唯一标识一行——但多了一个分布式诉求:在没有中心协调者的情况下,多个客户端能各自生成不冲突的 ID。
底层机制(比文档深一层):默认的 ObjectId 是 12 字节,结构是 4 字节时间戳 + 5 字节随机值 + 3 字节自增计数器。关键在于:这 12 字节由客户端驱动生成,不需要往返数据库去取一个自增序号(对比 MySQL AUTO_INCREMENT 要数据库分配、或 Postgres sequence 要中心计数器)。代价藏在第一段:因为前 4 字节是时间戳,ObjectId 大致随生成时间单调递增。这带来一个好处和一个陷阱——好处是按 _id 排序近似按时间排序;陷阱是如果直接拿 ObjectId 当 shard key,新写入会全落到"最大值"所在的那个分片,造成热点(06 章会还这笔账)。
两个问题:(a) 已经写入的文档,能改它的 _id 吗?(b) 两个不同集合里能存在相同的 _id 吗?
展开答案
(a) 不能。_id 不可变;要"改"只能删除旧文档再插入新的。(b) 能。唯一性约束是集合级的,每个集合是独立命名空间,users 和 orders 各有一个 _id: 1 互不冲突。
1.316MB 上限:不是限制,是信号
单个 BSON 文档最大 16MB;与其把它当成需要绕过的限制,不如当成建模出错的报警。
这个上限防止单文档无界增长拖垮两个地方:内存(WiredTiger 以整个文档为载入单位,04 章)和网络传输。它不是任意拍脑袋的数字,而是一道护栏。
底层机制(比文档深一层):MongoDB 读写的最小单位是整个文档,不是文档里的某个片段。一次 findOne 把整文档读进 WiredTiger cache;一次更新(哪怕只改一个字段)在存储层也要重写整个文档版本(04 章的 MVCC 会讲)。所以一个臃肿的大文档被频繁读取时,会把 cache 里的热数据挤出去,引发更多磁盘读。由此,16MB 上限的真正含义是一句话:当你的文档在逼近 16MB,你的访问模式已经错了——几乎总是因为把一个无界数组(评论流、事件日志、历史记录)嵌进了文档。需要存超大二进制(视频、大文件)时用 GridFS 把它分块成多条文档,而不是硬塞。
"反正 16MB 很大,先嵌着,撞上限再说"——这是把一个建模信号当噪音忽略。等真撞上限时,往往已经有大量超大文档拖垮了 cache 命中率,性能问题在撞上限之前很久就出现了。把它当成"数组该有界"的早期提醒,而不是终点线。
一个 IoT 设备每秒产生一条读数,你把读数嵌进设备文档的一个数组里。假设每条读数约 200 字节,大约多久撞上 16MB?这说明什么?
展开答案
16MB ÷ 200B ≈ 8 万条,每秒一条约 22 小时就撞上限。结论不是"调大上限"(不能调),而是"设备 + 时间窗才是正确的文档粒度"——把读数按小时/天分桶存成多条文档(这正是 03 章的 Bucket 设计模式,也是时序集合背后的思路)。无界的时间序列从来不该整段嵌进一个文档。
1.4为访问模式建模:方向翻转
先确定查询怎么读,再决定文档怎么放——schema 是访问模式的函数,这是全教程最该先内化的一条。
这是从关系型迁移过来最大的认知翻转,也是最容易"读着顺、用着错"的地方。范式化(3NF)优化的目标是写入与一致性:每个事实只存一处,更新无需同步多处。文档建模优化的目标是读取:一起读的数据放一起,读取无需重组。两者不是"谁更先进",是优化目标不同。
底层机制(比文档深一层):为什么不能沿用关系型的"先规范结构、查询时再 JOIN"?因为 MongoDB 没有关系型那种高效的跨表 JOIN——$lookup 本质是嵌套循环(02 章会拆开看),数据量一大代价陡增。既然查询期拼装很贵,就把拼装提前到建模期:列出最高频的几个查询 → 看哪些数据总是被一起读 → 把它们嵌进同一个文档。代价诚实地摆在这里——反范式带来冗余和更新放大(同一个事实存在多个文档里,更新要改多处)。你是在用"写入复杂度"换"读取简单度"。值不值,取决于读写比和访问模式是否稳定。
把这条原则压成一个可操作的判断序列,遇到"这个字段放哪"时走一遍:
| 问自己 | 倾向嵌入 | 倾向引用 |
|---|---|---|
| 这块数据和主体总是一起被读吗? | 是 → 嵌入 | 否,常被单独查 → 引用 |
| 它会无界增长吗? | 有界(如地址、几个标签)→ 嵌入 | 无界(评论、订单、事件)→ 引用 |
| 它被多个不同实体共享吗? | 否,专属于主体 → 嵌入 | 是(如商品被多订单引用)→ 引用 |
| 它和主体的写入时机一致吗? | 一起写 → 嵌入 | 各自独立高频写 → 引用 |
一个博客系统:文章(post)有作者(author)和评论(comments)。按上表,author 和 comments 各该嵌入还是引用?
展开答案
author 倾向引用:一个作者被很多文章共享(共享判据命中),且作者资料独立更新;通常存 authorId,需要时再查。但若只展示"作者名"且几乎不变,把 {name} 冗余嵌进 post 也合理(用读取简单度换一点冗余)——这正是"看访问模式"而非"看数据关系"。comments 看量级:评论无界增长(无界判据命中),且常分页独立加载,倾向引用到单独集合;只有"最多几条、总和文章一起显示"的场景才嵌入。注意这两个判断都没有唯一答案——它们取决于你的读法,不取决于数据本身。这正是为访问模式建模的含义。
§本章 self-check
先合上教程,把答案写在纸上或编辑器里。写完再点开对照——直接点开等于把这一节当再读一遍。
- BSON 相比文本 JSON,多了哪一类机制性能力?举出它带来的一个具体好处和一个具体代价。
_id的三条硬规则是什么?ObjectId 的 12 字节为什么能在客户端生成而不必访问数据库?- 16MB 上限"是信号不是限制"——这句话具体在提醒你检查什么?
- (设计题)一句话说清:为什么把"先规范结构、查询时 JOIN"的关系型策略直接搬到 MongoDB 会代价高昂?
答案(先做完再展开)
- 多了带类型与长度前缀的二进制字段编码(以及 ObjectId/Decimal128/Date/Binary 等类型)。好处:能不解析整文档就跳过字段、定位嵌套路径,并精确表达十进制金额。代价:每文档各存一份字段名,存储通常比规范化行更占空间,短字段名更省。
- 强制存在、不可变、自带唯一索引。ObjectId = 4B 时间戳 + 5B 随机 + 3B 计数器,三段都能在客户端本地算出,无需向数据库申请自增序号,因此天然适合分布式无协调生成。
- 提醒你检查:是不是把一个无界增长的数组嵌进了文档。逼近上限几乎总意味着访问模式选错了粒度,该改成分桶/引用。
- 因为 MongoDB 的跨文档
$lookup是嵌套循环、没有关系型那种高效 JOIN;查询期拼装很贵,所以要把拼装提前到建模期(嵌入),否则就是在最贵的路径上反复付费。
给一个二手交易 App 设计 listing(商品)文档
需求:商品列表页要展示标题、价格、卖家昵称和头像;商品详情页额外展示卖家信用分、最近 3 条买家评价、以及该商品的全部问答(数量无界,常被翻页加载)。先不查任何资料,写出你的文档结构,并对每个"嵌入还是引用"的决策标注判据。
提示(卡住再展开)
分三类想:(1) 卖家昵称+头像——列表页高频读、几乎不变 → 适合冗余嵌入少量字段(用一点冗余换列表页零 JOIN);(2) 卖家信用分——会变、且多个商品共享同一卖家 → 引用,详情页再查;(3) 问答——无界增长 + 翻页独立加载 → 一定引用到单独集合,按 listingId 关联。"最近 3 条评价"是个有趣的中间态:可以用 Subset 模式冗余存最近 3 条、其余引用(03 章)。