编辑
2026-10-23
canal
00

目录

Canal + Elasticsearch 实时同步实战(三):8 个踩坑记录与生产环境注意事项
目录
一、坑位总表
二、逐个击破
坑 0:MySQL 8.4 不支持 mysqlnativepassword
坑 1:MySQL 8.4 没有 SHOW MASTER STATUS 了
坑 2:JDBC 驱动太老,不认 cachingsha2password
坑 3:es7 适配器默认居然走 TransportClient
坑 4:REST 模式下 hosts 必须带协议头
坑 5:application.yml 少写两行 → 启动 NPE
坑 6:索引没建好,全量同步必 NPE
坑 7:ETL 说导入了 8 条,count 却是 0
坑 8:CentOS 7 装不上 JDK
三、注意事项与最佳实践
3.1 位点管理是最大的风险点
3.2 DDL 变更不会自动同步到 ES
3.3 _id 的设计决定能否正确更新删除
3.4 join 查询的触发陷阱
3.5 性能调优的两个方向
四、写在最后
附录:全链路验证清单

Canal + Elasticsearch 实时同步实战(三):8 个踩坑记录与生产环境注意事项

系列文章

  • 第一篇:选型、架构与前置准备
  • 第二篇:部署 canal-server 与 adapter
  • 第三篇:踩坑与注意事项 ← 本篇

目录


一、坑位总表

按实际踩到的顺序排列,也是推荐的排查顺序:

#现象根因解法
0Plugin 'mysql_native_password' is not loadedMySQL 8.4 默认禁用该插件改用 caching_sha2_password
1command 'show master status' has an error!MySQL 8.4 移除了该语句固定位点启动
2认证失败 / 驱动不兼容adapter 自带 5.1.48 驱动换 8.0.33 驱动
3None of the configured nodes are availablees7 适配器默认走 TransportClientmode: rest
4Illegal character in scheme name at index 0hosts 缺协议头改成 http://ip:9200
5启动 NullPointerExceptionapplication.yml 缺 accessKey补上空键
6全量同步 NPE / 导入 0 条索引没建或没有 mapping先建带 mapping 的索引
7ETL 成功但 count=0ES 未 refresh_refresh 或等 1 秒
8CentOS 7 装不上 JDK系统 EOL,yum 源失效用二进制包

坑 0 严格说是 MySQL 侧的坑,但它决定了后面所有配置的方向,所以放第一位。


二、逐个击破

坑 0:MySQL 8.4 不支持 mysql_native_password

现象

在 MySQL 8.4 上按教程创建 Canal 账号:

sql
CREATE USER 'canal'@'%' IDENTIFIED WITH mysql_native_password BY 'xxx';
ERROR 1524 (HY000): Plugin 'mysql_native_password' is not loaded

按网上说的先装插件:

sql
INSTALL PLUGIN mysql_native_password SONAME 'mysql_native_password.so';
ERROR 1125 (HY000): Function 'mysql_native_password' already exists

再按另一篇教程改系统变量:

sql
SET PERSIST mysql_native_password = ON;
ERROR 1193 (HY000): Unknown system variable 'mysql_native_password'

三条路全堵死。

根因

MySQL 8.4 把 mysql_native_password 改成了内置插件,但默认关闭:

  • 因为是内置的,INSTALL PLUGIN 会报 "already exists"——它确实存在,只是没启用
  • 启用开关不是系统变量,所以 SET PERSIST 报 "Unknown system variable"

官方给的启用方式是启动参数:

bash
mysqld --mysql-native-password=ON

Docker 环境两种改法:

bash
# 方式 A:改 my.cnf 后重启容器(不丢数据) docker exec <容器名> sh -c "sed -i '/^\[mysqld\]/a mysql_native_password=ON' /etc/my.cnf" docker restart <容器名> # 方式 B:重建容器时加启动参数 docker run -d ... mysql:8.4 --mysql-native-password=ON

但本文没有走这条路,而是直接使用默认的 caching_sha2_password:

sql
CREATE USER 'canal'@'%' IDENTIFIED BY '<CANAL_DB_PASSWORD>';

为什么这么选?

方案影响面风险
开 mysql_native_password全库所有客户端改动的是数据库全局配置,其他服务可能受影响
用 caching_sha2 + 换驱动只有 Canal 一个组件换一个 jar 包,可回滚

在别人的库上动全局配置,永远比在自己的组件里换 jar 风险大。

延伸:MySQL 9.x 怎么办

mysql_native_password 在 MySQL 9.0 被彻底移除,没有开关能打开。如果业务库已经上了 9.x,只能走 caching_sha2 路线(换驱动),或者降级到 8.0。


