做搜索系统的时候,最现实的一个问题就是:MySQL里的数据怎么稳定实时地同步到Elasticsearch?canal就是解决这个问题的一套成熟链路。它利用MySQL主从复制的原理,把自己伪装成一个从库,订阅binlog日志,把每一次插入、更新、删除都解析成结构化数据,再交给下游消费。整条链路由canal-server负责抓取和解析日志,canal-adapter负责连接ES,完成索引文档的新增和更新。这篇文章我会从业务场景出发,先解释为什么需要这样一条同步链路,再带大家一步步配置部署,最后把我在实操中踩过的坑都整理出来。
1. 数据同步问题:业务为什么要把MySQL的数据搬到ES
1.1 MySQL和ES的角色边界
很多系统初期只有MySQL一个存储。事务、关联查询、数据一致性都是它的强项,但搜索体验是另一回事。一旦业务数据量到了百万甚至千万级别,用户在前端输入一个关键词,后端拿模糊查询去扫表,那响应时间基本是用秒来计的。更别说分词、拼音搜索、按相关度排序这些能力,数据库原生根本不具备。
所以比较常见的架构是:MySQL继续承担OLTP业务,负责订单、用户、商品等数据的读写;Elasticsearch负责全文检索、聚合分析和一些复杂过滤场景。ES的倒排索引天然适合搜索,对海量数据的查询性能要好得多。两个系统各司其职,于是就有了一个绕不开的问题:MySQL的数据怎么同步到ES?如果同步不及时,用户搜出来的结果就是旧的,那体验比搜不到还糟糕。
1.2 常见同步方案对比,为什么最后选canal
我早期先试过几种方案,各有各的问题,用下来都不太顺手。
双写方案是业务代码里同时写MySQL和ES。这种方案实时性最高,但侵入性强,每处写操作都要改代码。如果某次ES写入失败但MySQL成功了,还得做补偿或者回滚,数据一致性非常难保证。业务一多,到处埋雷。
定时任务批量同步是最简单暴力的方案,写个脚本每分钟扫一次增量数据,然后批量灌进ES。实现成本低,但延迟高,而且增量怎么界定本身就很麻烦。如果数据有物理删除,还要额外标记或软删除,否则ES里永远是删不掉的数据。每分钟跑一次,数据量大了之后任务本身也会成为负担。
binlog订阅方案从根上解决了侵入性问题,canal就是这类方案的代表。它不碰业务代码,直接订阅MySQL的binlog日志,解析出所有数据变更事件,再推给下游。数据库做了什么操作,它就捕获什么操作,天然跟业务解耦。延迟可以做到毫秒级,还能保证MySQL和ES在绝大多数情况下保持一致。横向对比一句话总结:想要实时性,不想改业务代码,又需要比较可靠的一致性,binlog订阅是最合适的路线。
1.3 canal同步链路全貌
canal这套方案里有几个核心部件需要先理清楚。MySQL开启binlog并配置好授权账号之后,canal-server作为主节点负责连接数据库,模拟MySQL主从复制协议,订阅并解析binlog。解析出来的数据变更事件,通过TCP或消息队列发送出去。canal-adapter是下游适配器,接收这些事件,然后根据预先配置好的映射关系,把数据写入Elasticsearch。
整个链路的简单描述就是:MySQL产生binlog -> canal-server抓取解析 -> canal-adapter接收并转换 -> ES索引文档更新。中间每一步都有独立的配置和监控点,排查问题的时候能逐步缩小范围。这也是为什么我比较喜欢canal这套机制,它把复杂的数据同步拆成了清晰的模块,出了问题不会一把抓。
2. canal工作原理与双端基础准备
2.1 binlog是canal的地基
要理解canal,必须先理解binlog。MySQL的binlog是二进制日志,记录了对数据库执行更改的所有操作,包括INSERT、UPDATE、DELETE,也包括表结构变更。主从复制就是基于binlog的:主库把binlog发给从库,从库重放这些日志,保持数据一致。
canal的原理恰恰就是把自己伪装成一个从库。它向MySQL发送dump协议请求,MySQL就以为它是一个正常的从库,开始把binlog持续推送过来。canal拿到binlog之后做两件事:解析日志事件,按表结构反推出具体的数据变更内容。比如一条UPDATE语句在binlog里记录的是某一行数据的完整前后镜像,canal就能解析出这条记录更新前的值和更新后的值,然后封装成统一的数据结构。
这里有个关键前提:binlog格式必须设为ROW。如果还是默认的STATEMENT格式,binlog里面存的是SQL语句本身,canal没法可靠地解析出每一行的变化。这个在后面的MySQL配置里会重点说明。
2.2 canal-server和adapter的分工
canal-server是整个同步管道的源头,负责连接数据库、解析日志、把变更事件派发出去。它支持订阅多个库表,也支持把数据投递到TCP客户端、Kafka、RocketMQ等不同的消息通道。单独用canal-server,自己写客户端去消费解析结果,是完全可行的,只是需要自己处理数据转换和ES写入逻辑。
canal-adapter则省掉了这部分开发工作。它是围绕各种目标存储封装好的适配层,把canal-server推过来的数据变更事件,按照我们配置的SQL和映射关系,自动转换成ES的写入请求。支持ES的多个大版本,也支持HBase、RDB等目标源。还有一点值得说,adapter支持将多张MySQL表的数据聚合到同一个ES索引,也可以用一条SQL做联表查询或字段加工,灵活性很高。
对于大多数团队来说,用adapter比开发自定义客户端经济得多,这也是我最终选择这套组合的直接原因。
2.3 MySQL端配置:开启binlog并创建授权账号
前面强调了binlog是地基,那么第一步自然就是让MySQL把binlog打开。修改MySQL配置文件,常用路径是/etc/my.cnf,在[mysqld]段增加如下配置:
[mysqld] server-id=1 log-bin=mysql-bin binlog_format=ROW binlog_row_image=FULL expire_logs_days=7逐个解释一下。server-id是必须的,主从复制场景下每个节点都要有唯一ID,这里设置为1即可。log-bin指定binlog文件前缀名,开启之后MySQL会在数据目录下生成mysql-bin.000001这样的日志文件。binlog_format=ROW这个我前面提过,必须设置。binlog_row_image=FULL是让binlog记录完整行数据,默认情况下也是FULL,但显式配上保险一些,否则canal可能拿不到完整的字段信息。expire_logs_days=7控制日志保留时间,避免磁盘被长期占满,具体天数可以根据自己的磁盘空间调整。
配置完之后需要重启MySQL使配置生效,然后确认一下:
mysql -uroot -p -e "SHOW VARIABLES LIKE 'log_bin';" mysql -uroot -p -e "SHOW VARIABLES LIKE 'binlog_format';"log_bin必须是ON,binlog_format必须是ROW,这两项确认没问题再进行下一步。
接着创建一个canal专用的MySQL账号。这个账号不涉及业务写权限,只需要有读取binlog的权限就够了:
CREATE USER 'canal'@'%' IDENTIFIED BY 'canal_pass_2024'; GRANT SELECT, SHOW VIEW, REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'canal'@'%'; FLUSH PRIVILEGES;这里的账号和密码后面配置canal-server的时候要原样填进去。权限里REPLICATION SLAVE和REPLICATION CLIENT是获取binlog和主从状态所必需的,SELECT和SHOW VIEW是让canal在需要读取表结构信息时有权限。建好之后可以用这个账号连一下数据库,确认能正常登录。
提示:如果MySQL版本较老,权限字段名称略有差异,但基本就是这四项。授权的host不要只给localhost,因为canal-server和MySQL大概率不在一台机器上。
3. canal-server和adapter的部署与配置实操
3.1 配置样例与版本选型
canal-server和adapter都是免安装的,下载对应版本的压缩包解压就能用。版本选择上我建议直接使用1.1.5以上的较新版本,因为1.1.5之后adapter对ES7的支持才比较完善。如果用的是ES8,或者ES6,需要确认对应的adapter名称和兼容性。早期版本存在一些表结构解析和配置解析的问题,新版本修复了不少,尽量选新版本能少踩很多雷。
下载解压之后,canal-server的目录结构大致是这样的:bin目录放启动脚本,conf目录放配置,lib目录放依赖包。核心配置文件是conf/canal.properties和conf/example/instance.properties。这里example是默认的实例名,一个canal-server可以配置多个实例,每个实例独立订阅一组库表。
3.2 部署canal-server
先改conf/canal.properties,这个文件控制canal-server全局行为。关键的配置项如下:
# server模式,tcp表示直接通过TCP推送给客户端 canal.serverMode = tcp # 注册到admin的IP和端口,单机部署可以忽略 canal.admin.manager = 127.0.0.1:11110 # 当前实例列表 canal.destinations = example # tcp模式下,adapter连接时使用的端口 canal.port = 11111如果暂时不用canal-admin做管理端,canal.admin.manager可以不配。canal.port默认11111,adapter连接server的时候要用到这个端口。canal.destinations可以配置多个实例名,用逗号分隔,每个实例对应conf目录下一个同名子目录。
再看conf/example/instance.properties,这个文件决定canal连接哪个MySQL、订阅哪些表:
# MySQL主库地址 canal.instance.master.address = 192.168.1.100:3306 # 账号密码,和前面建好的canal账号保持一致 canal.instance.dbUsername = canal canal.instance.dbPassword = canal_pass_2024 # 连接MySQL的字符集 canal.instance.connectionCharset = UTF-8 # 订阅规则:test库下的所有表 canal.instance.filter.regex = test\\..*订阅规则canal.instance.filter.regex的写法要特别说明一下。它遵循的是MySQL多库多表的正则规则,test\\..*表示test库下所有表。如果要多库多表,格式是db1\\..*,db2\\.table1,注意点号前面要加转义。这里只影响canal监听哪些库表的binlog,不影响adapter端最终如何写入ES。
配置完成之后执行启动脚本:
bin/startup.sh启动之后看日志确认是否连上了MySQL:
tail -f logs/example/example.log正常的话日志里会出现成功连接MySQL、开始dump binlog的记录。如果出现权限报错或者连接被拒,优先检查账号密码、master.address的IP端口和防火墙策略。
3.3 部署canal-adapter
adapter的部署相对简单,核心配置都在conf/application.yml里。先看全局配置:
server: port: 8081 canal.conf: canalServerHost: 127.0.0.1:11111 srcDataSources: defaultDS: url: jdbc:mysql://192.168.1.100:3306/test?useSSL=false&characterEncoding=utf8 username: canal password: canal_pass_2024 canalAdapters: - instance: example groups: - groupId: g1 outerAdapters: - name: es7 hosts: 127.0.0.1:9200 properties: mode: rest security.auth: elastic:es_pass_2024canalServerHost填canal-server所在机器的IP和端口,前面配置的11111。srcDataSources是adapter连接MySQL用于执行同步SQL的数据源,adapter做全量同步或查询表结构时需要用到。outerAdapters里name填es7代表适配ES7版本,hosts填ES的地址,mode: rest表示通过REST接口通信。如果ES开了安全认证,需要在properties里配置账号密码。
配置完成后启动adapter:
bin/startup.sh启动日志在logs/adapter/adapter.log。看到类似"canal adapter started"的日志就说明adapter已经连上canal-server,并开始监听数据变更了。
3.4 配置ES索引和adapter映射关系
ES索引需要提前创建,canal-adapter本身不会自动帮你建索引。这一步最好在同步开始前完成,否则adapter往不存在的索引写文档会直接报错。假设业务场景是用户数据同步到ES,需要建一个shop_user索引,mapping大概长这样:
{ "mappings": { "properties": { "userId": { "type": "keyword" }, "userName": { "type": "text", "analyzer": "ik_max_word" }, "mobile": { "type": "keyword" }, "email": { "type": "keyword" }, "status": { "type": "integer" }, "createdAt": { "type": "date" } } } }字段类型的设计要结合查询需求。userName作为搜索字段,用text类型并指定分词器,这样用户搜索关键词才能命中。mobile和email这类字段只需要精确匹配,用keyword就够了。日期字段用date类型,后面在坑点部分会提到时区问题,mapping里可以提前考虑好格式。
接下来配置adapter的映射文件。在adapter的conf/es7目录下新建一个yml文件,文件名随意,推荐用索引名来命名,比如shop_user.yml:
dataSourceKey: defaultDS outerAdapterKey: es7 destination: example esMapping: _index: shop_user _id: userId sql: "SELECT id AS userId, user_name AS userName, mobile, email, status, created_at AS createdAt FROM shop_user" commitBatch: 1000 upsert: true这段配置的含义是:从MySQL的shop_user表查询数据,把id别名为userId,对应ES索引的userId字段,同时作为ES文档的_id。SQL查询结果的每个字段,都会按别名映射到ES文档的对应字段。commitBatch表示批量提交的条数,数据量大时调高这个值能提升写入吞吐。upsert: true的意思是存在则更新,不存在则新增,这样同步操作具备幂等性。
配置好之后重新启动adapter,它会自动加载conf/es7下的所有yml文件。观察启动日志,如果没有报错,数据变更就能自动同步到ES了。可以用一个简单的插入语句在MySQL里测试,然后在ES里查一下:
curl -X GET "http://127.0.0.1:9200/shop_user/_search?pretty" -H 'Content-Type: application/json' -d '{"query":{"match_all":{}}}'如果能看到刚插入的数据,说明整条链路已经通了。
提示:adapter的映射文件支持定义多个,每个文件对应一个ES索引。文件名本身没有强制要求,但建议保持和索引名一致,方便后期维护。
4. 全量与增量的衔接:数据同步完整落地
4.1 为什么需要先全量再增量
很多第一次接触canal的朋友会有一个疑问:配置好之后,是从现在开始的新数据才会同步吗?那MySQL里已有的历史数据怎么办?这个问题非常关键。
canal一启动就开始订阅binlog,也就是说它只关注启动之后的数据变更。存量数据还在MySQL里,ES索引里一条都没有。如果你只在adapter里配置了增量同步,上线第二天用户搜索历史数据,依然什么都搜不到。所以完整的数据同步流程一定是先做一次全量同步,把存量数据灌进ES,再启动增量同步,持续同步新增和变更。
这个过程也叫做全量+增量双跑。理想的情况下,全量同步期间产生的增量数据也要被缓存起来,等全量结束之后再回放,避免漏数据。canal-adapter的设计就考虑到了这个场景。
4.2 adapter的initial模式实现全量+增量
canal-adapter提供了三种工作模式:simple、canal和initial。simple模式不订阅canal,只是提供写入接口,一般用于自定义写入。canal模式是纯增量订阅,也就是不管存量数据。initial模式则是先执行全量数据同步,再自动切换到增量订阅,正好满足前面说的场景。
在adapter的application.yml里,通过canalAdapters配置下的instance控制。启动时指定MODE=initial,adapter就会先读取srcDataSources配置的MySQL数据源,执行映射文件里定义的SQL,把当前所有数据批量写入ES,然后自动启动增量监听。之前shop_user.yml里的SQL会作为全量导数的查询语句,commitBatch: 1000在这里就开始起作用,每攒够1000条就批量提交一次。
全量同步的时候建议先停掉业务对ES的写入,或者至少让ES集群资源充裕一些。数据量大的情况下,全量同步对ES的写入压力非常明显,可能拖慢正常的搜索请求。如果一次全量需要跑很久,建议把commitBatch调小一点,比如200或500,减轻ES的bulk压力。
4.3 全量同步的实际执行策略
如果已经上线了增量同步,中途发现ES数据不完整,需要重新做全量,那就不能简简单单重启adapter了。重启之后如果又进入initial模式,它会再跑一次全量,重复写一遍数据,倒也不是不行,只是没有增量期间的数据保护。
我这边实际的做法是:先清理ES索引或删掉索引重建,保证索引里是干净的初始状态。然后启动adapter并指定初始模式,让全量脚本跑起来。全量完成之后观察adapter日志,看到开始订阅增量事件,再恢复ES对外服务。这样索引里的数据就是存量数据加上增量数据的完整结果,不存在半新半旧的情况。
不要忽略全量同步过程中的报错。之前我跑全量的时候,曾经出现过程序内存溢出,原因是映射SQL关联了多张表,一次查询加载了几十万行大字段数据。后来把SQL改成分批查询,通过etlCondition控制每次同步的数据范围,才把问题解决。adapter的映射文件里可以配置etlCondition,比如etlCondition: "where id > {0} and id <= {1}",配合一些处理逻辑实现分批拉取,对大数据量很管用。
5. 常见坑点与稳定性排查经验
5.1 时区偏移八小时
这是非常经典的坑。MySQL的datetime字段同步到ES之后,发现查询出来的时间比原数据晚了8小时。原因是adapter在通过JDBC读取数据时,默认使用JVM所在时区解析时间,而ES默认存储的是UTC时间。系统时区是东八区的时候,MySQL的时间字符串被解析成东八区的时间戳,存储到ES里再展示就会差8小时。
解决方案有几个。最直接的方式是在adapter的JVM启动参数里加上-Duser.timezone=Asia/Shanghai,或者修改application.yml中数据源URL,加serverTimezone=Asia/Shanghai参数。同时确保ES索引mapping里的date字段没有额外指定错误时区。我改完之后,时间显示就恢复正常了。排查这类问题时,如果发现时间偏差不是整数小时,那大概率是MySQL连接参数里的字符集或时区设置不一致。
5.2 字段类型映射不匹配
MySQL和ES的字段类型不是一一对应的。比如MySQL的tinyint会映射成ES的long,如果你在mapping里定义成了integer,写入的时候ES会抛字段解析异常。decimal类型在ES里需要明确指定为scaled_float或者转成字符串,否则小数精度可能会丢。
解决这个问题只有一个笨办法:提前梳理MySQL表字段和ES mapping的对应关系。建mapping之前,先跑一条adapter的同步SQL,用结果集字段类型去对照ES类型。把容易出问题的大字段、时间字段、布尔字段都排查一遍。像text和keyword的选择,会直接影响分词查询结果,一开始设计错了后期改映射要重建索引,代价比较大。
5.3 更新主键造成的问题
canal同步到ES时,一般用MySQL表的主键作为ES文档的_id。这个设计在绝大多数场景下没问题,但一旦业务里出现了主键更新的操作,问题就来了。ES的_id是不可变的,MySQL主键更新后,adapter按新主键去更新文档,ES里查不到对应文档,最终结果就是ES变成了新旧两条重复数据。
这种情况在业务设计阶段就应该避免,主键必须是稳定不变的。如果确实遇到了存量数据主键变了,最简单的处理方式是在MySQL层面先删除旧记录再新增,让canal解析出delete和insert两个事件,这样ES的旧文档会被删掉,新文档会被写入。但从业务角度讲,不要在主键上做更新才是正解。
5.4 canal消费延迟排查
同步延迟是线上最常遇到的问题。ES里的数据和MySQL差了十几分钟甚至更久,可以先定位到是canal-server慢还是adapter慢。canal-server不慢的话,adapter侧日志会一直打印成功写入的batch记录,说明是ES写入慢;如果adapter没有任何日志输出,说明canal-server没有推送数据过来。
常见的延迟原因有几个。canal-server连接MySQL的账号权限不够,导致拿不到binlog事件流;单条SQL在MySQL执行时间过长,binlog生成速度大于canal消费速度;ES集群写入瓶颈,bulk队列满了之后开始拒绝请求。排查时先看canal-server的example.log,里面有没有持续接收binlog的记录,再看adapter日志里最近一次写入ES耗时长不长,基本能定位。
还可以在canal-server上查看conf/example/meta.dat文件里的位点信息,这个文件记录了当前消费到的binlog文件名和位置。拿这个位置和MySQL当前binlog位置对比,差值越大说明消费差距越大。顺着日志一层层查,一般不会跑偏。
5.5 大字段和特殊类型解析
MySQL里的JSON类型、BLOB、VARBINARY这类字段,canal在解析binlog的时候偶尔会出问题。JSON类型在binlog里存储格式比较特殊,旧版本的canal解析容易丢失字段或报类型转换异常。遇到这种情况,要么升级canal-server到较新版本,要么在映射SQL里提前把这些字段转换成字符串再同步。
大字段带来的问题是内存占用。如果一张表里存了很大的text字段,全量同步时一次性把整张表load到内存里,很容易导致adapter堆内存溢出。处理方法是调整映射SQL,只查需要的字段,或者用etlCondition分批同步,每批处理完就释放内存。如果大字段本身对搜索没有价值,干脆就不要同步到ES,只同步检索需要的字段就够用了。
5.6 常见问题速查整理
| 问题现象 | 可能原因 | 排查方向 |
|---|---|---|
| 同步完全没有生效 | cannal-server未启动、账号权限不足、binlog未开启 | 查canal-server日志和binlog配置 |
| 时间显示差8小时 | JVM时区或JDBC时区配置不一致 | 调整adapter启动参数和数据源URL |
| ES写入报字段解析异常 | MySQL字段类型和ES mapping不匹配 | 核对字段类型映射关系 |
| 数据同步延迟不断增大 | ES写入性能瓶颈或binlog积压 | 查看bulk队列和canal消费位点 |
| 删除数据在ES里仍存在 | 物理删除未同步或配置未支持 | 确认binlog里delete事件被正常解析 |
| 全量同步时adapter内存溢出 | SQL一次性加载数据过多 | 使用分批条件同步 |
6. 生产维护建议与扩展方向
6.1 同步稳定性的三个保障手段
同步链路搭好只是开始,长期稳定运行才是重点。我在项目里维护这套链路一年多,有三件事是必须做的。
第一是监控canal消费位点。canal-server自带metrics接口,可以通过http://canal-server-ip:11111/metrics获取位点信息。出现的消费积压能第一时间感知。第二是ES的bulk队列监控。adapter写入ES用的是bulk接口,如果bulk队列满了,同步速度会直线下降。第三是定期数据校验。每周跑一次全量比对逻辑,抽样对比MySQL和ES的文档数量和关键字段,能发现很多隐蔽的同步遗漏。
校验脚本的核心逻辑其实就是按主键范围对比两边数据。从MySQL查出主键集合,再去ES批量查询文档ID,对比两边ID的一致性,差异部分重新触发同步。这套脚本不复杂,但对数据可靠性帮助很大。
6.2 高可用和扩展思路
单节点的canal-server挂了,同步就会中断。稍微正规一点的部署都会配上HA方案。canal支持多实例订阅同一批binlog,或者配合canal-admin做管理,但最省心的方式还是把canal-server和adapter都做成多节点,通过负载均衡去连接。不过要注意,同一份binlog如果被两个canal消费,下游写入事件可能重复,所以最好让adapter保持幂等。配置了upsert: true之后,重复写入对ES来说是安全的。
另一个思路是把canal-server的消息投递到Kafka或RocketMQ,adapter消费MQ消息再写ES。这样能解耦canal和ES之间的连接,消息队列作为缓冲层,能扛住瞬时写入压力。数据量大的场景下,这条链路比TCP直连要稳得多。canal-server的canal.serverMode改成kafka,然后配置MQ的地址和topic,adapter改成从MQ消费消息,配置思路是类似的,只是多了一层消息中间件。
6.3 这套方案留给我的几个经验
最后分享一些我在实际项目里的体会。第一次搭建的时候,总想一次把所有库表都配置进去,结果出了几个问题之后,排查起来特别麻烦。后来学乖了,先挑一张核心表跑通全流程,确认同步链路没问题,再慢慢追加其他表。这个节奏比较稳妥,也方便定位是配置问题还是数据问题。
数据校验这件事不要省。binlog订阅方案虽然很成熟,但底层还是依赖网络、日志、内存这些基础设施。曾经有一次因为MySQL磁盘满了,binlog写入异常,canal消费位点一直不往前走,ES里缺失了将近一个小时的增量数据。没有校验机制的话,这个问题可能要很久才能发现。
还有adapter的JVM参数要提前调。默认的堆内存肯定不够用,我一般会根据同步数据量设置-Xms1g -Xmx2g,全量同步的时候再临时调大。JVM内存不够的时候,adapter的表现很诡异,看起来日志正常,实际上同步已经停了,这个问题排查起来非常费劲。
如果你也在准备搭建这套链路,可以按照文章里的顺序一步一步来:先确认MySQL的binlog配置没问题,再部署canal-server和adapter,跑通一条SQL的新增和更新,最后再折腾全量同步和索引优化。把基础链路跑通,后面的优化都是水到渠成的事。canal的生态文档比较零散,实际动手过程中大概率会遇到我之前踩过的那几个坑,希望这篇文章能帮大家少走一些弯路。