跳到主要内容

深入浅出 Canal 数据同步中的时区问题

前言

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

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

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

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

测试 Canal 中的时区问题

在实际使用中,不仅在 Canal 订阅 MariaDB 过程中会遇到时区问题,其他同步工具中也可能会引发头疼的时区相关困扰,就如我之前遇到的:解决 PostgreSQL 同步到 ES 后时间类型少了 8 小时

最近的问题背景是:公司的 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 信息,然后进行解析和转换。

canal_timezone_01.png

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

  • 对于 Datetime 类型,代码中可以发现它以 YYYYMMDDhhmmss 的形式呈现,因此在拼接为字符串时不进行时区转换。 canal_timezone_02.png

  • 对于 Timestamp 类型,解析出时间戳后,通过 java.sql.TimestamptoString 方法来转换为字符串形式的时间。这里使用了时间戳的类,可能导致时区问题。 canal_timezone_03.png

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

Timestamp 默认时区问题

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

canal_timezone_04.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() 来设置时区。

总结

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