Java日期时间三十年战争:从Date考古到LocalDateTime革命,以及数据库与前端的那点事儿

14 阅读21分钟

Java日期时间三十年战争:从Date考古到LocalDateTime革命,以及数据库与前端的那点事儿

一份横跨Java类型演进、数据库映射、前端交互的全景式避坑指南

引言:一个让无数程序员崩溃的“小问题”

如果你是一位Java开发者,你一定见过这样的代码:

// 猜猜这是几月?
Calendar cal = Calendar.getInstance();
cal.set(2024, 2, 1);
System.out.println(cal.get(Calendar.MONTH)); // 猜猜输出什么?

答案是2——但如果你以为这是2月,那就掉坑了。Calendar的月份从0开始,2代表的是3月。这个“反人类”的设计,坑了一代又一代Java程序员。

更令人头疼的是,当你小心翼翼地在Java中处理好了时间,把它存进数据库,再展示到前端——时区漂移、格式错乱、2038年危机……每一个环节都可能让你彻夜难眠。

日期时间处理,看似简单,实则是Java生态中最容易被低估的复杂性黑洞。从JDK 1.0的java.util.Date,到JDK 1.1的Calendar,再到JDK 8的java.time包(JSR-310),Java花了二十多年才把“时间”这件事整明白。而数据库映射和前端交互,又为这道难题增加了新的维度。

今天,我们就来一次Java日期类型的全景式深度剖析——从Java类型本身,到数据库映射,再到前端交互,看看到底谁该用、谁该扔、哪里会踩坑、如何优雅地“过河”。


第一章:考古现场——JDK 8之前的“黑暗时代”

1. java.util.Date —— 一个“名不副实”的类

Date是Java 1.0就存在的元老级类。但它的名字极具欺骗性——它并不代表一个日历上的日期(比如“2026-07-29”),而是代表一个时间戳——从Unix纪元(1970-01-01T00:00:00Z)开始的毫秒数。

// 看似在创建日期,实际上在操作时间戳
Date date = new Date();
System.out.println(date.getTime()); // 1710517044440 —— 一串数字

Date的三大“罪状”:

罪状一:可变性(线程不安全) —— Date对象的值可以被随意修改(setTime()方法)。在多线程环境下,一个线程修改了Date,另一个线程可能读到错误的值。

罪状二:设计反直觉 —— 月份从0开始(0=一月),年份从1900开始偏移。比如new Date(124, 2, 1)代表的是2024年3月1日(124代表2024年,2代表3月)。

罪状三:大部分方法已废弃 —— getYear()getMonth()getDay()等方法都被标记为@Deprecated。官方都告诉你“别用了”,但无数遗留代码还在用。

2. java.util.Calendar —— 一次“失败的救赎”

JDK 1.1引入了Calendar,目的是矫正Date的缺陷。但遗憾的是,Calendar只是把问题换了一种方式呈现

Calendar calendar = Calendar.getInstance();
int month = calendar.get(Calendar.MONTH); // 依然是0-11
int dayOfWeek = calendar.get(Calendar.DAY_OF_WEEK); // 周日=1,周一=2...

Calendar的问题清单同样触目惊心:

  • 依然是可变的,线程不安全
  • 月份依然从0开始,只是去掉了年份从1900开始的坑
  • API极其臃肿,需要通过Calendar.getInstance()获取实例
  • 字段常量混乱——Calendar.MONTHCalendar.DAY_OF_MONTHCalendar.DAY_OF_WEEK……光记住这些常量就够喝一壶的

有人甚至说:“Calendar可以说是JDK中最糟糕的类。”

3. SimpleDateFormat —— 线程不安全的“定时炸弹”

配套的SimpleDateFormat同样是个大坑——它不是线程安全的。在高并发场景下,多个线程共享同一个SimpleDateFormat实例会导致格式化错乱甚至数据损坏

// 错误示范:共享SimpleDateFormat
private static final SimpleDateFormat sdf = 
    new SimpleDateFormat("yyyy-MM-dd"); // ❌ 线程不安全!

// 正确做法:每次new一个,或者用ThreadLocal

实测数据显示:在10个线程并发处理时间操作的场景下,使用Date+SimpleDateFormat的方案会出现约15%的概率格式错乱,而新API方案零错误


第二章:革命曙光——JDK 8的java.time

