mysql 8.0客户端时区识别错误问题修复方法及其源码分析

最近将mysql的驱动MySQL Connector/J版本升级到8.x后,突然发生了大bug,时间比较的都乱套了。

检查

排查后发现,mysql从命令行读取到的值,和实际时间相比,早了14个小时。立刻猜到是时区问题,然而,检查服务器和客户端的时区配置,其实是没问题的。

1
2
3
4
5
6
7
8
mysql> show variables like '%time_zone%';   
+------------------+--------+
| Variable_name | Value |
+------------------+--------+
| system_time_zone | CST |
| time_zone | SYSTEM |
+------------------+--------+
2 rows in set (0.00 sec)

处理

既然在老版本没问题,配置也正常,肯定就是各个地方的兼容问题,那简单的方式就是直接手动指定一下。覆盖了事。

方法1 修改数据库配置

临时修改

控制台直接执行下面的语句

1
2
set global time_zone = '+08:00';
set time_zone = '+08:00';

记得改完后,客户端要重新链接才能取到新的值。

永久修改

在my.cnf的 mysqld节下面加上

1
default-time-zone='+08:00'

需要重启mysql才生效。

方法2 修改客户端配置

在某些公司,可能修改数据库的配置不是那么容易的,这种情况下,修改客户端配置就是比较好的选择了。只需要在jdbc url里加上

serverTimezone=Asia/Shanghai
这里的时区需要与服务器的真实时区相同

源码分析

客户端源码分析

在5.7中,处理时区的逻辑在 com.mysql.jdbc.ConnectionImpl这个类中。

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
private void configureTimezone() throws SQLException {
String configuredTimeZoneOnServer = this.serverVariables.get("timezone");

if (configuredTimeZoneOnServer == null) {
configuredTimeZoneOnServer = this.serverVariables.get("time_zone");

if ("SYSTEM".equalsIgnoreCase(configuredTimeZoneOnServer)) {
configuredTimeZoneOnServer = this.serverVariables.get("system_time_zone");
}
}

String canonicalTimezone = getServerTimezone();

if ((getUseTimezone() || !getUseLegacyDatetimeCode()) && 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 SQLError.createSQLException(iae.getMessage(), SQLError.SQL_STATE_GENERAL_ERROR, getExceptionInterceptor());
}
}
}

if (canonicalTimezone != null && canonicalTimezone.length() > 0) {
this.serverTimezoneTZ = 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.serverTimezoneTZ.getID().equals("GMT")) {
throw SQLError.createSQLException("No timezone mapping entry for '" + canonicalTimezone + "'", SQLError.SQL_STATE_ILLEGAL_ARGUMENT,
getExceptionInterceptor());
}

this.isServerTzUTC = !this.serverTimezoneTZ.useDaylightTime() && this.serverTimezoneTZ.getRawOffset() == 0;
}
}

而8.x则在 com.mysql.cj.protocol.a.NativeProtocol 这个类里

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
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(PropertyDefinitions.PNAME_serverTimezone).getValue();

//主要变化在这里,这里不再判断url里是否设置了UseTimezone或者UseLegacyDatetimeCode
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());
}

在新老版本中,时区设置的优先级均为url里的servertimezone字段,服务器里的timezone变量,systemtimezone变量。然而,在新版本中,默认就是使用服务器上的时区设置,除非url里显示的设置了serverTimezone参数。而按照上面服务的配置,就是要设置时区为CST。

然而很不幸的是,CST这种时区缩写,是很不靠谱的, 这个缩写可能对应了 “Central Standard Time”, “China Standard Time”, “Cuba Standard Time”,在java中,这个缩写默认情况下会去 sun.util.calendar.ZoneInfoFile里查找,具体定义如下

1
{{"ACT", "Australia/Darwin"},{"AET", "Australia/Sydney"}, {"AGT", "America/Argentina/Buenos_Aires"}, {"ART", "Africa/Cairo"}, {"AST", "America/Anchorage"}, {"BET", "America/Sao_Paulo"}, {"BST", "Asia/Dhaka"}, {"CAT", "Africa/Harare"}, {"CNT", "America/St_Johns"}, {"CST", "America/Chicago"}, {"CTT", "Asia/Shanghai"}, {"EAT", "Africa/Addis_Ababa"}, {"ECT", "Europe/Paris"}, {"IET", "America/Indiana/Indianapolis"}, {"IST", "Asia/Kolkata"}, {"JST", "Asia/Tokyo"}, {"MIT", "Pacific/Apia"}, {"NET", "Asia/Yerevan"}, {"NST", "Pacific/Auckland"}, {"PLT", "Asia/Karachi"}, {"PNT", "America/Phoenix"}, {"PRT", "America/Puerto_Rico"}, {"PST", "America/Los_Angeles"}, {"SST", "Pacific/Guadalcanal"}, {"VST", "Asia/Ho_Chi_Minh"}}

可以看到CST变成了芝加哥时间。

服务端源码分析。

我们可以顺便看看服务端的system_time_zone值为啥是cst。这个变量的值,设置的地方在mysqld.cc的init_common_variables 函数中

首先调用

1
tzset();

然后取tzname里的值。

1
2
3
4
5
6
7
#ifdef _WIN32
strmake(system_time_zone, _tzname[tm_tmp.tm_isdst != 0 ? 1 : 0],
sizeof(system_time_zone) - 1);
#else
strmake(system_time_zone, tzname[tm_tmp.tm_isdst != 0 ? 1 : 0],
sizeof(system_time_zone) - 1);
#endif

tzset的函数定义在 tzset.c文件中,他最终会去调用 tzfile.c里的__tzfile_read函数里,通过这个函数名,我们也可以猜到他就是去读文件。而实际上也确实如此,它最终是去linux的时区配置文件夹 /usr/share/zoneinfo/ 中去读取 asia/shanghai文件。 tzfile的结构,这里就不再详细叙述了,可以粗略的看一下

cat /usr/share/zoneinfo/Asia/Shanghai

可以看到CST-8字样(注意不是+8),其中的CST就是缩写。