Jackson 序列化踩坑:LocalDateTime、Long 精度丢失和 boolean isXxx 字段

Jackson 序列化踩坑:LocalDateTime、Long 精度丢失和 boolean isXxx 字段

接口明明没报错,前端说数据全不对

后端写接口时,最容易被忽略的一类坑,不是 SQL 慢,也不是空指针,而是返回 JSON 的那一刻字段变了。

比如一个订单接口:

java 复制代码
@Data
public class OrderVO {
    private Long orderId;
    private LocalDateTime createTime;
    private boolean isPaid;
}

你以为返回的是这样:

json 复制代码
{
  "orderId": 7549013372036854771,
  "createTime": "2026-06-11 10:30:00",
  "isPaid": true
}

结果联调时可能变成:

json 复制代码
{
  "orderId": 7549013372036855000,
  "createTime": [2026, 6, 11, 10, 30, 0],
  "paid": true
}

三个字段,三个坑:

  • orderId 后几位变了;
  • createTime 不是字符串,而是数组;
  • isPaid 变成了 paid

后端日志里看一切正常,数据库也没问题,接口也没有 500。真正出问题的是:Java 对象到 JSON 之间的契约没有被你显式控制。

一句话结论

Jackson 序列化不是"把字段原样输出",而是先根据 JavaBean 规则识别属性,再按模块和序列化器把属性写成 JSON;LocalDateTimeLongboolean isXxx 正好踩在时间格式、数字精度和属性命名三个边界上。

下面按三个具体问题拆。

坑一:LocalDateTime 为什么变成数组?

问题现象

有些项目里,接口返回时间是正常字符串:

json 复制代码
{
  "createTime": "2026-06-11 10:30:00"
}

但换一个项目、换一个自定义 ObjectMapper,就变成了数组:

json 复制代码
{
  "createTime": [2026, 6, 11, 10, 30, 0]
}

还有一种更直接的报错:

text 复制代码
Java 8 date/time type `java.time.LocalDateTime` not supported by default:
add Module "com.fasterxml.jackson.datatype:jackson-datatype-jsr310"

最小复现

java 复制代码
ObjectMapper mapper = new ObjectMapper();

OrderVO vo = new OrderVO();
vo.setCreateTime(LocalDateTime.of(2026, 6, 11, 10, 30));

System.out.println(mapper.writeValueAsString(vo));

如果你没有注册 JavaTimeModule,Jackson 2 不能稳定按你想要的方式处理 Java 8 时间类型。即使注册了模块,如果 WRITE_DATES_AS_TIMESTAMPS 开着,LocalDateTime 也可能按数组形式输出。

Jackson 的 JavaTimeModule 文档说得很明确:多数 java.time 类型在 WRITE_DATES_AS_TIMESTAMPS 开启时会按数字或数组输出,关闭后才会按 ISO-8601 字符串输出;其中 LocalDateTime 因为不能稳定转换成 timestamp,在该特性开启时会表示成数组。

根因

这里有两层原因。

第一,LocalDateTime 属于 Java 8 java.time API,Jackson 需要 jackson-datatype-jsr310 这个模块来处理。

第二,LocalDateTime 没有时区信息。它表示的是"本地日期时间",不是一个绝对时间点。所以当 Jackson 被配置为写 timestamp 时,不能像 Instant 那样自然写成一个毫秒值,只能退回到数组结构。

这也是为什么你有时在 Spring Boot 项目里没遇到问题,一旦自己 new 一个 ObjectMapper 就出事:Spring Boot 会自动配置 JSON mapper,但你手写的 new ObjectMapper() 不会继承那些配置。

截至 Spring Boot 4.0,官方文档已经把 Jackson 3 作为默认 JSON 库,Spring 官方博客也提到 Jackson 3 相关默认值发生了变化,WRITE_DATES_AS_TIMESTAMPS 默认倾向 ISO-8601 字符串。但大量线上项目仍在 Spring Boot 2/3 和 Jackson 2 上,所以不要靠"我这个版本默认应该没事"来赌。

推荐解决

如果只是某个字段需要指定格式,可以用字段级注解:

java 复制代码
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createTime;

如果是整个项目统一格式,推荐全局配置:

java 复制代码
@Configuration
public class JacksonConfig {

