java

关注公众号 jb51net

关闭
首页 > 软件编程 > java > SpringBoot日期处理

SpringBoot项目中日期处理的最佳实践

作者:程序员越

这篇文章主要为大家详细介绍了SpringBoot项目中日期处理的相关知识,文中的示例代码讲解详细,感兴趣的小伙伴可以跟随小编一起学习一下

住在公司附近的坏处就是,夜里可能被领导一通电话叫去公司看问题。服务总是报错,重启也没用。到公司打开电脑,日志好多这个错误:

Exception in thread "pool-1-thread-4" java.lang.NumberFormatException: For input string: ""

顺着堆栈找过去,发现是SimpleDateFormat在多线程环境下出了幺蛾子。一个用了3年的工具类,在并发量上来之后,直接让服务跪了。

今天就把这次踩坑换来的经验分享给你。全文3000字,看完至少让你少踩3个生产级别的坑。

一、故事要从那个凌晨说起:老套路的致命问题

那天晚上修复完bug,我翻了翻项目代码,发现一个扎心的事实:

其实早在Java 8发布时,官方就已经给我们提供了一套完整、安全、高效的日期处理解决方案,只是很多人(包括之前的我)一直固守老套路,从未认真了解过这套“新玩具”。

先给大家梳理一下这套新方案的核心成员,记住它们的分工,就能避开80%的坑:

适用场景特点
Instant机器时间,记录时间戳无时区,精确到纳秒,对应绝对时间点
LocalDateTime本地日期时间(如生日、营业时间)不带时区,面向人类阅读和使用
ZonedDateTime带时区的日期时间(如跨时区会议)跨时区应用必备,明确时区信息
DateTimeFormatter日期时间格式化/解析线程安全,性能强悍,可全局复用

这里先给大家抛一个核心原则,后面所有内容都围绕这个原则展开:数据库存储用bigint,java中用Instant和LocalDateTime,展示用DateTimeFormatter

二、数据库存储:用BIGINT存时间戳,真香

聊完核心工具类,我们先解决第一个基础问题:日期数据到底该怎么存?这也是我这次踩坑的间接原因之一。

我当时的第一版数据库设计,用的是MySQL的DATETIME类型,Java代码中对应java.util.Dat e。乍一看逻辑通顺,日期类型存日期,直观又方便,但实 际运行后,接连踩了3个坑:

后来将数据库字段全部改成BIGINT存时间戳(毫秒级),所有问题瞬间迎刃而解,感觉整个世界都清净了。

推荐的实体类设计如下,兼顾数据库性能和业务语义:

@Entity
@Table(name = "orders")
public class Order {
   
    // 数据库存BIGINT,追求极致的查询性能
    @Column(name = "create_time", nullable = false)
    private Long createTimeStamp;
   
    // 业务代码里用Instant操作,两全其美(语义准确+操作便捷)
    public Instant getCreateTime() {
        return Instant.ofEpochMilli(createTimeStamp);
    }
   
    public void setCreateTime(Instant instant) {
        this.createTimeStamp = instant.toEpochMilli();
    }
}

这样设计的优势非常明显,主要有3点:

有同学可能会问:“用BIGINT存数字,我想在数据库里直接查看具体时间,岂不是很麻烦?” 其实一点都不复杂,写SQL时简单转换一下即可:

SELECT from_unixtime(create_time/1000) FROM orders;

这里除以1000,是因为我们存的是毫秒级时间戳,而MySQL的from_unixtime函数接收的是秒级时间戳,根据自己的存储精度调整即可。

三、Java中的使用:优先选Instant,按需转LocalDateTime

解决了数据库存储(BIGINT时间戳)的问题,接下来重点聊核心疑问:数据库存的是BIGINT时间戳,Java代码里为什么不直接用long类型操作?反而要先映射成Instant?

总结来说,不直接用long时间戳、优先将BIGINT映射到Instant,核心有3点原因,每一点都能帮我们避开生产坑:

