Canal深度解析:MySQL增量数据订阅与消费实战

一、引言:为什么我们需要Canal?
你的电商系统中,用户下单后修改了商品库存,但是Redis缓存中的库存数据还是旧的,导致超卖问题;或者你需要将MySQL中的数据实时同步到Elasticsearch中供搜索使用,传统的定时任务方式延迟高、性能差。
这些问题的根源在于:数据在不同系统间的实时同步缺乏优雅的解决方案。
Canal应运而生。它是阿里巴巴开源的基于MySQL数据库binlog的增量订阅&消费组件,能够帮助我们实现:
- 实时性高:毫秒级数据同步
- 侵入性低:无需修改业务代码
- 可靠性强:基于MySQL主从复制协议
- 扩展性好:支持多种下游消费场景
二、Canal应用场景全景

Canal在实际生产环境中有着广泛的应用场景:
1. 数据库实时备份
通过订阅MySQL的binlog,可以实时将数据变更同步到备份库,实现数据的实时容灾。相比传统的MySQL主从复制,Canal提供了更灵活的控制能力。
2. 缓存同步
这是最常见的场景。当MySQL中的数据发生变更时,通过Canal实时捕获变更事件,更新Redis、Memcached等缓存,保证缓存与数据库的一致性。
3. 实时数据分析
将MySQL的数据变更实时推送到Kafka等消息队列,供下游的实时计算系统(如Flink、Spark Streaming)消费,构建实时数据仓库。
4. 数据异构迁移
将关系型数据库MySQL中的数据实时同步到NoSQL数据库(如MongoDB、HBase)或搜索引擎(如Elasticsearch),满足不同业务场景的查询需求。
三、Canal工作原理深度剖析

核心原理
Canal的工作原理基于MySQL的主从复制协议。我们知道,MySQL的主从复制过程如下:
- Master将数据变更写入binlog(二进制日志)
- Slave的IO线程读取Master的binlog并写入relay log(中继日志)
- Slave的SQL线程读取relay log并重放SQL,完成数据同步
Canal模拟了MySQL Slave的交互协议,把自己伪装成MySQL的从库,向MySQL Master发送dump请求,MySQL推送binlog给Canal,Canal解析binlog对象(原始为byte流)。
工作流程
markdown
1. Canal连接MySQL,伪装成Slave
2. MySQL推送binlog给Canal
3. Canal解析binlog内容
4. Canal将解析后的数据发送给客户端
5. 客户端处理数据(写入缓存、MQ等)
四、Canal架构设计

Canal的架构设计非常优雅,主要包含以下核心组件:
1. EventParser(事件解析器)
负责作为MySQL Slave与MySQL Master建立连接,模拟slave协议与MySQL Master进行交互,请求并接收binlog流,并将其解析成Canal内部的数据结构。
核心职责:
- 连接管理:建立与MySQL的连接
- 协议模拟:模拟MySQL Slave协议
- Binlog获取:从MySQL获取binlog流
- 事件解析:将binlog解析为结构化事件
2. EventSink(事件过滤器)
负责对EventParser解析出来的数据进行过滤、加工、分发。可以根据正则表达式配置,过滤出需要的表、字段等。
核心职责:
- 数据过滤:按规则过滤数据
- 数据转换:格式转换和加工
- 事件路由:将事件路由到不同的处理器
3. EventStore(事件存储)
负责将数据存储到本地,提供消费端获取数据。采用环形队列结构,支持批量消费和消费位点记录。
核心职责:
- 数据存储:持久化事件数据
- 位点管理:记录消费位置
- 数据提供:为消费者提供数据
五、MySQL Binlog机制详解