2014年,Java 8发布了。它借鉴了Joda-Time的成功经验,引入了全新的java.time包(JSR-310)。这次,Java终于把“时间”这件事做对了。

新API的核心设计哲学是:不可变、线程安全、语义清晰

新API家族谱系

含义是否含时区精度
LocalDate仅日期(年-月-日)
LocalTime仅时间(时:分:秒.纳秒)纳秒
LocalDateTime日期+时间纳秒
ZonedDateTime日期+时间+时区纳秒
OffsetDateTime日期+时间+UTC偏移量⚠️ 仅偏移量纳秒
Instant时间戳(机器时间)✅ UTC纳秒
Period日期量(年/月/日)
Duration时间量(秒/纳秒)
DateTimeFormatter格式化/解析

核心类的精确定位

LocalDate —— “只要日期,不要时间”。适合:生日、纪念日、合同签订日期、财务报表日期等。

LocalDate birthday = LocalDate.of(1990, 5, 20); // 月份从1开始!✅
LocalDate today = LocalDate.now();
int age = Period.between(birthday, today).getYears(); // 计算年龄

LocalTime —— “只要时间,不要日期”。适合:营业时间、打卡时间、会议开始时间等。

LocalTime openingTime = LocalTime.of(9, 0);
LocalTime closingTime = LocalTime.of(18, 0);

LocalDateTime —— “日期+时间,但不管时区”。适合:订单创建时间、本地事件记录、日志时间戳(不跨时区时)。

LocalDateTime orderTime = LocalDateTime.now(); // 2026-07-29T14:30:00
LocalDateTime deadline = orderTime.plusDays(3).withHour(23).withMinute(59);

ZonedDateTime —— “带时区的完整时间”。适合:跨时区会议、国际航班时刻、全球化系统的用户时间。

ZonedDateTime beijingTime = ZonedDateTime.now(ZoneId.of("Asia/Shanghai"));
ZonedDateTime newYorkTime = beijingTime.withZoneSameInstant(ZoneId.of("America/New_York"));

Instant —— “纯机器时间”。适合:日志时间戳、分布式系统事件排序、缓存过期时间、API时间戳传输。

Instant now = Instant.now(); // 2026-07-29T06:30:00Z (UTC)
// 分布式系统中,所有节点用Instant记录事件,天然可排序

OffsetDateTime —— 介于InstantZonedDateTime之间。它只记录UTC偏移量(如+08:00),但不记录完整的时区规则(如夏令时)。适合:网络协议传输、数据库存取


第三章:性能大对决——数据会说话

光说不练假把式。我们用数据说话:

创建性能

  • Instant.now() 最快
  • new Date()Calendar.getInstance() 快约3倍
  • Java 8新API整体优于传统API

格式化性能

  • DateTimeFormatterSimpleDateFormat 快约2-3倍
  • SimpleDateFormat 线程不安全,每次需要new或加锁

实测:将字符串"2024-03-15T12:00:00"转换为对象——

  • Date方案:平均耗时 1.2ms/百次
  • LocalDateTime方案:平均耗时 0.3ms/百次

新版API速度提升4倍

内存占用

创建百万个时间对象:

  • Date堆内存:约 480MB
  • LocalDateTime堆内存:约 210MB
  • GC次数减少60%

新API更省内存的原因是:去掉了冗余的时区字段,对象结构更紧凑。


第四章:选型决策树——别再选错了

面对这么多类型,该怎么选?这是一棵决策树

开始
  │
  ├─ 只需要日期(年--日)? → LocalDate
  │
  ├─ 只需要时间(时:分:秒)? → LocalTime
  │
  ├─ 需要日期+时间?
  │   │
  │   ├─ 不需要时区? → LocalDateTime
  │   │
  │   └─ 需要时区?
  │       │
  │       ├─ 需要完整的时区规则(夏令时等)? → ZonedDateTime
  │       │
  │       └─ 只需要UTC偏移量(如+08:00)? → OffsetDateTime
  │
  └─ 只需要时间戳(机器时间,不关心人类可读)? → Instant

快速参考卡片

你的需求用这个别用这个
生日、日期LocalDateDateCalendar
打卡、营业时间LocalTimeDateCalendar
订单创建时间LocalDateTimeDateCalendar
跨时区会议ZonedDateTimeDate+TimeZone
日志时间戳InstantDate
格式化日期DateTimeFormatterSimpleDateFormat
日期计算(年龄)Period手动Calendar计算
时间计算(耗时)Duration手动毫秒计算

