Long 类型前后端传输精度丢失:根源、复现与解法
一个问题
先看一个场景。后端定义的订单 ID 是 Long 类型:
@Data
public class Order {
private Long id; // 主键
private String name;
}
数据库里的值是 18014398509481985。前后端联调的时候,前端说拿到的 ID 不对。
后端查日志:对的,查数据库:对的。前端 console.log 打出来:18014398509481984——少了 1。
不是网络传输的问题,不是数据库的问题。问题出在 JSON 到 JavaScript 的路上。
JSON 本身没毛病
JSON 规范里其实对数字类型没有明确的精度限制。RFC 7159 里说数字可以是任意精度,但落到具体实现时,就看解析器怎么处理了。
后端把 Long 序列化成 JSON,假如用的是 Jackson,默认行为就是原样输出:
{"id": 18014398509481985, "name": "测试订单"}
这个数字本身在 JSON 字符串里是正确无误的。HTTP 传输也不丢数据。问题出在前端收到之后:JavaScript 不认识这么大的整数。
JavaScript 的 Number 只能安全表示 53 位整数
JavaScript 的 Number 类型采用的是 IEEE 754 双精度浮点数标准。它的有效数字位是 53 位(包括隐含的 1 位)。
这意味 JavaScript 能精确表示的整数范围是:
2^53 - 1 = 9007199254740991
也就是大约 9 千万亿。超过这个范围,整数就会出现精度损失。
后端 Java 的 Long 是 64 位有符号整数,范围是:
-2^63 ~ 2^63 - 1
-9223372036854775808 ~ 9223372036854775807
这个范围的上限(约 922 亿亿)远大于 JavaScript 的 53 位安全整数(约 9 千万亿)。
换句话说,后端 Long 能装下的数字,前端 JavaScript 不一定能精确表示。
再看最开始的例子:18014398509481985,换算成二进制是 55 位的整数,JavaScript 装不下,丢精度了。
为什么后端偏爱 Long 做 ID?
既然 Long 有这个问题,那为什么那么多系统还是用 Long 做主键?
历史原因和实际需求都有。
自增 ID 的规模演进:
- 早期用
int(32 位),最大 21 亿 - 后来不够了,升级到
Long(64 位),最大约 922 亿亿 - 分库分表、分布式 ID 生成器(雪花算法等)直接输出 64 位整数
雪花算法生成的 ID 通常是 64 位的 Long,结构大致是:
1位符号位 + 41位时间戳 + 10位机器ID + 12位序列号
这个值很容易就超过 53 位。比如 179318286682791936,放 JSON 里没问题,放到 JavaScript 里就变了。
所以这个问题本质上是:后端用 64 位整数做主键,而 JavaScript 只能精确处理 53 位整数。
复现一下
在浏览器控制台试一下就知道了:
// JavaScript 能精确表示的最大整数
Number.MAX_SAFE_INTEGER // 9007199254740991
// 超过安全范围
9007199254740992 // 9007199254740992 — 还好
9007199254740993 // 9007199254740992 — 不对了,精度丢了!
// 更大的数,后端常见的雪花 ID
console.log(18014398509481985)
// 18014398509481984 — 比原值少了 1
Java 后端:
Long id = 18014398509481985L;
经过 Jackson 序列化变成 JSON:
{"id": 18014398509481985}
前端用 JSON.parse() 解析:
const obj = JSON.parse('{"id": 18014398509481985}');
console.log(obj.id); // 18014398509481984
差了 1。如果是作为 key 去查询或者判等,这就是 bug。
怎么解决?
核心思路就一个:不让 JavaScript 把它当数字处理,而是当字符串。
解法一:后端改序列化,Long → String
Jackson 方案:
@JsonSerialize(using = ToStringSerializer.class)
private Long id;
或者全局配置,把所有的 Long 都序列化成字符串:
@Bean
public Jackson2ObjectMapperBuilderCustomizer customizer() {
return builder -> builder.serializerByType(Long.class, ToStringSerializer.instance);
}
之后生成的 JSON 就变成了:
{"id": "18014398509481985"}
前端收到的就是字符串,不会丢精度。前端使用时如果需要计算,再自己转。
Fastjson 方案:
// 全局配置
SerializerFeature.WriteNonStringValueAsString
解法二:前端用 BigInt
现代浏览器支持 BigInt,可以处理任意精度的整数:
const id = BigInt("18014398509481985");
console.log(id.toString()); // 18014398509481985
但需要注意:
JSON.parse()不会自动把大整数转成 BigInt- 需要自定义 reviver 或者在序列化时就处理
- BigInt 不能直接和普通 Number 混用运算
通常的配合方式:
{"id": 18014398509481985}
// JSON.parse 的 reviver 参数
const obj = JSON.parse(jsonStr, (key, value) => {
if (key === 'id' && typeof value === 'number' && !Number.isSafeInteger(value)) {
return BigInt(value);
}
return value;
});
解法三:后端换个 ID 生成策略
如果条件允许,可以避免使用 64 位整数做前端可见的 ID:
- 用 UUID / ULID,天然是字符串
- 用分布式发号器但输出的值控制在 53 位以内
- 前端只展示不使用的 ID,不对 ID 做运算(但判等和查询还是会用到)
解法四:自定义序列化格式
定义一个专门的大整数类型,序列化时同时输出字符串和数字:
{"id": {"value": "18014398509481985", "_type": "long"}}
或者用约定好的格式标记:
{"id": "L:18014398509481985"}
前端解析时检测前缀,按字符串处理。
大部分场景下不需要这么重,但如果是前后端由不同团队维护、使用不同语言的微服务架构,这种显式的类型标记可以避免歧义。
推荐方案
综合来看,最务实的方案是:
后端将 Long 类型的 ID 序列化为字符串。
理由:
- 改动最小,后端一个注解或一行配置
- 前端不需要额外处理,拿到就是对的
- 不依赖前端是否支持 BigInt
- 对老版本浏览器友好
- 兼容 GraphQL、gRPC-Web 等其他传输协议
具体就是 Jackson 的全局配置:
@Bean
public Jackson2ObjectMapperBuilderCustomizer longToStringCustomizer() {
return builder -> builder
.serializerByType(Long.class, ToStringSerializer.instance)
.serializerByType(Long.TYPE, ToStringSerializer.instance);
}
前端 axios 请求拿到 id 是 "18014398509481985",不管你是查详情、点列表、做缓存 key,都不会遇到精度问题。
总结
| 角色 | 发生了什么 |
|---|---|
| 后端 Long | 64 位有符号整数,范围超大 |
| JSON 序列化 | 原样输出数字,没问题 |
| HTTP 传输 | 透传,没问题 |
| JavaScript 解析 | IEEE 754 双精度浮点,只能安全表示 53 位 |
| 前台收到 | 大整数被截断或近似,精度丢失 |
解决思路就是把 Long 序列化成字符串,不让 JavaScript 用 Number 去解析它。
下次遇到前端说 ID 不对时,先看看那个数字有没有超过 9007199254740991。超过了,就别怪前端——它已经很努力了。