一、起因:一个看似"玄学"的订单号
某天联调时前端同事截图过来:订单详情返回的 orderId 明明是 18056123456789012345,小程序里打印出来却是 18056123456789012000。查数据库、查日志都对,唯独响应不对。最后定位到根因:雪花 ID 是 Long 类型,经 Jackson 默认序列化成 JSON 数字后,超过了 JavaScript 的 Number.MAX_SAFE_INTEGER(2^53 - 1,约 9007199254740991),精度被静默截断。这正是 Spring Boot 项目里最高频的序列化坑之一。
本文把 Long 精度、LocalDateTime 格式、时区偏移、循环引用、BigDecimal 这五个实战高频坑一次讲透,并给出可落地的全局配置方案。
二、坑 1:Long 类型精度丢失
分布式 ID(雪花、号段)普遍是 64 位 Long,序列化为 JSON 数字后,Java 与 JavaScript 的数值精度差异直接导致末位失真。修复思路:传给前端的 Long 统一序列化为字符串。
// 方案一:字段级,只对 ID 生效
public class OrderVO {
@JsonSerialize(using = ToStringSerializer.class)
private Long orderId; // 输出为 "18056123456789012345"
// 其余字段略
}
// 方案二:全局级,所有 Long 都转字符串(推荐)
@Configuration
public class JacksonConfig {
@Bean
public Jackson2ObjectMapperBuilderCustomizer longToStringCustomizer() {
return builder -> builder
.serializerByType(Long.class, ToStringSerializer.instance);
}
}
⚠️ 注意:全局转字符串会影响所有 Long 字段(如统计数值),若部分字段需要保留数字含义,改用字段级注解更精准。线上建议「默认全局转字符串 + 个别字段 @JsonSerialize 覆盖」。
三、坑 2:LocalDateTime 序列化成数组 [2026,10,24,14,30]
Spring Boot 默认注册了 JavaTimeModule,但默认策略下 LocalDateTime 会输出成 [2026,10,24,14,30,5] 这样的数组,前端拿到的不是字符串,直接导致时间渲染失败。需要在配置里关闭时间戳模式:
spring:
jackson:
date-format: yyyy-MM-dd HH:mm:ss
time-zone: Asia/Shanghai
serialization:
write-dates-as-timestamps: false
加上 write-dates-as-timestamps: false 后,LocalDateTime 输出为 "2026-10-24T14:30:05"。若想输出带空格的格式,配合 date-format 与 @JsonFormat 双保险:
public class OrderVO {
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private LocalDateTime createTime;
}
四、坑 3:时区偏移八小时
服务端部署在 UTC 时区、数据库存 UTC、前端在 GMT+8 时,经常出现"晚上八点提交的订单,接口返回凌晨四点"。根因是 Instant/Date 序列化时未统一时区。修法三件套:
- JVM 启动参数加
-Duser.timezone=Asia/Shanghai - Spring 配置
spring.jackson.time-zone: Asia/Shanghai - 数据库连接串统一时区:MySQL 加
serverTimezone=Asia/Shanghai,PostgreSQL 用TimeZone=Asia/Shanghai
⚠️ 真正的坑在于:LocalDateTime 不带时区,偏移问题通常来自 Date/Instant 字段。排查时先分清字段类型,别把 LocalDateTime 的锅甩给时区。
五、坑 4:双向关联导致 StackOverflowError
实体里 Order 持有 User、User 又持有 List<Order>,Jackson 递归序列化到栈溢出,接口直接 500。三种解法按推荐度排序:
- VO/DTO 隔离(最佳):响应对象只放需要的字段,彻底避免实体循环引用;
- @JsonIgnoreProperties:在反向字段上声明忽略,保留单向序列化;
- @JsonIgnore:直接忽略某个字段,简单但不够精细。
public class User {
private Long id;
private String nickname;
@JsonIgnoreProperties("user") // 序列化 orders 时不再展开 user
private List<Order> orders;
}
还有一个隐藏坑:Redis 缓存实体时也会触发同样的递归(Jackson 序列化到 Redis),所以实体设计上尽量避免双向强引用,必要时用 @JsonIgnore 兜底。
六、坑 5:BigDecimal 变科学计数法 / Double 精度丢失
金额字段用 Double 接收 JSON,0.1 + 0.2 ≠ 0.3 是经典事故。正确姿势:金额一律 BigDecimal,且 setScale 固定小数位。另外大数 BigDecimal(如 1E+10)默认可能输出科学计数法,前端解析易错:
spring:
jackson:
generator:
write-bigdecimal-as-plain: true # 大数输出普通数字而非科学计数法
// Java 侧:金额计算务必带 scale
BigDecimal amount = new BigDecimal("19.90");
BigDecimal discounted = amount.multiply(new BigDecimal("0.88"))
.setScale(2, RoundingMode.HALF_UP);
// 输出 17.51,而不是 17.512
七、收口:一份全局配置解决 80% 序列化问题
与其在每个 VO 上加注解,不如在配置层统一收口。推荐用 Jackson2ObjectMapperBuilderCustomizer(Spring Boot 官方推荐方式,不会覆盖自动配置):
@Configuration
public class JacksonConfig {
@Bean
public Jackson2ObjectMapperBuilderCustomizer globalJacksonCustomizer() {
return builder -> builder
// Long 转字符串防精度丢失
.serializerByType(Long.class, ToStringSerializer.instance)
// Date 统一格式
.simpleDateFormat("yyyy-MM-dd HH:mm:ss")
.timeZone(TimeZone.getTimeZone("Asia/Shanghai"))
// 忽略 null 字段,减小响应体积
.serializationInclusion(JsonInclude.Include.NON_NULL);
}
}
spring:
jackson:
serialization:
write-dates-as-timestamps: false
generator:
write-bigdecimal-as-plain: true
八、踩坑记录与最佳实践清单
- 别直接 new ObjectMapper():手动 new 的 Mapper 不会带 Spring Boot 的自动配置,必须用注入的 ObjectMapper 或经过 Customizer;
- @JsonFormat 对 LocalDateTime 有效,对 Java8 时间需配合 jackson-datatype-jsr310(Spring Boot 2.x 默认已带,3.x 也内置);
- 给前端的所有 ID 类字段,统一走「Long→String」策略,前端联调少一半问题;
- 实体类与 VO 分离:实体做 ORM 映射,VO 控制对外字段与格式,序列化问题在 VO 层一次解决;
- 上线前用
curl抓一次真实响应,肉眼核对 ID、时间、金额三类字段,比单元测试更快发现格式化问题。
序列化问题看着小,一旦线上爆雷往往是 P0:订单号对不上、时间全错、500 刷屏。把这五个坑的配置沉淀成一个 web-common 起步依赖,新服务开箱即用,是性价比最高的做法。