
做个前后端分离项目时最常见也最容易被忽视的一个坑就是后端接口返回的 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 类型不够用但仔细一想就知道根源其实在后端。后端数据库里存的是 bigintJava 里对应的是 Long这些类型本身都能精确表达 19 位甚至 20 位的数字。问题出在 JSON 序列化这一层Jackson、Fastjson 这类序列化库默认会把 Long 序列化成 JSON 里的数字类型而 JSON 里的数字一旦被前端解析成 Number精度就保不住了。所以后端的核心任务就一句话在 JSON 序列化时把可能超范围的 Long 类型转成字符串输出。这个改动可以在字段级别做也可以在项目级别做全局配置具体看你的控制粒度。3.2 字段级处理最适合接口数量少、能改动实体类的场景如果你的项目里只有少数几个字段需要处理比如个别实体的主键、订单号直接用注解是最简单的方案。使用 Jackson 的JsonSerialize注解配合ToStringSerializerimport 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 设置为 STRINGJsonFormat(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其实只是数据结构从后端到前端的传递过程中某个环节把类型弄丢了。把这条排查思路记在心里遇到类似问题会少走很多弯路。