    private static final String DATE_TIME_PATTERN = "yyyy-MM-dd HH:mm:ss";

    @Bean
    public Jackson2ObjectMapperBuilderCustomizer jacksonCustomizer() {
        DateTimeFormatter formatter = DateTimeFormatter.ofPattern(DATE_TIME_PATTERN);

        return builder -> {
            builder.featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
            builder.serializers(new LocalDateTimeSerializer(formatter));
            builder.deserializers(new LocalDateTimeDeserializer(formatter));
        };
    }
}

需要引入的包通常是:

xml 复制代码
<dependency>
    <groupId>com.fasterxml.jackson.datatype</groupId>
    <artifactId>jackson-datatype-jsr310</artifactId>
</dependency>

如果你用的是 spring-boot-starter-web,很多版本会间接带上相关依赖和自动配置;但只要你手写 ObjectMapper,就要重新确认模块和特性。

避坑建议

LocalDateTime 适合表示"不带时区的本地时间",比如"活动开始时间:2026-06-11 10:30"。如果这个字段表示一个绝对发生时刻,比如支付成功时间、消息发送时间、审计日志时间,跨时区系统里更建议用 InstantOffsetDateTime 或统一存 UTC,再在展示层转换。

不要把所有时间都无脑写成 LocalDateTime

坑二:Long 为什么到前端就精度丢失?

问题现象

后端返回:

json 复制代码
{
  "orderId": 7549013372036854771
}

前端拿到后变成:

js 复制代码
7549013372036855000

后端同学第一反应通常是:是不是 Jackson 把 Long 序列化错了?

不是。Jackson 大概率没错,它把 Java Long 按 JSON number 正常写出去了。真正出问题的是 JavaScript 数字模型。

根因

JSON 规范本身没有 int64int32decimal 这种类型区分,只有 number。浏览器里的 JavaScript Number 使用双精度浮点数表示数字,安全整数范围是:

text 复制代码
-(2^53 - 1) 到 2^53 - 1

也就是:

text 复制代码
-9007199254740991 到 9007199254740991

MDN 对 Number.MAX_SAFE_INTEGER 的解释是:超过这个范围后,整数级别的精确表示和比较就不再可靠。

而 Java 后端常见的雪花 ID、订单 ID、用户 ID,很容易超过这个范围。

所以这个坑本质不是:

text 复制代码
Java Long -> Jackson -> JSON 出错

而是:

text 复制代码
Java Long -> JSON number -> JavaScript Number 精度不足

错误修法

最粗暴的修法是把所有 number 都写成字符串:

yaml 复制代码
spring:
  jackson:
    generator:
      write-numbers-as-strings: true

这能止血,但不建议作为默认方案。

原因是它会影响所有数字,包括金额、数量、分页参数、统计指标。前端拿到后每个数字都变字符串,接口契约反而更混乱。

推荐解决:只把 ID 类 Long 转成字符串

字段级修复:

java 复制代码
@JsonSerialize(using = ToStringSerializer.class)
private Long orderId;

或者:

java 复制代码
@JsonFormat(shape = JsonFormat.Shape.STRING)
private Long orderId;

如果你的项目里所有 Long 基本都是 ID,可以做全局配置:

java 复制代码
@Configuration
public class JacksonConfig {

    @Bean
    public Jackson2ObjectMapperBuilderCustomizer longToStringCustomizer() {
        return builder -> {
            builder.serializerByType(Long.class, ToStringSerializer.instance);
            builder.serializerByType(Long.TYPE, ToStringSerializer.instance);
        };
    }
}

但这里要有一个工程判断:不是所有 Long 都应该变字符串,ID 类 Long 才应该优先变字符串。

比如:

  • userIdorderIdskuId:推荐字符串;
  • countstockdurationMs:一般仍然用数字;
  • amount:金额不要用浮点数,通常用 BigDecimal 或"分"为单位的整数,再明确前后端契约。

前端能不能用 BigInt?

可以,但不要把"前端用 BigInt"当成后端接口的默认答案。

原因有三个:

第一,JSON 解析默认不会自动把 number 变 BigInt。

第二,BigInt 和普通 Number 混用有额外成本。

第三,很多接口 ID 并不需要参与数学计算,它本来就是标识符,用字符串更符合语义。

