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。发现调用的地方在NativeProtocol
的configureTimezone
方法里。而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。