前后端Long精度丢失的踩坑

image

那天前端同事跑过来跟我说,"你那个接口返回的订单ID,更新数据时接口没返回异常,但数据重新查又没更新成功"

我当时第一反应是:不可能,后端查出来是什么就返回什么,肯定是你前端哪里处理出了问题。

然后我打开浏览器F12一看,Network面板里接口返回的JSON里,orderId 的值是 1784523698741256200,而数据库里存的是 1784523698741256193,后端拿1784523698741256200来更新数据确实没报异常。

好家伙,最后几位确实被改了。但问题是,这串数字在Java后端打印日志的时候是完完全全正确的,偏偏到了浏览器这边就变了。

根因其实很简单

稍微查了一下就明白了。我们后端之前是用的mysql的自增id做主键 设置的为int型,后来为了统一系统主键值 用雪花算法生成主键 设置为了Long,JavaScript里的 Number 类型是双精度浮点数,它能安全表示的最大整数是 2^53 - 1,也就是 9007199254740991。而Java的 Long 最大值是 2^63 - 1,远远超过了JS能精确表示的范围。

所以当后端把一个超出JS安全范围的Long值以数字形式写进JSON,浏览器在解析的时候就会丢精度。这个不是后端的bug,也不是前端的bug,是JavaScript语言本身的限制。

我们项目里的ID基本都是雪花算法生成的,长度在18~19位,妥妥超出 Number.MAX_SAFE_INTEGER,所以只要以数字返回,前端就一定拿不到精确值。

解决思路

思路其实很直接:后端在序列化成JSON的时候,把所有Long类型转成字符串。 这样JSON里的数字就变成了字符串,浏览器解析的时候不会当作Number去处理,精度自然不会丢失。

实现方式有很多种,可以在每个DTO的Long字段上加 @JsonSerialize(using = ToStringSerializer.class) 注解,但这样做太麻烦了,项目里几十个实体类,每个类里可能都有Long字段,一个个加注解纯粹是体力活。

更好的做法是全局统一配置,在消息转换器层面一次性解决。

项目里的实现

我们项目里是通过继承 WebMvcConfigurationSupport,重写 configureMessageConverters 方法来实现的。核心代码其实就这么几行:

@Override
public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
    MappingJackson2HttpMessageConverter jackson2HttpMessageConverter = 
            new MappingJackson2HttpMessageConverter();
    ObjectMapper objectMapper = new ObjectMapper();
    
    // 序列化成json时,将所有的long变成string
    SimpleModule simpleModule = new SimpleModule();
    simpleModule.addSerializer(Long.class, ToStringSerializer.instance);
    simpleModule.addSerializer(Long.TYPE, ToStringSerializer.instance);
    objectMapper.registerModule(simpleModule);
    
    // 前端多传了字段也不报错
    objectMapper.configure(
        DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
    jackson2HttpMessageConverter.setObjectMapper(objectMapper);
    
    converters.add(jackson2HttpMessageConverter);
}

做的事情拆开来看就三步:

第一步,创建一个 SimpleModule,往里面注册两条序列化规则——Long.class(包装类型)和 Long.TYPE(基本类型 long)都使用 ToStringSerializer。这两个缺一不可,因为有的实体用 Long,有的用 long,少注册一个就会漏掉。

第二步,把这个 SimpleModule 注册到 ObjectMapper 里,这样Jackson在序列化任何Long值的时候都会自动调用 toString(),输出到JSON里就是带引号的字符串。

第三步,把自定义的 ObjectMapper 设给 MappingJackson2HttpMessageConverter,再把它加到转换器列表里。

一个容易踩的坑

这里有个细节要注意。如果你用的是实现 WebMvcConfigurer 接口的方式,configureMessageConverters 方法里的配置可能会失效。因为 WebMvcConfigurer 的 configureMessageConverters 在Spring Boot某些版本下行为不太一致,自定义的转换器可能被默认转换器覆盖或者追加顺序不对。

我们项目里是直接继承 WebMvcConfigurationSupport,这个方式的优先级更高,配置更可靠。但代价是继承了它之后,Spring Boot关于WebMvc的自动配置就不再完全生效了,所以像静态资源映射、拦截器这些都得自己手动配,项目里也确实都配了:

@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
    registry.addResourceHandler("/static/**")
            .addResourceLocations("classpath:/static/");
    registry.addResourceHandler("swagger-ui.html", "doc.html")
            .addResourceLocations("classpath:/META-INF/resources/");
    // ...
}

另外顺带解决的两个问题

在同一个方法里,还顺带处理了两件事:

一是把 FAIL_ON_UNKNOWN_PROPERTIES 设成了 false。前端有时候会多传一些字段过来,如果不关掉这个配置,Jackson反序列化时遇到不认识字段就会抛 Unrecognized field 异常,很烦人。

二是加了一个 StringHttpMessageConverter 并且关掉了 WriteAcceptCharset,解决的是访问 actuator/prometheus 接口返回406的问题。

StringHttpMessageConverter stringHttpMessageConverter = new StringHttpMessageConverter();
stringHttpMessageConverter.setWriteAcceptCharset(false);
converters.add(stringHttpMessageConverter);

效果

改完之后重启服务,再看接口返回:

// 改之前
{ "orderId": 1784523698741256193 }

// 改之后
{ "orderId": "1784523698741256193" }

前端拿到的是字符串,精度不再丢失,问题解决。

这个问题说实话不算难,也是个容易复现的问题,但第一次遇到的时候确实容易懵,尤其是排查的时候日志里看到的值都是对的,偏偏前端拿到就是错的,会让人怀疑人生。核心就一句话:JS的数字装不下Java的Long,让后端序列化成字符串就行了。

全局配置比注解优雅得多,一处配置,全项目生效省心。在这里仅记录一下。

posted @ 2026-11-03 08:08  一个码农的日常  阅读(208)  评论(1)    收藏  举报