所以后端返回给浏览器的超长 ID,最稳妥的契约仍然是字符串。

坑三:boolean isXxx 为什么返回时少了 is?

问题现象

后端 DTO:

java 复制代码
@Data
public class OrderVO {
    private boolean isPaid;
}

你以为 JSON 是:

json 复制代码
{
  "isPaid": true
}

结果返回:

json 复制代码
{
  "paid": true
}

这类问题很烦,因为它通常不是后端单测失败,而是前端取字段时发现:

js 复制代码
res.isPaid // undefined
res.paid   // true

根因

Jackson 识别 POJO 属性时,不是永远按字段名来,而是会结合 getter、setter、字段可见性和注解推断"逻辑属性名"。

JavaBean 规则里,布尔 getter 可以叫:

java 复制代码
public boolean isPaid()

这个 getter 对应的属性名是:

text 复制代码
paid

不是:

text 复制代码
isPaid

Lombok 又会进一步放大这个坑。Lombok 官方文档说明:对于以 is 开头且后面跟大写字母的 boolean 字段,生成 getter 时不会再额外加前缀;但如果字段类型是包装类型 Boolean,就不会使用 is 前缀,而是走 get 前缀。

也就是说:

java 复制代码
private boolean isPaid;  // getter: isPaid()
private Boolean isPaid;  // getter: getIsPaid()

前者按 JavaBean 属性推断容易得到 paid,后者则更可能得到 isPaid

这就是为什么同样叫 isPaid,基本类型和包装类型的 JSON 字段可能表现不一致。

推荐解决一:DTO 字段不要以 is 开头

最推荐的写法是:

java 复制代码
@Data
public class OrderVO {
    private boolean paid;
}

返回 JSON:

json 复制代码
{
  "paid": true
}

如果前端确实想要 isPaid,那就把它当成外部契约显式写出来,而不是靠字段命名推断。

推荐解决二:用 @JsonProperty 固定外部字段名

java 复制代码
@Data
public class OrderVO {

    @JsonProperty("isPaid")
    private boolean paid;
}

这样 Java 内部字段叫 paid,JSON 外部字段叫 isPaid

如果你必须保留历史字段:

java 复制代码
@Data
public class OrderVO {

    @JsonProperty("isPaid")
    private boolean isPaid;
}

这能解决返回字段名问题,但我不建议新 DTO 继续这么命名。内部字段和外部字段混在一起,后续 Lombok、MapStruct、BeanUtils、OpenAPI 生成工具都可能继续踩坑。

包装类型 Boolean 怎么选?

如果字段只有两种状态,用 boolean

java 复制代码
private boolean paid;

如果字段有三种状态:是、否、未知,用 Boolean

java 复制代码
private Boolean paid;

不要为了保留 isPaid 这个字段名,把类型从 boolean 改成 Boolean。类型语义应该由业务决定,不应该被 JSON 命名问题绑架。

推荐的项目级配置

如果是 Spring Boot 3 + Jackson 2 的常见项目,我会把时间格式和 Long ID 处理集中到一个配置里:

java 复制代码
package com.example.config;

import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

@Configuration
public class JacksonConfig {

    private static final String DATE_TIME_PATTERN = "yyyy-MM-dd HH:mm:ss";

    @Bean
    public Jackson2ObjectMapperBuilderCustomizer jacksonCustomizer() {
        DateTimeFormatter formatter = DateTimeFormatter.ofPattern(DATE_TIME_PATTERN);

        return builder -> {
            // 1. 时间不要写成 timestamp / 数组
            builder.featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
            builder.serializers(new LocalDateTimeSerializer(formatter));
            builder.deserializers(new LocalDateTimeDeserializer(formatter));

            // 2. Long 按字符串输出,避免浏览器 Number 精度丢失
            builder.serializerByType(Long.class, ToStringSerializer.instance);
            builder.serializerByType(Long.TYPE, ToStringSerializer.instance);

            // 3. 是否输出 null 看团队接口规范决定,不要随便改
            builder.serializationInclusion(JsonInclude.Include.ALWAYS);
        };
    }
}

如果你们项目不是所有 Long 都是 ID,不要全局把 Long 转字符串。可以只在 ID 字段上加 @JsonSerialize,或者写一个更细粒度的业务注解。

最小验证用例

这类坑最好别靠肉眼联调,直接写一个序列化单测。

