JSON 是通用数据交换格式,但 MongoDB 用 BSON。为什么不用 JSON —— 类型不够、精度不够、解析不够快。这篇讲清楚 BSON 比 JSON 多了什么,以及几个 MongoDB 专属类型的工程价值。
BSON 是什么
Binary JSON —— MongoDB 在 2009 年定义的二进制数据格式,长这样:
[文档总长 4 字节][字段 1][字段 2]...[字段 N][\0]
每个字段:
[类型 1 字节][字段名 C 字符串][字段值]
例:{"hello": "world"} 编码:
1B 00 00 00 # 文档总长 27 字节
02 # 类型 2 = string
68 65 6C 6C 6F 00 # 字段名 "hello\0"
06 00 00 00 # 字符串长度 6(含末尾 \0)
77 6F 72 6C 64 00 # "world\0"
00 # 文档结束
关键设计:
- 长度前缀 —— 跳过任意字段不用扫描分隔符
- 每字段自带类型 —— 不依赖 schema
- 字段名是 C 字符串 ——
\0结尾,C/C++ 友好 - 小端字节序 —— x86/ARM 直接 memcpy
BSON 比 JSON 多的类型
JSON 只有 6 种类型,BSON 有 18+:
| BSON 类型 | JSON 等价物 | 解决了什么 |
|---|---|---|
| Double | number | (JSON 共用) |
| String | string | (JSON 共用) |
| Object | object | (JSON 共用) |
| Array | array | (JSON 共用) |
| Binary | base64 string | 存二进制不用 base64 膨胀 33% |
| ObjectId | hex string | 12 字节天然有序 ID |
| Boolean | boolean | (JSON 共用) |
| Date | ISO 字符串 | 类型化时间戳,可索引 |
| Null | null | (JSON 共用) |
| Regex | - | 持久化正则 |
| Int32 | number | 不用 64 位 double |
| Timestamp | - | oplog 内部使用 |
| Int64 / Long | string | 大整数不丢精度 |
| Decimal128 | string | 金融级十进制 |
| MinKey / MaxKey | - | 排序边界值 |
加粗的是 JSON 表达不了或会丢信息的类型。这就是为什么不能简单 JSON.stringify 一个 MongoDB 文档。
ObjectId:时间戳挂在 ID 里
12 字节结构:
| 4 bytes 时间戳秒 | 5 bytes 随机 | 3 bytes 计数器 |
前 4 字节是 Unix 秒级时间戳(big-endian)——这是 ObjectId 最被低估的特性。
const oid = new ObjectId();
const seconds = parseInt(oid.toHexString().substring(0, 8), 16);
const date = new Date(seconds * 1000);
实际工程价值:
1. 省一列
业务文档不需要 createdAt 字段——从 _id 直接提取:
const createdAt = ObjectId.createFromHexString(doc._id).getTimestamp();
每条文档省 8 字节存储 + 一个索引。规模大了能省 GB 级。
2. 按 ID 范围 = 按时间范围
// 过去 1 小时的文档
const oneHourAgo = new Date(Date.now() - 3600000);
const cutoff = ObjectId.createFromTime(oneHourAgo.getTime() / 1000);
db.events.find({ _id: { $gt: cutoff } });
完全不需要时间字段索引——_id 索引天然按时间有序,查询快、内存小。
3. 调试 / 排错
// 拿到一条出问题的记录 ID
> db.errors.findOne({ _id: ObjectId("664a1234567890abcdef1234") })
// 立刻知道这个 bug 是 2024-05-19 13:00:36 写入的
不用翻日志,不用关联 createdAt——ID 自带时间。
Long / Int64:JS Number 的天花板
MongoDB Long 是 64 位整数,JS Number 是双精度浮点——尾数 52 位 + 隐含 1 位 = 53 位整数精度。
Number.MAX_SAFE_INTEGER === 9007199254740991 // 2^53 - 1
9007199254740993 === 9007199254740992 // true,丢精度了
9007199254740993 + 1 === 9007199254740994 // false,可能等于 9007199254740992
典型场景:
- Twitter / 微博 / B 站 推文 ID(19 位数字,超出 2^53)
- 雪花算法生成的分布式 ID
- 大型业务的递增 user_id
直接 JSON.stringify(doc) 后传给前端:
{"id": 9007199254740993} // 后端发送
前端 JSON.parse 得到:
9007199254740992 // 静默截断了
修复方案:
| 方案 | 怎么做 |
|---|---|
| EJSON Long 包装 | {"id": {"$numberLong": "9007199254740993"}} |
| 字符串传输 | {"id": "9007199254740993"} |
| BigInt | 前端用 BigInt("9007199254740993") |
| 截位 | 业务上禁止超过 2^53(保守但简单) |
本工具检测到 Long 超过 ±2^53 自动保留字符串形式,避免静默截断。
Decimal128:金融级十进制
IEEE 754 双精度浮点的经典坑:
0.1 + 0.2 === 0.3 // false
0.1 + 0.2 // 0.30000000000000004
1.005 * 100 // 100.49999999999999
原因——0.1 在二进制浮点里是无限循环小数(类似十进制的 1/3 = 0.333…)。
Decimal128 是十进制浮点——34 位有效十进制 + 指数:
NumberDecimal("0.1") + NumberDecimal("0.2") // === NumberDecimal("0.3")
金融场景必须 Decimal128:
- 货币金额(账户余额、订单总价、退款)
- 利率 / 汇率(需要 4-6 位小数精确)
- 法币和加密货币转换
- 任何需要 SUM 聚合不放大误差的场景
Double 仍可用的场景——物理量、统计、ML 特征——这些场景误差 1e-15 完全可接受,且性能比 Decimal128 快 5-10 倍。
EJSON:BSON ↔ JSON 的桥
直接 JSON.stringify(bsonDoc) 会丢类型。EJSON(Extended JSON) 是 MongoDB 定义的”带类型的 JSON”:
{
"_id": {"$oid": "664a1234567890abcdef1234"},
"createdAt": {"$date": "2024-05-19T13:00:36.000Z"},
"amount": {"$numberDecimal": "1234.56"},
"userId": {"$numberLong": "9007199254740993"}
}
每个 BSON 专属类型用一个带 $ 前缀的对象包装。这样 JSON 解析器仍能解析(合法 JSON),而 MongoDB 驱动能识别 $oid $date 等还原成 BSON 类型。
两种风格:
| EJSON 风格 | 数字处理 | 典型用途 |
|---|---|---|
| Relaxed | 安全范围内的 int / double 用原生 number | 接口默认输出 |
| Canonical | 所有数字都包 $numberInt $numberLong $numberDouble | 存档 / 跨版本迁移 |
接口对外用 Relaxed(人类友好),数据迁移用 Canonical(类型完全保真)。
mongosh 输出不是 JSON
shell 里看到的:
{
_id: ObjectId("664a1234..."),
name: "Alice",
age: NumberInt(30),
amount: NumberDecimal("128.50")
}
这不是 JSON——key 没引号、ObjectId(...) 是函数调用、字符串可能用单引号。直接 JSON.parse 会报错。
本工具自动处理——粘贴 mongosh 输出,工具识别 ObjectId / ISODate / NumberLong / NumberDecimal 等语法,转成 EJSON 后再解析。所以排错时直接复制 shell 输出粘进来就行。
实战:调试一条 MongoDB 文档
场景:业务报告”订单 6650abc… 金额错了”。
步骤:
- 在 mongo shell 跑
db.orders.findOne({_id: ObjectId("6650abc...")}) - 复制输出粘进 BSON 格式化
- 工具显示:
- ObjectId 时间面板:2024-05-25 09:23:45 创建(确认时间对)
- 切换到 Pretty 模式:看到金额
amount: 99.99(应该是 999.99) - 切换 Canonical 模式:
amount: {"$numberDecimal": "99.99"}—— 确认 Decimal128 类型对,但值错了
- 数据本身错——交给业务排查写入逻辑
整个调试不需要写代码——shell 取数据 + 工具看结构 + ObjectId 提时间,3 分钟定位。
配套工具
- JSON 工具 —— EJSON 输出后做 JSONPath 分析
- Hex 二进制查看 —— 看 .bson 文件二进制结构
- DuckDB SQL 工作台 —— 把 EJSON 导出 JSON / NDJSON 后用 SQL 分析
BSON 不是 JSON 的”二进制版”——它多了关键的工程类型。理解 ObjectId / Decimal128 / Long 的精度边界,才能写出不丢数据、不丢精度、不丢时间的 MongoDB 业务代码。