2.1 BSON数据类型与精度事故


2.1 BSON 数据类型与精度事故

本节摘要:BSON 是 JSON 的二进制扩展,增加了日期、二进制、Decimal128、ObjectId 等类型。本节从一次对账事故讲类型选择:金额用 double 会丢精度,必须用 Decimal128;日期必须统一 UTC。

事故档案 04:对账永远差一分钱

支付平台每日对账,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。精度问题必须在写入端解决,读出来再四舍五入只是遮羞布。

ObjectId:不是随机数

默认主键 _id 若不指定,自动生成 ObjectId:12 字节 = 4 字节时间戳 + 5 字节随机值 + 3 字节递增计数器。因此同一进程内生成的 ObjectId 近似按时间有序,这让它天生适合做 B 树索引的插入键——不像 UUIDv4 那样把索引写成随机乱序(对比见第 3 章)。

日期与数组

  • Date:毫秒级 UTC 时间。事故高发点是各服务时区不一致,规范应是"入库一律 UTC,展示层再转本地时区"。
  • Array:一列多值,查询时自动按元素匹配。它的膨胀会撑爆 16MB 文档上限——这正是第 5 章建模事故的主角。

常用类型速查

类型 写法 用途 事故高发
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,错误类型在写入端直接被拒绝。

int、long、double 与 JS 数字陷阱

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,需要"业务日期"(如订单归属日)时另存一个明確的字符串字段,不要靠时区换算反推。

相邻概念辨析:NumberInt、NumberLong、NumberDecimal

三者在 mongosh 里都存在,选择依据是值域与精度需求而非习惯:NumberInt 是 32 位整数,计数器、状态码够用且省空间;NumberLong 是 64 位整数,大 ID、时间戳毫秒值必须用它;NumberDecimal 是 128 位十进制浮点,只要值代表钱或任何需要十进制精确的量就用它。跨语言团队还要注意 Python 的 int 天生无限精度、Java 的 long 与 BigDecimal 各自映射到哪个 BSON 类型,序列化层的一次映射错配,效果等同于直接写错类型。

本节要点回顾

  • 金额一律 Decimal128,double 累加必出差额;
  • ObjectId 近似递增,对索引友好;
  • 日期统一 UTC,展示层转换;
  • 数组要评估上界,16MB 是硬顶。

作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U