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;
LocalDateTime、Long和boolean 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"。如果这个字段表示一个绝对发生时刻,比如支付成功时间、消息发送时间、审计日志时间,跨时区系统里更建议用 Instant、OffsetDateTime 或统一存 UTC,再在展示层转换。
不要把所有时间都无脑写成 LocalDateTime。
坑二:Long 为什么到前端就精度丢失?
问题现象
后端返回:
json
{
"orderId": 7549013372036854771
}
前端拿到后变成:
js
7549013372036855000
后端同学第一反应通常是:是不是 Jackson 把 Long 序列化错了?
不是。Jackson 大概率没错,它把 Java Long 按 JSON number 正常写出去了。真正出问题的是 JavaScript 数字模型。
根因
JSON 规范本身没有 int64、int32、decimal 这种类型区分,只有 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 才应该优先变字符串。
比如:
userId、orderId、skuId:推荐字符串;count、stock、durationMs:一般仍然用数字;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 字段异常时,按这个顺序查:
- 先看真实响应:浏览器 Network、Postman、curl,不要只看后端对象。
- 确认 ObjectMapper 来源 :是不是 Spring Boot 自动注入的?有没有自己
new ObjectMapper()? - 查 java.time 模块 :有没有
jackson-datatype-jsr310,有没有注册JavaTimeModule或等效配置? - 查时间特性 :
WRITE_DATES_AS_TIMESTAMPS是否关闭? - 查 Long 是否超 2^53 - 1:如果是浏览器消费,ID 类字段优先字符串化。
- 查 boolean getter :
isXxx()对应的属性名通常是xxx,不要想当然以为字段名原样输出。 - 查 Lombok 生成结果 :必要时看编译后的 getter/setter,尤其是
boolean和Boolean混用。 - 用 @JsonProperty 固定外部契约:只要字段名是接口契约,就不要靠推断。
- 给 DTO 加序列化单测:接口字段名、时间格式、Long 类型都要断言。
总结
Jackson 这三个坑,本质上不是三个独立小问题,而是同一个问题:
后端接口返回的 JSON 是一份跨语言契约,不是 Java 字段的自然投影。
LocalDateTime 的坑,是时间语义和格式没有显式约定。
Long 精度丢失,是 Java 的 64 位整数进入了 JavaScript 的双精度数字世界。
boolean isXxx 字段名变化,是 JavaBean 属性命名规则和字段名不是一回事。
以后写 DTO 时,记住三条规则:
text
时间字段:明确格式,明确是否带时区。
ID 字段:超过 JS 安全整数范围,返回字符串。
布尔字段:Java 内部用 paid,外部需要 isPaid 就用 @JsonProperty 固定。
别等前端联调时才发现字段变了。JSON 契约应该在后端单测里被固定下来。
参考资料
- Jackson JavaTimeModule Javadoc:https://developer.adobe.com/experience-manager/reference-materials/cloud-service/javadoc/com/fasterxml/jackson/datatype/jsr310/JavaTimeModule.html
- Jackson Serialization Features Wiki:https://github.com/FasterXML/jackson-databind/wiki/Serialization-features
- Jackson
@JsonPropertyJavadoc:https://fasterxml.github.io/jackson-annotations/javadoc/2.6/com/fasterxml/jackson/annotation/JsonProperty.html - Jackson
@JsonFormatJavadoc:https://fasterxml.github.io/jackson-annotations/javadoc/2.9/com/fasterxml/jackson/annotation/JsonFormat.html - Jackson Annotations Wiki:https://github.com/FasterXML/jackson-annotations/wiki/Jackson-Annotations
- Project Lombok
@Getter/@Setter文档:https://projectlombok.org/features/GetterSetter - Project Lombok
@Accessors文档:https://projectlombok.org/features/experimental/Accessors - MDN
Number.MAX_SAFE_INTEGER:https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/MAX_SAFE_INTEGER - Spring Boot JSON 文档:https://docs.spring.io/spring-boot/reference/features/json.html
- Spring Blog:Introducing Jackson 3 support in Spring:https://spring.io/blog/2025/10/07/introducing-jackson-3-support-in-spring/