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

2024年 1月 18日 52.3k 0

前言

在 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 信息,然后进行解析和转换。

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 程序中提前设置好时区:

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

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

    相关文章

    Oracle如何使用授予和撤销权限的语法和示例
    Awesome Project: 探索 MatrixOrigin 云原生分布式数据库
    下载丨66页PDF,云和恩墨技术通讯(2024年7月刊)
    社区版oceanbase安装
    Oracle 导出CSV工具-sqluldr2
    ETL数据集成丨快速将MySQL数据迁移至Doris数据库

    发布评论