要深入理解Canal,必须先理解MySQL的binlog机制。
Binlog是什么?
Binlog(Binary Log)是MySQL的二进制日志,记录了所有DDL和DML语句(不包括SELECT和SHOW等查询语句),以事件的形式记录,并包含语句执行的时间信息。
Binlog的作用
- 主从复制:Master将binlog发送给Slave,Slave重放binlog实现数据同步
- 数据恢复:通过binlog可以进行point-in-time恢复
- 审计:可以通过binlog审计数据库的变更历史
Binlog格式
MySQL binlog有三种格式:
1. Statement格式
sql
-- 记录SQL语句
UPDATE users SET age = age + 1 WHERE id < 100;
优点:日志量少 缺点:某些函数如NOW()、UUID()等会导致主从数据不一致
2. Row格式
ini
-- 记录每一行的实际变更
[ROW] id=1, age: 25 -> 26
[ROW] id=2, age: 30 -> 31
...
优点:准确记录每一行的变更,保证主从一致 缺点:日志量大
3. Mixed格式 混合使用Statement和Row格式,MySQL自动判断使用哪种格式。
Canal要求使用Row格式,因为只有Row格式才能准确捕获每一行的变更详情。
六、Canal环境搭建实战
6.1 MySQL配置
首先需要开启MySQL的binlog功能,并设置为Row格式。
编辑MySQL配置文件my.cnf:
ini
[mysqld]
# 开启binlog
log-bin=mysql-bin
# 设置binlog格式为ROW
binlog-format=ROW
# 设置server-id(必须唯一)
server-id=1
# 设置binlog过期时间(天)
expire_logs_days=7
# 设置单个binlog文件大小
max_binlog_size=100M
# 需要同步的数据库
binlog-do-db=test_db
重启MySQL使配置生效:
bash
sudo systemctl restart mysql
创建Canal用户并授权:
sql
CREATE USER 'canal'@'%' IDENTIFIED BY 'canal@123';
GRANT SELECT, REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'canal'@'%';
FLUSH PRIVILEGES;
验证binlog是否开启:
sql
SHOW VARIABLES LIKE 'log_bin';
SHOW VARIABLES LIKE 'binlog_format';
SHOW MASTER STATUS;
6.2 Canal Server部署
下载Canal Server:
bash
wget https://github.com/alibaba/canal/releases/download/canal-1.1.6/canal.deployer-1.1.6.tar.gz
mkdir canal-server && cd canal-server
tar -zxvf canal.deployer-1.1.6.tar.gz
配置Canal Server,编辑conf/example/instance.properties:
properties
# MySQL连接配置
canal.instance.master.address=127.0.0.1:3306
canal.instance.dbUsername=canal
canal.instance.dbPassword=canal@123
canal.instance.connectionCharset=UTF-8
# 订阅的binlog位置(首次启动会从最新位置开始)
canal.instance.master.journal.name=
canal.instance.master.position=
canal.instance.master.timestamp=
# 订阅的数据库和表(支持正则)
canal.instance.filter.regex=test_db\\..*
# 黑名单(不订阅的表)
canal.instance.filter.black.regex=
# 启用事务支持
canal.instance.tsdb.enable=true
启动Canal Server:
bash
sh bin/startup.sh
查看日志确认启动成功:
bash
tail -f logs/canal/canal.log
tail -f logs/example/example.log
七、Canal客户端开发实战