第五章:数据库映射——从“鸡同鸭讲”到“精准对齐”

日期类型在Java和数据库之间的映射,是生产环境中最容易出现“幽灵Bug”的地方。你以为存的是“2026-07-29 14:30:00”,查出来却变成了“2026-07-29 06:30:00”——8小时时差,足以让一个跨国电商系统的订单时间全线崩溃

5.1 映射全景图

JDBC 4.2(Java 8引入)规范定义了Java java.time类型与SQL标准类型的映射关系:

SQL标准类型MySQL类型PostgreSQL类型推荐Java类型语义说明
DATEDATEDATELocalDate纯日期,无时间
TIMETIMETIMELocalTime纯时间,无日期
TIMESTAMP WITHOUT TIME ZONEDATETIMETIMESTAMPLocalDateTime日期+时间,无时区
TIMESTAMP WITH TIME ZONETIMESTAMPTIMESTAMPTZOffsetDateTime / Instant日期+时间,有时区

⚠️ 关键认知LocalDateTime在SQL标准中对应的是TIMESTAMP WITHOUT TIME ZONE。它不是一个时间点,而是一组“日历字段”(年、月、日、时、分、秒)的集合。把它存进TIMESTAMP WITH TIME ZONE(MySQL的TIMESTAMP)列,就像把一张写着“下午3点”的纸条交给不同时区的人——每个人理解的“下午3点”都不一样。

5.2 MySQL的“DATETIME vs TIMESTAMP”——一字之差,天壤之别

这是最容易踩的坑,没有之一

DATETIME —— “照镜子”类型

  • 存储你给它的字面值,不做任何转换
  • 例如:你存2026-07-29 14:30:00,读出来就是2026-07-29 14:30:00
  • 不包含时区信息,适合LocalDateTime

TIMESTAMP —— “翻译官”类型

  • 存储时:从当前时区转换到UTC再存储
  • 读取时:从UTC转换回当前时区再返回
  • 例如:你在东八区存2026-07-29 14:30:00,数据库存的是2026-07-29 06:30:00(UTC)。换一个UTC时区的应用来读,拿到的是2026-07-29 06:30:00——同一个时间点,不同时区的“翻译”结果不同
  • 存储上限TIMESTAMP只能存到2038-01-19(32位时间戳溢出问题)
// ❌ 错误示范:将LocalDateTime存入TIMESTAMP列
LocalDateTime localTime = LocalDateTime.of(2026, 7, 29, 14, 30, 0);
// 数据库是UTC时区,存进去的是 2026-07-29 06:30:00
// 换一个东八区的应用来读,拿到的却是 2026-07-29 14:30:00
// 你以为存的是"东八区下午2点半",实际上存的是"UTC上午6点半"
// ——两个应用拿到的是同一个"字面值"吗?不是!是同一个时刻的不同表示!

MySQL官方文档明确指出:LocalDateTime存储到TIMESTAMP列时,你可能无法存储正确的基于UTC的值,因为该值的时区实际上是未定义的。

最佳实践

  • 数据库字段用DATETIME → Java用LocalDateTime(语义对齐,无时区转换风险)
  • 数据库字段用TIMESTAMP → Java用InstantOffsetDateTime(语义对齐,明确时区)

5.3 JPA/Hibernate映射实战

新项目(推荐) :直接使用LocalDateTime/LocalDate无需@Temporal注解

@Entity
public class Order {
    @Id
    private Long id;
    
    // ✅ 推荐:LocalDateTime + 无@Temporal
    @Column(name = "created_at", columnDefinition = "DATETIME")
    private LocalDateTime createdAt;
    
    // ✅ 纯日期用LocalDate
    @Column(name = "delivery_date", columnDefinition = "DATE")
    private LocalDate deliveryDate;
    
    // ✅ 需要时区用Instant
    @Column(name = "updated_at", columnDefinition = "TIMESTAMP")
    private Instant updatedAt;
    
    @PrePersist
    protected void onCreate() {
        createdAt = LocalDateTime.now();
        updatedAt = Instant.now();
    }
}

遗留系统(兼容旧代码) :如果必须用java.util.Date,需要配合@Temporal注解:

@Entity
public class LegacyEntity {
    @Column(name = "create_time")
    @Temporal(TemporalType.TIMESTAMP)  // DATE / TIME / TIMESTAMP
    private Date createTime;
}

