Contents

Mysql jdbc how to handle write and read datetime field

background

目的:弄清jdbc是如何处理datetime的写与读的,即数据库写入UTC,Java Application读出在转成本地时区对应时间。

背景:mysql-connector-java-8, jooq-3.12

write datetime

首先看下write:

不论什么data access frameworks,最终都会走到jdbc的StatementImpl里executeInternal方法,该方法里会进一步escape SQL。如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
private boolean executeInternal(String sql, boolean returnGeneratedKeys) throws SQLException {
  //...
  if (this.doEscapeProcessing) {
	Object escapedSqlResult = EscapeProcessor.escapeSQL(sql,
    this.session.getServerSession().getDefaultTimeZone(),
    this.session.getServerSession().getCapabilities().serverSupportsFracSecs(), 
    this.session.getServerSession().isServerTruncatesFracSecs(), 
    getExceptionInterceptor());
	sql = escapedSqlResult instanceof String ? (String) escapedSqlResult ((EscapeProcessorResult) escapedSqlResult).escapedSql;
 	}
  //...
}

关键点在:EscapeProcessor.escapeSQL方法里,发现如下代码:

1
2
3
else if (StringUtils.startsWithIgnoreCase(collapsedToken, "{ts")) {
  processTimestampToken(defaultTimeZone, newSql, token, serverSupportsFractionalSecond, serverTruncatesFractionalSecond, exceptionInterceptor);
}

继续看:processTimestampToken方法,这里不长就全部贴出来了:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
private static void processTimestampToken(TimeZone tz, StringBuilder newSql, String token, boolean serverSupportsFractionalSecond,
            boolean serverTruncatesFractionalSecond, ExceptionInterceptor exceptionInterceptor) throws SQLException {
  int startPos = token.indexOf('\'') + 1;
  int endPos = token.lastIndexOf('\''); // no }

  if ((startPos == -1) || (endPos == -1)) {
    newSql.append(token); // it's just part of the query, push possible syntax errors onto server's shoulders
  } else {

    String argument = token.substring(startPos, endPos);

    try {
      Timestamp ts = Timestamp.valueOf(argument);
      ts = TimeUtil.adjustTimestampNanosPrecision(ts, 6, !serverTruncatesFractionalSecond);
      SimpleDateFormat tsdf = TimeUtil.getSimpleDateFormat(null, "''yyyy-MM-dd HH:mm:ss", null, tz);

      newSql.append(tsdf.format(ts));

      if (serverSupportsFractionalSecond && ts.getNanos() > 0) {
        newSql.append('.');
        newSql.append(TimeUtil.formatNanos(ts.getNanos(), 6));
      }

      newSql.append('\'');
    } catch (IllegalArgumentException illegalArgumentException) {
      SQLException sqlEx = SQLError.createSQLException(Messages.getString("EscapeProcessor.2", new Object[] { argument }),
                                                       MysqlErrorNumbers.SQL_STATE_SYNTAX_ERROR, exceptionInterceptor);
      sqlEx.initCause(illegalArgumentException);

      throw sqlEx;
    }
  }
}

到这里,基本就很清晰了,jdbc会截取timestamp字符串,然后通过Java的TimeStamp.valueOf转成一个Unix timestamp(注意这里 会把argument解释成,在Java运行中的local time zone的date time,详见java timestamp valueOf源码),然后根据传进来的TimeZone进而format,我的测试里会format成UTC时间。

到这里就会有个疑问,这个TimeZone是怎么配置进去的。继续看代码。在上一步的executeInternal方法里,TimeZone通过this.session.getServerSession().getDefaultTimeZone()获取。到这里重新debug,进入getDefaultTimeZone,进而跳入NativeServerSession类,发现了setDefaultTimeZone。

到这里后,继续重新debug看setDefaultTimeZone。发现调用的地方在NativeProtocolconfigureTimezone方法里。而configureTimeZone又在NativeProtocol的initServerSession里,这个initServerSession要继续追下去的话,它是在每次jdbc与MySQL建立连接时,用来设置jdbc的各种properties

