背景
开发的应用运行在东八区,无国际化需求,也无时区相关要求。
后端使用 Spring Boot 和 Mybatis,数据库使用达梦数据库,数据库中存储时间的类型为 Timestamp(不存储时区信息)
其中实体如下
public class Student{
String id;
ZonedDateTime entryTime;
}
前端和后端约定时间格式采用ISO 8601,例如 2000-01-01T12:00:00Z
。
如后端的实体类字段使用 LocalDateTime
,那么 jackson 转换JSON会无视时区信息,导致存储的时间会和实际时间偏差8小时。
因此我的 DTO 和 实体类均使用了 ZonedDateTime
,Mybatis
的实体映射类也使用了 ZonedDateTime
,功能上一切正常。
疑问
使用过程中我发现存储在数据库的时间实际上是UTC+8 的时间。我产生了疑问, 在后端中的 UTC 时间转换为 UTC+8 时间是在哪里进行的,如果应用环境变化,会不会导致严重的时间bug。
我尝试修改了应用的时区为UTC(之前是UTC+8),发现后端从数据库获取到的日期发生了错误偏移,比实际时间减少了8小时。
调查
对 Mybatis 的调查
我打印了执行的 SQL 语句,了解到 ZonedDateTime
会被转换为 java.sql.Timestamp
。这部分处理由 mybatis-typehandlers-jsr310
的 ZonedDateTimeTypeHandler
提供。
@Override
public void setNonNullParameter(PreparedStatement ps, int i, ZonedDateTime parameter, JdbcType jdbcType)
throws SQLException {
ps.setTimestamp(i, Timestamp.from(parameter.toInstant()));
}
Timestamp 和 Instant 都是没有时区信息的,它们存储UTC时间,也不进行时区转换。所以时区转换并不是 mybatis
直接进行的
Timestamp 的
toLocalDateTime
和toString
都会转换为默认时区后显示,没有显式设置的话,会取操作系统的时区。因此调试时可能会直接看到默认时区的时间,而不是UTC时间
对 JDBC驱动 的调查
写入日期到数据库
时区转换是否可能是预处理SQL时进行的? 我找到了预处理Timestamp的相关代码,这部分功能是达梦的JDBC驱动实现的。
PreparedStatement#setTimestamp(int,Timestamp)
为预处理 Timestamp 的接口方法。
setTimestamp
的最终实现如下
public void do_setTimestamp(int parameterIndex, Timestamp x, Calendar cal) throws SQLException {
if (x instanceof DmdbTimestamp) {
this.do_setTIMESTAMP(parameterIndex, (DmdbTimestamp)x);
} else if (x == null) {
this.do_setNull(parameterIndex, 93);
} else {
int iparam = this.bindInParam(parameterIndex, 16, false);
this.curRowDatas[iparam] = J2DB.fromDate(x, this.bindParameters[iparam], this.connection, cal != null ? (Calendar)cal.clone() : null);
}
}
再看看 J2DB#fromDate
的实现
// 本场景中 param.type 为16 ,Calendar 为null
public static byte[] fromDate(Date val, Column param, IDmdbConnection connection, Calendar calendar) throws SQLException {
try {
byte[] bytes = null;
switch (param.type) {
// ...其他分支代码
case 16:
case 22:
case 23:
case 26:
case 27:
DmdbTimestamp dt = DmdbTimestamp.valueOf(val, param.mask == 4 ? null : calendar);
bytes = dt.encode(param, connection);
break;
default:
DBError.ECJDBC_DATA_CONVERTION_ERROR.throwz(new Object[0]);
}
return checkLength(param, bytes, connection.getServerEncoding());
}catch(){// ...异常处理代码}
}
public byte[] encode(Column column, IDmdbConnection connection) throws SQLException {
int[] dt = this.dt;
int rtype = column != null ? column.type : this.dtype;
int rscale = column != null ? column.scale : this.scale;
if (column != null && column.mask == 4) {
dt = transformTZ(dt, connection.getLocalTimezone(), connection.getDBTimezone());
}
dt = roundHalfup(dt, rtype, rscale);
if (dt[0] < -4712 || dt[0] > 9999) {
DBError.EC_DATETIME_OVERFLOW.throwz(new Object[0]);
}
int year = dt[0];
int month = dt[1];
int day = dt[2];
int hour = dt[3];
int min = dt[4];
int sec = dt[5];
int msec = dt[6];
int tz = dt[7] == Integer.MIN_VALUE && connection != null ? connection.getLocalTimezone() : dt[7];
byte[] ret = null;
// ..其他类型的处理代码
} else if (rtype == 16) {
msec /= 1000;
ret = new byte[8];
ret[0] = (byte)(year & 255);
if (year >= 0) {
ret[1] = (byte)(year >> 8 | (month & 1) << 7);
} else {
ret[1] = (byte)(year >> 8 & ((month & 1) << 7 | 127));
}
ret[2] = (byte)((month & 14) >> 1 | day << 3);
ret[3] = (byte)(hour | (min & 7) << 5);
ret[4] = (byte)((min & 56) >> 3 | (sec & 31) << 3);
ret[5] = (byte)((sec & 32) >> 5 | (msec & 127) << 1);
ret[6] = (byte)(msec >> 7 & 255);
ret[7] = (byte)(msec >> 15 & 255);
}
// ..其他类型的处理代码
return ret;
}
DmdbTimestamp
也是 Timestamp
的子类。
其中 DmdbTimestamp#encode(Column, IDmdbConnection)
将 DmdbTimestamp
中的 dt 数组转换成二进制数据。
dt数组是一个字节数组,存储着日期时间和时区信息。
值得一提的是,dt数组生成时会根据 TimeZone.getDefault()
进行转换,也就是说dt数组存储的是东八区的日期和时间以及时区信息,即 2000-01-01T20:00:00+08:00
。
达梦数据库的 Timestamp 并不存储时区信息,因此直接将东八区的日期时间丢弃时区信息后存入了数据库。所以数据库中看到的是UTC+8的时间(但无时区信息)
应用从数据库读取日期
java.sql.Timestamp
转换为 ZonedDateTime
依靠 mybatis-typehandlers-jsr310
的 ZonedDateTimeTypeHandler#(ResultSet,String)
@Override
public ZonedDateTime getNullableResult(ResultSet rs, String columnName) throws SQLException {
Timestamp timestamp = rs.getTimestamp(columnName);
return getZonedDateTime(timestamp);
}
private static ZonedDateTime getZonedDateTime(Timestamp timestamp) {
if (timestamp != null) {
return ZonedDateTime.ofInstant(timestamp.toInstant(), ZoneId.systemDefault());
}
return null;
}
其中从 JDBC 获取到 timestamp 时,timestamp 已经被转换为 2000-01-01T20:00:00+08:00
。
转换由 rs.getTimestamp
,其底层调用了达梦JDBC驱动的 DB2J#toTimestamp(byte[],Column,DmdbConnection,Calendar)
,该方法将数据库原始数据转换为 DmdbTimestamp
(是 Timestamp
的子类。
public static Timestamp toTimestamp(byte[] bytes, Column column, DmdbConnection connection, Calendar cal) throws SQLException {
switch (column.type) {
case 0:
case 1:
case 2:
case 19:
String str = charToString(bytes, column, connection);
return DmdbTimestamp.valueOf(str).toTimestamp(cal);
case 14:
case 15:
case 16:
case 26:
// 达梦 timestamp 的 column.type 为 16
DmdbTimestamp dt = DmdbTimestamp.valueOf(bytes, column, connection);
return dt.toTimestamp(column.mask == 4 ? null : cal);
//... 其他代码
}
}
从原始数据解析获得的 DmdbTimestamp
实际为 2000-01-01T20:00:00Z
,尚未偏移。
第二行代码 DmdbTimestamp#toTimestamp(Calendar)
源码如下。其中 cal 为 null
获取的 Calendar 是默认时区的,而 Calendar 设置的日期是默认时区的时间。因此转换后的 Timestamp
对象 val ”错误”地变成了 2000-01-01T20:00:00+08:00
,而这时间刚好是我们存进去的时间。
public Timestamp toTimestamp(Calendar cal) {
Timestamp val = new Timestamp(this.getTime(cal));
val.setNanos(this.dt[6]);
return val;
}
public long getTime(Calendar calendar) {
if (calendar == null) {
calendar = getCalendar();
}
int year = this.dt[0];
int month = this.dt[1] - 1;
int day = this.dt[2];
if (year == 0 && month == -1 && day == 0) {
year = 1970;
month = 0;
day = 1;
}
calendar.set(Math.abs(year), month, day, this.dt[3], this.dt[4], this.dt[5]);
calendar.set(14, this.dt[6] / 1000000);
calendar.set(0, this.dt[0] >= 0 ? 1 : 0);
long ret = calendar.getTimeInMillis();
return ret;
}
private static Calendar getCalendar() {
Calendar calendar = (Calendar)calendars.get();
if (calendar == null) {
calendar = Calendar.getInstance(TimeZone.getDefault());
calendars.set(calendar);
}
return calendar;
}
总结
使用 ZonedDateTime
作为 Mybatis
实体映射类的字段类型,数据库列类型使用没有时区信息的Timestamp 很危险,”正确”运行依赖数据库JDBC驱动的实现和 mybatis
的 ZonedDateTimeTypeHandler
实现。
在达梦数据库中,如果 Java 应用时区保持不变,那么在 mybatis
和 达梦 jdbc 驱动相关实现不变化的情况下,这种”正确”运行可以一直维持下去。
修复
安全起见还是把实体类的字段类型修改为 LocalDateTime
,接收前端的参数时手动将 ZonedDateTime
转换为 UTC+8 或 UTC 的 LocalDateTime
。这样就不用担心后端应用默认时区变更造成的影响。
下面是修改后的代码
class ZoneConfig{
public static final ZoneId APP_ZONE_ID = ZoneId.of("UTC+8");
}
class StudentDto{
String id;
ZonedDateTime entryTime;
public static updateEntity(Student student){
student.setEntryTime(LocalDateTime.ofInstant(now.toInstant(),APP_ZONE_ID));
}
}
class Student{
String id;
LocalDateTime entryTime;
}
标签:实体类,中字段,int,Timestamp,dt,null,ZonedDateTime,Calendar
From: https://www.cnblogs.com/mad-fox/p/18243349