⚠️ 时区统一配置:在application.yml中明确设置Hibernate的时区,避免JVM默认时区差异导致的问题:

spring:
  jpa:
    properties:
      hibernate:
        jdbc:
          time_zone: UTC  # 统一用UTC,避免时区漂移

5.4 MyBatis映射

MyBatis 3.4.5+内置了LocalDateTimeTypeHandler,无需额外配置:

<resultMap id="orderResultMap" type="Order">
    <result property="createTime" column="create_time" 
            typeHandler="org.apache.ibatis.type.LocalDateTimeTypeHandler"/>
</resultMap>

或者直接在实体类中使用,MyBatis会自动处理:

public class Order {
    private LocalDateTime createTime;  // 自动映射
}

5.5 数据库映射决策矩阵

你的业务需求数据库字段类型Java类型理由
生日、合同日期(无时间)DATELocalDate语义完全一致
订单创建时间(不跨时区)DATETIMELocalDateTime无时区转换,最安全
用户操作日志(需全球排序)TIMESTAMPInstantUTC存储,天然可排序
跨时区会议时间TIMESTAMPZonedDateTime保留完整时区规则
API传输/网络协议TIMESTAMPOffsetDateTimeJDBC标准推荐

第六章:特殊专题——当“TIMESTAMP的2038年限制”遇上“Instant的时区需求”

这是一个非常经典且现实的难题:我想存储Instant/OffsetDateTime来明确时区,但又想避开MySQL TIMESTAMP的2038年限制,该怎么办?

6.1 问题的根源

  • MySQL的TIMESTAMP类型使用4字节存储,范围是'1970-01-01 00:00:01' UTC'2038-01-19 03:14:07' UTC
  • InstantOffsetDateTime可以表示远超过2038年的时间点。
  • 直接映射InstantTIMESTAMP,在存2038年之后的日期时会报错。

6.2 核心解决方案:用 DATETIME + 约定存储 UTC 时间

你的目标“存储明确时区的时间”,本质上是要记录一个绝对的时间点(Instant)。最稳健的做法是:

  1. 数据库字段:定义为 DATETIME 类型(范围1000-9999年,无2038限制)。
  2. 存储规则约定所有时间值均以UTC时区存储
  3. Java映射:在Java代码中,将InstantOffsetDateTime转换为UTC时间的LocalDateTime再存入数据库;读取时,再逆向转换回来。
代码示例

假设你的数据库字段是 created_at DATETIME

存储时(Java -> 数据库)

// 1. 你的Java对象,使用Instant表示一个绝对时间点
Instant now = Instant.now();

// 2. 【关键】将精度截断到微秒,匹配MySQL DATETIME精度
Instant truncated = now.truncatedTo(ChronoUnit.MICROS);

// 3. 转换为UTC的LocalDateTime
LocalDateTime utcLdt = LocalDateTime.ofInstant(truncated, ZoneOffset.UTC);

// 4. 使用JDBC的setObject方法存入
preparedStatement.setObject(1, utcLdt);

读取时(数据库 -> Java)

// 1. 从数据库读取,得到的是无时区信息的LocalDateTime
LocalDateTime utcLdt = resultSet.getObject(1, LocalDateTime.class);

// 2. 将其视为UTC时间,转换回Instant
Instant instant = utcLdt.toInstant(ZoneOffset.UTC);

// 3. 你的Java对象使用Instant
myObject.setCreatedAt(instant);

JDBC驱动支持说明:JDBC 4.2规范要求驱动支持OffsetDateTime,但对InstantZonedDateTime的支持是可选的。为了更好的兼容性,你可以在与JDBC交互的边界上使用OffsetDateTime(带UTC偏移),内部业务逻辑则继续使用Instant

6.3 ⚠️ 必须注意的关键问题与对策

这个方案非常成熟,但在落地时有几个关键点必须处理:

问题1:防止“时区漂移”(最重要!)

现象:如果你的JDBC驱动或应用程序的默认时区不是UTC,LocalDateTime在存入DATETIME字段时,可能会被驱动“自作主张”地转换,导致存进去的时间和你预期的不一致。

