ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

前后端分离下 Long 型 ID 精度丢失:原理、排查与解决方案

前后端分离下 Long 型 ID 精度丢失:原理、排查与解决方案 前阵子做一个前后端分离的管理系统用户列表点“编辑”屡屡 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 后端接收前端回传 IDLong 还是 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 的对象需要自定义 replacerJSON.stringify(obj, (key, value) typeof value bigint ? value.toString() : value );3.3 前端需要生成 19 位 ID 时怎么办有些系统允许前端在本地生成 ID比如先用临时 ID 记录草稿提交时再往后端同步。这时候绝对不能再用Date.now()拼一个随机数然后当数字用因为在 Number 精度限制下生成的 19 位 ID 就已经不精确了。如果一定要自己拼可以用 BigIntlet 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 上就是userId8712394817293405184后端收到也是精确的。如果某个地方不小心写成了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 末尾几位变成 0JSON.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 位在中途偷走了几位数字。
返回列表