头图

前言

在 MySQL 实时数据同步领域,Alibaba 的 Canal 工具无疑在数据同步方面发挥着重要的作用。在我的日常工作中,我经常使用 Canal 处理与大数据相关的数据同步任务。然而,正如使用任何开源项目一样,Canal 也存在一些使用上的注意事项和挑战。

因为 Canal 是个开源项目,所以你在使用一个开源项目时,就务必要接受其的不完美性;同时,也不能一味地等待社区的 Bug 修复,就如我的上篇文章阐述的一样(参与 GitHub 开源项目 Canal:从 Bug 修复到 Pull Request),我们应该积极的去奉献整个社区。

本文将重点分享我最近在大数据同步项目中遇到的 Canal 时区问题,并希望通过这个案例为读者提供一些实用的经验。

我计划在接下来的文章中分享我在开发中遇到的问题,以期能为读者提供更多帮助。

测试 Canal 中的时区问题

在实际使用中,不仅在 Canal 订阅 MariaDB 过程中会遇到时区问题,其他同步工具中也可能会引发头疼的时区相关困扰。

最近的问题背景是:公司的 MariaDB 数据库托管在 AWS 上(使用 UTC 时区)。在最近一次数据同步中,发现将 Timestamp 类型的数据同步到 Kafka 后,时间多了 8 个小时,而 Datetime 类型则同步正常。

首先,我们对这两种时间类型进行了简单的测试:

