2. MongoDB文档模型与BSON

MongoDB 的数据组织方式与关系型数据库有本质差异。理解这种差异,不能停留在“它存 JSON”这种表面说法上,而要从 BSON 这一底层二进制编码格式出发,逐层看清文档、集合、数据库的边界,以及字段类型、内嵌文档、数组、ObjectId 在工程实践中真正意味着什么。

一、从 JSON 到 BSON:为什么不是纯文本

MongoDB 对外呈现的是类似 JSON 的文档结构,但磁盘和网络传输中实际使用的是 BSON(Binary JSON)。BSON 在 JSON 的基础上做了三件关键的事:一是把类型信息编码进二进制,使解析器无需猜测字段类型;二是扩展了 JSON 不具备的类型,如 ObjectId、Date、Binary、Decimal128、Timestamp;三是每个文档头部带有长度前缀,使服务端可以快速跳过或定位文档,而不必完整解析。

这意味着一个看似普通的字段 { "price": 19.9 },在 BSON 中会带上类型字节和长度信息。工程上的直接后果是:字段类型一旦写入就固定下来,后续查询若用不同类型匹配,可能命中不了。例如用字符串 "19.9" 去查数值 19.9,在严格类型匹配下不会返回结果。这是很多初学者排查“数据明明存在却查不到”问题的根源。

二、文档、集合、数据库的层级边界

MongoDB 的层级是:数据库(Database)→ 集合(Collection)→ 文档(Document)。数据库是命名空间和权限的边界;集合是文档的容器;文档是数据的最小完整单元。

与关系型数据库最容易被误解的一点是:集合不强制 schema。同一个集合里的文档可以有完全不同的字段。这带来灵活性,也带来风险——如果应用层不加以约束,集合会逐渐演变成字段混乱的“垃圾场”。工程实践中通常用 Mongoose、MongoDB Schema Validation 或应用层 DTO 来补上这层约束。

另一个关键点是文档大小上限:单个 BSON 文档默认不能超过 16MB。这决定了内嵌文档和数组不能无限增长。当子数据可能持续膨胀时,应改为独立集合并用引用关联,而不是硬塞进一个文档。

数据库 shopdb 命名空间 / 权限边界 集合 users 无强制 schema · 文档可异构 文档 1 { _id: ObjectId("...a1"), name: "张伟", age: 32, tags: ["vip","new"], addr: { city: "北京" } } 文档 2(异构) { _id: ObjectId("...b2"), name: "李娜", email: "li@x.com", score: NumberDecimal("88.5") } 集合 orders 内嵌数组 + 引用关联 订单文档 { _id: ObjectId("...c3"), userId: ObjectId("...a1"), items: [ { sku:"A1", qty:2, price:19.9 }, { sku:"B7", qty:1, price:45.0 } ], total: NumberDecimal("84.8"), createdAt: ISODate("2025-01-15") } ⚠ 单文档上限 16MB 数组/内嵌文档持续膨胀时 应拆分为独立集合 + 引用

三、字段类型与工程陷阱

BSON 支持的类型远多于 JSON。工程中最需要警惕的几类:

  • 数值类型:BSON 有 int32、int64、double、Decimal128。金额类字段若用 double,会出现浮点精度问题,应使用 Decimal128。例如 NumberDecimal("19.9") 才能精确表示。
  • Date:BSON Date 存储的是 UTC 毫秒时间戳,不带时区。应用层负责时区转换,否则跨时区查询会出现偏差。
  • ObjectId:12 字节,包含 4 字节时间戳 + 5 字节随机值 + 3 字节递增计数器。它大致有序,因此常被用作默认主键,兼具唯一性和插入顺序的近似性。
  • Null 与缺失字段:{ a: null } 和 {}(无 a 字段)在查询 { a: null } 时都会命中,这是容易误判的地方。

四、内嵌文档与数组:建模的核心决策

MongoDB 建模的核心问题不是“怎么建表”,而是“什么该内嵌,什么该引用”。判断依据主要有三条:

  1. 访问模式:如果子数据总是随父数据一起读取,内嵌更优,一次查询即可返回完整对象。
  2. 增长性:如果子数据会无限增长(如日志、评论),内嵌会导致文档逼近 16MB 上限,应改为独立集合。
  3. 独立性:如果子数据需要被独立查询、更新或跨父级共享,应使用引用。

数组在查询中有特殊语义。例如 { tags: "vip" } 会匹配 tags 数组中包含 "vip" 的文档;而 { "items.sku": "A1" } 会匹配 items 数组中任一元素的 sku 为 "A1" 的文档。若需要同时满足同一数组元素内的多个条件,必须使用 $elemMatch,否则条件可能落在不同元素上,造成误匹配。

内嵌文档适合“一对一”或“一对少量”且访问模式固定的场景;数组适合“一对多”但数量可控的场景;引用适合“一对多且持续增长”或“多对多”的场景。三者不是互斥的,同一集合中可以混合使用。

五、ObjectId 的工程含义

ObjectId 常被当作“自动生成的字符串 ID”,但它有明确的工程属性。它的前 4 字节是创建时间戳,因此可以通过 ObjectId("...").getTimestamp() 反推文档创建时间,无需额外字段。它的近似有序性使得按 _id 排序大致等价于按插入时间排序,这对分页和范围查询有实际价值。但它不是严格单调的,不能替代业务时间字段做精确排序。

在分布式系统中,ObjectId 由客户端生成,不依赖服务端自增,因此天然适合分片和并发写入。这是它相比自增主键的重要优势。

六、BSON 的二进制特性对工程的影响

BSON 是二进制格式,这意味着它不能像 JSON 那样直接用文本编辑器查看。调试时需要借助 mongosh、Compass 或驱动程序的序列化工具。同时,BSON 的字段名会重复存储在每个文档中,字段名过长会显著增加存储开销。工程上建议字段名简洁但可读,避免用超长命名。

另一个容易被忽略的点是:BSON 文档的字段顺序在写入时被保留,但查询和更新不保证顺序。依赖字段顺序做逻辑判断是不可靠的。

不要用 double 存储金额、不要用字符串存储日期、不要用无界数组内嵌持续增长的数据、不要依赖字段顺序。这四点是 MongoDB 文档建模中最常见的工程事故来源。

总结来看,MongoDB 的文档模型不是“把表换成 JSON”那么简单。BSON 的类型系统、16MB 文档上限、ObjectId 的生成机制、内嵌与引用的取舍,共同构成了一套需要主动设计的工程约束。理解这些约束,才能在灵活性和可控性之间找到平衡点。