继续回到configureTimezone方法,断点debug。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
/**
* Configures the client's timezone if required.
* 
* @throws CJException
*             if the timezone the server is configured to use can't be
*             mapped to a Java timezone.
*/
public void configureTimezone() {
  String configuredTimeZoneOnServer = this.serverSession.getServerVariable("time_zone");

  if ("SYSTEM".equalsIgnoreCase(configuredTimeZoneOnServer)) {
    configuredTimeZoneOnServer = this.serverSession.getServerVariable("system_time_zone");
  }

  String canonicalTimezone = getPropertySet().getStringProperty(PropertyKey.serverTimezone).getValue();

  if (configuredTimeZoneOnServer != null) {
    // user can override this with driver properties, so don't detect if that's the case
    if (canonicalTimezone == null || StringUtils.isEmptyOrWhitespaceOnly(canonicalTimezone)) {
      try {
        canonicalTimezone = TimeUtil.getCanonicalTimezone(configuredTimeZoneOnServer, getExceptionInterceptor());
      } catch (IllegalArgumentException iae) {
        throw ExceptionFactory.createException(WrongArgumentException.class, iae.getMessage(), getExceptionInterceptor());
      }
    }
  }

  if (canonicalTimezone != null && canonicalTimezone.length() > 0) {
    this.serverSession.setServerTimeZone(TimeZone.getTimeZone(canonicalTimezone));

    //
    // The Calendar class has the behavior of mapping unknown timezones to 'GMT' instead of throwing an exception, so we must check for this...
    //
    if (!canonicalTimezone.equalsIgnoreCase("GMT") && this.serverSession.getServerTimeZone().getID().equals("GMT")) {
      throw ExceptionFactory.createException(WrongArgumentException.class, Messages.getString("Connection.9", new Object[] { canonicalTimezone }),
                                             getExceptionInterceptor());
    }
  }

  this.serverSession.setDefaultTimeZone(this.serverSession.getServerTimeZone());
}

到这里,就是jdbc配置timeZone的关键点了。

首先,看这三个的取值。

  • serverSession.getServerVariable("time_zone") = "SYSTEM",
  • serverSession.getServerVariable("system_time_zone") = "UTC",
  • getPropertySet().getStringProperty(PropertyKey.serverTimezone).getValue() = "UTC"

最后一个好理解,就是jdbc url上配置的property。而上面两个是什么含义,继续追踪,发现session是NativeServerSession,它的setServerVariables,会在NativeSession中调用,方法为NativeSession#loadServerVariables。到这里就清楚了,这个serverVariable是通过MySQLSHOW VARIABLES得到的。MySQL的system_time_zone是根据宿主机器上的时区来的,time_zone是MySQL server当前的时区,默认是SYSTEM(即 same as the system_time_zone)。

OK,到这里还有一个小问题,我们本地的MySQL是docker启动的,所以当你进入容器后date发现,它的时区就是UTC,而你到dev/qa的MySQL环境里去show variables,就是宿主机器的时区。

思路继续到configureTimezone方法里,如果我们jdbc url上没有serverTimeZone,就会用configuredTimeZoneOnServer(我本地就是system_time_zone=UTC,而服务器上system_time_zone是+8,所以本地在测试的时候,datetime转成了utc时间,而服务器上没有)。所以,我们需要强指定给jdbc,告诉它你去和MySQL通信的时候,serverSession用这个时区,这样在escapeSQL的时候,才能正确转换时间。

至此,datetime的写都已经说完了,关键点在于serverTimeZone的强设置。

read datetime

和写一样,不论什么data access frameworks,最终都会走到jdbc的StatementImpl里executeInternal方法,如下:

1
rs = ((NativeSession) locallyScopedConn.getSession()).execSQL(this, sql, this.maxRows, null, createStreamingResultSet(), getResultSetFactory(), getCurrentCatalog(), cachedMetaData, false);

