BSON 与 JSON 对比

候选人小周在面试美团的数据工程师岗位时,面试官问了一个看似简单的问题:

"MongoDB 内部用的是 BSON,而不是普通的 JSON。你知道 BSON 和 JSON 有什么区别吗?"

小周说:"BSON 就是二进制的 JSON 吧?传输效率高一点。"

面试官点点头,又问:"那 BSON 支持哪些 JSON 没有的数据类型?"

小周愣了两秒,说:"呃... Date 类型?"

面试官继续追问:"ObjectId 是什么?为什么不用 UUID?"

小周开始擦汗。

【面试官心理】 这道题我其实在试探候选人对 MongoDB 底层存储的了解程度。知道 BSON 是二进制格式的人很多,但能说出具体支持哪些扩展类型、每种类型的字节开销、以及为什么 MongoDB 选择 BSON 而不是 MessagePack 或 Protocol Buffers 的人,凤毛麟角。能把这一层讲清楚的,基本都有阅读源码或深入调研的经历。

一、BSON 的本质 🔴

1.1 什么是 BSON

BSON(Binary JSON)是一种二进制序列化的 JSON-like 格式,由 MongoDB 团队在 2009 年设计。它的目标是:保留 JSON 的可读性结构,同时增加 JSON 没有的数据类型和更高的序列化效率。

// JSON 格式(文本)
{
  "name": "张三",
  "age": 28,
  "createdAt": "2024-01-01T00:00:00Z"
}

// BSON 格式(二进制,内存中/磁盘上的实际存储形式)
// \x16\x00\x00\x00                    // 总文档大小:22字节
// \x02                               // 类型:字符串
// "name\x00"                         // 字段名 + null 终止符
// \x0e\x00\x00\x00"张三\x00"          // 字符串长度14 + "张三" + null
// \x10                               // 类型:32位整数
// "age\x00"                          // 字段名
// \x1c\x00\x00\x00                   // 值:28
// \x09                               // 类型:UTC日期时间
// "createdAt\x00"                   // 字段名
// \x01\x8b\x1e\xd4\x65\x58\x01\x00   // 值:毫秒时间戳
// \x00                               // 文档结束:null 字节
💡

注意一个关键区别:JSON 是文本格式,BSON 是二进制格式。但这不代表 BSON 不可读——它的结构设计使得 C 语言可以 memcpy 直接复制数据块,而不需要像解析 JSON 那样逐字符扫描和类型转换。

1.2 BSON vs JSON 核心对比

维度JSONBSON
格式文本(UTF-8 字符串)二进制
整数类型只有 number(IEEE 754 浮点)int32int64 分离
日期类型字符串 "2024-01-01"原生 8 字节毫秒时间戳
ObjectId不支持原生 12 字节
二进制数据Base64 编码字符串原生 binary 类型
未定义/Nullnullnull + undefined
正则表达式字符串原生 regex 类型
JavaScript 代码不支持$code 类型
文档大小紧凑(文本)略大(类型前缀+长度字段)
解析速度慢(字符串解析)快(二进制读取)

1.3 ❌ 错误示范

候选人原话:"BSON 就是把 JSON 压缩了一下,节省空间。"

问题诊断

  • 完全误解了 BSON 的设计目标——BSON 不是为了省空间,反而通常比 JSON 更大(每个字段都有类型字节和长度前缀)
  • 没有理解 BSON 的核心价值是"类型系统"而不是"压缩"
  • 混淆了 BSON 和二进制 JSON 压缩包的概念

面试官内心 OS:"这个候选人八成是从某篇博客上看到'BSON 是二进制 JSON'这个结论,没有深入理解。问他 BSON 比 JSON 大还是小,他答不上来;问他 BSON 的类型头占几个字节,他更不知道。"

1.4 标准回答

// P5 级别:知道 BSON 是二进制格式
"BSON 是 Binary JSON,MongoDB 内部用二进制格式存储数据,
相比文本 JSONBSON 的解析速度更快,因为不需要做字符串解析。"

// P6 级别:能说出 BSON 的扩展类型
"BSON 相比 JSON 支持了更多数据类型:ObjectId(12字节唯一ID)、
Date(8字节毫秒时间戳)、Binary Data(二进制数据)、
Int32/Int64(精确整数)、Decimal128(高精度金融计算)、
正则表达式、JS 代码等。这些类型在 MongoDB 查询和排序中
可以直接比较,不需要转换。"

// P7 级别:能解释设计权衡
"BSON 的设计是在'类型丰富度'和'存储效率'之间做权衡。
每个字段都有 1 字节类型 + 字段名(带 null 终止符)+ 长度前缀,
所以文档会比等效 JSON 略大。但换来的是:
第一,类型信息内嵌,不需要像 JSON 那样所有整数都用浮点表示导致精度损失;
第二,二进制读取可以直接 `memcpy`,避免了字符串解析的 CPU 开销;
第三,支持原地更新字段,不需要像 JSON 那样解析后重建整个文档。"

