做个前后端分离项目时,最常见也最容易被忽视的一个坑,就是后端接口返回的 long 型主键或订单号,到了前端页面突然变了样。比如数据库里存的明明是19101234567890123456,接口文档里写的也是这个值,可页面上渲染出来却成了19101234567890123000,末尾几位直接变成了 0。你盯着代码看半天,死活找不到逻辑错误,最后才意识到这是 number 类型超出 16 位后的精度丢失。这个事在面试里被反复问,在实际项目里也反复坑人,前端要防,后端也要防。这篇文章就把根因、前端处理、后端处理和联调排查一次性聊透,给遇到同样问题的人一份能直接抄作业的参考。
1. 先搞清楚根因:JS 的 number 到底能精确装下多少位整数
1.1 双精度浮点数的精度边界
JavaScript 里的 Number 类型,底层实现是 IEEE 754 标准的双精度浮点数,占用 64 位内存:1 位符号位、11 位指数位、52 位尾数位。这套设计是用来表达浮点数的,不是专门给整数准备的,所以它能精确表示的整数范围不是大家直觉里的“很大很大”,而是有硬性边界的。
这个边界就是Number.MAX_SAFE_INTEGER,值是9007199254740991,也就是 2 的 53 次方减 1。超过这个值之后,整数在转换成二进制浮点数时,尾数位不够用了,就只能做舍入,表现出来就是精度丢失。注意“安全整数”这四个字,它指的是在这个范围内,整数和浮点数是一一对应、可以精确表示的;超出这个范围,两个不同的整数可能映射到同一个浮点数上,或者某个整数根本无法被精确表示。
这里很容易有一个误解:很多人把问题总结成“超过 16 位就会出错”,其实不准确。9007199254740991本身是 16 位,但它没问题;而9007199254740993也是 16 位,却已经不能被精确表示了。反过来,1000000000000000这个 16 位数字又能被精确表示。所以判断标准从来不是“多少位”,而是“是否超过Number.MAX_SAFE_INTEGER”。只不过实际业务里,雪花 ID、订单号这类数据一旦超过 16 位,大概率就碰到这个边界了,所以大家习惯用“超过 16 位”来描述这个问题,其实背后的本质是安全整数范围的限制。
1.2 什么类型的业务数据最容易踩雷
我自己踩过坑的几类场景,基本覆盖了绝大多数情况:
第一类是数据库自增主键。单表数据量特别大的时候,bigint 自增很容易涨到几十亿、几百亿,配合分表分库之后,主键甚至会变成带分片位的组合数字,很容易突破 16 位。就算没突破,只要接近 2 的 53 次方,也有风险。
第二类是雪花 ID 及各类分布式 ID。雪花算法生成的 ID 默认是 64 位 long,转成十进制之后通常是 18 到 19 位,百分百超过安全整数范围。现在只要系统做到一定规模,几乎都会用这类 ID,所以这个问题在电商、支付、订单系统里特别普遍。
第三类是业务单号。很多团队喜欢用时间戳加随机数或者加自增序列生成订单号,比如2025010112000012345678,这种拼接出来的数字很容易就是 19 位甚至 20 位。
第四类是高精度计算场景,比如金额、数量、费率等。虽然这类场景一般会用 decimal 类型在后端做计算,但有时候后端图省事直接返回了 number,前端再一展示,小数部分或者大额整数就出现微妙偏差。
给新手一个判断技巧:在浏览器控制台里直接输入一个你很在意的数字,比如12345678901234567890,回车之后看输出,如果显示的和输入的不一致,那这个数字就已经不安全了。这是最快的自检方式。
1.3 精度丢失是“一次性损伤”,无法逆向恢复
这是整个问题里最重要、也最容易被忽略的一点:精度丢失发生在数据进入 JS Number 类型的那一瞬间,之后无论你怎么处理,丢失的信息都找不回来。
举个例子,后端返回的 JSON 文本是{"id": 19101234567890123456}。浏览器拿到这段文本时,它只是一串字符,还没出问题。可是当你用JSON.parse去解析它,或者框架内部自动解析它时,这个数字字符串就会被转成 JS 的 Number 类型,此时精度已经丢了。等你在页面上看到19101234567890123000,再去想怎么把它修复成...3456,是不可能的,因为你手里已经没有原始数据了。
这就引出了一个非常关键的结论:解决问题的核心时机,在于“数字字符串进入 JS 运行时之前”。要么在后端把类型改成字符串,要么在前端解析 JSON 时做拦截,两个位置二选一,必须在数据进入 Number 类型之前处理掉。理解了这一点,后面所有方案都是在围绕“别让它变成 Number”来展开。
2. 前端侧怎么兜底:拦截、转换与展示
2.1 方案优先级:能拿到字符串就别转数字
先说结论,最省心的方案是让后端把所有超过安全范围的 long 类型字段序列化成字符串返回。这样前端拿到的数据天然是字符串,展示直接用,传参也直接用,完全绕开了精度问题。
如果你能推动后端改接口,那前端这边几乎什么都不用做。但现实是很多项目里后端接口已经是既定的,你不能要求对方立刻改,或者接口是第三方提供的,你只能在前端想办法。这时候你需要的是一套“前端兜底方案”,让数据在进入业务代码之前就被处理成字符串。
核心思路就是:不要用浏览器默认的 JSON.parse,而是用支持大数解析的库来自定义解析逻辑。常用的是json-bigint,它可以设置storeAsString为 true,把超过安全范围的数字自动转成字符串。配合 axios 的transformResponse可以做到全局拦截。
import axios from 'axios' import JsonBig from 'json-bigint' const JSONBIG = JsonBig({ storeAsString: true }) const service = axios.create({ baseURL: '/api', transformResponse: [ function (data) { try { // data 是原始的 JSON 字符串,这里用 JSONBIG 解析 return JSONBIG.parse(data) } catch (e) { return data } } ] })这样设置之后,接口返回的id、orderNo等大数字字段,在业务代码里拿到的就直接是字符串了。页面绑定、传给后端做查询参数,都按字符串走,精度问题从源头上被拦截。
这里要特别注意两点。第一,storeAsString: true会把所有超出安全范围的数字都转成字符串,不只是你关心的 ID 字段。如果你的接口里还有大数字的统计字段,且前端后面要做数学运算,就得小心了。第二,这个方案只影响当前 axios 实例发出的请求,页面上普通的fetch调用不会被拦截,需要单独处理。
2.2 用 BigInt 处理大整数计算的场景
有些场景不只是展示,前端还需要对大整数做运算。比如购物车里某个商品数量校验,或者前端需要对一个超大 ID 做位运算、比较大小。这时候字符串就不够用了,你可能需要 BigInt。
BigInt 是 ES2020 引入的内置类型,专门用来表示任意精度的整数,可以这么用:
const bigId = BigInt('19101234567890123456') const anotherId = BigInt('19101234567890123455') console.log(bigId > anotherId) // true console.log(bigId + 1n) // 19101234567890123457n用 BigInt 有个很容易踩的坑:它不能直接和普通 Number 类型做混合运算,比如bigId + 1会直接报错,必须写成bigId + 1n,或者把普通数字用BigInt()包一层。另外,JSON.stringify默认不支持序列化 BigInt,如果你要把 BigInt 传回后端,得先手动转成字符串。
实际业务里,除非前端确实需要做大整数运算,否则我建议尽量别用 BigInt。它给代码带来了额外的类型约束,团队里如果有人不熟悉,很容易写出运行时报错。展示和透传用字符串就足够了,BigInt 只在“必须计算”的时候上。
2.3 后端已经返回 number 时,前端怎么做才靠谱
这里要先泼一盆冷水:如果后端已经返回了 number 类型,而且这个数字超过了安全范围,那前端无论用什么方案都救不回来,因为在 JSON.parse 阶段精度就已经丢了。前面说过,这是不可逆的一次性损伤。
那如果接口已经这样了,你还能做什么?只能分情况处理。
如果只是展示问题,而且这个数字后几位是固定的业务含义,比如订单号后 6 位是流水号,前面是时间戳,你可以考虑用字符串拆分的方式把它们拼回去。但这要求你非常清楚这个数字的生成规则,属于“手术式”修复,不够通用,而且一旦规则变了就会出错。
如果你是做前端自测,发现接口返回的数字不对,最有效的做法不是在前端折腾,而是直接去找后端同事商量,把接口改成返回字符串。这是最根本的修复方式。前端这边的兜底方案只能用于“无法修改后端”的过渡期,而且要尽快推动后端改造,否则隐患会一直在。
给一个实操建议:当前端团队在代码 review 里看到有人直接对后端返回的 long 型字段做 Number 转换时,一定要拦下来。因为这种写法等于把安全边界的问题重新引进来,属于人为制造 bug。
3. 后端侧的根治方案:从序列化层解决
3.1 为什么说问题根源在后端
前端丢精度,表面上看是 JS 的 Number 类型不够用,但仔细一想就知道根源其实在后端。后端数据库里存的是 bigint,Java 里对应的是 Long,这些类型本身都能精确表达 19 位甚至 20 位的数字。问题出在 JSON 序列化这一层:Jackson、Fastjson 这类序列化库默认会把 Long 序列化成 JSON 里的数字类型,而 JSON 里的数字一旦被前端解析成 Number,精度就保不住了。
所以后端的核心任务就一句话:在 JSON 序列化时,把可能超范围的 Long 类型转成字符串输出。这个改动可以在字段级别做,也可以在项目级别做全局配置,具体看你的控制粒度。
3.2 字段级处理:最适合接口数量少、能改动实体类的场景
如果你的项目里只有少数几个字段需要处理,比如个别实体的主键、订单号,直接用注解是最简单的方案。
使用 Jackson 的@JsonSerialize注解,配合ToStringSerializer:
import com.fasterxml.jackson.databind.annotation.JsonSerialize; import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; public class OrderVO { @JsonSerialize(using = ToStringSerializer.class) private Long id; @JsonSerialize(using = ToStringSerializer.class) private Long orderNo; // 其他字段省略 }这里要注意,@JsonSerialize(using = ToStringSerializer.class)会让这个字段在序列化时总是输出字符串,无论值是否超出安全范围。好处是一致性好,前端不用判断;坏处是如果前端确实需要数字类型做运算,会被迫先做一次转换。
另一种做法是使用@JsonFormat,把 shape 设置为 STRING:
@JsonFormat(shape = JsonFormat.Shape.STRING) private Long id;这两种做法的效果类似,@JsonFormat更语义化一些,而且对日期的格式化也支持得很好。不过我个人更推荐@JsonSerialize,因为它的意图更明确:就是这个字段序列化成字符串,没有歧义。
字段级方案的问题在于,如果项目里大量实体都有 long 主键,你得在每个字段上都加注解,维护成本会越来越高,而且很容易漏。一旦漏掉一个,前端就会在某个不起眼的接口上再次踩坑。
3.3 全局配置:一劳永逸处理所有 Long 类型
如果你的项目里有大量实体,或者你不想依赖同事记得加注解,那就用全局配置。Spring Boot 项目里,可以通过自定义 Jackson 的ObjectMapper来实现。
一个常见的配置写法如下:
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class JacksonConfig { @Bean public Jackson2ObjectMapperBuilderCustomizer longToStringCustomizer() { return builder -> { // Long 和 long 都转成字符串 builder.serializerByType(Long.class, ToStringSerializer.instance); builder.serializerByType(Long.TYPE, ToStringSerializer.instance); }; } }这个配置一经生效,整个项目里所有Long类型的字段,在 JSON 序列化时都会变成字符串。你可以把全局配置理解成一个兜底开关,后端所有接口都不再输出大数字,前端自然就不会再因为 Number 精度问题报错了。
不过全局配置也有一些副作用要提前考虑。首先,Integer和int不会被影响,这通常没问题,因为普通 int 很少超过安全范围;但如果你有特别大的 Integer,还得另行处理。其次,前端拿到Long字段后全是字符串,如果前端逻辑里对这个字段做了隐式数字运算,比如Number(data.id),那就等于又把精度问题引回来了,所以后端改完,前端也要同步对齐,保持“字符串透传”的习惯。
还有一点,某些场景会希望 ID 保留数字语义,比如给图表库传输数据时,如果字段本身是字符串,图表库可能解析不对。这种情况下,就需要通过字段级注解来做例外处理,全局配置加局部覆盖,两者配合使用。
3.4 MyBatis-Plus、雪花 ID 和数据库类型的联动处理
如果你用的是 MyBatis-Plus,并且主键生成策略是ASSIGN_ID(也就是雪花 ID),那么实体类里主键类型通常是Long。这类 ID 默认是 19 位,非常容易超过 JS 安全范围,所以这个场景下全局配置尤其有用。
配置完上面的longToStringCustomizer之后,MyBatis-Plus 返回的雪花 ID 在 JSON 里也会自动变成字符串,前端拿到后直接作为字符串使用。但这里有一个容易忽略的点:如果你后续要用这个 ID 去调用其他接口,前端传参时必须以字符串方式传递,后端接收时用Long类型接受,Spring 会自动把字符串转成 Long,这没问题。可是如果前端把 ID 转成 Number 再传,精度就丢了,后端查库时就可能查不到数据。
数据库层面,主键建议继续使用bigint类型,不需要为了这个问题改字符串主键。字符串主键在索引大小、存储空间和查询性能上都比 bigint 差,而且还会影响分页、排序等操作的效率。你只需要把 JSON 序列化层处理好,让数据在传输过程中保持字符串形态就够了。
另外提醒一下,如果你用的是 Fastjson,也有类似的全局处理方式:可以自定义ValueFilter,或者使用@JSONField(serialzeFeatures = SerializerFeature.WriteClassName)相关的功能。但 Fastjson 的版本兼容性问题比较多,新项目我建议直接用 Jackson,配置简单,生态也稳定。
4. 联调实战:问题复现、排查套路与避坑清单
4.1 一个典型的问题复现过程
我处理过的一个真实案例,接口返回的订单详情如下:
{ "orderId": 19101234567890123456, "status": "PAID", "amount": 100.00 }前端用 axios 调这个接口,拿到response.data后打日志,orderId已经变成了19101234567890123000。页面详情查询按钮把orderId拼到 URL 参数里去查订单轨迹,结果后端收到的是19101234567890123000,数据库里根本没有这个订单,返回 404。整个链路断点就出现在前端把 JSON 转成 JS 对象的那一步。
排查这个问题的标准路径是:先在浏览器开发者工具的 Network 面板里看原始响应,如果 Network 里显示的 JSON 文本是正确的19101234567890123456,而控制台打印的response.data.orderId已经变了,那就能确定是 JSON.parse 阶段的精度丢失。这一步就把问题范围锁定住了,不用怀疑后端,也不用怀疑数据库,直接把矛头指向序列化/反序列化的边界。
4.2 接口已经上线,最快的临时修复和永久修复
遇到这种情况,通常有两个层面的动作:
临时修复(前端侧):在 axios 的transformResponse里接入json-bigint,把超范围数字转成字符串,保证页面能正常显示和传参。这个操作一般 10 分钟就能完成,可以快速止血,但不建议长期保留,因为它只是前端侧的兜底,后端不改,始终存在隐患。
永久修复(后端侧):按第 3 节的方式,在后端加入全局序列化配置或字段级注解,让 Long 类型字段统一输出字符串。后端发版之后,前端把json-bigint去掉,恢复默认解析逻辑,业务正常。
这里有一个排期建议:如果问题已经影响了线上核心流程,那就先上前端临时修复,同时后端排期做永久修复。如果只是新功能联调阶段发现的问题,那就直接让后端一次性改到位,前端不用加临时方案,避免技术债。
4.3 常见问题与避坑速查表
我整理了一张排查表,按“现象 -> 可能原因 -> 解决思路”的格式,覆盖了我在项目里遇到的绝大多数问题。
| 现象 | 可能原因 | 解决思路 |
|---|---|---|
| 页面显示的 ID 末位变成 0 或多个 0 | 后端返回数字,前端 JSON.parse 精度丢失 | 后端 Long 序列化为字符串,或前端 json-bigint 兜底 |
| 用 ID 查详情 404,前端打印值和数据库不一致 | 前端把已经丢精度的 number 当参数传递 | 传参时保持字符串,不要转 Number |
| 控制台 console 是对的,但页面显示错 | 可能是页面里对字符串做了 Number() 转换 | 检查代码里的类型转换,保持字符串透传 |
| 金额字段大额时出现偏差 | 后端返回浮点数,前端精度不够 | 金额使用 decimal/字符串返回,前端用 decimal.js 计算 |
| 部分接口正常,部分接口 ID 变成字符串 | 实体类有的加了注解,有的没加 | 统一使用全局序列化配置,保证一致性 |
| 全局配置后,前端排序/比较结果异常 | Long 字段变成字符串,字符串比较和数字比较结果不同 | 前端比较时先 BigInt 转换,或后端只对主键类字段转字符串 |
这张表里最容易被忽略的是“控制台 console 是对的,但页面显示错”这一条。很多新手排查时会盯着页面代码反复看,其实真正的问题往往出现在上游的一个隐式转换。比如后端已经返回字符串了,但前端代码里写了个Number(value),把字符串转成了 number,精度又丢了。这种问题后端怎么改都没用,只能从前端代码层面修正。
4.4 我的一些实际操作心得
处理这种精度问题,最重要的不是选哪个库、用哪个注解,而是建立起对“类型边界”的敏感度。我现在的习惯是,在任何前后端联调场景里,只要看到接口返回的字段名里带id、no、code这种疑似标识符的字段,就会下意识看一眼它的类型和位数。如果是 long 型且接近或超过 16 位,就默认按字符串处理,不再信任它是数字。
后端同事之间协作时,我会在接口文档里直接约定:主键、订单号、流水号等标识性字段一律按 string 返回,字段类型标注清楚。这样前端不用猜,也不用每次联调都去试。如果项目里有接口文档平台或者 Swagger,这个约定最好作为自动校验规则写进去。
另外一个容易踩的坑是跨框架协作。比如后端是 Java,前端是 Vue,大家各自为政,后端觉得返回 Long 没问题,前端觉得是后端数据有问题。这种时候最有效的沟通方式就是把 Network 面板截图放到同一个缺陷单里,一边是原始 JSON,一边是前端打印值,谁的问题一目了然,能省掉大量扯皮时间。
最后说一个扩展场景。如果前端要做大量高精度数值运算,比如金额分摊、百分比计算,单纯靠字符串或 BigInt 是不够的,它们并不适合处理小数运算。这类场景要用专门的库,比如 decimal.js、big.js 或 bignumber.js,它们能精确处理十进制小数运算,避免浮点误差累积。换句话说,大整数精度问题是“超出安全范围需转字符串”,而高精度小数问题是“IEEE 754 表达不了所有小数”,两者本质类似,都是二进制浮点数的表达限制,但解决方案不同,千万别混为一谈。
我个人在实际操作中还有一个体会:遇到这种怪问题时,先想“它是不是一个类型边界问题”,再去查逻辑。很多时候你以为的代码 bug,其实只是数据结构从后端到前端的传递过程中,某个环节把类型弄丢了。把这条排查思路记在心里,遇到类似问题会少走很多弯路。