而Instant,正是为解决long时间戳的痛点而生,它与BIGINT时间戳是“天生一对”,也是我们将数据库BIGINT映射到Java实体类的首选。

延伸疑问:为什么还要把Instant转成LocalDateTime

有同学会问:“既然Instant这么好,为什么不全程用Instant?还要转成LocalDateTime,多此一举?”

答案很简单:Instant适合“机器处理”,LocalDateTime适合“人类交互”。两者的定位不同,各司其职——我们将数据库BIGINT映射为Instant,是为了保证数据语义准确、操作便捷;而将Instant转为LocalDateTime,是为了适配“与人相关”的业务场景,让代码更易读、更贴合实际需求。

哪些情况用Instant直接处理?哪些情况要转LocalDateTime

结合实际项目经验,我整理了清晰的场景划分,一看就懂:

可直接用Instant处理的场景(无需转LocalDateTime)

Instant的核心优势是“绝对时间点”,无需考虑时区,适合所有“机器层面”的时间操作,主要有3类场景:

举个直接用Instant处理的示例(时间比较):

// 订单创建时间(Instant),判断是否在30分钟内
Instant orderCreateTime = order.getCreateTime();
Instant now = Instant.now();
// 直接用Instant API判断,无需转LocalDateTime
if (orderCreateTime.isAfter(now.minus(30, ChronoUnit.MINUTES))) {
    System.out.println("订单创建时间在30分钟内");
}

需要将Instant转为LocalDateTime的场景

当时间需要“被人阅读”“与人交互”时,就需要将Instant转为LocalDateTime,主要有4类场景,每一类都贴合实际业务:

举个Instant转LocalDateTime的示例(前端展示):

// 实体类中的Instant(映射数据库BIGINT)
Instant orderCreateTime = order.getCreateTime();
// 转为LocalDateTime(指定时区,避免错乱)
LocalDateTime localDateTime = orderCreateTime.atZone(ZoneId.of("Asia/Shanghai")).toLocalDateTime();
// 格式化后返回给前端
String formatTime = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").format(localDateTime);

这里补充一个关键注意点:Instant转LocalDateTime时,必须指定时区(如Asia/Shanghai),因为Instant本身无时区,不指定时区会默认使用系统时区,可能导致时间错乱。

四、格式化:彻底告别SimpleDateFormat

回到文章开头的报警事件,罪魁祸首就是SimpleDateFormat的线程不安全问题。这也是很多老项目的通病,我们先看看常见的错误用法,再讲正确的姿势。

先看两个错误示范,尤其是第二个,几乎是“踩坑重灾区”:

// 错误示范1:每个请求都new一个,浪费资源
SimpleDateFormat formatter = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
String dateStr = formatter.format(new Date());
// 错误示范2:定义成static共享,线程不安全!(高并发下必出问题)
private static final SimpleDateFormat FORMATTER = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

SimpleDateFormat之所以线程不安全,是因为它内部有可修改的成员变量,多线程并发调用时,会出现资源竞争,导致格式化结果错乱、抛出异常(就像我这次遇到的一样)。

而Java 8提供的DateTimeFormatter,完美解决了这个问题——它是不可变的、线程安全的,可以放心地定义成静态常量,全局复用。

推荐的工具类写法如下,兼顾通用性和安全性:

public class DateUtils {
    // 定义为静态常量,全局复用,线程安全
    private static final DateTimeFormatter FORMATTER =
        DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
   
    /**
     * 格式化Instant(需要指定时区,因为Instant无时区)
     */
    public static String format(Instant instant) {
        // 这里用系统默认时区,也可根据业务指定(如ZoneId.of("Asia/Shanghai"))
        ZonedDateTime dateTime = instant.atZone(ZoneId.systemDefault());
        return dateTime.format(FORMATTER);
    }
   