解决方案(三选一)

  • 方案A:统一JVM时区参数(推荐):在启动JVM时,通过参数强制指定时区为UTC。

    java -Duser.timezone=UTC -jar your-app.jar
    

    这是最干净、最全局的解决方式。

  • 方案B:配置数据源时区:在数据库连接URL中指定时区。

    jdbc:mysql://localhost:3306/db?serverTimezone=UTC
    
  • 方案C:在代码中显式转换(最保险):在任何数据库读写操作前,都将时间转换为UTC的LocalDateTime,如上面“核心方案”中的代码所示。这种方式不依赖外部环境,代码自包含,最为可靠。

问题2:纳秒精度丢失

现象Instant的精度是纳秒(ns),而MySQL的DATETIME最高精度是微秒(µs)。直接存储Instant会导致纳秒部分被截断,可能在极端情况下(如单元测试)导致两个逻辑上相同的Instant对象不相等。

解决方案:在转换时,明确地对Instant进行截断(truncatedTo) 操作。

// 存储前,将Instant精度截断到微秒
Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS);
// 然后再进行后续的 LocalDateTime.ofInstant(...) 转换

6.4 三种方案的终极对比

方案数据库类型Java类型优点缺点与风险
❌ 不推荐TIMESTAMPInstant语义最匹配,存储空间小有2038年限制,无法存储未来时间
✅ 推荐方案DATETIMELocalDateTime (UTC) + 边界转换无2038年限制,范围广,可读性好严格约定所有时间均为UTC,并注意防止时区漂移
💡 备选方案BIGINTInstant无2038年限制,无时区问题,排序快可读性差,查询过滤不便(如按日期范围查询需计算),需在应用层转换

6.5 最终建议

最平衡、最推荐的做法是:

  • 数据库:使用 DATETIME 类型。
  • Java:在业务逻辑层统一使用 Instant
  • 边界转换:在数据库访问层(Repository/DAO),统一将 Instant 转换为 UTC 的 LocalDateTime 进行读写。
  • 环境配置:将应用服务器的默认时区设置为 UTC,或确保所有时间操作都显式使用 ZoneOffset.UTC

这套方案既能彻底规避2038年问题,又能保持代码中时间语义的清晰(Instant代表绝对时刻),同时数据库中的存储也具备良好的可读性。


第七章:前端交互——从“鸡同鸭讲”到“对表成功”

前后端时间交互的混乱程度,不亚于数据库映射。前端可能发来"2026-07-29T14:30:00",也可能发来"2026/07/29 14:30",甚至是一个时间戳数字——而后端必须全部正确解析。

7.1 两个核心注解,各司其职

