
一个 19 位的订单号从数据库一路跟到浏览器,看精度到底在哪一步丢的。这个号在数据库里躺得好好的,接口文档也写着这是数字字段,可请求打到后端时参数变成了 12345678901234567000,跟主键差 722。这个差值看着像随机噪声,其实是 IEEE 754 双精度浮点数把末尾几位抹平了。
下面这五种写法都挨个写过、挨个跑过。它们都不长,长得也都挺合理,但每一种都能把 19 位 ID 悄悄改掉,或者看上去像没改。
错法一:把 19 位 ID 当 number 接
JS 的 number 就是 double,能精确表示的整数到头是 2^53 - 1:
console.log(Number.MAX_SAFE_INTEGER); // 9007199254740991
console.log(2 ** 53 + 1 === 2 ** 53); // true
console.log(2 ** 53 + 1); // 9007199254740992
超过这条线之后,相邻的整数在 double 里是同一个值。所以 19 位的雪花 ID 一旦被当成 number 解析,它就已经不是原来那个数了。
接口这一侧其实没问题,用 Python 的 json 模块把出入参跑了一遍:
import json
d = json.loads('{"id":12345678901234567890}')
print(type(d["id"])) # <class 'int'>
print(json.dumps(d, separators=(",", ":"))) # {"id":12345678901234567890}
print(float(12345678901234567890) == 12345678901234567890) # False
输出跟原文一个字符都不差。Python 的 int 是任意精度,只要不主动 float(),响应体里就是完整的 19 位。同一个字符串交给 node 解析,结果就变了:
$ node -e 'console.log(JSON.parse("{\"id\":12345678901234567890}").id)'
12345678901234567000
也就是说,出问题的位置是「把响应体变成 JS 对象」那一步,跟数据库、跟 SQL 都没关系。这也是它难查的原因:服务端日志打了一屏,看到的都是对的。
而且它丢的不只是末尾几个零:
const raw = '{"id":12345678901234567890,"name":"订单"}';
const parsed = JSON.parse(raw);
console.log(JSON.stringify(parsed)); // {"id":12345678901234567000,"name":"订单"}
console.log(typeof parsed.id); // number
console.log(BigInt(parsed.id).toString()); // 12345678901234567168
后两行的数字不一样。JSON.stringify 打印出来是 ...567000,double 里真正存的是 ...567168,跟原文差 722。扫一眼很容易以为只是末尾补了几个零,其实整个值都偏了。
一开始也是先翻服务端的请求日志,参数已经是 ...567000,第一反应是前端把参数传错了,结果白翻了半天前端代码。后来把接口的原始响应文本打出来(不是序列化之后的对象),一眼就清楚了:响应体里是完整的 19 位,发回来的请求里是结尾 000 的那个数。加上前面那三行对照,责任落在哪一层就没有争论的余地。
这里有个细节容易把人带偏。如果在 Node 里直接打印请求体里的 id,看到的也是丢过精度的 number,跟实际存的值不是一回事。排查时把「原始文本」和「解析后的对象」分两层看,两层都打印真实值,别扫一眼控制台就觉得没问题。
还有个手感之谈,没验证过:如果两个 ID 的差值落在 2 的某个幂附近,或者差值小得离奇又找不到规律,就会先往浮点精度上想。
错法二:在 reviver 里用 BigInt(v) 硬转
第一版就是拿 reviver 救的:
const parsed = JSON.parse(raw, (k, v) => (k === "id" ? BigInt(v) : v));
console.log(parsed.id.toString()); // 12345678901234567168,还是错的
reviver 拿到的 v 是已经解析完的 number,精度在它之前就没了,BigInt() 只是把错的数换了个类型。
错法三:拿 Number(id) === 字面量 当断言
前端拿到的字段从 number 变 string 之后,比较写法得跟着改,当时是这么改的:
const { id } = JSON.parse('{"id":"12345678901234567890"}');
console.log(id === 12345678901234567890); // false
console.log(Number(id) === 12345678901234567890); // true
后一行返回 true,是因为等号右边的字面量本身也变成了 double,两边错到同一个值上了。这种断言通不了测试,它只能证明两边丢得一样多。
错法四:后端把 BigInt 直接塞进响应
数据库驱动返回的 19 位字段经常是 BigInt(PostgreSQL 的 bigint、Prisma 的 BigInt 类型都是这个形态)。JSON.stringify 碰到它直接抛:
JSON.stringify({ id: 12345678901234567890n });
// TypeError: Do not know how to serialize a BigInt
这段放进一个完整的 HTTP 处理函数里跑了一遍,客户端收到的是:
HTTP 500 {"error":"Do not know how to serialize a BigInt"}
这种 500 在本地不容易发现,因为单测里 mock 的对象一般写成普通 number,只有真库真数据才触发。
错法五:长度检查只盯冒号后面的数字
精度丢在哪一步已知之后,剩下的问题是怎么在开发阶段发现它,于是加了一道长度检查。思路没问题,写法有问题:第一版只写了 /:\s*-?\d{16,}/g 这种盯冒号的模式,在八组样本上跑完发现它漏掉了数组里的元素,因为冒号后面跟的是方括号。
正确写法
五种错法串起来看,问题只出在两个地方:出参那一侧有没有把 ID 当数字,收端那一侧解析时还有没有机会拿到原文。两处一起改,19 位才真的不丢。
出参这一侧:让「ID 是字符串」变成约定
做法是在仓储层出口统一把 BigInt 转成字符串,让「ID 是字符串」变成约定,别指望每个 handler 都记得转一次。
收端这一侧:找 reviver 的第三个参数
管用的是 reviver 的第三个参数,它拿得到数字在原文里的文本:
const parsed = JSON.parse(raw, (k, v, ctx) => (k === "id" ? BigInt(ctx.source) : v));
console.log(parsed.id.toString()); // 12345678901234567890
console.log(parsed.id === 12345678901234567890n); // true
这个参数来自 JSON.parse 的 source text access 提案,在本机 Node v26.8.1 上跑通了。它是哪个版本开始支持的,没法在这台机器上退回旧版验证,换个环境用 node -e 试一下 JSON.parse 有没有第三个参数更稳,老版本上它是 undefined,直接读 ctx.source 会抛 TypeError。
再补一道便宜的检查
现在的做法是不让 fetch 包装直接 JSON.parse,先留一份原始文本,封装成这样一个函数:
function parseKeepingBigInts(rawText) {
// 先摘掉所有字符串字面量,剩下的数字才可能是被解析成 number 的裸数字
const outsideStrings = rawText.replace(/"(?:[^"\\]|\\.)*"/g, '""');
const risky = outsideStrings.match(/-?\d{16,}/g);
if (risky) console.warn("响应里有超过 15 位的数字字面量:", risky);
return JSON.parse(rawText, (k, v, ctx) => {
// 只认数字字面量:字符串字段的 ctx.source 带引号,BigInt() 会直接抛
return k === "id" && /^-?\d+$/.test(ctx.source) ? BigInt(ctx.source) : v;
});
}
console.log(parseKeepingBigInts('{"id":12345678901234567890}').id); // 先打警告,再给出精确的 BigInt
console.log(parseKeepingBigInts('{"id":"12345678901234567890"}').id); // 不打警告,原样是字符串
第一版漏了 /^-?\d+$/ 这层判断,reviver 里只写了 k === "id" ? BigInt(ctx.source) : v。后来接口把 id 改成字符串返回,ctx.source 就成了带引号的 "12345678901234567890",BigInt() 拿到引号直接抛 SyntaxError,整个解析挂掉。数字和字符串的 source 看着一模一样,区别是后者带引号,判断时得认准。
replace 那一步是关键。先摘字符串字面量、再全局找 16 位以上整数,对象和数组都能抓到,字符串里的数字(包括带转义引号的)和 15 位的普通 ID 都不会误报。
这段只在本机跑通,还没进任何线上项目,先放进自己的小工具里用一阵子,看看误报多不多。
什么时候可以不管这些
自增 ID、UUID、带前缀的业务号(ORD20260915000123 这种)都不用管这一套,真正需要动手的是「纯数字且会超过 15 位」的标识。
我现在的习惯是:只要 ID 有长过 15 位的可能,接口文档里就写 string,序列化那层提前转掉。等它上线之后再回来改,代价就不是加一行 toString() 了。