本节摘要:BSON 是 JSON 的二进制扩展,增加了日期、二进制、Decimal128、ObjectId 等类型。本节从一次对账事故讲类型选择:金额用 double 会丢精度,必须用 Decimal128;日期必须统一 UTC。
支付平台每日对账,MongoDB 侧的流水总和与银行侧总差 0.01 到 0.07 元。开发反复核对代码没找到问题,最后发现字段类型是 double:金额 0.1 在二进制浮点里本就不精确,几十万笔累加后误差显形。
// 复现:浮点加法天然不准 db.t.find(); // 0.1 + 0.2 = 0.30000000000000004 // 正确写法:Decimal128,128 位十进制浮点 db.orders.insertOne({ orderId: "A1024", amount: NumberDecimal("99.90"), currency: "CNY" });
驱动层同理:Java 用 BigDecimal、Python 用 Decimal 传入,序列化时映射到 Decimal128。精度问题必须在写入端解决,读出来再四舍五入只是遮羞布。
默认主键 _id 若不指定,自动生成 ObjectId:12 字节 = 4 字节时间戳 + 5 字节随机值 + 3 字节递增计数器。因此同一进程内生成的 ObjectId 近似按时间有序,这让它天生适合做 B 树索引的插入键——不像 UUIDv4 那样把索引写成随机乱序(对比见第 3 章)。
| 类型 | 写法 | 用途 | 事故高发 |
|---|---|---|---|
| string | "abc" | 文本 | 混用编码 |
| int/long | NumberLong | 计数 | JS 端只有 double |
| Decimal128 | NumberDecimal | 金额 | 忘了用 |
| Date | ISODate | 时间 | 时区混乱 |
| ObjectId | ObjectId | 主键 | 手工造随机 id |
| array | [...] | 多值 | 无界增长 |
💡 一条团队规范建议:新建集合的字段评审只问三个问题——金额是不是 Decimal128?时间是不是 UTC?数组会不会无界增长?三问能拦下一大半数据质量事故。
定位这起对账事故花了四天。第一天怀疑对账脚本,把银行侧流水逐笔拉出来重算,总额没错;第二天怀疑聚合管道的求和逻辑,把 $sum 换成应用层累加,差额依旧;第三天才有人想到检查字段类型——db.orders.findOne() 的输出里,amount 显示为 99.9,看不出任何异常,必须显式查询类型才能现形:
db.orders.aggregate([ { $project: { t: { $type: "$amount" } } }, { $group: { _id: "$t", n: { $sum: 1 } } } ]) // { _id: "double", n: 4821103 } ← 全部是 double,问题坐实
根因明确:驱动默认把 Java 的 double、JavaScript 的 number 映射为 BSON double,而 0.1、99.9 这类十进制小数在二进制浮点里只能近似表示。误差单笔不可见(约 1e-17 量级),几十万笔累加后在分位上显形。修复是全量数据迁移:新字段 amountD 用 NumberDecimal 写入,旧字段保留一个对账周期后下线。迁移脚本本身也要用字符串中转,不能经过任何浮点变量。
// 存量修复:从字符串化后的旧值重建,避免中间再过浮点 db.orders.find({ amount: { $type: "double" } }).forEach(doc => { db.orders.updateOne( { _id: doc._id }, { $set: { amount: NumberDecimal(doc.amount.toFixed(2)) } } ); }); // 验证:类型分布应只剩 decimal
预防层面,除了代码评审盯类型,还可以给集合挂 JSON Schema 校验(第 5 章展开),把 amount 的 bsonType 锁死为 decimal,错误类型在写入端直接被拒绝。
JavaScript 的 number 只有 64 位浮点一种,这带来两个衍生坑。一是整数超过 2 的 53 次方后精度丢失,ID 雪花算法生成的值正好落在这个区间之上,从 mongosh 或 Node 应用写入时必须包 NumberLong;二是相等比较的假象,int 1 与 double 1.0 在 MongoDB 里视为相等(数值比较族内部互通),但与 string "1" 永远不等——隐式转换不会发生在数值与字符串之间,这是索引失效的高频原因,第 3 章会再遇到它。
时区事故的常见形态也值得点破:应用服务器在东八区,直接把本地时间字符串 new Date("2024-06-01 08:00:00") 传给驱动,不同驱动的解析结果不一致,有的当 UTC 有的当本地时区,跨服务汇总时数据错位八小时。规范做法只有一条:入库永远是 UTC 的 Date 对象或 ISODate,需要"业务日期"(如订单归属日)时另存一个明確的字符串字段,不要靠时区换算反推。
三者在 mongosh 里都存在,选择依据是值域与精度需求而非习惯:NumberInt 是 32 位整数,计数器、状态码够用且省空间;NumberLong 是 64 位整数,大 ID、时间戳毫秒值必须用它;NumberDecimal 是 128 位十进制浮点,只要值代表钱或任何需要十进制精确的量就用它。跨语言团队还要注意 Python 的 int 天生无限精度、Java 的 long 与 BigDecimal 各自映射到哪个 BSON 类型,序列化层的一次映射错配,效果等同于直接写错类型。