通过execSQL取出一个ResultSetImpl。继续进入NativeSession的execSQL方法,它里面的flow是:

NativeSession#execSQL -> NativeProtocol#sendQueryPacket -> NativeProtocol#readAllResults -> NativeProtocol#read -> TextResultsetReader#read

一直到这里,jdbc都是在和MySQL通信返回了一个ResultSetImpl的对象,里面包含着许多ValueFactory,如defaultTimestampValueFactory(SqlTimestampValueFactory类型),integerValueFactory(IntegerValueFactory类型),它们都会在ResultSetImpl里构造初始化。这个很重要,后面会用到。

我们知道,所有data access framework都是在和jdbc打交道,读数据就是在操作ResultSetImpl上的各种get方法。

我的情况下,使用的jooq。它会代理jdbc的ResultSetImpl,具体flow:SelectImpl#fetch -> … -> CursorImpl#fetchNext,如下代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
@SuppressWarnings("unchecked")
private final R fetchNext() {
  AbstractRecord record = null;

  try {
    if (!isClosed && rs.next()) {

      // [#1296] Force a row-lock by updating the row if the
      // FOR UPDATE clause is emulated
      if (lockRowsForUpdate) {
        rs.updateObject(1, rs.getObject(1));
        rs.updateRow();
      }

      record = Tools.newRecord(true, (RecordFactory<AbstractRecord>) factory, ((DefaultExecuteContext) ctx).originalConfiguration())
        .operate(new CursorRecordInitialiser(cursorFields, 0));

      rows++;
    }
  }
  // ...
}

这里还不是最终代码,注意里面的Tools.newRecord().operate()方法,一直追下去会跑到 CursorRecordInitialiser里的operate方法,这里的setValue方法最终才会去取ResultSetImpl的值,代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
private final <T> void setValue(AbstractRecord record, Field<T> field, int index) throws SQLException {
    try {
        T value;

        if (field instanceof RowField) {
            Field<?>[] emulatedFields = ((RowField<?, ?>) field).emulatedFields();

            value = (T) Tools.newRecord(true, RecordImpl.class, emulatedFields, ((DefaultExecuteContext) ctx).originalConfiguration())
                             .operate(new CursorRecordInitialiser(emulatedFields, offset + index));

            offset += emulatedFields.length - 1;
        }
        else {
            rsContext.index(offset + index + 1);
            field.getBinding().get((BindingGetResultSetContext<T>) rsContext);
            value = (T) rsContext.value();
        }

        record.values[index] = value;
        record.originals[index] = value;
    }

    // [#5901] Improved error logging, mostly useful when there are some data type conversion errors
    catch (Exception e) {
        throw new SQLException("Error while reading field: " + field + ", at JDBC index: " + (offset + index + 1), e);
    }
}

注意​​,field.getBinding().get((BindingGetResultSetContext<T>) rsContext);

当我们走到filed类型是LocalDateTime时,在debug界面会看到field的dataType是 MySQLDataType里的TIMESTAMP变量(public static final DataType<Timestamp> TIMESTAMP = new DefaultDataType<Timestamp>(SQLDialect.MYSQL, SQLDataType.TIMESTAMP, "timestamp", "datetime");)

最后一个参数datetime是SQLType会去cast的类型,最终会在ConvertedDataType#getCastTypeName里获取cast类型进而convert,我们这里给JOOQ配置的javaTimeTypes=true,也就是说用LocalDateTime来转MySQL的timestamp。