【面试官心理】 这道题我追问的方向通常是:先问"BSON 比 JSON 大还是小"(很多人会答错),然后问"ObjectId 是什么结构",再问"为什么 MongoDB 不用 UUID"。能答到第二层的说明看过文档;能答到第三层说明对分布式 ID 有深入理解。

二、BSON 数据类型详解 🟡

2.1 ObjectId:MongoDB 的默认主键

很多候选人只知道 ObjectId 是 MongoDB 的默认 _id,但被追问具体结构就卡壳了。

// ObjectId 是 12 字节的二进制数据,分为 4 部分
// 构造过程:
const { ObjectId } = require('mongodb');
const id = new ObjectId();
// 等价于手动构造:
const timestamp = Math.floor(Date.now() / 1000);  // 4 字节:Unix 时间戳
const machineId = 0x1a2b3c;                         // 3 字节:机器标识
const processId = 1234 % 0xFFFF;                   // 2 字节:进程ID
const counter = Math.floor(Math.random() * 0xFFFFFF); // 3 字节:递增计数器

// ObjectId 的优势:
// 1. 时间有序:前 4 字节是时间戳,ObjectId 自然按时间排序
// 2. 可嵌入:12 字节比 UUID 的 16 字节更紧凑
// 3. 可读性:ObjectId() 构造函数可以直接从字符串还原
//    ObjectId("507f1f77bcf86cd799439011")  // 可还原时间戳
⚠️

ObjectId 的"时间有序"特性是一把双刃剑。在分布式环境中,如果多个应用实例同时生成 ObjectId,后插入的 ObjectId 理论上比先插入的大(因为计数器递增)。但这不意味着 ObjectId 是全局有序的——在同一毫秒内,多个进程的 ObjectId 顺序取决于机器ID和计数器,存在冲突可能。如果需要严格全局有序,应该用 findAndModify + 外部序列号。

2.2 日期类型:Date vs Timestamp

BSON 提供了两种日期相关类型,但很多候选人分不清区别:

// Date 类型(BSON type 9):UTC 日期时间,8 字节毫秒时间戳
{
  createdAt: ISODate("2024-01-01T00:00:00.000Z")
}
// JavaScript Date 对象使用毫秒精度,可以表示 -2^63 到 2^63-1 的范围

// Timestamp 类型(BSON type 17):内部使用,2 个 32 位无符号整数
{
  ts: Timestamp(1234567890, 1)  // 第一个是 Unix 秒,第二个是递增序号
}
// Timestamp 主要用于 oplog,不应该用于业务数据
💡

面试中经常问到的一个陷阱:ISODatenew Date() 的区别。在 MongoDB shell 中,两者都会创建一个 BSON Date 类型。但 new Date() 会返回带时区的字符串表示,而 ISODate() 返回精确的 UTC 时间戳。在聚合查询中做日期比较时,用毫秒时间戳比较比字符串比较效率高得多。

2.3 整数类型:Int32 vs Int64 vs Decimal128

JSON 的 number 类型对应 IEEE 754 浮点数,存在精度问题。BSON 分离了整数和浮点:

// Int32(4字节):-2,147,483,648 到 2,147,483,647
{ status: NumberInt(200) }

// Int64(8字节):-9,223,372,036,854,775,808 到 9,223,372,036,854,775,807
{ bigCounter: NumberLong("9223372036854775807") }

// Decimal128(16字节):金融级精度,128位十进制浮点
{ price: NumberDecimal("999.99") }

// ⚠️ 精度陷阱演示:
db.orders.insertOne({
  total: 0.1 + 0.2,           // JavaScript 浮点:0.30000000000000004
  totalDecimal: NumberDecimal("0.1").add(NumberDecimal("0.2"))  // 正确
})
⚠️

MongoDB 默认的 Number 类型在 JavaScript 中映射为 64 位浮点数(Double),可能导致精度损失。金融、订单金额等场景必须使用 NumberDecimalNumberLong。我面试过太多候选人在这个问题上翻车——他们知道浮点精度问题,但不知道 MongoDB 的具体数据类型叫什么。

2.4 二进制与 JS 代码类型

// Binary Data(BSON type 5):存储任意二进制数据
{
  fileId: ObjectId("..."),
  fileData: BinData(0, "base64编码的二进制..."),  // 第一个参数是 subtype
  avatar: BinData(0, Buffer.from("二进制内容"))   // Node.js 用法
}

