深入浅出 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 信息,然后进行解析和转换。
通过代码排查,我发现解析二进制日志的代码:
对于 Datetime 类型,代码中可以发现它以
YYYYMMDDhhmmss
的形式呈现,因此在拼接为字符串时不进行时区转换。对于 Timestamp 类型,解析出时间戳后,通过
java.sql.Timestamp
的toString
方法来转换为字符串形式的时间。这里使用了时间戳的类,可能导致时区问题。
下面我们深入 java.sql.Timestamp
去看看在哪获取的时区。
Timestamp 默认时区问题
java.sql.Timestamp
的 toString
方法在转换为字符串形式的时间时,会调用如下的几个方法,我们这里以 super.getYears()
为例。
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()
来设置时区。
总结
时区问题在大数据工作中是一个很常见的问题,其排查过程比较繁琐,但是遇见一两次后,后续处理起来会更加顺手。希望我今天遇到的问题能给各位读者带来其他的思考。