SpringBoot項目中日期處理的最佳實踐
住在公司附近的壞處就是,夜里可能被領(lǐng)導(dǎo)一通電話叫去公司看問題。服務(wù)總是報錯,重啟也沒用。到公司打開電腦,日志好多這個錯誤:
Exception in thread "pool-1-thread-4" java.lang.NumberFormatException: For input string: ""
順著堆棧找過去,發(fā)現(xiàn)是SimpleDateFormat在多線程環(huán)境下出了幺蛾子。一個用了3年的工具類,在并發(fā)量上來之后,直接讓服務(wù)跪了。
今天就把這次踩坑換來的經(jīng)驗分享給你。全文3000字,看完至少讓你少踩3個生產(chǎn)級別的坑。
一、故事要從那個凌晨說起:老套路的致命問題
那天晚上修復(fù)完bug,我翻了翻項目代碼,發(fā)現(xiàn)一個扎心的事實:
- 數(shù)據(jù)庫里存的是DateTime;
- 業(yè)務(wù)代碼里混用Date,方法調(diào)用復(fù)雜,易出錯;
- 時間比較全靠
getTime()獲取毫秒值,可讀性極差; - 日期格式化用SimpleDateFormat,封裝好的工具類,埋下線程安全隱患。
其實早在Java 8發(fā)布時,官方就已經(jīng)給我們提供了一套完整、安全、高效的日期處理解決方案,只是很多人(包括之前的我)一直固守老套路,從未認真了解過這套“新玩具”。
先給大家梳理一下這套新方案的核心成員,記住它們的分工,就能避開80%的坑:
| 類 | 適用場景 | 特點 |
|---|---|---|
| Instant | 機器時間,記錄時間戳 | 無時區(qū),精確到納秒,對應(yīng)絕對時間點 |
| LocalDateTime | 本地日期時間(如生日、營業(yè)時間) | 不帶時區(qū),面向人類閱讀和使用 |
| ZonedDateTime | 帶時區(qū)的日期時間(如跨時區(qū)會議) | 跨時區(qū)應(yīng)用必備,明確時區(qū)信息 |
| DateTimeFormatter | 日期時間格式化/解析 | 線程安全,性能強悍,可全局復(fù)用 |
這里先給大家拋一個核心原則,后面所有內(nèi)容都圍繞這個原則展開:數(shù)據(jù)庫存儲用bigint,java中用Instant和LocalDateTime,展示用DateTimeFormatter。
二、數(shù)據(jù)庫存儲:用BIGINT存時間戳,真香
聊完核心工具類,我們先解決第一個基礎(chǔ)問題:日期數(shù)據(jù)到底該怎么存?這也是我這次踩坑的間接原因之一。
我當(dāng)時的第一版數(shù)據(jù)庫設(shè)計,用的是MySQL的DATETIME類型,Java代碼中對應(yīng)java.util.Dat e。乍一看邏輯通順,日期類型存日期,直觀又方便,但實 際運行后,接連踩了3個坑:
- 時區(qū)錯亂:不同環(huán)境(開發(fā)、測試、生產(chǎn))的MySQL時區(qū)配置不一致,導(dǎo)致存進去的時間和實際時間偏差幾小時,排查起來極其費力;
- 性能瓶頸:當(dāng)數(shù)據(jù)量達到百萬級后,日期字段的排序、篩選操作速度急劇下降,索引優(yōu)化效果甚微;
- 分庫分表麻煩:按時間分片時,DATETIME類型需要額外處理時區(qū)和格式,容易出現(xiàn)分片偏差,而整數(shù)類型的時間戳則能完美規(guī)避這個問題。
后來將數(shù)據(jù)庫字段全部改成BIGINT存時間戳(毫秒級),所有問題瞬間迎刃而解,感覺整個世界都清凈了。
推薦的實體類設(shè)計如下,兼顧數(shù)據(jù)庫性能和業(yè)務(wù)語義:
@Entity
@Table(name = "orders")
public class Order {
// 數(shù)據(jù)庫存BIGINT,追求極致的查詢性能
@Column(name = "create_time", nullable = false)
private Long createTimeStamp;
// 業(yè)務(wù)代碼里用Instant操作,兩全其美(語義準(zhǔn)確+操作便捷)
public Instant getCreateTime() {
return Instant.ofEpochMilli(createTimeStamp);
}
public void setCreateTime(Instant instant) {
this.createTimeStamp = instant.toEpochMilli();
}
}
這樣設(shè)計的優(yōu)勢非常明顯,主要有3點:
- 性能炸裂:整數(shù)的比較、索引查詢速度,遠超DATETIME等日期類型,尤其適合大數(shù)據(jù)量場景;
- 時區(qū)無關(guān):時間戳本身是絕對時間點,不依賴任何時區(qū)配置,不存在跨環(huán)境時區(qū)轉(zhuǎn)換錯誤;
- 兼容性好:無論使用MySQL、PostgreSQL還是Oracle,BIGINT類型都是通用支持的,無需額外適配。
有同學(xué)可能會問:“用BIGINT存數(shù)字,我想在數(shù)據(jù)庫里直接查看具體時間,豈不是很麻煩?” 其實一點都不復(fù)雜,寫SQL時簡單轉(zhuǎn)換一下即可:
SELECT from_unixtime(create_time/1000) FROM orders;
這里除以1000,是因為我們存的是毫秒級時間戳,而MySQL的from_unixtime函數(shù)接收的是秒級時間戳,根據(jù)自己的存儲精度調(diào)整即可。
三、Java中的使用:優(yōu)先選Instant,按需轉(zhuǎn)LocalDateTime
解決了數(shù)據(jù)庫存儲(BIGINT時間戳)的問題,接下來重點聊核心疑問:數(shù)據(jù)庫存的是BIGINT時間戳,Java代碼里為什么不直接用long類型操作?反而要先映射成Instant?
總結(jié)來說,不直接用long時間戳、優(yōu)先將BIGINT映射到Instant,核心有3點原因,每一點都能幫我們避開生產(chǎn)坑:
- 語義更清晰,避免歧義:long類型只是一個單純的數(shù)字,你無法直接判斷它是毫秒級、秒級時間戳,還是普通的計數(shù);而Instant明確表示“絕對時間點”,與數(shù)據(jù)庫的BIGINT時間戳語義完全對應(yīng),一看就知道是“某個具體的瞬間”,無需額外注釋。
- 操作更便捷,減少計算錯誤:long類型計算時間差(如“7天前”),需要手動換算毫秒數(shù)(72460601000L),極易漏算、錯算;而Instant自帶豐富的API(如minus、plus),可以直接按天、小時、分鐘操作,無需手動換算,從根源上避免錯誤。
- 可擴展性更強,適配復(fù)雜場景:long類型只能做簡單的大小比較和差值計算,無法直接轉(zhuǎn)換時區(qū)、格式化展示;而Instant可以輕松轉(zhuǎn)為LocalDateTime、ZonedDateTime,適配業(yè)務(wù)邏輯處理、前端展示等多種復(fù)雜場景,無需額外封裝工具類。
而Instant,正是為解決long時間戳的痛點而生,它與BIGINT時間戳是“天生一對”,也是我們將數(shù)據(jù)庫BIGINT映射到Java實體類的首選。
延伸疑問:為什么還要把Instant轉(zhuǎn)成LocalDateTime
有同學(xué)會問:“既然Instant這么好,為什么不全程用Instant?還要轉(zhuǎn)成LocalDateTime,多此一舉?”
答案很簡單:Instant適合“機器處理”,LocalDateTime適合“人類交互”。兩者的定位不同,各司其職——我們將數(shù)據(jù)庫BIGINT映射為Instant,是為了保證數(shù)據(jù)語義準(zhǔn)確、操作便捷;而將Instant轉(zhuǎn)為LocalDateTime,是為了適配“與人相關(guān)”的業(yè)務(wù)場景,讓代碼更易讀、更貼合實際需求。
哪些情況用Instant直接處理?哪些情況要轉(zhuǎn)LocalDateTime
結(jié)合實際項目經(jīng)驗,我整理了清晰的場景劃分,一看就懂:
可直接用Instant處理的場景(無需轉(zhuǎn)LocalDateTime)
Instant的核心優(yōu)勢是“絕對時間點”,無需考慮時區(qū),適合所有“機器層面”的時間操作,主要有3類場景:
- 時間比較操作:比如判斷訂單創(chuàng)建時間是否在30分鐘內(nèi)、用戶注冊時間是否超過7天,直接用Instant的isBefore、isAfter方法,語義清晰、操作便捷,無需轉(zhuǎn)換。
- 時間差值計算(無需展示):比如計算兩個操作之間的時間間隔(如接口調(diào)用耗時),用Instant的until方法,可直接獲取天、小時、分鐘等差值,無需轉(zhuǎn)為LocalDateTime。
- 數(shù)據(jù)存儲與傳輸(中間過程):實體類中映射數(shù)據(jù)庫BIGINT、服務(wù)間傳輸時間信息,直接用Instant,無需轉(zhuǎn)換——它語義準(zhǔn)確、體積小,還能避免時區(qū)錯亂。
舉個直接用Instant處理的示例(時間比較):
// 訂單創(chuàng)建時間(Instant),判斷是否在30分鐘內(nèi)
Instant orderCreateTime = order.getCreateTime();
Instant now = Instant.now();
// 直接用Instant API判斷,無需轉(zhuǎn)LocalDateTime
if (orderCreateTime.isAfter(now.minus(30, ChronoUnit.MINUTES))) {
System.out.println("訂單創(chuàng)建時間在30分鐘內(nèi)");
}
需要將Instant轉(zhuǎn)為LocalDateTime的場景
當(dāng)時間需要“被人閱讀”“與人交互”時,就需要將Instant轉(zhuǎn)為LocalDateTime,主要有4類場景,每一類都貼合實際業(yè)務(wù):
- 前端展示時間:將后端的時間數(shù)據(jù)返回給前端,需要展示為“yyyy-MM-dd HH:mm:ss”格式(如訂單創(chuàng)建時間、用戶注冊時間),需先將Instant轉(zhuǎn)為LocalDateTime,再用DateTimeFormatter格式化。
- 與人相關(guān)的業(yè)務(wù)計算:比如用戶生日、店鋪營業(yè)時間、本地定時任務(wù)(每天8點執(zhí)行),這些場景關(guān)注的是“本地時間”,與人的生活習(xí)慣相關(guān),適合用LocalDateTime。
- 接收前端傳入的時間參數(shù):前端傳遞的日期字符串(如“2025-05-08 10:10:10”),通常對應(yīng)本地時間,先解析為LocalDateTime,再根據(jù)需求轉(zhuǎn)為Instant存儲。
- 日志打印與調(diào)試:開發(fā)調(diào)試時,打印時間信息,用LocalDateTime能直觀看到具體的日期時間,方便排查問題;若打印Instant,還需要手動轉(zhuǎn)換才能看懂。
舉個Instant轉(zhuǎn)LocalDateTime的示例(前端展示):
// 實體類中的Instant(映射數(shù)據(jù)庫BIGINT)
Instant orderCreateTime = order.getCreateTime();
// 轉(zhuǎn)為LocalDateTime(指定時區(qū),避免錯亂)
LocalDateTime localDateTime = orderCreateTime.atZone(ZoneId.of("Asia/Shanghai")).toLocalDateTime();
// 格式化后返回給前端
String formatTime = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").format(localDateTime);
這里補充一個關(guān)鍵注意點:Instant轉(zhuǎn)LocalDateTime時,必須指定時區(qū)(如Asia/Shanghai),因為Instant本身無時區(qū),不指定時區(qū)會默認使用系統(tǒng)時區(qū),可能導(dǎo)致時間錯亂。
四、格式化:徹底告別SimpleDateFormat
回到文章開頭的報警事件,罪魁禍?zhǔn)拙褪?strong>SimpleDateFormat的線程不安全問題。這也是很多老項目的通病,我們先看看常見的錯誤用法,再講正確的姿勢。
先看兩個錯誤示范,尤其是第二個,幾乎是“踩坑重災(zāi)區(qū)”:
// 錯誤示范1:每個請求都new一個,浪費資源
SimpleDateFormat formatter = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
String dateStr = formatter.format(new Date());
// 錯誤示范2:定義成static共享,線程不安全?。ǜ卟l(fā)下必出問題)
private static final SimpleDateFormat FORMATTER = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
SimpleDateFormat之所以線程不安全,是因為它內(nèi)部有可修改的成員變量,多線程并發(fā)調(diào)用時,會出現(xiàn)資源競爭,導(dǎo)致格式化結(jié)果錯亂、拋出異常(就像我這次遇到的一樣)。
而Java 8提供的DateTimeFormatter,完美解決了這個問題——它是不可變的、線程安全的,可以放心地定義成靜態(tài)常量,全局復(fù)用。
推薦的工具類寫法如下,兼顧通用性和安全性:
public class DateUtils {
// 定義為靜態(tài)常量,全局復(fù)用,線程安全
private static final DateTimeFormatter FORMATTER =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
/**
* 格式化Instant(需要指定時區(qū),因為Instant無時區(qū))
*/
public static String format(Instant instant) {
// 這里用系統(tǒng)默認時區(qū),也可根據(jù)業(yè)務(wù)指定(如ZoneId.of("Asia/Shanghai"))
ZonedDateTime dateTime = instant.atZone(ZoneId.systemDefault());
return dateTime.format(FORMATTER);
}
/**
* 格式化LocalDateTime(自帶本地時區(qū)含義,可直接格式化)
*/
public static String format(LocalDateTime dateTime) {
return dateTime.format(FORMATTER);
}
/**
* 將字符串解析為Instant(反向操作)
*/
public static Instant parse(String dateStr) {
// 先解析成LocalDateTime,再轉(zhuǎn)Instant(指定時區(qū))
LocalDateTime dateTime = LocalDateTime.parse(dateStr, FORMATTER);
return dateTime.atZone(ZoneId.systemDefault()).toInstant();
}
}
這里有一個關(guān)鍵注意點,一定要記牢:格式化Instant時,必須指定時區(qū)——因為Instant本身不包含時區(qū)信息,直接格式化會報錯;而LocalDateTime自帶“本地”時區(qū)含義,無需額外指定時區(qū),可直接格式化。
五、Spring Boot中的實戰(zhàn)技巧:入?yún)ⅰ⒎祷?、?shù)據(jù)庫交互全適配
在實際的Spring Boot項目中,我們通常需要和前端交互(接收前端日期字符串、返回格式化后的日期),還要和數(shù)據(jù)庫交互(自動轉(zhuǎn)換時間戳和Instant)。這里分享3個實用技巧,幫你簡化開發(fā),避免重復(fù)編碼。
1. 接收前端數(shù)據(jù):@DateTimeFormat
前端傳遞的日期通常是字符串(如“2025-05-08 10:10:10”),我們無需手動解析,用@DateTimeFormat注解即可自動將字符串轉(zhuǎn)換為LocalDateTime/ZonedDateTime:
public class UserDTO {
// 前端傳"2025-05-08 10:10:10",自動轉(zhuǎn)換為LocalDateTime
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime birthday;
// getter/setter省略
}
注意:@DateTimeFormat主要用于接收前端的請求參數(shù)(如GET請求的參數(shù)、POST請求的表單參數(shù)),如果是JSON格式的請求體,需要用下面的@JsonFormat注解。
2. 返回給前端:@JsonFormat
我們需要將Java中的日期類型(LocalDateTime/Instant),格式化后以字符串形式返回給前端,用@JsonFormat注解即可實現(xiàn),還能指定時區(qū):
public class UserVO {
// 返回給前端時,格式化為"yyyy/MM/dd HH:mm:ss",指定時區(qū)為GMT+8(北京時間)
@JsonFormat(pattern = "yyyy/MM/dd HH:mm:ss", timezone = "GMT+8")
private LocalDateTime createTime;
// getter/setter省略
}
這里建議明確指定timezone為“GMT+8”,避免因服務(wù)器時區(qū)配置不同,導(dǎo)致返回給前端的時間錯亂。
3. 數(shù)據(jù)庫交互(MyBatis):自定義TypeHandler自動轉(zhuǎn)換
之前我們說過,數(shù)據(jù)庫存BIGINT時間戳,實體類用Instant——如果每次查詢、插入都手動轉(zhuǎn)換,會非常繁瑣。這時可以自定義MyBatis的TypeHandler,讓框架自動幫我們完成轉(zhuǎn)換。
自定義InstantTypeHandler的代碼如下:
public class InstantTypeHandler extends BaseTypeHandler<Instant> {
@Override
public void setNonNullParameter(PreparedStatement ps, int i,
Instant parameter, JdbcType jdbcType) throws SQLException {
// 插入/更新時,將Instant轉(zhuǎn)為BIGINT(毫秒級)
ps.setLong(i, parameter.toEpochMilli());
}
@Override
public Instant getNullableResult(ResultSet rs, String columnName) throws SQLException {
// 查詢時,將BIGINT轉(zhuǎn)為Instant
long timestamp = rs.getLong(columnName);
return Instant.ofEpochMilli(timestamp);
}
// 其他兩個方法(getNullableResult的另外兩種重載)省略,實現(xiàn)邏輯類似
}
定義好TypeHandler后,在MyBatis的配置文件中注冊,或者在實體類的字段上直接指定,MyBatis就會自動幫你完成BIGINT和Instant的轉(zhuǎn)換,無需手動處理,極大提升開發(fā)效率。
六、總結(jié)所有最佳實踐
5條黃金法則,記牢這5條,就能避開絕大多數(shù)日期處理坑:
- 存儲用時間戳:數(shù)據(jù)庫字段一律用BIGINT存毫秒值,兼顧性能和兼容性;
- 映射用Instant:實體類里用Instant對應(yīng)BIGINT,語義準(zhǔn)確,避免時區(qū)歧義;
- 業(yè)務(wù)用LocalDateTime:給人看的時間、日期計算,用LocalDateTime,代碼更易讀;
- 比較用Instant API:時間比較用
isBefore()、isAfter(),清晰表達業(yè)務(wù)意圖,避免計算錯誤; - 格式化用DateTimeFormatter:定義成static final常量,全局復(fù)用,線程安全。
到此這篇關(guān)于SpringBoot項目中日期處理的最佳實踐的文章就介紹到這了,更多相關(guān)SpringBoot日期處理內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
java實現(xiàn)區(qū)域內(nèi)屏幕截圖示例
這篇文章主要介紹了java截圖示例,需要的朋友可以參考下2014-04-04
Java使用@EnableEurekaServer實現(xiàn)自動裝配詳解
這篇文章主要介紹了Java使用@EnableEurekaServer實現(xiàn)自動裝配過程,文中通過示例代碼介紹的非常詳細,對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價值,需要的朋友們下面隨著小編來一起學(xué)習(xí)吧2022-10-10
Java并發(fā)Map面試線程安全數(shù)據(jù)結(jié)構(gòu)全面分析
本文將探討如何在Java中有效地應(yīng)對這些挑戰(zhàn),介紹一種強大的工具并發(fā)Map,它能夠幫助您管理多線程環(huán)境下的共享數(shù)據(jù),確保數(shù)據(jù)的一致性和高性能,深入了解Java中的并發(fā)Map實現(xiàn),包括ConcurrentHashMap和ConcurrentSkipListMap,及相關(guān)知識點2023-09-09
Spring Boot使用Value注解給靜態(tài)變量賦值的方法
這篇文章主要介紹了Spring Boot使用Value注解給靜態(tài)變量賦值的方法,小編覺得挺不錯的,現(xiàn)在分享給大家,也給大家做個參考。一起跟隨小編過來看看吧2018-07-07
Java實戰(zhàn)項目之斗地主和斗牛游戲的實現(xiàn)
讀萬卷書不如行萬里路,只學(xué)書上的理論是遠遠不夠的,只有在實戰(zhàn)中才能獲得能力的提升,本篇文章手把手帶你用Java實現(xiàn)一個斗地主和一個斗牛游戲,大家可以在過程中查缺補漏,提升水平2021-11-11
idea中springboot項目連接數(shù)據(jù)庫報錯的原因解析
這篇文章主要介紹了idea中springboot項目連接數(shù)據(jù)庫報錯的原因解析,本文給大家介紹的非常詳細,對大家的學(xué)習(xí)或工作具有一定的參考借鑒價值,需要的朋友可以參考下2020-12-12

