1. 从一次线上告警说起:时间戳序列化的“坑”
那天晚上,手机突然弹出一条告警,提示某个核心接口的响应时间飙升。排查日志,发现一个奇怪的现象:一个返回用户列表的接口,响应体大小比平时大了近一倍。用工具抓包一看,问题出在时间字段上。原本期望返回的是一个简洁的1689054661000这样的长整型时间戳,但实际返回的却是"2023-07-12T08:51:01.000+00:00"这种完整的 ISO 8601 字符串。对于包含上千条用户记录的列表,每个对象多出几十个字符,累积起来就是巨大的网络传输开销和客户端解析压力。
我们当时用的正是 Fastjson2。这个库以其极致的性能著称,但在默认配置下,它对java.util.Date或java.time类型的序列化行为,可能并不符合所有场景的预期。尤其是在前后端分离、移动端优先的架构下,时间戳(Timestamp)因其紧凑、无歧义、易于跨平台处理的特性,成为时间信息交换的首选格式。那么,如何让 Fastjson2 乖乖地把时间序列化成时间戳,而不是一长串日期字符串呢?这不仅仅是改个配置那么简单,背后涉及到序列化器的定制、全局与局部策略的权衡,以及对 Fastjson2 新特性的深入理解。如果你也在为类似问题头疼,或者想提前规避这个性能与协作的“坑”,下面的内容就是为你准备的实战指南。
2. 理解 Fastjson2 的默认时间序列化行为
在动手配置之前,我们必须先搞清楚 Fastjson2 默认是怎么处理时间对象的。知其然,更要知其所以然,这样才能在遇到复杂场景时游刃有余。
2.1 默认行为:基于@JSONField注解与DateFormat的序列化
Fastjson2 对时间的序列化策略有一个清晰的优先级链条。在没有进行任何全局配置的情况下,它会按照以下顺序决定如何输出一个时间字段:
- 字段级别的
@JSONField注解:这是最高优先级的配置。如果你在实体类的某个Date字段上使用了@JSONField(format = “yyyy-MM-dd HH:mm:ss”),那么无论全局配置如何,这个字段都会按照指定的格式进行序列化。 - 类级别的
@JSONType注解:如果在类上使用了@JSONType注解并指定了format,那么该类中所有未单独配置@JSONField的日期字段会继承这个格式。 - 全局默认日期格式:如果以上两级都没有配置,Fastjson2 会查看全局默认的日期格式。在 Fastjson2 中,默认的全局日期格式是
null。这是一个关键点。 - 内置默认行为:当全局格式为
null时,Fastjson2 对java.util.Date和java.time.temporal.TemporalAccessor(如LocalDateTime) 的默认序列化行为是将其转换为ISO-8601 扩展格式的字符串。例如:2023-07-12T08:51:01.000+08:00。
你可以通过一个简单的测试来验证:
import com.alibaba.fastjson2.JSON; import java.util.Date; public class DefaultBehaviorTest { public static void main(String[] args) { Date now = new Date(); System.out.println(JSON.toJSONString(now)); // 输出: "2023-07-12T16:51:01.000+08:00" } }这种 ISO 格式虽然标准、无歧义,但正如开篇提到的,它在网络传输和解析效率上不如纯数字的时间戳。特别是在高并发、大数据量的微服务间调用场景下,这种差异会被放大。
2.2 为什么需要时间戳?场景驱动的格式选择
选择时间戳而非日期字符串,通常是基于以下几个实际工程考量:
- 传输效率:一个
long类型的时间戳(如1689054661000)在 JSON 中通常占用 13-16 个字符(包括可能的引号)。而一个完整的 ISO 日期字符串轻松超过 30 个字符。数据量越大,节省的带宽和序列化/反序列化时间就越可观。 - 解析便利性与一致性:几乎所有编程语言和平台(JavaScript, Python, Java, Go等)都原生支持从时间戳(毫秒或秒)构建日期对象,且行为一致。而解析形如
“yyyy-MM-dd HH:mm:ss”的字符串,则需要考虑时区、Locale 等问题,容易出错。 - 排序与比较:时间戳本身是数字,在数据库索引、内存排序、范围查询等操作上,比字符串有天然的效率优势。
- 前端友好性:现代前端框架(如 Vue、React)和图表库(如 ECharts)在处理时间轴数据时,往往更倾向于接收时间戳数组,这能极大简化前端数据处理逻辑。
因此,将 Fastjson2 的默认时间输出改为时间戳,是很多追求性能和简洁架构的团队的共同选择。
3. 核心方案:配置全局序列化器为时间戳
要让 Fastjson2 将所有日期类型默认序列化为时间戳,最直接有效的方法是配置一个全局的ObjectWriter。ObjectWriter是 Fastjson2 中负责将 Java 对象写入 JSON 的核心组件,我们可以通过自定义它来改变特定类型的序列化行为。
3.1 方案一:使用JSON.config进行全局配置(推荐)
这是 Fastjson2 推荐的方式,简洁且功能强大。我们可以在应用启动时(如 Spring Boot 的@PostConstruct或配置类中)进行一次性配置。
import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONWriter; import com.alibaba.fastjson2.writer.ObjectWriter; import java.lang.reflect.Type; import java.util.Date; public class Fastjson2GlobalConfig { public static void init() { // 创建一个针对 Date 类型的自定义序列化器 ObjectWriter<Date> dateToTimestampWriter = new ObjectWriter<Date>() { @Override public void write(JSONWriter jsonWriter, Object object, Object fieldName, Type fieldType, long features) { Date date = (Date) object; if (date == null) { jsonWriter.writeNull(); } else { // 核心:将 Date 的 getTime() 值,即毫秒时间戳,直接写入 JSON jsonWriter.writeInt64(date.getTime()); } } }; // 将自定义序列化器注册为 Date 类型的全局默认序列化器 JSON.getDefaultObjectWriterProvider().register(Date.class, dateToTimestampWriter); // 如果你也使用了 java.time API,比如 LocalDateTime,也需要类似处理 // 这里以 LocalDateTime 为例,将其转换为毫秒时间戳(需先转为 Instant) ObjectWriter<LocalDateTime> localDateTimeToTimestampWriter = new ObjectWriter<LocalDateTime>() { @Override public void write(JSONWriter jsonWriter, Object object, Object fieldName, Type fieldType, long features) { LocalDateTime localDateTime = (LocalDateTime) object; if (localDateTime == null) { jsonWriter.writeNull(); } else { // 将 LocalDateTime 转换为 UTC 时间的毫秒时间戳 long epochMilli = localDateTime.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(); jsonWriter.writeInt64(epochMilli); } } }; JSON.getDefaultObjectWriterProvider().register(LocalDateTime.class, localDateTimeToTimestampWriter); } }配置解析与注意事项:
writeInt64方法:这是关键。它直接将long值写入 JSON 流,输出的是一个不带引号的数字。如果你错误地使用了writeString(String.valueOf(date.getTime())),那么输出的将是带引号的字符串“1689054661000”,虽然对人类阅读影响不大,但严格来说它不再是 JSON Number 类型,可能在某些严格的解析器中引发问题。- 时区处理:对于
java.util.Date,它本质上存储的就是自1970-01-01T00:00:00Z(UTC) 以来的毫秒数,所以getTime()直接就是 UTC 时间戳。对于LocalDateTime,它是不带时区信息的“本地日期时间”,必须通过atZone指定一个时区(通常是系统默认时区或 UTC)才能转换为Instant并获取时间戳。这里是一个潜在的坑:如果你的系统跨时区部署,必须统一时区标准(通常是 UTC),否则序列化出来的时间戳会因服务器时区不同而不同。 - 注册时机:这个配置需要在任何 JSON 序列化操作发生之前执行。在 Spring Boot 中,可以放在一个带有
@Configuration注解的类的@PostConstruct方法里,或者使用ApplicationRunner/CommandLineRunner。
提示:在实际项目中,你可能会遇到多种时间类型(
Date,LocalDateTime,LocalDate,Instant等)。建议为它们分别创建并注册对应的ObjectWriter,以确保全局行为一致。可以封装一个工具类来完成所有这些类型的注册。
3.2 方案二:使用JSONWriter.Feature进行特性配置
Fastjson2 提供了一些内置的Feature(特性)来影响序列化行为。虽然目前(2.0.64版本)没有直接提供WriteDateAsTimestamp这样的特性,但我们可以组合使用其他特性来接近目标,不过这种方法有局限性。
// 这种方法无法直接将 Date 序列化为纯数字时间戳。 // 以下配置尝试使用 ISO8601 优化格式,但结果仍是字符串。 JSONWriter.Context context = new JSONWriter.Context(JSONWriter.Feature.WriteDateUseDateFormat); context.setDateFormat(“yyyy-MM-dd HH:mm:ss”); // 无效,因为最终仍是字符串格式 // 更接近目标的一种尝试:使用 WriteClassName 特性?不,这完全偏离了方向。结论:对于“序列化为时间戳”这个明确需求,使用自定义ObjectWriter是唯一可靠且直接的全局解决方案。内置的Feature主要服务于日期格式字符串的变换。
3.3 方案三:局部注解覆盖全局配置
即使配置了全局时间戳序列化,你仍然可以在特定的字段上使用@JSONField注解来覆盖全局行为,以满足特殊需求。这是 Fastjson2 灵活性的一种体现。
import com.alibaba.fastjson2.annotation.JSONField; import java.util.Date; import java.time.LocalDateTime; public class UserDTO { private Long id; private String name; // 这个字段遵循全局配置,将被序列化为时间戳 (如 1689054661000) private Date createTime; // 使用注解覆盖全局配置,该字段将被序列化为指定格式的字符串 @JSONField(format = “yyyy-MM-dd”) private Date birthday; // 对于 LocalDateTime 同样适用 @JSONField(format = “yyyy/MM/dd HH:mm:ss”) private LocalDateTime updateTime; // 甚至可以直接序列化为秒级时间戳(注意单位是秒) @JSONField(format = “unix”) private Date loginTime; // 输出如 1689054661 // getters and setters... }@JSONField(format = “unix”)的妙用:这个格式值非常实用,它告诉 Fastjson2 将日期序列化为自 1970-01-01 00:00:00 UTC 以来的秒数(Unix 时间戳)。这在一些 API 设计(如某些社交媒体 API)中很常见。需要注意的是,unix格式输出的是秒,而我们的自定义ObjectWriter通常输出的是毫秒。务必根据你的接口契约谨慎选择。
4. 实战中的进阶问题与解决方案
配置好全局序列化器只是第一步。在真实的复杂项目中,你可能会遇到一些边界情况和进阶问题。
4.1 处理null日期值与空集合
当日期字段为null时,我们的自定义ObjectWriter已经通过判断处理了。但有时我们还需要控制整个对象或集合的序列化行为。
- 全局忽略
null值:可以通过JSONWriter.Feature.WriteMapNullValue来控制。默认情况下,Fastjson2不序列化值为null的字段。如果你需要包含null的日期字段(值为null),需要在序列化时传入这个特性,但通常不推荐,因为这会增大数据体积。String json = JSON.toJSONString(obj, JSONWriter.Feature.WriteMapNullValue); - 处理包含日期的空集合:这不是问题。集合为空时,序列化为
[],自定义的ObjectWriter不会被执行。
4.2 与 Spring Boot 的HttpMessageConverter集成
在 Spring Boot Web 应用中,我们通常使用HttpMessageConverter来转换 HTTP 请求和响应的 JSON 数据。为了让 Spring MVC 使用我们配置好的 Fastjson2 实例,需要替换默认的 Jackson 转换器。
- 添加 Fastjson2 Spring Boot Starter 依赖(如果可用)或直接引入核心库。
- 创建一个配置类来配置
HttpMessageConverter:
import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.support.config.FastJsonConfig; import com.alibaba.fastjson2.support.spring.http.converter.FastJsonHttpMessageConverter; import org.springframework.context.annotation.Configuration; import org.springframework.http.MediaType; import org.springframework.http.converter.HttpMessageConverter; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import java.nio.charset.StandardCharsets; import java.util.Collections; import java.util.List; @Configuration public class Fastjson2WebConfig implements WebMvcConfigurer { @Override public void configureMessageConverters(List<HttpMessageConverter<?>> converters) { // 1. 调用我们之前写的全局配置初始化方法 Fastjson2GlobalConfig.init(); // 2. 创建 FastJsonHttpMessageConverter FastJsonHttpMessageConverter converter = new FastJsonHttpMessageConverter(); FastJsonConfig config = new FastJsonConfig(); // 3. 设置 FastJsonConfig(这里可以配置序列化特性、日期格式等) // 注意:由于我们已经通过 JSON.config() 全局注册了自定义的 ObjectWriter, // 所以 FastJsonConfig 中的日期格式设置可能不会生效,因为 ObjectWriter 优先级更高。 // config.setDateFormat(“yyyy-MM-dd HH:mm:ss”); // 可能被覆盖 // 设置字符集和支持的 MediaType config.setCharset(StandardCharsets.UTF_8); converter.setFastJsonConfig(config); converter.setSupportedMediaTypes(Collections.singletonList(MediaType.APPLICATION_JSON)); converter.setDefaultCharset(StandardCharsets.UTF_8); // 4. 将 Fastjson2 的转换器添加到 converters 列表的最前面,优先使用 converters.add(0, converter); } }关键点:确保Fastjson2GlobalConfig.init()在 Converter 被使用之前执行。这样,所有通过 Spring MVC@ResponseBody或@RestController返回的对象,其日期字段都会按照我们的全局配置序列化为时间戳。
4.3 反序列化:如何将时间戳读回 Date 对象?
序列化是“出去”,反序列化是“回来”。当我们把时间戳传给后端,后端需要将其解析回Date对象。Fastjson2 在反序列化时,对数字类型的时间戳有很好的原生支持。
import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONReader; import java.util.Date; public class DeserializeTest { public static void main(String[] args) { // 场景1:JSON 中的时间戳是数字 String jsonWithNumber = “{\”createTime\”: 1689054661000}”; User user1 = JSON.parseObject(jsonWithNumber, User.class); System.out.println(user1.getCreateTime()); // 正确输出 Date 对象 // 场景2:JSON 中的时间戳是数字字符串(带引号) String jsonWithStringNumber = “{\”createTime\”: \”1689054661000\”}”; // 默认情况下,Fastjson2 也能处理这种格式,因为它会尝试将字符串转换为 long User user2 = JSON.parseObject(jsonWithStringNumber, User.class); System.out.println(user2.getCreateTime()); // 同样能正确输出 Date 对象 // 场景3:使用 ISO 日期字符串反序列化 String jsonWithISOString = “{\”createTime\”: \”2023-07-12T08:51:01Z\”}”; User user3 = JSON.parseObject(jsonWithISOString, User.class); System.out.println(user3.getCreateTime()); // 也能正确解析 } } class User { private Date createTime; // getter and setter }反序列化的智能之处:Fastjson2 的JSONReader在解析日期字段时非常灵活。它会依次尝试:
- 如果是数字(
long或int),直接将其作为毫秒时间戳构造Date。 - 如果是字符串,先尝试解析为数字(处理场景2),如果失败,再尝试用 ISO 8601 等格式解析为日期(处理场景3)。
这意味着,即使我们全局将日期序列化为时间戳,也完全不影响后端接收前端传来的各种格式的时间数据,兼容性很好。当然,为了规范,建议前后端约定统一使用数字时间戳。
4.4 性能考量与线程安全
- 性能:自定义
ObjectWriter的write方法实现非常简洁(直接调用getTime()和writeInt64),其性能与 Fastjson2 内置的默认序列化器处于同一量级,几乎没有额外开销。选择时间戳本身就是为了提升性能。 - 线程安全:通过
JSON.getDefaultObjectWriterProvider().register(...)注册的ObjectWriter是全局的。Fastjson2 的ObjectWriterProvider内部使用ConcurrentHashMap来存储这些Writer,因此注册操作和查找操作都是线程安全的。自定义的ObjectWriter实现也应该是无状态的(就像我们上面写的那样),以确保线程安全。
5. 避坑指南:从 Fastjson1 升级到 Fastjson2 的时间序列化问题
如果你的项目正在从 Fastjson1 升级到 Fastjson2,在时间序列化方面可能会遇到一些行为不一致的地方,需要特别注意。
5.1 默认行为的差异
- Fastjson1:在未配置
SerializerFeature.WriteDateUseDateFormat且未设置全局日期格式的情况下,默认会将Date序列化为毫秒时间戳数字。这与 Fastjson2 的默认 ISO 字符串行为截然不同。 - Fastjson2:默认序列化为 ISO 8601 字符串。
升级影响:直接替换依赖后,所有没有显式配置日期格式的接口,其时间字段的输出会从数字突然变成字符串,很可能导致前端解析失败。
解决方案:升级后,必须按照本文第3节的方法,显式配置全局的时间戳序列化器,以保持与 Fastjson1 默认行为的兼容,或者推动前端适配新的格式。
5.2 API 与配置方式的变化
Fastjson1 中常用的JSON.toJSONStringWithDateFormat()、SerializerFeature.WriteDateUseDateFormat等 API 在 Fastjson2 中已不存在或行为改变。Fastjson2 更强调通过ObjectWriter/ObjectReader、JSONWriter.Feature和注解来进行配置。
迁移步骤:
- 移除所有
SerializerFeature相关代码。 - 使用
JSONWriter.Feature替换功能类似的特性(但注意,没有直接对应WriteDateAsTimestamp的)。 - 对于日期格式控制,优先使用
@JSONField(format=“...”)注解。 - 对于全局默认行为,必须使用
JSON.config()配合自定义ObjectWriter来实现。
5.3 常见错误:“属性丢失”或“冒号缺失”
在热搜词中看到 “java实体序列化json字符串 冒号缺失” 和 “fastjson2 json.praseobject 属性丢失”。这些问题虽然不直接是时间序列化导致的,但在升级过程中可能因为其他原因出现。
- 属性丢失:检查字段的 Getter 方法是否符合 Java Bean 规范(
getXxx,isXxx)。Fastjson2 默认使用getter进行序列化。或者,使用@JSONField注解显式指定字段名。确保升级后没有因为字段名大小写等问题导致序列化策略变化。 - 冒号缺失:这通常是序列化结果字符串格式错误,极有可能是自定义的
ObjectWriter实现有误,没有正确调用JSONWriter的方法,或者直接操作了底层字符串导致 JSON 格式破坏。务必使用jsonWriter.writeInt64()、jsonWriter.writeString()等标准方法写入值。
6. 总结与最佳实践建议
经过以上分析,我们可以总结出在 Fastjson2 中优雅处理时间戳序列化的最佳路径:
- 明确需求,统一约定:在项目伊始,团队内部应约定时间字段的传输格式。毫秒级时间戳因其通用性和高效性,在绝大多数场景下是最佳选择。
- 全局配置,一劳永逸:在应用启动入口,使用
JSON.config()注册自定义的ObjectWriter,将java.util.Date、java.time.LocalDateTime等常用时间类型全局序列化为时间戳。这是最彻底、影响范围最广的方式。 - 善用注解,灵活覆盖:对于少数需要特殊格式的字段(如只显示生日的年月日),使用
@JSONField(format = “...” )进行精细化的局部控制。注解的优先级高于全局配置。 - 妥善处理时区:在自定义
ObjectWriter中,特别是处理LocalDateTime时,明确指定时区(建议统一使用 UTC)。在反序列化时,确保系统时区设置正确,或使用@JSONField注解的timezone属性。 - Spring Boot 集成:通过自定义
WebMvcConfigurer配置FastJsonHttpMessageConverter,并确保在配置 Converter 之前完成 Fastjson2 的全局配置初始化。 - 升级兼容性:从 Fastjson1 升级时,将时间戳序列化配置作为必须的迁移步骤,并进行充分的接口测试,避免对前端造成破坏性变更。
最后,记住一点:序列化配置是系统与外界通信的“协议”。保持协议的清晰、一致和高效,是构建稳定、可维护系统的重要一环。通过合理的 Fastjson2 配置,让时间数据以最简洁、最有力的方式——时间戳,在你的系统中流动。