MYSQL 时区问题现象及排查过程。
线上问题描述
遇到一个线上问题,数据库默认插入的创建时间是当前时间,但手动插入的时间却比实际时间刚好差了 14 小时,分秒不差。
这就比较奇怪了,但由于是整个数据库数据都有这个问题,因此怀疑是时区的问题。
先描述一下具体的现象:
现象 | 描述 |
---|---|
创建时间显示错误 | 创建时间在创建时由时区为 CST 数据库默认填充 2023-02-07 18:41:22,但 Date 解析为 2023-02-08 08:41:22 推进了 14 小时 |
完成时间填入错误 | 更新时间与之相对应 2023-02-07 18:44:30,但完成时间为 2023-02-07 04:44:31,完成时间为手动设置 |
大致猜测为中国和美国的时差,大致计算如下
2023-02-07 18:44:31(America -6)-> 2023-02-07 04:44:31(China +8)
2023-02-07 18:41:22(China +8)-> 2023-02-08 08:41:22(America -6)
概念
时区系统
GMT 时区系统
格林威治平均时间 Greenwich Mean Time,GMT是日常最常见的通过地球自转为基础的时间计量单位,也可以说是格林尼治所在地的标准时间,可以看出 GMT 的计算受地球自转影响很大,所以有一定的不确定性,已经逐渐被 UTC 标准取代。
另外,要注意的是,GMT 实际上是一个时区(time zone),并不是一个标准(比其它的时区特殊一些而已),我们在表达时间时会写为 GMT +8:00 ,实际上是指我所在的时区时间,是 GMT 时区的时间加上 8 小时。
UTC 时区系统
世界协调时间 Coordinated Universal Time,它是世界上调节时钟和时间的主要时间标准,说简单一些,就是通过原子钟计算得出的,它和 GMT 时间会存在 0.9 秒内的时间差,大于 0.9 会通过润秒进行调整,也就是【协调】最大不同是 UTC 为一个时间标准,我们可以这么说:
同时我们常常使用到的 NTP 服务,就是以 UTC 为标准校对的。
常见时区
常见的时区形式为 CST
、Asia/Shanghai
、UTC +08:00
。
CST 时区
CST可视为中国、古巴的标准时间或美国、澳大利亚的中部时间。
时区 | 英文 | 对应UTC标准时区 |
---|---|---|
中国标准时间 | China Standard Time | UT+8:00 |
古巴标准时间 | Cuba Standard Time | UT-4:00 |
美国中部时间 | Central Standard Time (USA) | UT-6:00 |
澳大利亚中部时间 | Central Standard Time (Australia) | UT+9:30 |
IANA 代管的时区标识符
Asia/Shanghai 这种表示法是 IANA 代管的时区标识符,一般来说互联网场景使用比较多。
UTC +08:00 时区
根据地理位置不同,为 UTC 偏移不同的时间段,例如上海与 UTC 的时间差为 + 8 小时。
JDBC 处理过程
时区相关参数
数据库服务端时区相关的参数:
时区参数 | 参数名 | 说明 |
---|---|---|
系统时区 | system_time_zone | 在MySQL启动时会检查当前系统的时区并根据当前系统时区设置的全局参数(不可变) |
会话时区 | time_zone | 用来设置每个连接会话的时区,默认为system时,使用全局参数system_time_zone的值。 |
数据库客户端时区相关的参数:
时区参数 | 参数名 | 说明 |
---|---|---|
客户端时区 | serverTimezone/connectionTimeZone | 定义客户端时区,不设置则采用服务端定义的时区 |
JDBC SQL翻译过程
引用自
mysql-connector-java 6.0.6
重点看三个函数
public void setTimestamp(int parameterIndex, Timestamp x) throws java.sql.SQLException {
synchronized (checkClosed().getConnectionMutex()) {
// 传入 session 默认的时区用于解析时间类型
setTimestampInternal(parameterIndex, x, this.session.getDefaultTimeZone());
}}
this.session.getDefaultTimeZone()
最终获取到了 MysqlaSession 中的成员变量 defaultTimeZone。defaultTimeZone 在初始化时有一个默认值 TimeZone.getDefault(),一般会被解析为 Asia/Shanghai。但其成员变量最终会根据 configureTimezone 执行结果设置对应内容。
public void configureTimezone() {
// 获取 time_zone 中时区
String configuredTimeZoneOnServer = getServerVariable("time_zone");
// 如果 time_zone 为 SYSTEM 则使用 system_time_zone
if ("SYSTEM".equalsIgnoreCase(configuredTimeZoneOnServer)) {
configuredTimeZoneOnServer = getServerVariable("system_time_zone");
}
// 获取 serverTimezone 参数,如存在后续根据客户端时区参数匹配时区
String canonicalTimezone = getPropertySet().getStringReadableProperty(PropertyDefinitions.PNAME_serverTimezone).getValue();
// 获取到服务端时区配置
if (configuredTimeZoneOnServer != null) {
// 未获取到客户端时区配置
if (canonicalTimezone == null || StringUtils.isEmptyOrWhitespaceOnly(canonicalTimezone)) {
// 根据服务端时区参数匹配时区
canonicalTimezone = TimeUtil.getCanonicalTimezone(configuredTimeZoneOnServer, getExceptionInterceptor());
}
}
// 使用 canonicalTimezone 解析对应时区
if (canonicalTimezone != null && canonicalTimezone.length() > 0) {
this.serverTimezoneTZ = TimeZone.getTimeZone(canonicalTimezone);
}
this.defaultTimeZone = this.serverTimezoneTZ;
}
获取到时区后,用 SimpleDateFormat 转换
private void setTimestampInternal(int parameterIndex, Timestamp x, TimeZone tz) throws SQLException {
if (x == null) {
setNull(parameterIndex, MysqlType.TIMESTAMP);
} else {
if (!this.sendFractionalSeconds.getValue()) {
x = TimeUtil.truncateFractionalSeconds(x);
}
this.parameterTypes[parameterIndex - 1 + getParameterIndexOffset()] = MysqlType.TIMESTAMP;
if (this.tsdf == null) {
this.tsdf = new SimpleDateFormat("''yyyy-MM-dd HH:mm:ss", Locale.US);
}
this.tsdf.setTimeZone(tz);
StringBuffer buf = new StringBuffer();
buf.append(this.tsdf.format(x));
if (this.session.serverSupportsFracSecs()) {
buf.append('.');
buf.append(TimeUtil.formatNanos(x.getNanos(), true));
} buf.append('\'');
setInternal(parameterIndex, buf.toString());
}}
问题造成的原因
问题主要出现在 MYSQL 和 Java 对于 CST 这个时区的理解上。
根据上述翻译过程,在解析 Timestamp 类型的数据前,需要获取 SimpleDateFormat 解释类,并且向其插入 TimeZone 也就是时区。
获取时区的过程比较简单,即:
- 先从 MYSQL 客户端配置 serverTimezone 获取
- 再从 MYSQL 服务端配置 time_zone 获取
- 最后从 MYSQL 服务端配置 system_time_zone 获取
问题的场景是:
- 客户端未配置 serverTimezone 参数
- 服务端默认 time_zone 为 SYSTEM
- 服务端从 Linux 系统获取的时区,定义 system_time_zone 参数为 CST
因此最终获取到的时区是 CST。
但是 Java 中执行 TimeZone.getTimeZone("CST")
的时区偏移量结果是 -21600000。
下面是 TimeZone JavaDoc 对 CST 的解释。
简单来说就是:TimeZone 的文档中,CST 能被解释成美国中部时间或是中国标准时间,但 Java 平台只会承认一个,默认为美国时区 Central Standard Time。
但是在 MYSQL 中 CST 会被解释成中国时区。
因此,在实际解析中如果设置了 CST,Java 向数据库存入 14:00 这样一个时间数据,在存入 MySQL 前会获取服务端时区(CST),Java 理解为 UTC-6,但 Java 存储的数据为其默认时区 UTC+8。因此,在向 MySQL 服务端发送最终数据时会将时间从 UTC+8 时区转化到 UTC-6 时区,结果最终为 00:00 存入库中,造成显示时间和库内存放时间相差 14 个小时的现象。
美国规定每年从“3月11日”至“11月7日”实行夏令时,美国中部时区改为UTC-05:00;而“11月7日”至“3月11日”实行冬令时,美国中部时区改为UTC-06:00,因排查时是冬令时,造成上述错误时间相隔14个小时。