OK,到这里field的type清晰了,进而得出它的binding,通过DefaultDataType的构造函数里去bind,如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
static final <T, U> Binding<T, U> binding(Converter<T, U> converter, boolean isLob) {
  Class<?> type = converter.fromType();

  // Concrete types
  if (type == BigDecimal.class)
    return new DefaultBigDecimalBinding(converter, isLob);
  else if (type == BigInteger.class)
    return new DefaultBigIntegerBinding(converter, isLob);
  else if (type == Blob.class)
    return new DefaultBlobBinding(converter, isLob);
  else if (type == Boolean.class)
    return new DefaultBooleanBinding(converter, isLob);
  else if (type == Byte.class || type == byte.class)
    return new DefaultByteBinding(converter, isLob);
  else if (type == byte[].class)
    return new DefaultBytesBinding(converter, isLob);
  else if (type == Clob.class)
    return new DefaultClobBinding(converter, isLob);
  else if (type == Date.class)
    return new DefaultDateBinding(converter, isLob);
  else if (type == DayToSecond.class)
    return new DefaultDayToSecondBinding(converter, isLob);
  else if (type == Double.class || type == double.class)
    return new DefaultDoubleBinding(converter, isLob);
  else if (type == Float.class || type == float.class)
    return new DefaultFloatBinding(converter, isLob);
  else if (type == Integer.class || type == int.class)
    return new DefaultIntegerBinding(converter, isLob);

  else if (type == LocalDate.class) {
    DateToLocalDateConverter c1 = new DateToLocalDateConverter();
    Converter<LocalDate, U> c2 = (Converter<LocalDate, U>) converter;
    Converter<Date, U> c3 = Converters.of(c1, c2);
    return (Binding<T, U>) new DelegatingBinding<LocalDate, Date, U>(c1, c2, new DefaultDateBinding<U>(c3, isLob), isLob);
  }
  else if (type == LocalDateTime.class) {
    TimestampToLocalDateTimeConverter c1 = new TimestampToLocalDateTimeConverter();
    Converter<LocalDateTime, U> c2 = (Converter<LocalDateTime, U>) converter;
    Converter<Timestamp, U> c3 = Converters.of(c1, c2);
    return (Binding<T, U>) new DelegatingBinding<LocalDateTime, Timestamp, U>(c1, c2, new DefaultTimestampBinding<U>(c3, isLob), isLob);
  }
  else if (type == LocalTime.class) {
    TimeToLocalTimeConverter c1 = new TimeToLocalTimeConverter();
    Converter<LocalTime, U> c2 = (Converter<LocalTime, U>) converter;
    Converter<Time, U> c3 = Converters.of(c1, c2);
    return (Binding<T, U>) new DelegatingBinding<LocalTime, Time, U>(c1, c2, new DefaultTimeBinding<U>(c3, isLob), isLob);
  }

  else if (type == Long.class || type == long.class)
    return new DefaultLongBinding(converter, isLob);

  else if (type == OffsetDateTime.class)
    return new DefaultOffsetDateTimeBinding(converter, isLob);
  else if (type == OffsetTime.class)
    return new DefaultOffsetTimeBinding(converter, isLob);

  else if (type == Short.class || type == short.class)
    return new DefaultShortBinding(converter, isLob);
  else if (type == String.class)
    return new DefaultStringBinding(converter, isLob);
  else if (type == Time.class)
    return new DefaultTimeBinding(converter, isLob);
  else if (type == Timestamp.class)
    return new DefaultTimestampBinding(converter, isLob);
  else if (type == UByte.class)
    return new DefaultUByteBinding(converter, isLob);
  else if (type == UInteger.class)
    return new DefaultUIntegerBinding(converter, isLob);
  else if (type == ULong.class)
    return new DefaultULongBinding(converter, isLob);
  else if (type == UShort.class)
    return new DefaultUShortBinding(converter, isLob);
  else if (type == UUID.class)
    return new DefaultUUIDBinding(converter, isLob);
  else if (type == YearToMonth.class)
    return new DefaultYearToMonthBinding(converter, isLob);

  // Subtypes of array types etc.
  // The type byte[] is handled earlier. byte[][] can be handled here
  else if (type.isArray())
    return new DefaultArrayBinding(converter, isLob);


  else if (EnumType.class.isAssignableFrom(type))
    return new DefaultEnumTypeBinding(converter, isLob);
  else if (Record.class.isAssignableFrom(type))
    return new DefaultRecordBinding(converter, isLob);
  else if (Result.class.isAssignableFrom(type))
    return new DefaultResultBinding(converter, isLob);

  // Undefined types
  else
    return new DefaultOtherBinding(converter, isLob);

}