    /**
     * 格式化LocalDateTime(自带本地时区含义,可直接格式化)
     */
    public static String format(LocalDateTime dateTime) {
        return dateTime.format(FORMATTER);
    }
   
    /**
     * 将字符串解析为Instant(反向操作)
     */
    public static Instant parse(String dateStr) {
        // 先解析成LocalDateTime,再转Instant(指定时区)
        LocalDateTime dateTime = LocalDateTime.parse(dateStr, FORMATTER);
        return dateTime.atZone(ZoneId.systemDefault()).toInstant();
    }
}

这里有一个关键注意点,一定要记牢:格式化Instant时,必须指定时区——因为Instant本身不包含时区信息,直接格式化会报错;而LocalDateTime自带“本地”时区含义,无需额外指定时区,可直接格式化。

五、Spring Boot中的实战技巧:入参、返回、数据库交互全适配

在实际的Spring Boot项目中,我们通常需要和前端交互(接收前端日期字符串、返回格式化后的日期),还要和数据库交互(自动转换时间戳和Instant)。这里分享3个实用技巧,帮你简化开发,避免重复编码。

1. 接收前端数据:@DateTimeFormat

前端传递的日期通常是字符串(如“2025-05-08 10:10:10”),我们无需手动解析,用@DateTimeFormat注解即可自动将字符串转换为LocalDateTime/ZonedDateTime:

public class UserDTO {
   
    // 前端传"2025-05-08 10:10:10",自动转换为LocalDateTime
    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime birthday;
   
    // getter/setter省略
}

注意:@DateTimeFormat主要用于接收前端的请求参数(如GET请求的参数、POST请求的表单参数),如果是JSON格式的请求体,需要用下面的@JsonFormat注解。

2. 返回给前端:@JsonFormat

我们需要将Java中的日期类型(LocalDateTime/Instant),格式化后以字符串形式返回给前端,用@JsonFormat注解即可实现,还能指定时区:

public class UserVO {
   
    // 返回给前端时,格式化为"yyyy/MM/dd HH:mm:ss",指定时区为GMT+8(北京时间)
    @JsonFormat(pattern = "yyyy/MM/dd HH:mm:ss", timezone = "GMT+8")
    private LocalDateTime createTime;
   
    // getter/setter省略
}

这里建议明确指定timezone为“GMT+8”,避免因服务器时区配置不同,导致返回给前端的时间错乱。

3. 数据库交互(MyBatis):自定义TypeHandler自动转换

之前我们说过,数据库存BIGINT时间戳,实体类用Instant——如果每次查询、插入都手动转换,会非常繁琐。这时可以自定义MyBatis的TypeHandler,让框架自动帮我们完成转换。

自定义InstantTypeHandler的代码如下:

public class InstantTypeHandler extends BaseTypeHandler<Instant> {
   
    @Override
    public void setNonNullParameter(PreparedStatement ps, int i,
                                     Instant parameter, JdbcType jdbcType) throws SQLException {
        // 插入/更新时,将Instant转为BIGINT(毫秒级)
        ps.setLong(i, parameter.toEpochMilli());
    }
   
    @Override
    public Instant getNullableResult(ResultSet rs, String columnName) throws SQLException {
        // 查询时,将BIGINT转为Instant
        long timestamp = rs.getLong(columnName);
        return Instant.ofEpochMilli(timestamp);
    }
   
    // 其他两个方法(getNullableResult的另外两种重载)省略,实现逻辑类似
}

定义好TypeHandler后,在MyBatis的配置文件中注册,或者在实体类的字段上直接指定,MyBatis就会自动帮你完成BIGINT和Instant的转换,无需手动处理,极大提升开发效率。

六、总结所有最佳实践

5条黄金法则,记牢这5条,就能避开绝大多数日期处理坑:

到此这篇关于SpringBoot项目中日期处理的最佳实践的文章就介绍到这了,更多相关SpringBoot日期处理内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

您可能感兴趣的文章:
阅读全文