格式化成日期为什么提前一天呢,同样的时间点还是非必现???

前言

很久没有写博客,上半年看房买房自己找人装,总算搞得差不多了,现在有时间继续写点博客了🤪

其实还有一个很重要的原因,就是现在 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 只看核心代码逻辑:

  1. 确定 Calendar :如果调用方没传 Calendar,就使用 getDefaultCalendar()(最终指向 JVM 默认时区)。

  2. 二进制模式分支..........

  3. 文本模式 (你贴的代码没展开):走 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"

解决方式

  1. 修改springboot 时区配置 和jvm保持一致
  2. 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
}
相关推荐
威哥爱编程1 小时前
【AI全栈12-05】Spring Boot 用 Tool Calling 让 AI 查实时订单与库存
java·spring boot·后端
Guarding and trust1 小时前
项目拆解(二):模型输出是‘野‘的——三层兜底+重分析,把AI变成可靠零件
java·人工智能·python·大模型·fastapi·ollama·错误兜底
用户298698530141 小时前
Python 实战:将 HTML 内容保存为图像文件
后端·python·html
春风霓裳1 小时前
0xc0000005 = 内存访问违规
java
挖掘狂人1 小时前
Java 26 深度解析:一次“性能兑现 + 契约收紧”的版本
javascript·后端·性能优化
威哥爱编程1 小时前
【AI全栈12-06】Spring Boot 用 RAG 搭企业私有知识库问答
java·spring boot·后端
迅猛龙办公室1 小时前
Python实现获取星期字符串
java·前端·python
小蒜学长1 小时前
基于微信小程序的医院医疗废弃物追溯管理系统(代码+数据库+LW)
java·spring boot·后端·微信小程序·医疗废弃物
掘金者阿豪1 小时前
SQL 编辑器技术实践:从写 SQL 到数据库治理,聊聊电科金仓 KSQL Developer 的实际价值
后端