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 单字段),不必重写整页。

下面是同一个"订单详情"在两种模型里的样子。先看摆放,再看读取代价。

关系型:结构决定摆放 文档:访问决定摆放 users id · name orders id · user_id order_items order_id · sku user_id order_id 一次详情查询 = 3-way JOIN order 文档 _id · total · status customer: { name, tier } items: [ {sku,qty}, {sku,qty} ] ↑ 内嵌数组,随文档一起读出 一次详情查询 = 按 _id 单次读
图 1.1同一份订单详情的两种摆法。注意:右侧不是"把三张表塞进一个字段",而是按"详情页总是一起读 customer 和 items"这个访问事实,把它们预拼进一个文档——摆放是被查询决定的。
order-as-document mongosh
// 一条文档就装下了关系型里要跨 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 章会还这笔账)。

ObjectId = 12 字节 = 4 + 5 + 3 时间戳 4 字节 · 秒级 随机值 5 字节 · 机器+进程 计数器 3 字节 · 自增 前 4 字节让 ObjectId 近似按时间递增 → 可排序,但直接做 shard key = 热点(06 章)
图 1.2ObjectId 的字节布局。注意:被高亮的是头部时间戳——它既是"客户端无协调生成又能粗略排序"的来源,也是单调递增 shard key 陷阱的根源。同一个设计,一面是优点一面是代价。
想一想

两个问题:(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 章会拆开看),数据量一大代价陡增。既然查询期拼装很贵,就把拼装提前到建模期:列出最高频的几个查询 → 看哪些数据总是被一起读 → 把它们嵌进同一个文档。代价诚实地摆在这里——反范式带来冗余和更新放大(同一个事实存在多个文档里,更新要改多处)。你是在用"写入复杂度"换"读取简单度"。值不值,取决于读写比和访问模式是否稳定。

关系型 文档 数据逻辑结构 范式化的表 查询时 JOIN 拼装 范式化 查询时 访问模式 / 查询 文档形状 单次读取命中 决定 预先拼好
图 1.3建模方向的翻转。注意:两条泳道的起点不同——关系型从"数据本身长什么样"起步,文档从"查询要怎么读"起步。被高亮的左下角"访问模式"是整条链的源头,这就是"为访问模式建模"的字面意思。

把这条原则压成一个可操作的判断序列,遇到"这个字段放哪"时走一遍:

表 1.1 · 嵌入 vs 引用的初判(03 章给完整版)
问自己倾向嵌入倾向引用
这块数据和主体总是一起被读吗?是 → 嵌入否,常被单独查 → 引用
它会无界增长吗?有界(如地址、几个标签)→ 嵌入无界(评论、订单、事件)→ 引用
它被多个不同实体共享吗?否,专属于主体 → 嵌入是(如商品被多订单引用)→ 引用
它和主体的写入时机一致吗?一起写 → 嵌入各自独立高频写 → 引用
想一想

一个博客系统:文章(post)有作者(author)和评论(comments)。按上表,author 和 comments 各该嵌入还是引用?

展开答案

author 倾向引用:一个作者被很多文章共享(共享判据命中),且作者资料独立更新;通常存 authorId,需要时再查。但若只展示"作者名"且几乎不变,把 {name} 冗余嵌进 post 也合理(用读取简单度换一点冗余)——这正是"看访问模式"而非"看数据关系"。comments 看量级:评论无界增长(无界判据命中),且常分页独立加载,倾向引用到单独集合;只有"最多几条、总和文章一起显示"的场景才嵌入。注意这两个判断都没有唯一答案——它们取决于你的读法,不取决于数据本身。这正是为访问模式建模的含义。

§本章 self-check

先合上教程,把答案写在纸上或编辑器里。写完再点开对照——直接点开等于把这一节当再读一遍。

  1. BSON 相比文本 JSON,多了哪一类机制性能力?举出它带来的一个具体好处和一个具体代价。
  2. _id 的三条硬规则是什么?ObjectId 的 12 字节为什么能在客户端生成而不必访问数据库?
  3. 16MB 上限"是信号不是限制"——这句话具体在提醒你检查什么?
  4. (设计题)一句话说清:为什么把"先规范结构、查询时 JOIN"的关系型策略直接搬到 MongoDB 会代价高昂?
答案(先做完再展开)
  1. 多了带类型与长度前缀的二进制字段编码(以及 ObjectId/Decimal128/Date/Binary 等类型)。好处:能不解析整文档就跳过字段、定位嵌套路径,并精确表达十进制金额。代价:每文档各存一份字段名,存储通常比规范化行更占空间,短字段名更省。
  2. 强制存在、不可变、自带唯一索引。ObjectId = 4B 时间戳 + 5B 随机 + 3B 计数器,三段都能在客户端本地算出,无需向数据库申请自增序号,因此天然适合分布式无协调生成。
  3. 提醒你检查:是不是把一个无界增长的数组嵌进了文档。逼近上限几乎总意味着访问模式选错了粒度,该改成分桶/引用。
  4. 因为 MongoDB 的跨文档 $lookup 是嵌套循环、没有关系型那种高效 JOIN;查询期拼装很贵,所以要把拼装提前到建模期(嵌入),否则就是在最贵的路径上反复付费。
进阶挑战 · 刚好够不着

给一个二手交易 App 设计 listing(商品)文档

需求:商品列表页要展示标题、价格、卖家昵称和头像;商品详情页额外展示卖家信用分、最近 3 条买家评价、以及该商品的全部问答(数量无界,常被翻页加载)。先不查任何资料,写出你的文档结构,并对每个"嵌入还是引用"的决策标注判据。

提示(卡住再展开)

分三类想:(1) 卖家昵称+头像——列表页高频读、几乎不变 → 适合冗余嵌入少量字段(用一点冗余换列表页零 JOIN);(2) 卖家信用分——会变、且多个商品共享同一卖家 → 引用,详情页再查;(3) 问答——无界增长 + 翻页独立加载 → 一定引用到单独集合,按 listingId 关联。"最近 3 条评价"是个有趣的中间态:可以用 Subset 模式冗余存最近 3 条、其余引用(03 章)。