首页 > 数据库 >Mybatis 实体类中字段使用 ZonedDateTime,但达梦数据库使用无时区的 Timestamp 时发生的怪事(底层逻辑是错误的,但执行对了)

Mybatis 实体类中字段使用 ZonedDateTime,但达梦数据库使用无时区的 Timestamp 时发生的怪事(底层逻辑是错误的,但执行对了)

时间:2024-06-12 09:56:57浏览次数:34  
标签:实体类 中字段 int Timestamp dt null ZonedDateTime Calendar

背景

开发的应用运行在东八区,无国际化需求,也无时区相关要求。

后端使用 Spring Boot 和 Mybatis,数据库使用达梦数据库,数据库中存储时间的类型为 Timestamp(不存储时区信息)

其中实体如下

public class Student{
     String id;
     ZonedDateTime entryTime;
}

前端和后端约定时间格式采用ISO 8601,例如 2000-01-01T12:00:00Z

如后端的实体类字段使用 LocalDateTime,那么 jackson 转换JSON会无视时区信息,导致存储的时间会和实际时间偏差8小时。

因此我的 DTO 和 实体类均使用了 ZonedDateTimeMybatis 的实体映射类也使用了 ZonedDateTime ,功能上一切正常。

疑问

使用过程中我发现存储在数据库的时间实际上是UTC+8 的时间。我产生了疑问, 在后端中的 UTC 时间转换为 UTC+8 时间是在哪里进行的,如果应用环境变化,会不会导致严重的时间bug。

我尝试修改了应用的时区为UTC(之前是UTC+8),发现后端从数据库获取到的日期发生了错误偏移,比实际时间减少了8小时。

调查

对 Mybatis 的调查

我打印了执行的 SQL 语句,了解到 ZonedDateTime 会被转换为 java.sql.Timestamp 。这部分处理由 mybatis-typehandlers-jsr310ZonedDateTimeTypeHandler 提供。

  @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 的 toLocalDateTimetoString 都会转换为默认时区后显示,没有显式设置的话,会取操作系统的时区。因此调试时可能会直接看到默认时区的时间,而不是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-jsr310ZonedDateTimeTypeHandler#(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驱动的实现和 mybatisZonedDateTimeTypeHandler 实现。

在达梦数据库中,如果 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

相关文章

  • Java 实体类之间的互相复制
    Java实体类之间的互相复制,一般可以通过以下几种方法实现:1.手动赋值:最简单的方法是通过手动为每个属性设置值来实现复制。但是在实际应用中,即使实体类属性较少,手动复制也很繁琐。2.BeanUtils:ApacheBeanUtils是一个常用的Java类库,可以用于快速实现JavaBean之间的互相赋值。......
  • C# 使用Newtonsoft.Json的JsonProperty设置返回的Json数据列名/C# 通过实体类序列化生
    原文链接:https://blog.csdn.net/weixin_44917045/article/details/103236167         https://blog.csdn.net/bazinga_y/article/details/134416680在写分页的时候,返回Json数据给前台的时候,数据不能出来,原因就是Json数据的列名是大写的,而页面需要的是小写的。......
  • 使用Wesky.Net.Opentools库,一行代码实现实体类类型转换为Json格式字符串
    安装1.0.10以及以上版本的Wesky.Net.OpenTools包 包内,该功能的核心代码如下:自定义属性:实体类JSON模式生成器: 使用方式:引用上面的1.0.10版本或以上的包。如果实体类有特殊需求,例如映射为其他名称,可以用OpenJson属性来实现。实体类对象案例如下:上面实体类,提供了属性......
  • POSTGRESQL中时间戳的奥秘timestamptz
    哈喽,大家好,我是木头左!一、前言在日常的数据库操作中,经常会遇到各种时间相关的数据类型,如DATE、TIME、TIMESTAMP等。这些时间类型的处理方式和精度差异,直接影响到对数据的查询和分析结果。今天,就来深入探讨一下POSTGRESQL中的两个重要时间戳类型:timestamp和timestamptz,看看它们......
  • 实体类为啥要序列化
     实体类实现Serializable的作用作用:第一个是便于存储,第二个是便于传输Serializable,之前一直有使用,默认的实体类就会实现Serializable接口,对具体原因一直不是很了解,同时如果没有实现序列化,同样没什么影响,什么时候应该进行序列化操作呢?今天查了下资料,大致总结一下。1、其实......
  • `jsonb` 报错 `invalid input syntax for type timestamp with time zone ““
    哈喽,大家好,我是木头左!大家好,我是你们的朋友,公众号博主。今天要聊一聊一个常见的数据库问题:jsonb报错invalidinputsyntaxfortypetimestampwithtimezone:""。这个问题可能会影响到你的开发工作,但是别担心,我会用最简单易懂的方式,帮助你解决这个问题。1.问题解析需要......
  • .Net项目快速生成数据库的实体类
    MySQL数据库在NuGet包管理中安装以下包,选择符合项目.Net版本的包Microsoft.EntityFrameworkCore.ToolsMicrosoft.EntityFrameworkCore.DesignMySql.EntityFrameworkCore 在程序包控制管理台执行以下命令Scaffold-DbContext"DataSource=localhost;InitialCatalog=mydb;......
  • java动态获取实体类的字段
    1.使用反射(Reflection)API来动态地获取实体类的字段在Java中,我们可以使用反射(Reflection)API来动态地获取实体类的字段。以下是一个详细的代码示例,演示了如何获取一个实体类的所有字段:首先,我们定义一个简单的实体类(EntityClass):publicclassPerson{privateStringname;......
  • 实体类对象和map集合相互转化的常用方法
    准备数据@Data@Builder@NoArgsConstructor@AllArgsConstructorpublicclassUser{privateStringaccount;privateStringname;privateStringpassword;privateStringmobile;}第一种:使用Hutool工具publicclasstestClass{......
  • Springboot Data Jdbc实体类json格式存储
    日常需求中有些需求需要在某字段存储json格式数据,例如日志审计接口传参数据等1.首先我们得保证数据库字段为text或者json2.设置读转换和写转换器importcom.fasterxml.jackson.databind.ObjectMapper;importorg.springframework.core.convert.converter.Converter;importo......