大家好,我是程序员柏风,AI 最全的 Vibecoding 学习地图 http://coderoadmap.cn 作者。
BSON 与类型
这一课你将能做到
- 说清楚 BSON 和 JSON 的区别,以及为什么 MongoDB 选择 BSON
- 正确使用 ObjectId、Date、Decimal128、内嵌文档与数组,并知道每种类型的典型坑
- 用
$type和typeof排查线上「类型不一致」导致的查询异常
为什么要学
MongoDB 查询报错很少,但「查不到数据」很多,其中一大半是类型问题:Java 端存进去的 userId 是 Long(BSON int64),你在 mongosh 里用 1024(BSON int32)去查,结果能查到;但如果某条记录被 Node 服务写成了字符串 "1024",这条记录就「隐身」了——不报错,就是查不到。
BSON 是 MongoDB 的存储格式,搞懂它的类型系统,才能理解比较规则、索引行为、磁盘占用,以及为什么金额绝对不能存 double。这是后面 CRUD、查询、索引所有课的地基。
先建立图像
JSON 只有 6 种类型,BSON 扩展到 20 多种,常用的如下:
JSON 有的: BSON 额外增加的:
string ObjectId 12 字节主键
number Date 毫秒时间戳(UTC)
boolean Int32 / Int64 区分 32/64 位整数
null Decimal128 高精度十进制(钱!)
array Binary 二进制
object Regex / Timestamp / MinKey / MaxKey ...
一条混合类型的文档:
{
"_id": ObjectId("..."), // ObjectId
"userId": NumberLong(1024), // int64
"score": NumberInt(95), // int32
"price": NumberDecimal("9.99"), // Decimal128
"balance": 9.99, // double(危险!)
"tags": ["mongo", "bson"], // array
"profile": { "city": "上海" }, // 内嵌文档
"createdAt": ISODate("2026-10-07T00:00:00Z")
}
跟着做
步骤 1:观察 JavaScript 数字的「陷阱」
use codeforge
db.typeDemo.drop()
db.typeDemo.insertOne({ a: 1, b: NumberInt(1), c: NumberLong(1), d: 1.0 })
db.typeDemo.find().forEach(d => {
print(`a=${typeof d.a} b=${typeof d.b} c=${typeof d.c} d=${typeof d.d}`)
})
输出全是 number——mongosh 的 typeof 是 JavaScript 语义,看不出 BSON 类型差异。要看真实类型:
db.typeDemo.find().forEach(d => printjson(d))
或用聚合看 BSON 类型名:
db.typeDemo.aggregate([
{ $project: { aType: { $type: "$a" }, bType: { $type: "$b" }, cType: { $type: "$c" } } }
])
$type 返回 int / long / double,这才是存储层的真相。
步骤 2:ObjectId 拆解
const id = new ObjectId()
id.getTimestamp() // 从 ObjectId 里还原出创建时间
id.toString() // 24 位十六进制字符串
ObjectId 的 12 字节 = 4 字节秒级时间戳 + 5 字节随机值 + 3 字节递增计数。所以按 _id 排序约等于按创建时间排序,索引自带单调性。
步骤 3:Date 与 Decimal128
db.typeDemo.insertOne({
now: new Date(),
utc: ISODate("2026-10-07T08:00:00Z"),
money: NumberDecimal("19.99"),
badMoney: 19.99
})
验证 double 的精度问题:
db.typeDemo.aggregate([
{ $project: { doubleSum: { $add: ["$badMoney", 0.01] } } }
])
// 可能得到 20.000000000000004
而 Decimal128:
db.typeDemo.aggregate([
{ $project: { decSum: { $add: ["$money", NumberDecimal("0.01")] } } }
])
// 精确得到 20.00
步骤 4:内嵌文档与数组的存储语义
db.progress.insertOne({
userId: 1024,
trackSlug: "mongodb-roadmap-essentials",
doneLessons: ["mdbrm-intro", "mdbrm-types"],
meta: { lastLesson: "mdbrm-types", percent: 16.7 }
})
点号访问内嵌字段,注意必须加引号:
db.progress.find({ "meta.lastLesson": "mdbrm-types" })
db.progress.find({ doneLessons: "mdbrm-intro" }) // 数组包含该元素即命中
步骤 5:用 $type 揪出脏数据
找出 userId 被写成字符串的异常记录:
db.progress.find({ userId: { $type: "string" } })
讲清楚要点
- BSON = Binary JSON:在 JSON 基础上增加类型信息、长度前缀,支持更多类型,遍历更快。代价是体积略大(字段名重复存储,这也是「字段名要短」说法的由来,但 7.x 有压缩,不必过度纠结)。
- 比较是「类型优先」的:BSON 比较顺序里类型不同则按类型排序(如
null < number < string < object < array)。所以字符串"100"和数字100永远不会互相匹配,范围查询也会漏数据。 - 钱用 Decimal128,别用 double:double 是二进制浮点,
0.1 + 0.2 != 0.3在数据库里同样成立。Decimal128 是十进制 128 位,34 位有效数字。 - Date 存的是 UTC 毫秒:时区是展示层的事。聚合里
$dateToString可以指定timezone: "Asia/Shanghai"再格式化。 - 数组可以装不同类型:BSON 允许
["a", 1, {x: 2}],但索引和排序行为会变复杂,同一字段保持同一类型是工程纪律。 - ObjectId 不是字符串:
ObjectId("66f1...")和"66f1..."是两种类型,互不相等。驱动层拿到_id要转字符串再放进 JSON 响应,前端再传回来时要转回 ObjectId 才能查询。
你可能会懵的地方
find({a: 1})能匹配a: 1.0或a: NumberLong(1)吗? 能。数值类型之间(int/long/double/decimal)按数值相等比较,不受子类型影响。但跨大类(number vs string)绝不匹配。- 为什么我的
_id到前端变成了对象? Java 驱动默认把 ObjectId 序列化成{"$oid": "..."}或{"timestamp":..., "date":...}(取决于 Jackson 配置)。显式.toString()或配置序列化器输出纯字符串。 - Date 存进去为什么少了 8 小时?
没少。数据库存 UTC,mongosh 展示为
ISODate(...Z),你的应用层负责转东八区展示。如果你看到「少 8 小时」,多半是应用把本地时间当 UTC 存了。 - 数组和标量能共存于一个字段吗?
BSON 允许,但查询语义会混乱:
{a: "x"}既能匹配a: "x"也能匹配a: ["x", "y"]。别让同一字段一会儿是数组一会儿是标量。 - 16MB 限制是单个文档还是整个集合? 单个文档。集合无上限。内嵌设计时永远问自己:这个数组会无限长吗?会就拆出去。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
金额存 double 19.99 | 浮点误差,对账对不上 | NumberDecimal("19.99") |
_id 用字符串存 ObjectId 的十六进制 | 体积翻倍、失去时间戳、排序混乱 | 保留 ObjectId 类型 |
存时间用字符串 "2026-10-07" | 无法范围查询、无法按时间聚合 | 用 BSON Date |
| 同一字段一会 int 一会 string | 查询漏数据且极难排查 | 应用层统一类型,脏数据用 $type 清洗 |
用 typeof 判断 BSON 类型 | 只能看到 JS 类型 | 用 $type 聚合或 bsonType 校验器 |
卡点急救
- 查询条件明明对,就是查不到:先
db.col.find().limit(3)看实际存的值,再用$type确认类型。90% 是 string/number 不一致或 ObjectId 被转成了字符串。 - 日期范围查询没结果:确认你传的是 Date 对象而非字符串:
new Date("2026-10-07"),而不是"2026-10-07"。 - Decimal128 在 Java 里变成科学计数法字符串:Jackson 序列化
BigDecimal时配置WRITE_BIGDECIMAL_AS_PLAIN,或统一toPlainString()。
面试常问
- BSON 和 JSON 的区别? JSON 是文本格式、6 种类型;BSON 是二进制格式、带类型和长度信息、20+ 类型(ObjectId/Date/Decimal128/Binary 等),遍历和随机访问更快,适合存储与网络传输。
- ObjectId 的结构和优缺点? 4 字节时间戳 + 5 字节随机 + 3 字节计数。优点:本地生成、全局唯一、大致有序利于索引。缺点:无序性不如纯递增 ID 对 B+ 树友好(但比 UUID v4 好得多),且暴露创建时间。
- 为什么 MongoDB 里数字比较要关心类型? BSON 有类型序,跨类型不匹配;但 int/long/double 之间按数值比较。真正的事故来自 string vs number,以及 double 精度误差。
自测三问
find({price: 9.99})能匹配到price: NumberDecimal("9.99")吗?反过来呢?- 为什么 ObjectId 排序近似等于按创建时间排序?
- 本站的「用户余额」字段该用哪种 BSON 类型?为什么不能用 double?
小结一句话
BSON 给 JSON 加上了「类型系统」这把尺子——理解它,才能解释为什么查不到、为什么算错钱、为什么排序乱。
延伸
- 官方文档:BSON Types
- 下一课:CRUD 操作——用对的类型写进数据,再用增删改查把它玩起来。