BSON 比 JSON 多了什么:ObjectId 时间戳 / Decimal128 / Long 精度

· 约 5 分钟 🍃 BSON / MongoDB

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 等价物解决了什么
Doublenumber(JSON 共用)
Stringstring(JSON 共用)
Objectobject(JSON 共用)
Arrayarray(JSON 共用)
Binarybase64 string存二进制不用 base64 膨胀 33%
ObjectIdhex string12 字节天然有序 ID
Booleanboolean(JSON 共用)
DateISO 字符串类型化时间戳,可索引
Nullnull(JSON 共用)
Regex-持久化正则
Int32number不用 64 位 double
Timestamp-oplog 内部使用
Int64 / Longstring大整数不丢精度
Decimal128string金融级十进制
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… 金额错了”。

步骤

  1. 在 mongo shell 跑 db.orders.findOne({_id: ObjectId("6650abc...")})
  2. 复制输出粘进 BSON 格式化
  3. 工具显示:
    • ObjectId 时间面板:2024-05-25 09:23:45 创建(确认时间对)
    • 切换到 Pretty 模式:看到金额 amount: 99.99(应该是 999.99)
    • 切换 Canonical 模式:amount: {"$numberDecimal": "99.99"} —— 确认 Decimal128 类型对,但值错了
  4. 数据本身错——交给业务排查写入逻辑

整个调试不需要写代码——shell 取数据 + 工具看结构 + ObjectId 提时间,3 分钟定位。

配套工具

BSON 不是 JSON 的”二进制版”——它多了关键的工程类型。理解 ObjectId / Decimal128 / Long 的精度边界,才能写出不丢数据、不丢精度、不丢时间的 MongoDB 业务代码。

❓ 常见问题

为什么 MongoDB 不直接用 JSON 而要 BSON?

JSON 表达力不够 + 解析慢类型不够——JSON 只有 string / number / boolean / null / array / object 六种,MongoDB 业务需要的 Date / ObjectId / Binary / Decimal 都没有;number 精度坑——JSON 规范不规定 number 是 32 位、64 位、整数还是浮点,跨语言互通经常丢精度;解析慢——JSON 文本要逐字符扫描判断 { } , : 引号转义,BSON 是长度前缀的二进制格式,能直接 memcpy 跳过任意字段;字段 update 难——JSON 改一个字段要重新序列化整个文档,BSON 能按字段长度精确 patch。综合起来,BSON 是 MongoDB 选的存储格式 + 网络协议格式,JSON 仅作为对外接口的"扩展形式"(EJSON)。

ObjectId 不就是个 ID 吗?时间戳功能有啥用?

12 字节 ObjectId 前 4 字节就是 Unix 秒级时间戳——免费的"创建时间"字段。实际价值——(1) 节省一列:业务文档不需要 createdAt 字段,从 _id 直接提取即可;(2) 按 _id 范围查就是按时间范围查{_id: {$gt: ObjectId("65f00000...")}} 等价于"过去半小时的文档",索引天然有序;(3) 数据迁移 / 排错:拿到一条记录的 _id 就知道何时创建的,不用查日志;(4) 基本单调递增:同一进程内 ObjectId 严格递增,跨节点近似有序,对索引和排序友好。注意——精度只到秒,毫秒级精度需求要业务字段;时间是 ObjectId 生成时间不是文档插入时间,理论上能差几毫秒。

NumberLong / Long / int64 在 JS 里为什么会丢精度?

JS Number 是 IEEE 754 双精度浮点——尾数 52 位 + 隐含 1 位 = 53 位有效精度。安全整数范围 ±2^53 = ±9007199254740992(约 9×10¹⁵)。超过这个范围相邻整数无法区分——9007199254740993 会被存成 9007199254740992+1 操作可能等于 0。MongoDB 的 Long 是 64 位——范围 ±9.2×10¹⁸,远超 JS 安全整数。所以驱动把 Long 反序列化到 JS 时默认返回字符串或专用 Long 对象——保留全部精度。典型踩坑——业务直接 JSON.stringify(doc) 后传给前端,前端 JSON.parse 得到 number,超出 2^53 的 ID 静默截断变错号。修复——使用 EJSON {"$numberLong":"..."} 或前端显式 BigInt。

Decimal128 vs Double 怎么选?

金额 / 货币 / 财务一定 Decimal128——理由是十进制加减乘除精确。0.1 + 0.2 在 Double 下等于 0.30000000000000004(IEEE 754 二进制无法精确表示 0.1),在 Decimal128 下严格等于 0.3Decimal128 范围——34 位有效十进制 + 指数 ±6143,覆盖所有金融、科学计数场景。Double 适合的场景——物理量(温度、距离、速度)允许 1e-15 级误差、统计分析(平均值、方差)误差不放大、性能敏感(Decimal128 比 Double 慢 5-10 倍)。实务规则——任何要做 SUM 聚合、要展示到精确小数位、要和会计系统对账的字段,全部 Decimal128。

ObjectId 单调递增是什么意思?真能用作时间排序吗?

同一进程内严格单调递增——3 字节计数器在每次生成时 +1。跨进程 / 跨节点只是"近似有序"——不同节点的本地时钟可能差几秒,机器 ID 不同也会让字节序比较结果偏离时间顺序。实务结论——(1) 精度到秒的时间排序db.coll.find().sort({_id: 1}) 等价于按创建时间排,单调性误差 < 1 秒可接受;(2) 精度到毫秒的排序:必须加业务的 createdAt 字段,ObjectId 不够;(3) 分布式幂等:ObjectId 不是全局单调,不能用作分布式 lock 顺序号。Snowflake / ULID 是更严格的分布式有序 ID 方案,但绝大多数业务用 ObjectId 就够。

ISODate / new Date / Timestamp 是同一个东西吗?

Date 是普通时间戳类型——MongoDB 内部存毫秒级 Unix 时间戳(int64),mongosh 显示为 ISODate("2026-05-02T..."),业务文档里的 createdAt 一般是这个。Timestamp 是 MongoDB 内部使用的特殊类型——存 (秒, 计数器),主要用于 oplog(操作日志)和复制集。Timestamp 看起来像时间但不是给业务用的——精度只到秒,且每个 mongod 自己维护计数器。EJSON 表示——Date 是 {"$date":"ISO 字符串"},Timestamp 是 {"$timestamp":{"t":秒,"i":计数}}——本工具自动区分。经验法则——业务字段永远用 Date,看到 Timestamp 大概率是抓到了 oplog。

为什么 mongosh 输出的不是合法 JSON 不能直接 JSON.parse?

mongosh 输出是人眼可读的 shell 语法——{ _id: ObjectId("..."), name: "Alice" }——不带引号的 key、ObjectId(...) 函数调用 —— 不是 JSON改用 EJSON——db.coll.find().toArray()JSON.stringify 得到 Relaxed EJSON 是合法 JSON。或者——mongoexport --jsonFormat=canonical 直接导出 EJSON 文件。本工具的方便处——直接接受 mongosh 输出,自动改写为 EJSON 后再解析。所以从 shell 复制粘贴文档来看结构 / 改格式 / 提时间戳,不用先手动转。

🍃 打开 BSON / MongoDB mongosh 输出/EJSON/二进制 BSON 互转·ObjectId 提时间戳·Pretty/Relaxed/Canonical 三种输出·Long/Decimal128 不丢精度