// JS 代码(BSON type 13):存储可执行的 JavaScript 代码
{
  $where: function() { return this.score > 80; }  // 不推荐,效率低
}

// JS 作用域(BSON type 15):带作用域的 JS 代码
// 用于存储带参数的存储过程
📖 点击展开 BSON 规范中的所有类型码
类型码类型名字节大小说明
0x01double864位 IEEE 754 浮点
0x02string变长UTF-8 字符串
0x03object变长嵌套文档
0x04array变长数组
0x05binary变长二进制数据
0x07objectId12ObjectId
0x08boolean1true/false
0x09UTC datetime8UTC 毫秒时间戳
0x10int32432位有符号整数
0x11timestamp8MongoDB 内部时间戳
0x12int64864位有符号整数
0x13decimal12816128位十进制浮点
0xFFminKey1小于所有其他值
0x7FmaxKey1大于所有其他值

三、面试追问链

3.1 追问一:为什么 MongoDB 选择 BSON 而不是 Protocol Buffers?

这是 P7 级别的深度追问:

标准回答: "MongoDB 和 Protocol Buffers 的设计目标不同。Protocol Buffers 是为了跨语言的接口定义和高效序列化(通常用于 RPC 和消息队列),它需要预先定义 schema(.proto 文件),编译时生成代码。MongoDB 的设计目标是:灵活的数据模型 + 动态 schema + 人类可读的存储格式。BSON 的优势是: schema-less,不需要预定义;文本可读,bsondump 可以直接查看原始数据;支持原地字段更新,不需要像 PB 那样解析整个消息才能修改一个字段。代价是空间效率不如 PB。"

3.2 追问二:ObjectId vs UUID 哪个更好?

标准回答: "ObjectId 的优势是时间有序(查询 _id 时天然按时间排序)、体积更小(12字节 vs 16字节)、可读性好(ObjectId() 可还原时间戳)。UUID 的优势是全局唯一性保证更强(UUID 的冲突概率远低于 ObjectId 的计数器方案)、不暴露时间戳信息(ObjectId 的前 4 字节是时间戳,理论上可以反推数据创建时间)。在 MongoDB 内部,ObjectId 是默认选择;但在跨系统数据交换场景,UUID 更通用。"

3.3 追问三:BSON 文档的 16MB 限制是怎么来的?

这个问题测试候选人对 MongoDB 内部机制的了解:

"16MB 限制的主要考量是:第一,内存压力——MongoDB 服务端在处理查询时需要将整个文档加载到内存,太大的文档会导致 OOM;第二,复制和分片——oplog 和 chunk 迁移都需要操作整个文档,16MB 是性能和功能的一个平衡点;第三,索引大小——MongoDB 的索引是 B-Tree,如果文档太大,索引效率会下降。实际上,16MB 不是一个技术上无法突破的限制(你可以用 GridFS 存更大的文件),而是一个设计上的边界——超过这个大小的数据通常应该考虑是否适合用文档数据库存储。"

【面试官心理】 这三个追问分别测试了候选人对序列化格式选型的理解(架构能力)、对分布式 ID 设计的理解(工程经验)、以及对 MongoDB 内部机制的理解(源码深度)。能答到第二层的已经是 P6 了,能答到第三层的 P7 候选人也不多见。

四、生产避坑

4.1 场景:时间字段存储导致的排序错误

某团队用 MongoDB 存储用户操作日志,字段类型是字符串而非 Date:

// 错误存储方式
db.logs.insertOne({
  action: "click",
  timestamp: new Date().toISOString()  // "2024-01-01T00:00:00.000Z"
})

// 查询:按时间排序
db.logs.find().sort({ timestamp: 1 })  // ❌ 按字符串排序,结果错误
// "2024-01-10" 会排在 "2024-01-02" 前面(字典序)
// 正确存储方式
db.logs.insertOne({
  action: "click",
  timestamp: new Date()  // ISODate 类型
})

db.logs.find().sort({ timestamp: 1 })  // ✅ 按时间戳排序,正确

4.2 场景:整数精度丢失

// JavaScript 精度陷阱
db.products.insertOne({
  _id: 9223372036854775807,  // JavaScript 最大安全整数
  name: "测试商品"
})

db.products.findOne({ _id: 9223372036854775807 })  // ❌ 可能找不到
// 因为 MongoDB 驱动在某些情况下会把 Int64 转为 JavaScript Number

// 正确做法
db.products.insertOne({
  _id: Long("9223372036854775807"),  // 使用 Long 类型
  name: "测试商品"
})

【面试官心理】 BSON 类型系统看似简单,但生产环境中因为类型选择错误导致的 bug 比想象中多得多。面试中问这类问题,我是想看候选人有没有被这些坑"毒打"过的经历。能讲出具体案例和排查过程的,都是加分项。