@DateTimeFormat (Spring框架)—— “接收专用”

  • 作用于数据绑定阶段:将前端传来的字符串转换为Java时间对象
  • 适用场景:表单提交(@RequestParam@ModelAttribute
  • ⚠️ @RequestBody JSON请求无效——JSON反序列化由Jackson处理,不经过Spring的数据绑定

@JsonFormat (Jackson)—— “收发一体”

  • 控制JSON的序列化(Java→JSON返回前端)和反序列化(JSON→Java接收前端)
  • 适用场景@RequestBody JSON请求、@ResponseBody JSON响应
  • 同时控制输入和输出格式,是前后端JSON交互的主力
public class OrderDTO {
    // 场景1:表单提交(非JSON)—— 用@DateTimeFormat
    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime submitTime;
    
    // 场景2:JSON交互 —— 用@JsonFormat
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
    private LocalDateTime createTime;
    
    // 场景3:两者都用到(既接收JSON又返回JSON,格式不同)
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
    @DateTimeFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
    private LocalDateTime updateTime;
}

💡 一句话总结@DateTimeFormat表单/URL参数(Spring MVC层),@JsonFormatJSON(Jackson层)。混用不冲突,各管各的。

7.2 全局配置——让规范成为默认

方案一:全局application.yml配置(推荐)

spring:
  jackson:
    date-format: yyyy-MM-dd HH:mm:ss
    time-zone: Asia/Shanghai
    serialization:
      write-dates-as-timestamps: false  # 不输出时间戳,输出格式化字符串

方案二:全局Jackson配置(更灵活)

@Configuration
public class JacksonConfig {
    @Bean
    public Jackson2ObjectMapperBuilderCustomizer jsonCustomizer() {
        return builder -> {
            // 统一序列化格式
            builder.serializers(new LocalDateTimeSerializer(
                DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
            builder.deserializers(new LocalDateTimeDeserializer(
                DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
            // 统一时区
            builder.timeZone(TimeZone.getTimeZone("Asia/Shanghai"));
        };
    }
}

方案三:字段级注解(覆盖全局配置)

public class OrderVO {
    // 覆盖全局配置,此字段用不同格式
    @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss'Z'", timezone = "UTC")
    private Instant createdAt;
}

7.3 时区——最容易“翻车”的一环

典型事故现场

前端在东八区发送"2026-07-29 14:30:00",后端(默认UTC)收到后解析为2026-07-29 14:30:00 UTC,存入数据库变成14:30 UTC。前端再查出来,看到的是22:30(东八区)——整整差了8小时

解决方案

原则:服务端统一用UTC,前端展示时再转换

// 后端统一配置
spring.jackson.time-zone: UTC

// 接收前端时间时,明确时区
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "UTC")
private LocalDateTime createTime;

// 或者使用Instant(天然UTC)
private Instant createdAt;  // 前端传时间戳或ISO-8601

前端最佳实践:使用new Date().toISOString()发送标准ISO-8601格式:

// 前端发送
const now = new Date().toISOString();  // "2026-07-29T06:30:00.000Z"
// 后端用Instant或配置了UTC时区的LocalDateTime接收

7.4 前端交互决策速查表

场景推荐方案说明
表单提交(非JSON)@DateTimeFormat只控制接收
JSON请求/响应@JsonFormat同时控制收发
全项目统一格式application.yml全局配置省心省力
特定字段特殊格式字段级@JsonFormat覆盖全局
跨时区系统服务端UTC + 前端toISOString()最安全
微服务间通信Instant + ISO-8601无歧义

第八章:新旧互操作——如何安全“过河”

如果你的项目还在用Date,但想迁移到新API,这是安全转换的方式:

// Date → Instant → ZonedDateTime → LocalDateTime
Date oldDate = new Date();
LocalDateTime newDateTime = oldDate.toInstant()
    .atZone(ZoneId.systemDefault())
    .toLocalDateTime();

// LocalDateTime → Instant → Date
LocalDateTime ldt = LocalDateTime.now();
Date date = Date.from(ldt.atZone(ZoneId.systemDefault()).toInstant());

⚠️ 关键陷阱Date本身不包含时区信息,转换时必须明确指定时区,否则会依赖系统默认时区,导致不同环境结果不一致。


第九章:避坑指南——那些年我们踩过的“时间坑”

坑1:LocalDateTime存TIMESTAMP,时区漂移

现象:存进去是14:30,读出来变成06:3022:30

原因LocalDateTime没有时区信息,存入TIMESTAMP列时,JDBC驱动用当前JVM时区将其转换为UTC。

解决DATETIMELocalDateTimeTIMESTAMPInstant

坑2:@DateTimeFormat放在@RequestBody上无效

现象:前端传JSON,@DateTimeFormat纹丝不动,一直报400。

原因@DateTimeFormat只对表单/URL参数生效,JSON反序列化由Jackson处理。

解决:JSON用@JsonFormat,表单用@DateTimeFormat

坑3:前端传时间戳,后端用LocalDateTime接收失败

现象:前端传1710517044440,后端报InvalidFormatException

原因LocalDateTime无法直接从时间戳反序列化。

解决:改用Instant接收时间戳,或自定义反序列化器。

坑4:MySQL TIMESTAMP的2038年问题

现象:存2039-01-01时报错。

原因:MySQL的TIMESTAMP用32位存储,上限2038-01-19

解决:需要用2038年以后的日期,用DATETIME + UTC LocalDateTime方案(详见第六章)。

坑5:SimpleDateFormat线程不安全(经典老坑)

现象:生产环境偶发格式错乱、数字乱跳。

原因SimpleDateFormat在多线程下共享状态。

解决:用DateTimeFormatter(线程安全),定义为static final常量。

坑6:数据库驱动版本过低

现象java.time类型报UnsupportedConversionException

原因:旧版JDBC驱动不支持JSR-310类型。

解决:MySQL驱动 ≥ 8.0.23,PostgreSQL驱动 ≥ 42.2.0。

坑7:Period/Duration混淆使用

现象:用Period计算时间差,结果不对。

原因Period用于日期(年/月/日),Duration用于时间(时/分/秒/纳秒)。

解决:日期差用Period.between(),时间差用Duration.between()


第十章:最佳实践——写进团队规范

基于以上分析,建议你的团队建立以下规范:

✅ 新项目

  1. 全面拥抱java.time,禁止使用DateCalendar
  2. 纯日期用LocalDate,纯时间用LocalTime,日期时间用LocalDateTime
  3. 跨时区用ZonedDateTime,时间戳用Instant
  4. DateTimeFormatter定义为静态常量(线程安全,可复用)
  5. 实体类用LocalDate/LocalDateTime不要用Date
  6. 数据库字段:DATETIMELocalDateTimeTIMESTAMPInstant
  7. 服务端统一时区为UTC,前端展示时再转换

⚠️ 遗留系统维护

  1. 逐步将SimpleDateFormat替换为DateTimeFormatter
  2. Instant替代Date做时间戳存储
  3. 新旧API转换层明确指定时区
  4. 兼容旧接口时,在DTO或转换层Date领域层保持干净

📊 性能敏感场景

  1. Instant作为内部时间表示(最快、最轻量)
  2. 仅在需要展示给用户时,才转换为ZonedDateTime或格式化
  3. 缓存DateTimeFormatter实例(它是线程安全的)

第十一章:终极总结——一张图看懂全貌

┌─────────────────────────────────────────────────────────────────────────────────┐
│                         Java日期时间全景决策图                                  │
├─────────────────────────────────────────────────────────────────────────────────┤
│                                                                                 │
│  ┌──────────────┐    ┌──────────────┐    ┌──────────────────┐    ┌──────────┐ │
│  │   业务需求    │ →  │  Java类型    │ →  │   数据库映射     │ →  │  前端交互 │ │
│  └──────────────┘    └──────────────┘    └──────────────────┘    └──────────┘ │
│                                                                                 │
│  纯日期(生日)     →    LocalDate      →    DATE              →    LocalDate   │
│  纯时间(打卡)     →    LocalTimeTIMELocalTime   │
│  日期+时间(无时区) →   LocalDateTime  →    DATETIME          →    LocalDateTime│
│  日期+时间(有时区) →   Instant        →    TIMESTAMP         →    Instant     │
│  跨时区业务       →   ZonedDateTime   →    TIMESTAMPTZ       →    ZonedDateTime│
│  2038年后时间     →   Instant +       →    DATETIME(UTC)     →    Instant     │
│                     截断到微秒         │     + 约定UTC       │                  │
│                                                                                 │
├─────────────────────────────────────────────────────────────────────────────────┤
│                        三端交互铁律                                              │
├─────────────────────────────────────────────────────────────────────────────────┤
│                                                                                 │
│  【Java选型三原则】                                                             │
│  1. 无时区用LocalDateTime,有时区用Instant/ZonedDateTime                       │
│  2. 机器时间用Instant,人类时间用ZonedDateTime                                 │
│  3. 永远不要用Date和Calendar(除非维护遗留代码)                               │
│                                                                                 │
│  【数据库映射三原则】                                                           │
│  1. DATETIME ↔ LocalDateTime(语义对齐)                                      │
│  2. TIMESTAMP ↔ Instant/OffsetDateTime(明确时区)                            │
│  3. 统一配置hibernate.jdbc.time_zone = UTC                                    │
│                                                                                 │
│  【前端交互三原则】                                                             │
│  1. JSON用@JsonFormat,表单用@DateTimeFormat                                  │
│  2. 服务端统一UTC,前端展示时转换                                              │
│  3. 全局配置 + 字段覆盖 = 最灵活                                               │
│                                                                                 │
└─────────────────────────────────────────────────────────────────────────────────┘

结语:时间会证明一切

Java用二十多年的时间,走过了从DateCalendar再到java.time的演进之路。这条路充满了设计失误、历史包袱和痛苦教训

而数据库的TIMESTAMPDATETIME之争、前端的时区漂移、2038年的“时间炸弹”……每一个环节都在提醒我们:时间处理没有银弹,但有铁律

  1. 语义对齐 —— Java类型、数据库类型、前端格式的语义必须一致(有/无时区)
  2. 显式时区 —— 永远不要依赖系统默认时区,显式指定
  3. 统一规范 —— 全局配置 + 团队约定,比单点防御重要一万倍
  4. 边界转换 —— 在系统边界(数据库层、API层)明确转换,内部保持纯净

今天,我们终于有了一个正确、安全、高效的日期时间方案。2014年Java 8就已经发布了java.time,距今已超过10年。如果你的项目还在用DateCalendar——是时候升级了

毕竟,连时间自己都在往前走,你的代码凭什么停在原地?


全文完