前言
很久没有写博客,上半年看房买房自己找人装,总算搞得差不多了,现在有时间继续写点博客了🤪
其实还有一个很重要的原因,就是现在 AI 太强大了,对技术失去信心了,对于未来的路博主也是很迷茫呀。
最近发现一个奇怪的问题,同一个接口有些时间格式化就正常有些时间就有问题,而且时分秒都一样,奇怪了如果是时区问题,相同时分秒的数据格式化出来应该都一样呀。
问题
数据库 现在有两条数据,其中的生日字段(pgsql 字段类型 timestamp without time zone ) 分别为: 1990-09-09 00:00:00 和 1996-09-09 00:00:00 但是返回给前端 就变成了 1990-09-08 和 1996-09-09 java的实体属性如下:
java
@JsonFormat(pattern = "yyyy-MM-dd")
private Date birthDate;
时分秒都是00:00:00为什么有的时间格式化成日期会少一天呢,如果有了解过夏令时 的兄弟可能瞬间就明白,没有了解过的可能死活也排查不出问题。解决不了,AI能解决啊😂
📢看到这儿肯定有很多同志要吐槽了,怎么还在用
Date对象啊,没错按照规范来讲确实不应该用Date对象了,并且存日期也不应该用timestamp。这里就不多说这个问题,大家按照规范来写就行了,还是能避免很多问题。
解决过程
直接问AI就解决了......................
还是看看AI怎么说的吧
时间字段到返回前端都经历了什么
数据库中的时间字段birthday,类型是timestamp without timezone
1. 数据库birthday字段 转成 java 中的 Date 对象。
代码入口我们定位到mybatis的类型转换代码DateTypeHandler.class
java
@Override
public Date getNullableResult(ResultSet rs, String columnName)
throws SQLException {
Timestamp sqlTimestamp = rs.getTimestamp(columnName);
if (sqlTimestamp != null) {
// Date 对象只存一个毫秒值
return new Date(sqlTimestamp.getTime());
}
return null;
}
可以看到构造Date的时候只获取了一个时间搓,这个时间搓是怎么得到的了,直接按照UTC时区? org.postgresql.jdbc.PgResultSet.class 只看核心代码逻辑:
-
确定 Calendar :如果调用方没传
Calendar,就使用getDefaultCalendar()(最终指向 JVM 默认时区)。 -
二进制模式分支..........
-
文本模式 (你贴的代码没展开):走
getString()拿到字符串,再交给TimestampUtils.toTimestamp(cal, string)去解析。
java
@Pure
@Override
public @Nullable Timestamp getTimestamp(
int i, java.util.@Nullable Calendar cal) throws SQLException {
byte[] value = getRawValue(i);
// null 直接返回
if (value == null) {
return null;
}
// 确定 Calendar:如果调用方没传 `Calendar`,就使用 `getDefaultCalendar()`(最终指向 JVM 默认时区)。
if (cal == null) {
cal = getDefaultCalendar();
}
int col = i - 1;
int oid = fields[col].getOID();
if (isBinary(i)) {
................................
}
// If this is actually a timestamptz, the server-provided timezone will override
// the one we pass in, which is the desired behaviour. Otherwise, we'll
// interpret the timezone-less value in the provided timezone.
String string = castNonNull(getString(i));
if (oid == Oid.TIME || oid == Oid.TIMETZ) {
// If server sends us a TIME, we ensure java counterpart has date of 1970-01-01
// 通过cal 对象和数据库字符串,解析成Timestamp 对象
return new Timestamp(connection.getTimestampUtils().toTime(cal, string).getTime());
}
return connection.getTimestampUtils().toTimestamp(cal, string);
}
转换毫秒的具体逻辑:通过解析字符串的年月日时分秒,设置到Calendar对象上,如果数据库字符串自带时区则使用自带时区的Calendar,否则使用JVM默认时区的 Calendar 对象,最后通过Calendar 的 getTimeInMillis 构造一个 tiamstamp
java
public synchronized @PolyNull Timestamp toTimestamp(@Nullable Calendar cal,
@PolyNull String s) throws SQLException {
if (s == null) {
return null;
}
int slen = s.length();
// convert postgres's infinity values to internal infinity magic value
if (slen == 8 && s.equals("infinity")) {
return new Timestamp(PGStatement.DATE_POSITIVE_INFINITY);
}
if (slen == 9 && s.equals("-infinity")) {
return new Timestamp(PGStatement.DATE_NEGATIVE_INFINITY);
}
ParsedTimestamp ts = parseBackendTimestamp(s);
// 数据库中的数据如果自带时区,则用自带的时区,否则使用JVM默认时区
Calendar useCal = ts.tz != null ? ts.tz : setupCalendar(cal);
useCal.set(Calendar.ERA, ts.era);
useCal.set(Calendar.YEAR, ts.year);
useCal.set(Calendar.MONTH, ts.month - 1);
useCal.set(Calendar.DAY_OF_MONTH, ts.day);
useCal.set(Calendar.HOUR_OF_DAY, ts.hour);
useCal.set(Calendar.MINUTE, ts.minute);
useCal.set(Calendar.SECOND, ts.second);
useCal.set(Calendar.MILLISECOND, 0);
// 从Calendar对象中获取时间搓
Timestamp result = new Timestamp(useCal.getTimeInMillis());
result.setNanos(ts.nanos);
return result;
}
2. Date对象格式化
看到这个大家肯定都很熟悉了,我们springboot项目经常需要配置的spring.jackson.time-zone: GMT+8 这个时区就会对我们的时间格式造成影响。
这个过程大家都很清楚了,会把Date对象的包装的绝对时间点(UTC 毫秒) 格式化成指定时区的墙面时间。
解决问题
搞清楚了时间字段从数据库-->date-->string 这个过程,我就要来确认问题在哪儿了。
数据库时间字符串在转成Date对象的时候,是需要获取一个时区的,也就是说这个时间是哪个时区的时间,然后在获取这个时间的时间搓。
因为我们的数据库字段是 timestamp without time zone 不自带时区,就是使用jvm的时区,jvm没有指定就是用服务器的时区。执行 timedatectl 命令 发现是Asia/Shanghai (CST, +0800) ,果然和springboot 配置的spring.jackson.time-zone: GMT+8 有点出入啊。
Asia/Shanghai 和 GMT+8的区别 :
Asia/Shanghai不只是一个 +8 的偏移,它背后有一整套历史规则 , 中国在 1986--1991 年实行过夏令时 (每年夏天 实际偏移UTC+9)而GMT+8没有这些规则,它永远是 UTC+8。
所以看到这儿我们就知道问题出在哪儿了, 看下面:
text
① 数据库裸值 1990-09-09 00:00:00 (无时区)
│
│ JDBC 按 JVM 时区(Asia/Shanghai)锚定,获取这个时区 1990-09-09 00:00:00 的时间搓
▼
② Date 内部 时间搓 650127600000 对应UTC 时间 1990-09-08 15:00:00
│
│ Jackson 按 系统配置的GMT+8 时区 格式化日期 1990-09-08 23:00:00
▼
③ 返回前端字符串 "1990-09-08"
解决方式
- 修改springboot 时区配置 和
jvm保持一致 - java中的日期格式还是用
localDate吧,数据库用Date。 localDate 对象在格式化成字符串的没有时区参与,所以不存在Date 对象经过两次时区转换的过程,就避免时区不一样带来的问题。
总结
我19年开始写的java,那时候jdk1.8已经很流行了,当时都已经知道不推荐用Date对象了,要用LocalDate 代替。但是我一直不知道 Date 在什么时候会出问题,没有去深究过。看到还在用Date 对象也没有去纠正。这次总算是遇见Date对象在某些场景引发的问题,当然也不能全怪Date对象,时区配置不统一也是个隐藏的问题啊!!!
Date 和 LocalDate 对比
Date 是"绝对时间点"(内部是 UTC 毫秒),LocalDate 是"日历上的日期"(年月日,无时区)。 一个是"宇宙中的某一刻",一个是"日历上写的某年某月某日"。语义完全不同。
| 维度 | java.util.Date |
java.time.LocalDate |
|---|---|---|
| 所属 API | 旧 API(JDK 1.0) | 新 API(JDK 8,JSR-310) |
| 内部存储 | 一个 long fastTime(UTC 毫秒) |
int year + int month + int day |
| 语义 | 绝对时间点 | 日历日期(无时区) |
| 是否含时区 | ❌ 无,但内部是 UTC 时间戳 | ❌ 无,纯年月日 |
| 是否含时间 | ✅ 含(时分秒毫秒) | ❌ 只到日 |
| 可变性 | ⚠️ 可变(setTime 能改) |
✅ 不可变(final) |
| 线程安全 | ❌ 不安全 | ✅ 安全 |
| 是否会被时区影响 | ✅ 会(格式化/转换时) | ❌ 不会 |
| 配套格式化类 | SimpleDateFormat(线程不安全) |
DateTimeFormatter(线程安全) |
| 能否直接对应 DB 裸日期 | ❌ 不能,要借时区 | ✅ 能,同类直接对应 |
内部存储最本质的区别:
Date:只存"距离 1970-01-01 00:00:00 UTC 过了多少毫秒",不含年月日、不含时区。
java
public class Date {
private transient long fastTime; // 就这一个字段
}
LocalDate:直接存年月日,不换算、不借时区。
java
public final class LocalDate {
private final int year; // 1996
private final int month; // 9
private final int day; // 9
}