冒险契约
53.65M · 2026-02-28
住在公司附近的坏处就是,夜里可能被领导一通电话叫去公司看问题。服务总是报错,重启也没用。到公司打开电脑,日志好多这个错误:
Exception in thread "pool-1-thread-4" java.lang.NumberFormatException: For input string: ""
顺着堆栈找过去,发现是SimpleDateFormat在多线程环境下出了幺蛾子。一个用了3年的工具类,在并发量上来之后,直接让服务跪了。
今天就把这次踩坑换来的经验分享给你。全文3000字,看完至少让你少踩3个生产级别的坑。
那天晚上修复完bug,我翻了翻项目代码,发现一个扎心的事实:
getTime()获取毫秒值,可读性极差;其实早在Java 8发布时,官方就已经给我们提供了一套完整、安全、高效的日期处理解决方案,只是很多人(包括之前的我)一直固守老套路,从未认真了解过这套“新玩具”。
先给大家梳理一下这套新方案的核心成员,记住它们的分工,就能避开80%的坑:
| 类 | 适用场景 | 特点 |
|---|---|---|
| Instant | 机器时间,记录时间戳 | 无时区,精确到纳秒,对应绝对时间点 |
| LocalDateTime | 本地日期时间(如生日、营业时间) | 不带时区,面向人类阅读和使用 |
| ZonedDateTime | 带时区的日期时间(如跨时区会议) | 跨时区应用必备,明确时区信息 |
| DateTimeFormatter | 日期时间格式化/解析 | 线程安全,性能强悍,可全局复用 |
这里先给大家抛一个核心原则,后面所有内容都围绕这个原则展开:数据库存储用bigint,java中用Instant和LocalDateTime,展示用DateTimeFormatter。
聊完核心工具类,我们先解决第一个基础问题:日期数据到底该怎么存?这也是我这次踩坑的间接原因之一。
我当时的第一版数据库设计,用的是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函数接收的是秒级时间戳,根据自己的存储精度调整即可。
解决了数据库存储(BIGINT时间戳)的问题,接下来重点聊核心疑问:数据库存的是BIGINT时间戳,Java代码里为什么不直接用long类型操作?反而要先映射成Instant?
总结来说,不直接用long时间戳、优先将BIGINT映射到Instant,核心有3点原因,每一点都能帮我们避开生产坑:
而Instant,正是为解决long时间戳的痛点而生,它与BIGINT时间戳是“天生一对”,也是我们将数据库BIGINT映射到Java实体类的首选。
有同学会问:“既然Instant这么好,为什么不全程用Instant?还要转成LocalDateTime,多此一举?”
答案很简单:Instant适合“机器处理”,LocalDateTime适合“人类交互”。两者的定位不同,各司其职——我们将数据库BIGINT映射为Instant,是为了保证数据语义准确、操作便捷;而将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,主要有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);
回到文章开头的报警事件,罪魁祸首就是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();
}
}
在实际的Spring Boot项目中,我们通常需要和前端交互(接收前端日期字符串、返回格式化后的日期),还要和数据库交互(自动转换时间戳和Instant)。这里分享3个实用技巧,帮你简化开发,避免重复编码。
前端传递的日期通常是字符串(如“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注解。
我们需要将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”,避免因服务器时区配置不同,导致返回给前端的时间错乱。
之前我们说过,数据库存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条,就能避开绝大多数日期处理坑:
isBefore()、isAfter(),清晰表达业务意图,避免计算错误;那天凌晨的bug修复后,我在新的时间工具类里,加了一段注释,时刻提醒自己和团队:
/**
* 时间工具类
* 修订:弃用SimpleDateFormat,改用DateTimeFormatter
* 原因:凌晨2点的报警电话太刺激,不想再经历一次
*/
希望这篇文章,能帮你避开我踩过的坑,写出更安全、更易维护的日期处理代码。如果这篇文章对你有帮助,点赞让更多人看到,避免更多人踩坑。
你项目里还在用SimpleDateFormat吗?遇到过哪些诡异的日期问题?评论区见!