先说结论
MyBatis-Plus 默认的雪花算法(IdType.ASSIGN_ID)生成的是19 位 Long,而 JS 的 Number 能精确表示的最大整数是2^53 - 1 = 9007199254740991(16 位)。
19 位 > 16 位 →id 以数字形式进 JSON 就必然丢精度,末几位会变。这不是后端"传错了",是数字在 JS 里存不下。
我们的收口方式:在全局 Jackson 配置里把所有Long序列化成 String——而不是在实体字段上逐个加注解,那条路一定会漏。
现象:id 传到前端就变了个数
起因是列表页点「详情」偶尔查不到数据,对着看才发现前端拿到的 id 和数据库里的不是一个数:
数据库 1934567890123456789 接口 JSON(原始) 1934567890123456789 浏览器里读到的 1934567890123456800 ← 末 3 位变了用 node 复现更直白:
>String(Number("1934567890123456789"))'1934567890123456800'>Number("1934567890123456789")===1934567890123456789false它不报错、不抛异常。前端拿着这个"差不多对"的 id 去请求详情、去提交更新,结果是查不到数据、或者更新到了别的行——数据量少的时候还可能一直撞不出来,一路带到线上才发现。
根因:19 位超出了 JS 的安全整数范围
JS 里所有数字都是双精度浮点(IEEE 754),能精确表示的整数上限是 2^53 - 1:
Number.MAX_SAFE_INTEGER// 9007199254740991 ← 16 位超出这个范围,整数只剩 53 位精度,末位被舍入:
Number("9007199254740993")// 9007199254740992 ← 只差 1,也存不下而雪花 id 的结构(时间戳 41 位 + 机器位 10 位 + 序列号 12 位)决定了它是 19 位十进制数,正好越线。所以只要主键用雪花,前端就一定不能用数字接 id。
我第一版方案:给实体字段加 @JsonSerialize(不够用)
第一反应很自然——既然是主键字段出问题,那就在字段上加注解:
@TableId(type=IdType.ASSIGN_ID)@JsonSerialize(using=ToStringSerializer.class)privateLongid;单表查询确实好了。但很快发现还有漏网的——只要 id 不是从实体字段序列化出去的,注解就管不到:
- 统一返回体里的裸 Long:
R<Long>直接返回getId()的结果 Map<String, Object>拼装的返回值(统计、聚合、联表查询很常见)List<Long>这类集合,以及没继承实体基类的 VO- 第三方/工具类里带 Long 字段的对象
这些都是"字段注解覆盖不到"的路径,照样按数字出去、照样变形。加注解的粒度是字段,丢精度的风险却是全链路的。
最终方案:全局把所有 Long 序列化成 String
不逐个字段加注解了,改成声明一条全局规则:所有 Long 类型都用ToStringSerializer:
@AutoConfigurationpublicclassJacksonConfig{@BeanpublicJsonMapperBuilderCustomizerlongToStringCustomizer(){returnbuilder->builder.addModule(newJacksonModule(){@OverridepublicStringgetModuleName(){return"long-to-string";}@OverridepublicVersionversion(){returnVersion.unknownVersion();}@OverridepublicvoidsetupModule(SetupContextcontext){context.addSerializers(newSerializers(){@OverridepublicValueSerializer<?>findSerializer(SerializationConfigconfig,JavaTypetype,BeanDescription.SupplierbeanDesc,JsonFormat.Valueformat){if(type.isTypeOrSubTypeOf(Long.class)){returnToStringSerializer.instance;}returnnull;// 其余类型保持默认序列化}});}});}}两个容易踩的点:
- 用
JsonMapperBuilderCustomizer接入,别自己 new 一个 ObjectMapper。交给 Spring Boot 的构建链,spring.jackson.*的既有配置和项目里其它定制不会被顶掉。 - 注意包名是
tools.jackson.*(Jackson 3),不是网上随手能搜到的com.fasterxml.jackson.*:模块基类从SimpleModule变成了JacksonModule,findSerializer的签名也多出BeanDescription.Supplier和JsonFormat.Value两个参数。照 Jackson 2 的博客抄会直接编译不过。
实体上原来那些@JsonSerialize(using = ToStringSerializer.class)我没删——不冲突,也算兜底;但**"不丢精度"这件事的责任,已经交给全局配置了**。
配套要注意的三件事
- 前端把 id 当字符串用:别
parseInt(id)、别Number(id)、别+id转数字;路由参数和下拉选中值比较也用字符串(id === String(row.id))。前端"id 就该是数字"的直觉,是最容易埋雷的地方。 - 后端接口不用改:
@PathVariable Long id、@RequestParam Long id收到字符串照样能自动转换,前端传字符串是安全的。 - Long → String 是"全量"的:分页
total、计数、状态码这类 Long 也会变成字符串。需要数字的地方(分页组件、图表数据)要么前端Number(),要么在 VO 里改用Integer。这是改全局之后唯一需要额外留意的地方——我一开始也没想到total会跟着变。
其他几种方案,为什么没选
- 前端上 BigInt / json-bigint 解析:能解决,但要动整条请求解析链路,第三方 UI/图表/富文本组件未必兼容,改动面和回归范围都压在前端。
- 把主键真的改成 String 存:表结构、索引、数据迁移、外部系统对接全要动——为"展示"付"存储"的代价,不划算。
- 每个 VO 手动加注解:就是上面那个漏网的方案。人一多、路径一杂,必然会漏,漏一次就是一个线上 bug。
一句话总结:19 位雪花 id 超出 JS 安全整数,丢精度是必然;最省事的收口点在后端序列化——全局把所有 Long 转成 String,再把"Long 会变字符串"这件事同步给前端。
你们项目的雪花 id 是怎么处理的?是字段级注解,还是也做了全局配置?评论区聊聊。