注意,我们这里传入的class type是SQLDataType.TIMESTAMP,所以,在装timestamp就会找到DefaultTimestampBinding,进而调用里面的get方法如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
@Override
final Timestamp get0(BindingGetResultSetContext<U> ctx) throws SQLException {

  // SQLite's type affinity needs special care...
  if (ctx.family() == SQLDialect.SQLITE) {
    String timestamp = ctx.resultSet().getString(ctx.index());
    return timestamp == null ? null : new Timestamp(parse(Timestamp.class, timestamp));
  }

  else {
    return ctx.resultSet().getTimestamp(ctx.index());
  }
}

这里走到else,最终又会通过代理的jdbc的ResultSetImpl去getTimestamp,也就是我们最上面说的ResultSetImpl里的defaultTimestampValueFactory(SqlTimestampValueFactory类型)。

至此,读的flow已经走了2/3,后面的现象很容易迷糊人。

开始分析jdbc如何读取出datetime的代码,在SqlTimestampValueFactory里如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
@Override
public Timestamp localCreateFromTimestamp(InternalTimestamp its) {
  if (its.getYear() == 0 && its.getMonth() == 0 && its.getDay() == 0) {
    throw new DataReadException(Messages.getString("ResultSet.InvalidZeroDate"));
  }

  synchronized (this.cal) {
    try {
      // this method is HUGEly faster than Java 8's Calendar.Builder()
      this.cal.set(its.getYear(), its.getMonth() - 1, its.getDay(), its.getHours(), its.getMinutes(), its.getSeconds());
      Timestamp ts = new Timestamp(this.cal.getTimeInMillis());
      ts.setNanos(its.getNanos());
      return ts;
    } catch (IllegalArgumentException e) {
      throw ExceptionFactory.createException(WrongArgumentException.class, e.getMessage(), e);
    }
  }
}

首先,第一点这个calendar是个GregorianCalendar的类型,它的构造函数里会去接受一个TimeZone来自serverSession.getDefaultTimeZone,还记得我们上面写操作分析的这个time_zone吗,在我们显示配置serverTimezone=UTC时,构造出来的calendar的zone也就是UTC。

继续看上面的代码,这个cal只是将MySQL里的datetime读取出来(注意datetime没有时区的概念),进而用UTC的calendar去计算出一个距离epoch的millisecond在转成Unix timestamp。

至此,都只是将MySQL的datetime拿出来,然后转成Unix timestamp。但是还没有对应到我们jooq上的LcalDateTime类型呢,还记得上面的filed吗,构造它时会设置一个convert,对timestamp设置的是TimestampToLocalDateTimeConverter,代码如下:

1
2
3
4
@Override
public final LocalDateTime from(Timestamp t) {
  return t == null ? null : t.toLocalDateTime();
}

讲究这在Java timestamp.toLocalDateTime方法,它会将一个timestamp转成Java运行时配置的TimeZone所在的datetime。至此读的flow也结束了。其中在debug过程中,产生了confused的现象:idea的debug的variables界面是和application在一个相同的jvm运行时环境,此时你对一个timestamp toString也是会转成Java配置的TimeZone所在的datetime。

总结

  • 写的时候:用先将一个SQL中的datetime string用Java配置TimeZone转成Unix timestamp,然后用jdbc的defaultTimeZone继续format设置时区后的 datetime string,最后存入MySQL。

  • 读的时候:用jdbc的defaultTimeZone将MySQL取出的datetime转成一个Unix timestamp,后续通过java timestamp.toLocalDateTime进而转成Java配置的TimeZone所在的datetime。

tools