CREATE TABLE `test_timezone` (
  `datetime_0` datetime DEFAULT NULL,
  `datetime_1` datetime(1) DEFAULT NULL,
  `datetime_3` datetime(3) DEFAULT NULL,
  `datetime_6` datetime(6) DEFAULT NULL,
  `timestamp_0` timestamp NULL DEFAULT NULL,
  `timestamp_1` timestamp(1) NULL DEFAULT NULL,
  `timestamp_3` timestamp(3) NULL DEFAULT NULL,
  `timestamp_6` timestamp(6) NULL DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci

INSERT INTO `test_timezone` VALUES('2024-01-17 03:05:05','2024-01-17 03:05:05','2024-01-17 03:05:05','2024-01-17 03:05:05','2024-01-17 03:05:05','2024-01-17 03:05:05','2024-01-17 03:05:05','2024-01-17 03:05:05')

接着,查看当前写入数据的 Binlog:

mysqlbinlog -vv --base64-output=decode-rows ./mysql-bin.00123 > binlog_file

解析出的 Binlog 如下:

### INSERT INTO `test`.`test_timezone`
### SET
###   @1='2024-01-17 03:05:05' /* DATETIME(0) meta=0 nullable=1 is_null=0 */
###   @2='2024-01-17 03:05:05.0' /* DATETIME(1) meta=1 nullable=1 is_null=0 */
###   @3='2024-01-17 03:05:05.000' /* DATETIME(3) meta=3 nullable=1 is_null=0 */
###   @4='2024-01-17 03:05:05.000000' /* DATETIME(6) meta=6 nullable=1 is_null=0 */
###   @5=1705460705 /* TIMESTAMP(0) meta=0 nullable=1 is_null=0 */
###   @6=1705460705.0 /* TIMESTAMP(1) meta=1 nullable=1 is_null=0 */
###   @7=1705460705.000 /* TIMESTAMP(3) meta=3 nullable=1 is_null=0 */
###   @8=1705460705.000000 /* TIMESTAMP(6) meta=6 nullable=1 is_null=0 */

从上述结果中可以看到:

  • Datetime 类型在 Binlog 中以字符串形式存储。
  • Timestamp 类型在 Binlog 中以时间戳形式存储。

根据这个现象,我猜测问题的原因是:Datetime 类型不涉及时区转换,而 Timestamp 类型由于是时间戳需要在 Canal 转换时发生问题。

排查 Canal 中的代码

Canal 作为 MySQL 从库,通过向 MySQL 发送 Dump 请求获取 Binlog 信息,然后进行解析和转换。

image.png

通过代码排查,我发现解析二进制日志的代码:

  • 对于 Datetime 类型,代码中可以发现它以 YYYYMMDDhhmmss 的形式呈现,因此在拼接为字符串时不进行时区转换。
    image.png
  • 对于 Timestamp 类型,解析出时间戳后,通过 java.sql.TimestamptoString 方法来转换为字符串形式的时间。这里使用了时间戳的类,可能导致时区问题。
    image.png

下面我们深入 java.sql.Timestamp 去看看在哪获取的时区。

Timestamp 默认时区问题

java.sql.TimestamptoString 方法在转换为字符串形式的时间时,会调用如下的几个方法,我们这里以 super.getYears() 为例。

image.png

  • super.getYears 第一次调用 normalize()TimeZone.getDefaultRef() 获取当前系统的时区。
public int getHours() {
    return normalize().getHours();
}

private final BaseCalendar.Date normalize() {
    if (cdate == null) {
        BaseCalendar cal = getCalendarSystem(fastTime);
        // 这里 TimeZone.getDefaultRef() 会获取当前系统的时区
        cdate = (BaseCalendar.Date) cal.getCalendarDate(fastTime,
                                                        TimeZone.getDefaultRef());
        return cdate;
    }

    // other code...
}
  • 第一次调用 getDefaultRef() 时,会调用 setDefaultZone() 进行初始化默认的时区。

    static TimeZone getDefaultRef() {
      TimeZone defaultZone = defaultTimeZone;
      if (defaultZone == null) {
          // Need to initialize the default time zone.
          defaultZone = setDefaultZone();
          assert defaultZone != null;
      }
      // Don't clone here.
      return defaultZone;
    }
    
  • 当 JVM 中的 user.timezone 变量未设置值时,根据上述源码分析,将读取系统的默认时区。

    private static synchronized TimeZone setDefaultZone() {
      TimeZone tz;
      // get the time zone ID from the system properties
      String zoneID = AccessController.doPrivileged(
              new GetPropertyAction("user.timezone"));
    
      // if the time zone ID is not set (yet), perform the
      // platform to Java time zone ID mapping.
      if (zoneID == null || zoneID.isEmpty()) {
          String javaHome = AccessController.doPrivileged(
                  new GetPropertyAction("java.home"));
          try {
              zoneID = getSystemTimeZoneID(javaHome);
              if (zoneID == null) {
                  zoneID = GMT_ID;
              }
          } catch (NullPointerException e) {
              zoneID = GMT_ID;
          }
      }
    
      // Get the time zone for zoneID. But not fall back to
      // "GMT" here.
      tz = getTimeZone(zoneID, false);
    
      if (tz == null) {
          // If the given zone ID is unknown in Java, try to
          // get the GMT-offset-based time zone ID,
          // a.k.a. custom time zone ID (e.g., "GMT-08:00").
          String gmtOffsetID = getSystemGMTOffsetID();
          if (gmtOffsetID != null) {
              zoneID = gmtOffsetID;
          }
          tz = getTimeZone(zoneID, true);
      }
      assert tz != null;
    
      final String id = zoneID;
      AccessController.doPrivileged(new PrivilegedAction<Void>() {
          @Override
          public Void run() {
              System.setProperty("user.timezone", id);
              return null;
          }
      });
    
      defaultTimeZone = tz;
      return tz;
    }

风险就出在这里,如果系统安装时时区未正确设置,将导致程序获取的默认时区与预期不符,从而引发问题。

解决 Canal 时区问题

从上面 java.sql.Timestamp 的源码中可以发现,如果我部署 Canal 的服务器时区是 +8 的话,这样会将 Timestamp 字段加上 8 个小时,这也是问题的根本原因。

因此,解决方案是在 Java 程序中提前设置好时区:

  1. 在 Java 程序启动时,在 JVM 参数中添加 -Duser.timezone=UTC
  2. 在程序首次启动时,使用 TimeZone.setDefault() 来设置时区。

总结

时区问题在大数据工作中是一个很常见的问题,其排查过程比较繁琐,但是遇见一两次后,后续处理起来会更加顺手。希望我今天遇到的问题能给各位读者带来其他的思考。


卷土的土
20 声望9 粉丝