住在公司附近的坏处就是,夜里可能被领导一通电话叫去公司看问题。服务总是报错,重启也没用。到公司打开电脑,日志好多这个错误:
java
Exception in thread "pool-1-thread-4" java.lang.NumberFormatException: For input string: ""
顺着堆栈找过去,发现是SimpleDateFormat在多线程环境下出了幺蛾子。一个用了3年的工具类,在并发量上来之后,直接让服务跪了。
今天就把这次踩坑换来的经验分享给你。全文3000字,看完至少让你少踩3个生产级别的坑。
一、故事要从那个凌晨说起:老套路的致命问题
那天晚上修复完bug,我翻了翻项目代码,发现一个扎心的事实:
- 数据库里存的是DateTime;
- 业务代码里混用Date,方法调用复杂,易出错;
- 时间比较全靠
getTime()获取毫秒值,可读性极差; - 日期格式化用SimpleDateFormat,封装好的工具类,埋下线程安全隐患。
其实早在Java 8发布时,官方就已经给我们提供了一套完整、安全、高效的日期处理解决方案,只是很多人(包括之前的我)一直固守老套路,从未认真了解过这套"新玩具"。
先给大家梳理一下这套新方案的核心成员,记住它们的分工,就能避开80%的坑:
| 类 | 适用场景 | 特点 |
|---|---|---|
| Instant | 机器时间,记录时间戳 | 无时区,精确到纳秒,对应绝对时间点 |
| LocalDateTime | 本地日期时间(如生日、营业时间) | 不带时区,面向人类阅读和使用 |
| ZonedDateTime | 带时区的日期时间(如跨时区会议) | 跨时区应用必备,明确时区信息 |
| DateTimeFormatter | 日期时间格式化/解析 | 线程安全,性能强悍,可全局复用 |
这里先给大家抛一个核心原则,后面所有内容都围绕这个原则展开:数据库存储用bigint,java中用Instant和LocalDateTime,展示用DateTimeFormatter。
二、数据库存储:用BIGINT存时间戳,真香
聊完核心工具类,我们先解决第一个基础问题:日期数据到底该怎么存?这也是我这次踩坑的间接原因之一。
我当时的第一版数据库设计,用的是MySQL的DATETIME 类型,Java代码中对应java.util.Dat e。乍一看逻辑通顺,日期类型存日期,直观又方便,但实 际运行后,接连踩了3个坑:
- 时区错乱:不同环境(开发、测试、生产)的MySQL时区配置不一致,导致存进去的时间和实际时间偏差几小时,排查起来极其费力;
- 性能瓶颈:当数据量达到百万级后,日期字段的排序、筛选操作速度急剧下降,索引优化效果甚微;
- 分库分表麻烦:按时间分片时,DATETIME类型需要额外处理时区和格式,容易出现分片偏差,而整数类型的时间戳则能完美规避这个问题。
后来将数据库字段全部改成BIGINT存时间戳(毫秒级),所有问题瞬间迎刃而解,感觉整个世界都清净了。
推荐的实体类设计如下,兼顾数据库性能和业务语义:
java
@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点:
- 性能炸裂:整数的比较、索引查询速度,远超DATETIME等日期类型,尤其适合大数据量场景;
- 时区无关:时间戳本身是绝对时间点,不依赖任何时区配置,不存在跨环境时区转换错误;
- 兼容性好:无论使用MySQL、PostgreSQL还是Oracle,BIGINT类型都是通用支持的,无需额外适配。
有同学可能会问:"用BIGINT存数字,我想在数据库里直接查看具体时间,岂不是很麻烦?" 其实一点都不复杂,写SQL时简单转换一下即可:
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点原因,每一点都能帮我们避开生产坑:
- 语义更清晰,避免歧义:long类型只是一个单纯的数字,你无法直接判断它是毫秒级、秒级时间戳,还是普通的计数;而Instant明确表示"绝对时间点",与数据库的BIGINT时间戳语义完全对应,一看就知道是"某个具体的瞬间",无需额外注释。
- 操作更便捷,减少计算错误 :long类型计算时间差(如"7天前"),需要手动换算毫秒数(724 60601000L),极易漏算、错算;而Instant自带丰富的API(如minus、plus),可以直接按天、小时、分钟操作,无需手动换算,从根源上避免错误。
- 可扩展性更强,适配复杂场景:long类型只能做简单的大小比较和差值计算,无法直接转换时区、格式化展示;而Instant可以轻松转为LocalDateTime、ZonedDateTime,适配业务逻辑处理、前端展示等多种复杂场景,无需额外封装工具类。
而Instant,正是为解决long时间戳的痛点而生,它与BIGINT时间戳是"天生一对",也是我们将数据库BIGINT映射到Java实体类的首选。
延伸疑问:为什么还要把Instant转成LocalDateTime?
有同学会问:"既然Instant这么好,为什么不全程用Instant?还要转成LocalDateTime,多此一举?"
答案很简单:Instant适合"机器处理",LocalDateTime适合"人类交互" 。两者的定位不同,各司其职------我们将数据库BIGINT映射为Instant,是为了保证数据语义准确、操作便捷;而将Instant转为LocalDateTime,是为了适配"与人相关"的业务场景,让代码更易读、更贴合实际需求。
哪些情况用Instant直接处理?哪些情况要转LocalDateTime?
结合实际项目经验,我整理了清晰的场景划分,一看就懂:
一、可直接用Instant处理的场景(无需转LocalDateTime)
Instant的核心优势是"绝对时间点",无需考虑时区,适合所有"机器层面"的时间操作,主要有3类场景:
- 时间比较操作:比如判断订单创建时间是否在30分钟内、用户注册时间是否超过7天,直接用Instant的isBefore、isAfter方法,语义清晰、操作便捷,无需转换。
- 时间差值计算(无需展示):比如计算两个操作之间的时间间隔(如接口调用耗时),用Instant的until方法,可直接获取天、小时、分钟等差值,无需转为LocalDateTime。
- 数据存储与传输(中间过程):实体类中映射数据库BIGINT、服务间传输时间信息,直接用Instant,无需转换------它语义准确、体积小,还能避免时区错乱。
举个直接用Instant处理的示例(时间比较):
java
// 订单创建时间(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类场景,每一类都贴合实际业务:
- 前端展示时间:将后端的时间数据返回给前端,需要展示为"yyyy-MM-dd HH:mm:ss"格式(如订单创建时间、用户注册时间),需先将Instant转为LocalDateTime,再用DateTimeFormatter格式化。
- 与人相关的业务计算:比如用户生日、店铺营业时间、本地定时任务(每天8点执行),这些场景关注的是"本地时间",与人的生活习惯相关,适合用LocalDateTime。
- 接收前端传入的时间参数:前端传递的日期字符串(如"2025-05-08 10:10:10"),通常对应本地时间,先解析为LocalDateTime,再根据需求转为Instant存储。
- 日志打印与调试:开发调试时,打印时间信息,用LocalDateTime能直观看到具体的日期时间,方便排查问题;若打印Instant,还需要手动转换才能看懂。
举个Instant转LocalDateTime的示例(前端展示):
java
// 实体类中的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的线程不安全问题。这也是很多老项目的通病,我们先看看常见的错误用法,再讲正确的姿势。
先看两个错误示范,尤其是第二个,几乎是"踩坑重灾区":
java
// 错误示范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 ,完美解决了这个问题------它是不可变的、线程安全的,可以放心地定义成静态常量,全局复用。
推荐的工具类写法如下,兼顾通用性和安全性:
java
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:
java
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注解即可实现,还能指定时区:
java
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的代码如下:
java
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条,就能避开绝大多数日期处理坑:
- 存储用时间戳 :数据库字段一律用BIGINT存毫秒值,兼顾性能和兼容性;
- 映射用Instant :实体类里用Instant对应BIGINT,语义准确,避免时区歧义;
- 业务用LocalDateTime :给人看的时间、日期计算,用LocalDateTime,代码更易读;
- 比较用Instant API :时间比较用
isBefore()、isAfter(),清晰表达业务意图,避免计算错误; - 格式化用DateTimeFormatter :定义成static final常量,全局复用,线程安全。
写在最后
那天凌晨的bug修复后,我在新的时间工具类里,加了一段注释,时刻提醒自己和团队:
java
/**
* 时间工具类
* 修订:弃用SimpleDateFormat,改用DateTimeFormatter
* 原因:凌晨2点的报警电话太刺激,不想再经历一次
*/
希望这篇文章,能帮你避开我踩过的坑,写出更安全、更易维护的日期处理代码。如果这篇文章对你有帮助,点赞让更多人看到,避免更多人踩坑。
你项目里还在用SimpleDateFormat吗?遇到过哪些诡异的日期问题?评论区见!