坑 1:MySQL 8.4 没有 SHOW MASTER STATUS 了

现象

Canal 启动后实例日志刷屏:

ERROR com.alibaba.otter.canal.common.alarm.LogAlarmHandler - destination:example [com.alibaba.otter.canal.parse.exception.CanalParseException: command : 'show master status' has an error! Caused by: java.io.IOException: ErrorPacket [errorNumber=1064, fieldCount=-1, message=You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'master status' at line 1, sqlState=42000, sqlStateMarker=#] with command: show master status

根因

MySQL 从 8.0.22 开始废弃 SHOW MASTER STATUS,8.4 直接移除,替代品是:

sql
SHOW BINARY LOG STATUS;

而 Canal 1.1.7 的 MysqlConnection.query("show master status") 是硬编码在 jar 里的,改配置没有任何用。

解法 A:固定位点(本文采用,零代码改动)

先查出当前位点:

sql
SHOW BINARY LOG STATUS;
+----------------+----------+ | File | Position | +----------------+----------+ | bin-log.000048 | 4059448 | +----------------+----------+

写进 instance.properties:

properties
canal.instance.master.journal.name = bin-log.000048 canal.instance.master.position = 4059448

Canal 看到位点已指定,就跳过 SHOW MASTER STATUS 直接开始 dump。

⚠️ 副作用必须知道:

位点是写死的。以下情况会让 Canal 起不来:

  • MySQL 重启后 binlog 轮转到新文件,旧文件被清理
  • 手动执行了 PURGE BINARY LOGS
  • 位点对应的文件不存在

报错长这样:

Could not find first log file name in binary log index file

处理办法是重新查位点、改配置、删掉 conf/example/meta.dat、重启。

所以生产环境如果要用这个方案,必须做两件事:

  1. 调大 binlog_expire_logs_seconds(默认 30 天),确保保留时间大于最长停机时间
  2. 把位点纳入监控,写个脚本定期比对

解法 B:升级 Canal

官方在后续版本修复了 MySQL 8.4 兼容。但 1.1.7 是大量教程和配套配置的基准版本,升级可能引入其他差异,学习阶段不建议折腾。

我们的选择理由:业务库是 8.4 且不能动,Canal 版本是教程指定的 1.1.7。两座山都搬不动,只能在中间找条路——固定位点是成本最低的那条。


坑 2:JDBC 驱动太老,不认 caching_sha2_password

现象

adapter 启动时数据库连接失败,日志里是认证相关的异常(Access denied 或密钥交换失败)。

根因

canal.adapter-1.1.7.tar.gz 的 lib/ 里带的是 mysql-connector-java-5.1.48.jar——2015 年的版本。

这个版本只支持 mysql_native_password,而 MySQL 8.x 默认是 caching_sha2_password。

解法

换成 8.x 驱动,并且把旧驱动移出 lib 目录:

bash
cd /opt/canal/adapter/lib curl -fsSL -o mysql-connector-java-8.0.33.jar \ https://maven.aliyun.com/repository/public/com/mysql/mysql-connector-j/8.0.33/mysql-connector-j-8.0.33.jar mv mysql-connector-java-5.1.48.jar /opt/canal/

JDBC URL 同时加上:

&allowPublicKeyRetrieval=true&useSSL=false

两个额外踩到的细节:

① Maven 坐标变了

8.0.31 之后,坐标从 mysql:mysql-connector-java 改成了 com:mysql-connector-j:

❌ mysql/mysql-connector-java/8.0.33/... ✅ com/mysql/mysql-connector-j/8.0.33/...

按旧路径下载 8.0.33 会 404。我一开始以为是镜像没同步,换了三个源都是 404,才意识到是坐标变了。

② 旧驱动必须"移走",不能改名

startup.sh 是这样拼 CLASSPATH 的:

bash
for i in "$base"/lib/*.jar ; do ... done

*.jar 会匹配到 mysql-connector-java-5.1.48.jar.disabled 吗?

不会——但 *.jar 匹配不到 .jar.disabled,问题是脚本里用的是 lib/*,或者有些版本用 -cp "$base/lib/*" 由 JVM 展开,JVM 的 * 是匹配所有文件。

我第一次把旧驱动改成 .disabled,结果 Spring Boot 启动时两个驱动都在 classpath 里,出现了驱动类冲突。移出目录才是干净的做法。

验证:

bash
ls -l /opt/canal/adapter/lib/ | grep mysql # 只应该剩 mysql-connector-java-8.0.33.jar

坑 3:es7 适配器默认居然走 TransportClient

现象

全量同步接口返回:

json
{ "succeeded": false, "resultMessage": "导入ES 数据:0 条", "errorMessage": "nfturbo_collection etl failed! ==> None of the configured nodes are available: [{#transport#-1}{9J2-VM7wRZCdaZhH8WjiSg}{127.0.0.1}{127.0.0.1:9200}]" }

关键线索:报错里的 #transport#-1。

这是 TransportClient 的典型特征,不是 REST 客户端。而且注意——它把 9200 当成了 transport 端口去连。

根因

client-adapter.es7x 虽然名字叫 es7,但默认模式是 transport。

解开 jar 看 classes,里面确实打包了:

org/elasticsearch/client/transport/TransportClient.class org/elasticsearch/client/transport/TransportClientNodesService.class ...

适配器的 ESConnection 类同时持有两个字段:

java
private ESClientMode mode; private TransportClient transportClient; private RestHighLevelClient restHighLevelClient;

默认 mode = TRANSPORT,所以你写 hosts: 127.0.0.1:9200,它就拿 9200 当传输端口去连,自然连不上。

解法

显式声明走 REST:

yaml
- name: es7 hosts: http://127.0.0.1:9200 properties: mode: rest

为什么会这样设计?

Canal 的 ES 适配器最早是为 ES 5/6 写的,那时候 TransportClient 是主流(性能好、支持批量、能拿元数据)。 ES 7 虽然废弃了 TransportClient,但为了兼容老用户,适配器保留了它并设为默认。

结果就是:ES 7 用户必须手动改,而官方文档里这个点写得非常隐蔽。


坑 4:REST 模式下 hosts 必须带协议头

现象

切到 mode: rest 后重启,直接启动失败:

java.lang.RuntimeException: java.lang.IllegalArgumentException: Illegal character in scheme name at index 0: 127.0.0.1:9200

根因

REST 模式下,配置会传给 HttpHost 构造,它要求是完整 URL(带 scheme)。纯 ip:port 会被当成 scheme 解析,第一个字符 1 不合法。

解法

yaml
hosts: http://127.0.0.1:9200

多节点逗号分隔:

yaml
hosts: http://10.0.0.1:9200,http://10.0.0.2:9200

HTTPS 同理:https://es.example.com:9200。

这个坑和坑 3 是一对:改了 mode: rest 之后,hosts 的格式也得跟着改。 很多人改了一半就重启,结果从一个错误跳到另一个错误。


坑 5:application.yml 少写两行 → 启动 NPE

现象

启动 adapter 直接崩:

ERROR c.a.o.canal.adapter.launcher.loader.CanalAdapterService - ## something goes wrong when starting up the canal client adapters: java.lang.NullPointerException: null at java.util.Hashtable.put(Hashtable.java:460) at com.alibaba.otter.canal.adapter.launcher.loader.AdapterProcessor.<init>(AdapterProcessor.java:72) at com.alibaba.otter.canal.adapter.launcher.loader.CanalAdapterLoader.lambda$init$0(CanalAdapterLoader.java:85) at java.util.HashMap.computeIfAbsent(HashMap.java:1127) ...

排查过程

堆栈只显示 NPE,没有任何业务信息。日志里前后文也没有别的线索。

于是我反编译了 AdapterProcessor,定位到第 72 行:

java
Properties.put("canal.aliyun.accessKey", canalClientConfig.getAccessKey());

java.util.Properties 继承自 Hashtable,而 Hashtable 不允许 null 值。

配置里没写 accessKey 时,getAccessKey() 返回 null,put 直接抛 NPE。

解法

application.yml 里这些键必须存在,哪怕值为空:

yaml
canal.conf: mode: tcp flatMessage: true zookeeperHosts: # ← 保留 syncBatchSize: 1000 retries: -1 timeout: # ← 保留 accessKey: # ← 保留(空值可以,键不能没有) secretKey: # ← 保留

为什么会踩?

我最初为了"配置文件干净",把用不到的 key 全删了——zookeeperHosts、timeout、accessKey、secretKey 一个没留。

结果启动就崩。

结论:Canal 的配置文件,不用的键留空,但别删。

这条经验适用于所有用 Spring Boot + Properties 的老中间件——很多项目都有类似的"隐式必填项"。

顺带说下反编译命令,遇到这类无头 NPE 很有用:

bash
unzip -o -q client-adapter.launcher-1.1.7.jar \ "com/alibaba/otter/canal/adapter/launcher/loader/AdapterProcessor.class" javap -p -c -l com/alibaba/otter/canal/adapter/launcher/loader/AdapterProcessor.class

-l 会输出 LineNumberTable,能把行号和字节码对应起来。


坑 6:索引没建好,全量同步必 NPE

现象

第一次跑 ETL:

json
{"succeeded":false,"errorMessage":"nfturbo_collection etl failed! ==> Elasticsearch exception [type=index_not_found_exception, reason=no such index [nfturbo_collection]]"}

手工建了个空索引再跑:

json
{"succeeded":false,"errorMessage":"nfturbo_collection etl failed! ==>null"}

日志里:

java.lang.NullPointerException: null at ...ES7xTemplate.getEsType(ES7xTemplate.java:405) at ...ES7xTemplate.getValFromRS(ES7xTemplate.java:186) at ...ESEtlService.lambda$executeSqlImport$1(ESEtlService.java:82)

根因

adapter 写入前会读索引的 mapping,用字段类型决定如何转换值(日期格式、数字类型等)。

  • 索引不存在 → index_not_found_exception
  • 索引存在但没有 mapping → getEsType() 返回 null → NPE

注意这是两个不同阶段的问题。别以为"我建了索引啊"就好了——建了空索引只是把错误从 A 变成了 B。

解法

建索引时把 mapping 一起写进去(完整示例见第二篇 4.4):

bash
curl -u 'elastic:<ES_PASSWORD>' -X PUT "http://127.0.0.1:9200/nfturbo_collection" \ -H 'Content-Type: application/json' -d '{ "settings": { "number_of_shards": 1, "number_of_replicas": 0 }, "mappings": { "properties": { "name": { "type": "text" }, "price": { "type": "double" }, "create_time": { "type": "date", "format": "yyyy-MM-dd HH:mm:ss||epoch_millis" } } } }'

字段一个都不能漏吗?

不是。adapter 只对SQL 里 select 出来的字段查类型。少写字段会导致该字段同步失败或类型不符,但不会全部 NPE。

保险起见,把映射 SQL 里的字段都写进 mapping。


坑 7:ETL 说导入了 8 条,count 却是 0

现象

json
{"succeeded":true,"resultMessage":"导入ES 数据:8 条"}

紧接着查:

json
{"count":0,"_shards":{"total":1,"successful":1,"skipped":0,"failed":0}}

根因

ES 的 refresh_interval 默认 1 秒。写入的数据先进内存 buffer,1 秒后才刷新成可搜索的段。你查得太快了。

这不是同步失败,是时序问题。

解法

bash
# 强制刷新 curl -u 'elastic:<ES_PASSWORD>' -X POST "http://127.0.0.1:9200/nfturbo_collection/_refresh" # 或查询时带参数 curl -u 'elastic:<ES_PASSWORD>' "http://127.0.0.1:9200/nfturbo_collection/_search?refresh=true" # 或干脆 sleep 1

延伸:什么时候该调 refresh_interval?

场景建议
日常同步保持默认 1s,查不到就多等一秒
大批量导入临时设 "refresh_interval": "-1",导完再改回来,能显著提升吞吐
要求强实时设 1s 已经是最小值,再小会严重影响写入性能

坑 8:CentOS 7 装不上 JDK

现象

bash
yum install -y java-1.8.0-openjdk

各种 404、Cannot find a valid baseurl for repo: base/7/x86_64。

根因

CentOS 7 已于 2024-06-30 EOL,官方镜像源下线,mirrorlist.centos.org 不再解析。

解法

别折腾 yum 源,直接下 JDK 8 二进制包解压(详见第一篇第五节):

bash
mkdir -p /usr/local/java tar -xzf jdk-8u202-linux-x64.tar.gz -C /usr/local/java

要修复 yum 也可以把源换成 vault.centos.org:

bash
sed -i 's|mirrorlist=|#mirrorlist=|g' /etc/yum.repos.d/CentOS-*.repo sed -i 's|#baseurl=http://mirror.centos.org|baseurl=http://vault.centos.org|g' /etc/yum.repos.d/CentOS-*.repo yum clean all && yum makecache

但为了装个 JDK 做这些,不如直接解压——二进制包不依赖系统的包管理器,还不会污染全局环境。


三、注意事项与最佳实践

3.1 位点管理是最大的风险点

本文因为 MySQL 8.4 的兼容问题用了固定位点,这是权宜之计。

生产环境要关注:

  • binlog 保留时间要大于"可能的最长停机时间"。binlog_expire_logs_seconds 别调太小。
  • 加监控:Canal 位点长时间不推进 = 同步卡住了。可以定时执行 SHOW MASTER STATUS 和 meta.dat 里的位点做比对。
  • 换 MySQL 实例、重建 binlog 后必须重新定位。

能用位点自增就别写死。 如果 MySQL 版本允许,把 journal.name 和 position 留空,让 Canal 自己管理。

3.2 DDL 变更不会自动同步到 ES

Canal 会把 DDL 事件传给 adapter,但 adapter 不会自动修改 ES 的 mapping。

加字段、改类型时:

  1. 手工更新 ES 索引的 mapping(PUT /index/_mapping)
  2. 更新映射文件里的 SQL
  3. 重启 adapter 或等配置热加载

否则新字段不会进索引,或者动态 mapping 猜错类型(比如把日期猜成 text)。

3.3 _id 的设计决定能否正确更新删除

单表 → id 分表 → concat('order_', id) 多表 join → concat(a.id, '_', b.id)

没有正确的 _id,UPDATE 会变成新增,DELETE 完全不生效,最终 ES 里全是重复数据。

一个自查方法:改一条数据,看 ES 里的文档数是不变(正确)还是**+1**(错误)。

3.4 join 查询的触发陷阱

sql
select a.id, a.title, b.name as category_name from article a left join category b on b.id = a.category_id

这条 SQL 能查出 category_name,但只有 article 表变更才会触发同步。

改了 category.name,ES 里的文章文档不会更新。

两种处理方式:

  • 接受延迟(大部分内容站都这么做,分类改名是低频操作)
  • 给 category 单独配一条映射,同步到独立索引

3.5 性能调优的两个方向

吞吐量:

yaml
canal.conf: syncBatchSize: 1000 # adapter 每批处理条数 esMapping: commitBatch: 1000 # 每批提交 ES 的条数

调大能提升吞吐,但会增加单批延迟和内存占用。

ES 侧:

  • 首次全量导入时,可以把 number_of_replicas 临时设为 0,导完再改回来
  • refresh_interval 在批量导入期间设为 -1
  • 索引的 number_of_shards 按数据量规划,建好后不能改(只能 reindex)

四、写在最后

整套东西跑通后回头看,配置量其实不大——两个 properties、一个 yml、一个映射文件,加起来不到 50 行。

真正的成本全在版本兼容上。

Canal 1.1.7 是 2022 年的产物,MySQL 8.4 是 2024 年的。中间隔了两年,三处破坏性变更全撞上了:

  • SHOW MASTER STATUS 被移除
  • mysql_native_password 默认关闭
  • JDBC 驱动认证方式升级

如果你也在做类似的"老版本中间件 + 新版本数据库"的组合,几个建议:

  1. 先在测试环境把版本组合跑通,别直接上生产
  2. 报错先看堆栈最底层,Caused by 里往往才是真相
  3. 配置文件不要随手删键,留空比删掉安全
  4. 遇到说不通的错误,反编译看源码往往比搜索引擎快
  5. 每一步都要有可验证的信号(Binlog Dump 线程、Subscribe succeed 日志、_count 结果),别靠"看起来对了"

这套东西现在跑在一台 CentOS 7 上,内存占用 1.5G 左右(两个 JVM 各 512m~1g),支持全量 + 秒级增量同步。

如果哪天业务上来了要同步正式表,只需要加一个映射文件 + 建一个索引 + POST 一次 ETL,不用动任何服务。


附录:全链路验证清单

按顺序检查,任何一步不过就别往下走:

MySQL 侧

  • SELECT @@log_bin, @@binlog_format, @@binlog_row_image; → 1 / ROW / FULL
  • SELECT user, host, plugin FROM mysql.user WHERE user='canal'; → 有 REPLICATION SLAVE 权限
  • SHOW BINARY LOG STATUS;(8.4)或 SHOW MASTER STATUS;(8.0)能返回位点

canal-server 侧

  • systemctl is-active canal-server → active
  • 日志出现 start successful 和 find start position successfully
  • MySQL SHOW PROCESSLIST; 有 Binlog Dump 线程

canal-adapter 侧

  • systemctl is-active canal-adapter → active
  • 日志出现 Load canal adapter: es7 succeed
  • 日志出现 Subscribe destination: example succeed

ES 侧

  • 索引存在且有 mapping:GET /<index>/_mapping
  • POST /etl/es7/xxx.yml 返回 "succeeded":true
  • GET /<index>/_count 数量与 MySQL count(*) 一致
  • 改一条数据,1~2 秒内 ES 能查到新值
  • 删除一条数据,ES 里对应文档也消失(验证 _id 配置正确)

开机自启

  • systemctl is-enabled canal-server canal-adapter 都是 enabled
  • 重启机器后两个服务能自动起来(这一条最好真的测一次)

如果对你有用的话,可以打赏哦
打赏
ali pay
wechat pay

本文作者:章鱼哥

本文链接:

版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!