51工具盒子

依楼听风雨
笑看云卷云舒,淡观潮起潮落

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

前言 {#前言}

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

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

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

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

测试 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-中的代码}

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 默认时区问题 {#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 时区问题 {#解决-canal-时区问题}

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

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

  1. 在 Java 程序启动时,在 JVM 参数中添加 -Duser.timezone=UTC

  2. 在程序首次启动时,使用 TimeZone.setDefault() 来设置时区。

总结 {#总结}

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

赞(3)
未经允许不得转载:工具盒子 » 深入浅出 Canal 数据同步中的时区问题