7.1 引入依赖
xml
<dependency>
<groupId>com.alibaba.otter</groupId>
<artifactId>canal.client</artifactId>
<version>1.1.6</version>
</dependency>
7.2 简单客户端实现
下面是一个完整的Canal客户端示例,演示如何订阅MySQL变更并处理:
java
import com.alibaba.otter.canal.client.CanalConnector;
import com.alibaba.otter.canal.client.CanalConnectors;
import com.alibaba.otter.canal.protocol.CanalEntry;
import com.alibaba.otter.canal.protocol.Message;
import com.google.protobuf.InvalidProtocolBufferException;
import java.net.InetSocketAddress;
import java.util.List;
public class SimpleCanalClient {
public static void main(String[] args) {
// 创建连接
CanalConnector connector = CanalConnectors.newSingleConnector(
new InetSocketAddress("127.0.0.1", 11111),
"example", // destination名称
"", // 用户名(默认为空)
"" // 密码(默认为空)
);
try {
// 连接Canal Server
connector.connect();
// 订阅数据库表
connector.subscribe("test_db\\..*");
// 回滚到未消费位置
connector.rollback();
while (true) {
// 获取指定数量的数据
Message message = connector.getWithoutAck(100);
long batchId = message.getId();
int size = message.getEntries().size();
if (batchId == -1 || size == 0) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
} else {
printEntries(message.getEntries());
}
// 确认消费
connector.ack(batchId);
}
} finally {
connector.disconnect();
}
}
private static void printEntries(List<CanalEntry.Entry> entries) {
for (CanalEntry.Entry entry : entries) {
if (entry.getEntryType() == CanalEntry.EntryType.TRANSACTIONBEGIN ||
entry.getEntryType() == CanalEntry.EntryType.TRANSACTIONEND) {
continue;
}
CanalEntry.RowChange rowChange;
try {
rowChange = CanalEntry.RowChange.parseFrom(entry.getStoreValue());
} catch (InvalidProtocolBufferException e) {
throw new RuntimeException("解析binlog失败", e);
}
CanalEntry.EventType eventType = rowChange.getEventType();
String tableName = entry.getHeader().getTableName();
String schemaName = entry.getHeader().getSchemaName();
System.out.println(String.format("数据库: %s, 表: %s, 事件类型: %s",
schemaName, tableName, eventType));
for (CanalEntry.RowData rowData : rowChange.getRowDatasList()) {
if (eventType == CanalEntry.EventType.DELETE) {
printColumns(rowData.getBeforeColumnsList(), "删除");
} else if (eventType == CanalEntry.EventType.INSERT) {
printColumns(rowData.getAfterColumnsList(), "插入");
} else if (eventType == CanalEntry.EventType.UPDATE) {
System.out.println("------更新前------");
printColumns(rowData.getBeforeColumnsList(), "");
System.out.println("------更新后------");
printColumns(rowData.getAfterColumnsList(), "");
}
}
}
}
private static void printColumns(List<CanalEntry.Column> columns, String operation) {
for (CanalEntry.Column column : columns) {
System.out.println(String.format("%s: %s = %s (类型: %s, 是否更新: %s)",
operation,
column.getName(),
column.getValue(),
column.getMysqlType(),
column.getUpdated()));
}
}
}
7.3 封装优雅的客户端
实际项目中,我们需要对客户端进行封装,提供更易用的API:
java
import com.alibaba.otter.canal.protocol.CanalEntry;
import java.util.List;
/**
* Canal事件监听器接口
*/
public interface CanalEventListener {
/**
* 处理INSERT事件
*/
void onInsert(String database, String table, List<CanalEntry.Column> data);
/**
* 处理UPDATE事件
*/
void onUpdate(String database, String table,
List<CanalEntry.Column> before,
List<CanalEntry.Column> after);
/**
* 处理DELETE事件
*/
void onDelete(String database, String table, List<CanalEntry.Column> data);
}
java
import com.alibaba.otter.canal.client.CanalConnector;
import com.alibaba.otter.canal.client.CanalConnectors;
import com.alibaba.otter.canal.protocol.CanalEntry;
import com.alibaba.otter.canal.protocol.Message;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.net.InetSocketAddress;
import java.util.List;
import java.util.concurrent.TimeUnit;
/**
* Canal客户端封装
*/
public class CanalClient {
private static final Logger logger = LoggerFactory.getLogger(CanalClient.class);
private CanalConnector connector;
private String filter;
private CanalEventListener listener;
private volatile boolean running = false;
public CanalClient(String host, int port, String destination, String filter) {
this.connector = CanalConnectors.newSingleConnector(
new InetSocketAddress(host, port),
destination,
"",
""
);
this.filter = filter;
}
public void setListener(CanalEventListener listener) {
this.listener = listener;
}
public void start() {
connector.connect();
connector.subscribe(filter);
connector.rollback();
running = true;
// 启动消费线程
new Thread(() -> {
while (running) {
try {
Message message = connector.getWithoutAck(100);
long batchId = message.getId();
int size = message.getEntries().size();
if (batchId != -1 && size > 0) {
processEntries(message.getEntries());
connector.ack(batchId);
} else {
TimeUnit.SECONDS.sleep(1);
}
} catch (Exception e) {
logger.error("处理Canal消息异常", e);
connector.rollback();
}
}
}, "canal-client-thread").start();
logger.info("Canal客户端启动成功");
}
public void stop() {
running = false;
connector.disconnect();
logger.info("Canal客户端已停止");
}
private void processEntries(List<CanalEntry.Entry> entries) {
for (CanalEntry.Entry entry : entries) {
if (entry.getEntryType() != CanalEntry.EntryType.ROWDATA) {
continue;
}
try {
CanalEntry.RowChange rowChange = CanalEntry.RowChange.parseFrom(entry.getStoreValue());
String database = entry.getHeader().getSchemaName();
String table = entry.getHeader().getTableName();
CanalEntry.EventType eventType = rowChange.getEventType();
for (CanalEntry.RowData rowData : rowChange.getRowDatasList()) {
if (listener != null) {
switch (eventType) {
case INSERT:
listener.onInsert(database, table, rowData.getAfterColumnsList());
break;
case UPDATE:
listener.onUpdate(database, table,
rowData.getBeforeColumnsList(),
rowData.getAfterColumnsList());
break;
case DELETE:
listener.onDelete(database, table, rowData.getBeforeColumnsList());
break;
default:
break;
}
}
}
} catch (Exception e) {
logger.error("处理Entry异常", e);
}
}
}
}
八、生产实战案例:Redis缓存同步
场景描述
电商系统中,商品信息存储在MySQL中,为了提高查询性能,在Redis中缓存了商品数据。当商品信息在MySQL中更新时,需要实时同步更新Redis缓存。
实现方案
java
import com.alibaba.otter.canal.protocol.CanalEntry;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 商品缓存同步监听器
*/
public class ProductCacheSyncListener implements CanalEventListener {
private JedisPool jedisPool;
private ObjectMapper objectMapper = new ObjectMapper();
public ProductCacheSyncListener(JedisPool jedisPool) {
this.jedisPool = jedisPool;
}
@Override
public void onInsert(String database, String table, List<CanalEntry.Column> data) {
if ("product".equals(table)) {
syncToRedis(data, "INSERT");
}
}
@Override
public void onUpdate(String database, String table,
List<CanalEntry.Column> before,
List<CanalEntry.Column> after) {
if ("product".equals(table)) {
syncToRedis(after, "UPDATE");
}
}
@Override
public void onDelete(String database, String table, List<CanalEntry.Column> data) {
if ("product".equals(table)) {
deleteFromRedis(data);
}
}
private void syncToRedis(List<CanalEntry.Column> columns, String operation) {
try (Jedis jedis = jedisPool.getResource()) {
Map<String, String> productMap = new HashMap<>();
String productId = null;
for (CanalEntry.Column column : columns) {
if ("id".equals(column.getName())) {
productId = column.getValue();
}
productMap.put(column.getName(), column.getValue());
}
if (productId != null) {
String key = "product:" + productId;
String value = objectMapper.writeValueAsString(productMap);
jedis.setex(key, 3600, value);
System.out.println(String.format("缓存同步成功 [%s]: %s", operation, key));
}
} catch (Exception e) {
System.err.println("同步到Redis失败: " + e.getMessage());
}
}
private void deleteFromRedis(List<CanalEntry.Column> columns) {
try (Jedis jedis = jedisPool.getResource()) {
for (CanalEntry.Column column : columns) {
if ("id".equals(column.getName())) {
String key = "product:" + column.getValue();
jedis.del(key);
System.out.println("缓存删除成功: " + key);
break;
}
}
} catch (Exception e) {
System.err.println("从Redis删除失败: " + e.getMessage());
}
}
}
启动应用
java
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;
public class CacheSyncApplication {
public static void main(String[] args) {
// 创建Redis连接池
JedisPoolConfig config = new JedisPoolConfig();
config.setMaxTotal(100);
config.setMaxIdle(20);
JedisPool jedisPool = new JedisPool(config, "localhost", 6379);
// 创建Canal客户端
CanalClient canalClient = new CanalClient(
"localhost",
11111,
"example",
"test_db\\.product"
);
// 设置监听器
canalClient.setListener(new ProductCacheSyncListener(jedisPool));
// 启动
canalClient.start();
// 添加关闭钩子
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
canalClient.stop();
jedisPool.close();
}));
System.out.println("商品缓存同步服务已启动...");
}
}
九、与Spring Boot集成

