0%

MySQL 时区造成的时间错位

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 为标准校对的。

常见时区

常见的时区形式为 CSTAsia/ShanghaiUTC +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 也就是时区。

获取时区的过程比较简单,即:

  1. 先从 MYSQL 客户端配置 serverTimezone 获取
  2. 再从 MYSQL 服务端配置 time_zone 获取
  3. 最后从 MYSQL 服务端配置 system_time_zone 获取

问题的场景是:

  1. 客户端未配置 serverTimezone 参数
  2. 服务端默认 time_zone 为 SYSTEM
  3. 服务端从 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个小时。