java 复制代码
@SpringBootTest
class JacksonContractTest {

    @Autowired
    private ObjectMapper objectMapper;

    @Test
    void should_keep_json_contract_stable() throws Exception {
        OrderVO vo = new OrderVO();
        vo.setOrderId(7549013372036854771L);
        vo.setCreateTime(LocalDateTime.of(2026, 6, 11, 10, 30));
        vo.setPaid(true);

        String json = objectMapper.writeValueAsString(vo);

        assertThat(json).contains("\"orderId\":\"7549013372036854771\"");
        assertThat(json).contains("\"createTime\":\"2026-06-11 10:30:00\"");
        assertThat(json).contains("\"isPaid\":true");
        assertThat(json).doesNotContain("\"paid\":true");
    }
}

这里有一个细节:如果你内部字段叫 paid,但外部契约要求 isPaid,记得加:

java 复制代码
@JsonProperty("isPaid")
private boolean paid;

测试不是为了证明 Jackson 能用,而是为了固定接口契约。以后升级 Spring Boot、Jackson、Lombok,或者有人改 DTO 字段名,这个测试会第一时间告诉你 JSON 变了。

排查清单

遇到 JSON 字段异常时,按这个顺序查:

  1. 先看真实响应:浏览器 Network、Postman、curl,不要只看后端对象。
  2. 确认 ObjectMapper 来源 :是不是 Spring Boot 自动注入的?有没有自己 new ObjectMapper()
  3. 查 java.time 模块 :有没有 jackson-datatype-jsr310,有没有注册 JavaTimeModule 或等效配置?
  4. 查时间特性WRITE_DATES_AS_TIMESTAMPS 是否关闭?
  5. 查 Long 是否超 2^53 - 1:如果是浏览器消费,ID 类字段优先字符串化。
  6. 查 boolean getterisXxx() 对应的属性名通常是 xxx,不要想当然以为字段名原样输出。
  7. 查 Lombok 生成结果 :必要时看编译后的 getter/setter,尤其是 booleanBoolean 混用。
  8. 用 @JsonProperty 固定外部契约:只要字段名是接口契约,就不要靠推断。
  9. 给 DTO 加序列化单测:接口字段名、时间格式、Long 类型都要断言。

总结

Jackson 这三个坑,本质上不是三个独立小问题,而是同一个问题:

后端接口返回的 JSON 是一份跨语言契约,不是 Java 字段的自然投影。

LocalDateTime 的坑,是时间语义和格式没有显式约定。

Long 精度丢失,是 Java 的 64 位整数进入了 JavaScript 的双精度数字世界。

boolean isXxx 字段名变化,是 JavaBean 属性命名规则和字段名不是一回事。

以后写 DTO 时,记住三条规则:

text 复制代码
时间字段:明确格式,明确是否带时区。
ID 字段:超过 JS 安全整数范围,返回字符串。
布尔字段:Java 内部用 paid,外部需要 isPaid 就用 @JsonProperty 固定。

别等前端联调时才发现字段变了。JSON 契约应该在后端单测里被固定下来。

参考资料

相关推荐
江华森1 小时前
Tomcat 10 实战部署指南:从零到生产级 Web 容器
java·前端·tomcat
曹牧1 小时前
Java:XML转义
xml·java·开发语言
swordbob1 小时前
【RabbitMQ】消息丢失的 6 大场景及解决方案
后端·rabbitmq
leo_yu_yty1 小时前
Go语言分布式计算(并发Debug)
开发语言·笔记·后端·golang
心之伊始1 小时前
Dubbo 3 Consumer 调用链路源码分析:从 Proxy 到 Cluster、Directory、Router、LoadBalance
java·微服务·dubbo·源码分析·服务治理
我认不到你1 小时前
【开源、教程】RAG全流程实现(java+完整代码):第一弹
java·开发语言·人工智能·深度学习·ai·语言模型·开源
swordbob1 小时前
Spring Bean 生命周期
开发语言·spring
程序员小羊!1 小时前
16 JAVA MySQL 8.0
java·开发语言·mysql
西凉的悲伤1 小时前
Spring Boot 中 RedisTemplate 与 StringRedisTemplate 常用 Redis API 速查
spring boot·redis·后端·redistemplate·stringredis