前阵子做一个前后端分离的管理系统,用户列表点“编辑”屡屡 404。第一反应是路由问题,查了半天,最后发现是主键 ID 在传输链路里已经被改了值:后端返回 8712394817293405184,浏览器控制台打印出来却变成 8712394817293405000。这就是典型的 number 类型超出 16 位引起的精度问题,前端、后端只要有一边没处理,数据就会在 JSON 解析这一步悄悄走样。这个问题不算难,但非常容易复现,面试也经常拿来当考察点,值得花点时间彻底讲透。
1. 为什么 number 类型一超 16 位就出问题
1.1 JavaScript 数字的真实存储范围
首先要搞清楚一个概念:我们说“16 位”,其实 JavaScript 的精确边界是Number.MAX_SAFE_INTEGER = 9007199254740991,也就是 2^53 - 1,刚好 16 位。这个范围内的整数,JS 都能精确表示;一旦超过,比如 9007199254740993,就保存不了,会被就近取整成 9007199254740992。平时大家嘴里的“超过 16 位会出问题”,严格说应该是“超过 2^53 - 1 会出问题”。只是业务里真正频繁出现的超长 ID,往往就是这个数量级。
JavaScript 的数字类型基于 IEEE 754 双精度浮点数标准,符号位占 1 位,指数位占 11 位,尾数位占 52 位,真正能精确表达的整数上限就是 2^53 - 1。超过这个上限的整数,尾数位不够用,只能按“最近可表示值”取整。理解这个原理很重要,因为很多同学只知道“大数字会丢精度”,但不知道为什么丢,导致排查时总往错误的方向想。
1.2 前后端数据通道里的精度断层
为什么前后端分离场景特别容易踩?因为 JSON 是前后端之间的传输协议,后端序列化时若没做特殊处理,Long 类型的 ID 会以 JSON 数字形式输出。前端拿到响应文本后,得用JSON.parse或浏览器自带的解析器转成对象,这一步里所有大数字会被当成 JS Number 存储。JSON.parse解析完成的那一瞬间,精度就已经丢了,而且是不可逆的。你无法在控制台里通过二次格式化找回原始值,因为原始数字在内存里已经不存在了。
换不做前后端分离的服务端渲染架构,ID 在服务端就渲染成字符串,反而碰不到这个问题——这也解释了为什么许多老系统从来没有这种 Bug。也就是说,这个问题的根因不在数据库、不在后端业务逻辑,而在“JSON 数字文本 → JS Number”这一层转换。明白了这一点,解决思路就清晰了:只要不让超长数字以数字形式出现在 JSON 里,问题就不会发生。
1.3 哪些业务最多踩到这个坑
什么样的业务最常见?分布式 ID、雪花 ID、数据库自增 BIGINT 主键这三类。
- 雪花 ID 一般是 64 位整数的十进制表示,常见的 19 位长度,百分百超过安全范围。
- 自增主键在单表数据量不大时,可能还是 10 位、11 位,一旦分库分表或者用顺序型 ID 替代,长度马上上去。
- 第三方开放平台回调里的实体 ID、Excel 导入导出场景中的行 ID,也可能遇到类似问题。
还有一类容易忽略的是报表统计字段。比如某个数值型字段本身是 Long 类型,单个值不一定超长,但多个值加起来很容易超过 2^53 - 1。所以我的习惯是:凡是超过 15 位的整数,无论是什么业务含义,出参统一按字符串处理,这个规则比逐个字段判断省心得多。
2. 后端处理:把超长数字安全地交给前端
2.1 全局序列化配置:一次改动,全项目生效
后端处理的核心思路是:不让超长数字以数字形式出现在 JSON 里,而是主动转成字符串。Spring Boot 默认用 Jackson 做序列化,最推荐的做法是加一个全局 Jackson 配置,把 Long 类型统一序列化为字符串:
import java.math.BigInteger; 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 -> { builder.serializerByType(Long.class, ToStringSerializer.instance); builder.serializerByType(Long.TYPE, ToStringSerializer.instance); builder.serializerByType(BigInteger.class, ToStringSerializer.instance); }; } }这段代码用Jackson2ObjectMapperBuilderCustomizer扩展 Spring Boot 自动配置好的 ObjectMapper,不碰其它默认行为。Long.class对应包装类型,Long.TYPE对应 long 基本类型,BigInteger看项目里有没有用到,顺手也处理掉。配置完之后,接口返回的所有 Long 字段都会变成 JSON 字符串,前端的JSON.parse不再触发精度丢失。实测下来,这种全局方案对存量项目侵入最小:不用改实体类,不用改 Controller,一行注解都不用加,测试环境回归一遍就完事。
全局序列化带来的副作用是:所有 Long 字段都变字符串,包括前端需要参与数字运算的统计值、金额、数量等。金额字段如果用 Long 存分,前端还要做加减法运算,字符串就会比较别扭。所以实践中我一般分两种策略:主键、外键、业务编号这类不做数学运算的标识字段,统一转字符串;确实需要在前端做数值运算的字段,保留数字类型,或者干脆改用 BigDecimal 再配合前端 decimal.js。如果你不想全局一刀切,可以走下面注解的方式。
2.2 注解定点处理:只对关键字段生效
定点处理就是在实体字段上加@JsonSerialize,例如:
import com.fasterxml.jackson.databind.annotation.JsonSerialize; import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; public class UserVO { @JsonSerialize(using = ToStringSerializer.class) private Long userId; private String userName; }这种方式的好处是精确控制,只把会超长的标识字段转字符串,其它 Long 字段保持数字,前端哪些地方能做运算、哪些不能做,一眼就能看出来。缺点是字段一多,注解写起来重复,而且后来人接手容易漏标。我遇到过一个项目,UserVO 里配了注解,OrderVO 里忘了,结果订单详情页又出现 404。所以我的建议是:新项目直接用全局方案,老项目为了降低回归风险可以先从关键 VO 注解入手,但一定要同步维护一份“哪些字段已处理”的清单,最好写在接口文档里。
2.3 Fastjson 与其它框架的序列化处理
不是所有项目都用 Jackson。如果用 Fastjson 1.x 做 JSON 序列化,可以在序列化时传入配置:
import com.alibaba.fastjson.serializer.SerializeConfig; import com.alibaba.fastjson.serializer.ToStringSerializer; SerializeConfig config = new SerializeConfig(); config.put(Long.class, ToStringSerializer.instance); config.put(Long.TYPE, ToStringSerializer.instance); String json = JSON.toJSONString(obj, config);如果你用的是 Spring Boot 且把默认 JSON 库切换成了 Fastjson,那么HttpMessageConverter的配置过程也要一并处理,而不只是手动调用JSON.toJSONString。国内很多老一点的后台管理系统还在用 Fastjson,配置方式跟 Jackson 略有差异,但思路一样:找到全局 ObjectMapper 或 SerializeConfig 的创建入口,把 Long 类型挂上ToStringSerializer。
其它序列化库如 Gson、Jackson XML,也都各自有定制点,本质都是把 Long 输出为字符串。还有一点容易被忽略:如果系统里存在多个序列化入口——比如缓存、MQ 消息体里也会写 Long——建议在每个入口都做一遍,否则可能出现“接口返回对、日志里错”的诡异现象。别问我是怎么知道的,线上查这种不一致问题的场面特别尴尬。
2.4 后端接收前端回传 ID:Long 还是 String
前面聊的是出参,入参方向也有讲究。前端一旦把 ID 当作字符串处理,请求后端时传的也是字符串,例如GET /user/8712394817293405184,或者 POST body 里的"userId": "8712394817293405184"。Spring MVC 接收路径参数时,如果 Controller 方法签名是Long userId,它能正确把字符串解析成 Long,不会丢精度,因为字符串到整数的转换是精确的。所以后端入参用 Long 本身没问题。
这里有一个容易被忽略的坑:如果前端没有全程字符串化,而是先Number(id),再塞进 URL 或 JSON,到后端时数字早就错了。后端即使接收 Long 也无济于事,因为错误发生在入参之前。所以入参方向的关键不在后端用哪种类型,而在前端必须保证传参时是原始字符串。如果你因为兼容性原因把参数声明成 String,那也没问题,只是业务代码里要自己负责把 String 再转成 Long 或交给 MyBatis 处理。两种方式按项目风格选一种,保持一致即可。
3. 前端处理:字符串到手之后,别手贱转数字
3.1 三个最容易丢精度的操作
后端把 ID 变成字符串返回后,问题只解决了一半。前端如果手痒,任何一个操作都可能让精度再次丢失。
第一个是Number(id),可能是为了类型统一而写。第二个是parseInt(id, 10),某些同学喜欢用这个把字符串转成数字,主键 Number 化之后直接凉。第三个是字符串拼接,比如id + ""这种看着无害,但如果拼接前 id 已经被转成了 Number,拼接结果也是已经被取整的值,等于把错误保留了下来。我见过更隐蔽的:在事件里写了Number(id)用于比较,比较时是好的,后面又顺手把这个 Number 回传给接口,精度就丢了。
这些操作的共同点是:把标识字段从字符串转成了 Number。标识字段的本质是“身份”,不是“数值”,我们根本不需要对它做任何数学运算。前端推荐的做法是:从接口拿到的 ID 一律保持字符串,不主动转换;展示、传参、比较都基于字符串;只有判断“这个 ID 是否合法”这类场景,才用Number.isSafeInteger检查,而检查用的输入也应该是从字符串转换来的临时值,不要污染原始数据。这个原则落实到团队里,就是 code review 时见一个Number(id)打回一个。
3.2 安全的比较方式:字符串优先,BigInt 兜底
前端最常见的比较场景是判断表格里的当前行 ID 是否等于某个已选中 ID。如果两边都是字符串,用===直接比较,100% 精确,没有任何问题。麻烦的是历史遗留代码,之前把 ID 存成了 Number,这时再拿字符串去===比较,两边类型不同,JS 会自动把字符串转数字再比,精度再次丢失,结果可能错误。所以一旦发现问题,最好的办法是从源头修正存储值,而不是在比较函数里做类型转换。实在要兼容旧数据,也要先把 Number 类型的 ID 转回字符串,然后用字符串比较,不要依赖==的隐式转换。
ES2020 引入的 BigInt 为前端提供了新的精确整数能力,例如:
const id1 = 9007199254740993n; const id2 = 9007199254740993n; console.log(id1 === id2); // trueBigInt 可以精确表示任意大的整数,也能跟字符串互相转换。但它有两个硬伤:不能与普通 Number 混合运算;JSON.stringify遇到 BigInt 会直接抛异常。所以实践中我一般只在做超大整数计算时用 BigInt,日常 ID 处理依旧用字符串。如果你要序列化含 BigInt 的对象,需要自定义 replacer:
JSON.stringify(obj, (key, value) => typeof value === "bigint" ? value.toString() : value );3.3 前端需要生成 19 位 ID 时怎么办
有些系统允许前端在本地生成 ID,比如先用临时 ID 记录草稿,提交时再往后端同步。这时候绝对不能再用Date.now()拼一个随机数然后当数字用,因为在 Number 精度限制下,生成的 19 位 ID 就已经不精确了。如果一定要自己拼,可以用 BigInt:
let seq = 0n; const workerId = 1n; const idPart = (BigInt(Date.now()) << 22n) | (workerId << 12n) | (seq++ & 4095n); const idStr = idPart.toString();这个例子只是示意,生产环境考虑到机器标识、时钟回拨、并发计数,建议使用现成且经过验证的库,而不是自己造轮子。选择库时要注意它返回的是字符串而不是 Number,否则等于白搭。另外,确认一下你们的 ID 生成策略是不是必须前端生成。大多数业务场景下,新建数据前可以先调后端接口拿一个 ID,这样既避免并发下的重复风险,也能把 ID 生成的精度问题彻底关在服务器端。我自己的默认选择是:能后端生成就绝不让前端生成,只在编辑草稿或本地临时关联这种场景才用前端临时 ID,而且临时 ID 明确加前缀区分,比如tmp_xxx。
3.4 传参与联调时的类型一致性检查
前端传参时,最容易出问题的不是普通字符串,而是数字类型被框架隐式转换。用 axios 的话,一般 GET 参数是对象:
const res = await axios.get("/user/detail", { params: { userId: "8712394817293405184" } });只要这个值是字符串,URL 上就是userId=8712394817293405184,后端收到也是精确的。如果某个地方不小心写成了Number(userId),URL 看起来一样,但数字已经错了,后端怎么接都救不回来。这里有个自查技巧:在浏览器 Network 面板里把请求 URL 复制出来,对比一下原始 ID 和后端日志里收到的 ID 是否一致,不一致就说明前端某个环节发生了隐式转换。
除了 URL 参数,POST body 里也常见类似问题。JSON 里的"userId": 8712394817293405184在前端构造 body 时如果是对象,会被JSON.stringify成数字文本,但这时候 Number 已经丢掉精度了,生成的数字文本本身就是错的。所以要检查的是源头:body 对象里这个属性值到底是什么类型。可以用typeof在控制台里打印,或者干脆在提交前做一个断言:
if (!/^\d{15,20}$/.test(userId)) { // 提示或者打日志,不要直接提交 }这种正则校验只针对主键格式的字符串,能在联调阶段就拦截掉不少“看起来正常、实际已经错了”的请求。
4. 常见问题与排查技巧实录
4.1 新增后跳详情 404:一个特别典型的现场
回到我开头说的那个 404。完整现场是这样:表格页面调新增接口,后端返回新记录的完整对象,前端拿到后把主键存到 store 里,然后跳详情页按 ID 查询。看起来链路没问题,实际上新增接口返回的对象里,id 是 Long 类型,后端没有做字符串化,JSON 里就是8712394817293405184。前端JSON.parse后,store 里的 id 已经变成8712394817293405000。详情页拿着这个已经改写的 ID 去请求,后端自然查不到数据,返回 404。整个过程没有报错、没有 500,只有“查不到”这个现象,定位起来特别耗时间。
这个 case 值得记下来,是因为它揭示了精度问题的典型特征:错误在下游表现为业务异常,根因却在上游的数据类型。排查思路反过来走,从详情接口的入参倒推到 store、再到新增接口的响应,才能发现中间某一步数字被改写。以后遇到“新增成功但点不进详情”这类问题,我有两个固定动作:先看 Network 里详情请求的 URL 是否带了正确 ID;再把新增接口响应里的原始 JSON 文本跟浏览器解析后的对象逐字段对比。这两个动作能在十分钟内锁定是不是精度问题,省去很多无谓的路由排查。
4.2 比对不一致、更新错数据的隐蔽坑
比 404 更隐蔽的是数据错乱:ID 没完全变样,只是末尾几位被取整,恰好撞上了数据库里另外一条记录。比如雪花 ID 最后几位本来各不相同,精度丢失后多条记录可能都落到同一个近似值上。前端列表里勾选一条记录,点击更新,后台收的却是另一条记录的 ID,于是把 A 的数据更新到了 B 上。这种问题危害极大,因为不会立刻报错,用户也发现不了,只能等数据对不上账才被查出来。规避办法就一条:确保全链路任何环节都不对主键做 Number 转换。
开发阶段想要尽早暴露这类问题,我建议在接口层做一个防御性校验。后端收到请求时,如果某些字段既是数值型又阈值很敏感(比如主键),可以根据业务范围判断是否合理。更简单有效的做法是前端在上报前断言,后端在日志里记录入参的字符串原文。把这些校验做成公共工具,而不是散落在业务代码里,这样不管前端哪个页面踩坑,后端日志都能立刻看到“入参和原始 ID 不一致”,定位时间能从小时级降为分钟级。
4.3 其他被精度问题牵连的场景
16 位问题不止发生在主键上。金额、积分这类对精度敏感的业务,如果后端用 Long 存“分”,超过 2^53 - 1 的累计值同样会在前端丢失。虽然单笔金额不太可能到 9 千万亿,但报表里求和后的总和完全可能很大。所以报表场景我喜欢让后端直接返回字符串或 BigDecimal 序列化后的字符串,前端只负责展示,不参与运算。还有坐标、高精度计数器、第三方开放平台的凭证号,都可能踩中同一个坑。
另外一个相关但容易混淆的问题是小数精度。浮点数0.1 + 0.2不等于0.3,属于 IEEE 754 二进制浮点数的表示误差,跟整数溢出不是一回事,但很多人会把它们混在一起。处理方式也完全不同:金额计算用 BigDecimal / decimal.js,主键超长用字符串 / BigInt。如果你的项目同时面临这两种问题,建议分开治理,不要在同一个工具函数里既做金额舍入又做 ID 转换,代码职责会变得很难维护。
4.4 前后端通用的排查清单与速查表
为了帮助团队快速定位,我整理过一份简易速查表,分享出来:
| 现象 | 可能原因 | 处理建议 |
|---|---|---|
| 详情/编辑 404 | 前端把 Long ID 转成 Number 后请求后端 | 后端 Long 序列化为字符串;前端保持字符串 |
| ID 末尾几位变成 0 | JSON.parse 时超范围数字被就近取整 | 后端全局序列化 Long 为 String |
| 更新了别的记录 | ID 精度丢失后撞上其它记录 | 全链路禁止对主键做数值运算 |
| JSON.stringify 报错 | 对象里包含 BigInt | 使用 replacer 把 BigInt 转成字符串 |
| 接口返回数字、前端显示为科学计数法 | 大数字由 JSON 数字存储 | 出参统一字符串 |
| 日志里 ID 与前端展示不一致 | 中间环节发生隐式转换 | 从前端到后端逐级打印入参原文 |
这张表不是理论推演,都是我实际排查过程中见过的现场。建议贴在工位上,联调的时候遇到类似现象先查这类问题,能少走很多弯路。
最后给一个实操顺序。拿到“疑似精度问题”的 Bug 工单时,按三步走:第一步,打开浏览器 Network,找到出错请求,复制 URL,确认里面的 ID 和后端数据库里的原始 ID 是否一致,如果 URL 里的 ID 末尾已经是 0,就是前端传参前丢了精度;第二步,看后端接口返回的 JSON 原文,找到对应 ID 字段,确认它在 JSON 文本里是数字还是字符串,如果是数字,说明需要后端补序列化配置;第三步,前后端各派一个人同时检查,后端查 Controller 入参日志的原始字符串,前端查 store 里保存时的数据类型,两边一对比,误差发生在哪一层立刻清楚。这三步做完还定位不到,那基本排除精度问题,可以去查业务逻辑或权限了。
处理这类问题久了,我的一个固定习惯是:项目初始化阶段就把 Long 序列化配置加上,并且在前端统一封装“主键字符串”约定,而不是等 Bug 出现再补救。前端代码规范里禁止对主键字段调用Number(),后端文档里所有 ID 字段标注为 string。这两条约定成本极低,但能保证后来接手的人不再踩同一个坑。如果你在联调时也遇到过那种“看起来没问题、数据就是不对”的糟心事,先别怀疑人生,按这个思路排查一遍,大概率就是 number 类型超出 16 位在中途偷走了几位数字。