1. 程式人生 > >SpringBoot時間戳與MySql資料庫記錄相差14小時排錯

SpringBoot時間戳與MySql資料庫記錄相差14小時排錯

專案中遇到儲存的時間戳與真實時間相差14小時的現象,以下為解決步驟.

問題

CREATE TABLE `incident` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `created_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  `recovery_time` timestamp NULL DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=utf8mb4;

以上為資料庫建表語句,其中created_time是插入記錄時自動設定,recovery_time需要手動進行設定.
測試時發現,created_time為正確的北京時間,然而recovery_time則與設定時間相差14小時.

嘗試措施

jvm時區設定

//設定jvm預設時間
System.setProperty("user.timezone", "UTC");

資料庫時區查詢

檢視資料庫時區設定:

show variables like '%time_zone%';
--- 查詢結果如下所示:
--- system_time_zone: CST
--- time_zone:SYSTEM

查詢CST發現其指代比較混亂,有四種含義(參考網址:https://juejin.im/post/5902e087da2f60005df05c3d):

  • 美國中部時間 Central Standard Time (USA) UTC-06:00
  • 澳大利亞中部時間 Central Standard Time (Australia) UTC+09:30
  • 中國標準時 China Standard Time UTC+08:00
  • 古巴標準時 Cuba Standard Time UTC-04:00

此處發現如果按照美國中部時間進行推算,相差14小時,與Bug吻合.

驗證過程

MyBatis轉換

程式碼中,時間戳使用Instant

進行儲存,因此跟蹤package org.apache.ibatis.type下的InstantTypeHandler.

@UsesJava8
public class InstantTypeHandler extends BaseTypeHandler<Instant> {

  @Override
  public void setNonNullParameter(PreparedStatement ps, int i, Instant parameter, JdbcType jdbcType) throws SQLException {
    ps.setTimestamp(i, Timestamp.from(parameter));
  }

  //...程式碼shenglve
}

除錯時發現parameter為正確的UTC時.
函式中呼叫Timestamp.fromInstant轉換為Timestamp例項,檢查無誤.

    /**
     * Sets the designated parameter to the given <code>java.sql.Timestamp</code> value.
     * The driver
     * converts this to an SQL <code>TIMESTAMP</code> value when it sends it to the
     * database.
     *
     * @param parameterIndex the first parameter is 1, the second is 2, ...
     * @param x the parameter value
     * @exception SQLException if parameterIndex does not correspond to a parameter
     * marker in the SQL statement; if a database access error occurs or
     * this method is called on a closed <code>PreparedStatement</code>     */
    void setTimestamp(int parameterIndex, java.sql.Timestamp x)
            throws SQLException;

繼續跟蹤setTimestamp介面,其具體解釋見程式碼註釋.

Sql Driver轉換

專案使用com.mysql.cj.jdbc驅動,跟蹤其setTimestampClientPreparedStatement類下的具體實現(PreparedStatementWrapper類下實現未進入).

    @Override
    public void setTimestamp(int parameterIndex, Timestamp x) throws java.sql.SQLException {
        synchronized (checkClosed().getConnectionMutex()) {
            ((PreparedQuery<?>) this.query).getQueryBindings().setTimestamp(getCoreParameterIndex(parameterIndex), x);
        }
    }

繼續跟蹤上端程式碼中的getQueryBindings().setTimestamp()實現(com.mysql.cj.ClientPreparedQueryBindings).

    @Override
    public void setTimestamp(int parameterIndex, Timestamp x, Calendar targetCalendar, int fractionalLength) {
        if (x == null) {
            setNull(parameterIndex);
        } else {

            x = (Timestamp) x.clone();

            if (!this.session.getServerSession().getCapabilities().serverSupportsFracSecs()
                    || !this.sendFractionalSeconds.getValue() && fractionalLength == 0) {
                x = TimeUtil.truncateFractionalSeconds(x);
            }

            if (fractionalLength < 0) {
                // default to 6 fractional positions
                fractionalLength = 6;
            }

            x = TimeUtil.adjustTimestampNanosPrecision(x, fractionalLength, !this.session.getServerSession().isServerTruncatesFracSecs());

            //注意此處時區轉換
            this.tsdf = TimeUtil.getSimpleDateFormat(this.tsdf, "''yyyy-MM-dd HH:mm:ss", targetCalendar,
                    targetCalendar != null ? null : this.session.getServerSession().getDefaultTimeZone());

            StringBuffer buf = new StringBuffer();
            buf.append(this.tsdf.format(x));
            if (this.session.getServerSession().getCapabilities().serverSupportsFracSecs()) {
                buf.append('.');
                buf.append(TimeUtil.formatNanos(x.getNanos(), 6));
            }
            buf.append('\'');

            setValue(parameterIndex, buf.toString(), MysqlType.TIMESTAMP);
        }
    }

注意此處時區轉換,會呼叫如下語句獲取預設時區:

this.session.getServerSession().getDefaultTimeZone()

獲取TimeZone資料,具體如下圖所示:

TimeZone定義

檢查TimeZone類中offset含義,具體如下所示:

    /**
     * Gets the time zone offset, for current date, modified in case of
     * daylight savings. This is the offset to add to UTC to get local time.
     * <p>
     * This method returns a historically correct offset if an
     * underlying <code>TimeZone</code> implementation subclass
     * supports historical Daylight Saving Time schedule and GMT
     * offset changes.
     *
     * @param era the era of the given date.
     * @param year the year in the given date.
     * @param month the month in the given date.
     * Month is 0-based. e.g., 0 for January.
     * @param day the day-in-month of the given date.
     * @param dayOfWeek the day-of-week of the given date.
     * @param milliseconds the milliseconds in day in <em>standard</em>
     * local time.
     *
     * @return the offset in milliseconds to add to GMT to get local time.
     *
     * @see Calendar#ZONE_OFFSET
     * @see Calendar#DST_OFFSET
     */
    public abstract int getOffset(int era, int year, int month, int day,
                                  int dayOfWeek, int milliseconds);

offset表示本地時間UTC時的時間間隔(ms).
計算數值offset,發現其表示美國中部時間,即UTC-06:00.

  • Driver推斷Session時區為UTC-6;
  • DriverTimestamp轉換為UTC-6String;
  • MySql認為Session時區在UTC+8,將String轉換為UTC+8.

因此,最終結果相差14小時,bug源頭找到.

解決方案

參照https://juejin.im/post/5902e087da2f60005df05c3d.

mysql> set global time_zone = '+08:00';
Query OK, 0 rows affected (0.00 sec)

mysql> set time_zone = '+08:00';
Query OK, 0 rows affected (0.00 sec)

告知運維設定時區,重啟MySql服務,問題解決.

此外,作為防禦措施,可以在jdbc url中設定時區(如此設定可以不用修改MySql配置):

jdbc:mysql://localhost:3306/table_name?useTimezone=true&serverTimezone=GMT%2B8

此時,就告知連線進行時區轉換,並且時區為UTC+8.

PS:
如果您覺得我的文章對您有幫助,可以掃碼領取下紅包,謝謝!