9.1 添加依赖
xml
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.otter</groupId>
<artifactId>canal.client</artifactId>
<version>1.1.6</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
</dependencies>
9.2 配置文件
yaml
# application.yml
spring:
redis:
host: localhost
port: 6379
database: 0
canal:
server:
host: localhost
port: 11111
destination: example
filter: test_db\\..*
batch-size: 100
9.3 Canal配置类
java
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Component
@ConfigurationProperties(prefix = "canal")
public class CanalProperties {
private Server server = new Server();
private String destination;
private String filter;
private int batchSize = 100;
// Getters and Setters
public static class Server {
private String host;
private int port;
// Getters and Setters
}
}
9.4 Canal启动器
java
import com.alibaba.otter.canal.client.CanalConnector;
import com.alibaba.otter.canal.client.CanalConnectors;
import com.alibaba.otter.canal.protocol.Message;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
import java.net.InetSocketAddress;
import java.util.concurrent.TimeUnit;
@Component
public class CanalStarter implements CommandLineRunner {
@Autowired
private CanalProperties canalProperties;
@Autowired
private CanalEventListener canalEventListener;
@Override
public void run(String... args) {
CanalConnector connector = CanalConnectors.newSingleConnector(
new InetSocketAddress(
canalProperties.getServer().getHost(),
canalProperties.getServer().getPort()
),
canalProperties.getDestination(),
"",
""
);
connector.connect();
connector.subscribe(canalProperties.getFilter());
connector.rollback();
new Thread(() -> {
while (true) {
try {
Message message = connector.getWithoutAck(canalProperties.getBatchSize());
long batchId = message.getId();
if (batchId != -1 && !message.getEntries().isEmpty()) {
// 处理消息
message.getEntries().forEach(entry -> {
// 调用监听器处理
});
connector.ack(batchId);
} else {
TimeUnit.SECONDS.sleep(1);
}
} catch (Exception e) {
e.printStackTrace();
connector.rollback();
}
}
}).start();
}
}
十、生产环境最佳实践

1. 高可用部署
生产环境建议使用Canal HA模式,基于ZooKeeper实现:
- 部署多个Canal Server实例
- 使用ZooKeeper进行协调和failover
- 客户端自动连接到可用的Server
2. 性能优化
批量消费
java
// 一次获取多条消息,提高吞吐量
Message message = connector.getWithoutAck(1000);
并行处理
java
ExecutorService executor = Executors.newFixedThreadPool(10);
message.getEntries().forEach(entry -> {
executor.submit(() -> processEntry(entry));
});
合理设置超时时间
properties
canal.instance.network.receiveBufferSize = 16384
canal.instance.network.sendBufferSize = 16384
canal.instance.network.soTimeout = 30
十一、总结
Canal作为阿里开源的MySQL增量数据订阅组件,凭借其高性能、低侵入、易扩展的特点,已经成为数据同步领域的标准解决方案。
适用场景:
- 数据库实时备份
- 缓存实时同步
- 搜索引擎索引构建
- 实时数据仓库
- 数据异构迁移
Canal虽然强大,但并非银弹。在实际使用中,需要根据业务场景选择合适的binlog格式、部署模式、消费策略,并做好异常处理和监控,才能真正发挥其价值。