侧边栏壁纸
博主头像
一笑痕

仙人之下我无敌,
仙人之上一换一。

  • 累计撰写 52 篇文章
  • 累计收到 7 条评论

19 位订单号从数据库到浏览器,精度在哪一步丢的

2026-9-15 / 0 评论 / 9 阅读

19 位订单号从数据库到浏览器,精度在哪一步丢的

一个 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() 了。

    🤞 分享