Canal解决MySQL与Redis数据一致性问题
-
- Canal标准概念与定义
- 工作原理
-
- MySQL主备复制原理
- [Canal 工作原理](#Canal 工作原理)
- 核心应用场景
- MySQL与Redis数据一致性问题
- [Canal 的核心组件定义](#Canal 的核心组件定义)
- Canal安装步骤
-
- 第一步:准备工作配置Mysql
- [第二步:使用 Docker 安装 Canal-Server](#第二步:使用 Docker 安装 Canal-Server)
- 第三步:修改配置文件`instance.properties`
-
- 过滤规则配置:`canal.instance.filter.regex`
- [多 Instance 独立监控 (解耦)](#多 Instance 独立监控 (解耦))
- [第四步:启动 Canal 容器](#第四步:启动 Canal 容器)
- 第五步:验证启动情况
- SpringBoot使用Canal
Canal标准概念与定义
Canal 是阿里巴巴开源的一款高性能数据同步系统,主要用途是基于 MySQL 数据库的增量日志(Binary Log)解析,提供增量数据订阅和消费功能。
-
核心原理:它将自己伪装成 MySQL 的从库(Slave),通过主备复制协议获取 Binlog(二进制日志),解析后将增量数据(INSERT, UPDATE, DELETE)推送给下游业务(如 Redis 缓存同步、ES 搜索索引更新)。
-
解决数据一致性问题:mysql与redis之间数据不一致
工作原理
MySQL主备复制原理
MySQL master将数据变更写入二进制日志(binary log, 其中记录叫做二进制日志事件binary log events,可以通过show binlog events进行查看)MySQL slave将master的binary log events拷贝到它的中继日志(relay log)MySQL slave重放relay log中事件,将数据变更反映它自己的数据
Canal 工作原理
- 伪装 :
canal模拟MySQL slave的交互协议,伪装自己为MySQL slave,向MySQL master发送dump协议 - 推送 :
MySQL master收到dump请求,开始推送binary log给slave(即canal) - 解析 :
canal解析binary log对象(原始为byte流)二进制数据后,将其解析成可读性强的对象(如 JSON 或特定的实体类)。 - 投递 :Canal 将解析后的数据发送至下游(如消息队列
Kafka/RocketMQ、Elasticsearch或另一个数据库)。
核心应用场景
在后端开发中,Canal 主要解决数据的一致性和解耦问题:
-
缓存一致性(Redis 旁路缓存同步) : 这是最经典的场景。当数据库数据更新时,不需要在业务代码里手动操作 Redis。Canal 监听 Binlog 变化,异步更新 Redis,确保数据库与缓存最终一致,且不侵入业务代码。
-
搜索引擎同步(Elasticsearch): 将数据库中的数据实时同步到 ES 索引中,实现复杂的搜索功能,而无需在 Service 层写双写逻辑。
-
实时数据分析: 将增量数据投递到 Kafka/Flink,进行流式计算或数据仓库(如 ClickHouse)的填充。
-
数据库拆分与迁移: 在分库分表或跨机房迁移时,用于保证新老库之间的数据平滑同步。
MySQL与Redis数据一致性问题
概念 :MySQL与Redis数据一致性问题,指的是在同时使用关系型数据库(MySQL)和缓存(Redis)的系统中,由于数据分别存储在两个地方,当数据发生变更时,如何保证两者数据的一致性(即同一份数据在MySQL和Redis中始终相同),避免出现"缓存脏数据"或"数据不同步"的现象。
常见的不一致场景
- 更新MySQL后,未能成功更新或删除Redis:导致Redis中仍是旧数据(脏数据)。
- 先删除Redis,后更新MySQL:在更新MySQL完成之前,有其他请求读取数据,会将MySQL中的旧数据重新加载到Redis,导致Redis再次变脏。
- 并发操作:多个写请求和读请求交织,容易产生时序问题。
解决方案思路
- 先更新数据库,再删除缓存(Cache Aside Pattern):保证最终一致性,但存在短暂不一致窗口。
- 延迟双删:更新数据库前后各删除一次缓存,降低并发脏数据概率。
- 订阅binlog异步同步:如使用Canal监听MySQL binlog,将变更同步到Redis,实现准实时一致性。
- 设置合理过期时间:作为兜底,即使出现不一致,也会自动恢复。
核心难点
在高并发场景下,既要保证性能(使用缓存),又要保证数据一致性,往往需要在强一致性 与最终一致性之间做出权衡。通常采用最终一致性方案,配合补偿机制。
延迟双删策略
- 主从复制问题:如果redis是集群,可能我们发送删除命令后,
延时双删策略是一种常见的保证MySQL和Redis数据一致性的方法。其主要流程包括:先删除缓存,然后更新数据库。这个过程完成后,大约在数据库从库更新后再次删除缓存。具体步骤如下:
- 先执行redis.del(key)操作删除缓存;
- 然后执行写数据库的操作;
- 休眠一段时间(例如:500毫秒),根据具体的业务时间来定;
- 在执行redis.del(key)操作删除缓存。
延时双删策略通过这种方式尝试达到最终的数据一致性,但是这并不是强一致性,因为Mysql和Redis主从节点数据的同步并不是实时的,所以需要等待一段时间以增强他们的数据一致性。
同时由于读写时并发的,可能出现缓存和数据库不一致的问题。
Canal 的核心组件定义
-
Canal Server (容器):代表一个 Canal 运行实例(通常对应一个 JVM 进程)。一个 Server 可以同时运行多个 Instance。
-
Canal Instance (会话实例):这是 Canal 的核心作业单元。每个 Instance 对应一个数据源(通常是一个 MySQL 实例或一个集群)。
-
Event Parser (解析器):负责与 MySQL 建立连接,模拟 Slave 协议获取 Binlog 数据,并将二进制协议解析成 Canal 内部定义的 Event 对象。
-
Event Sink (过滤器/归集器):作为 Parser 和 Store 之间的中转站。它负责对解析后的数据进行过滤(如排除某些表)、加工或路由。
-
Event Store (存储器) :负责暂存解析后的数据。目前主要实现是基于内存的
MemoryEventStoreWithBuffer(类似于一个环形队列 RingBuffer)。 -
Canal Client (消费者):外部业务应用通过 Canal 协议连接到 Server,从 Store 中"拉取"(Pull)或"订阅"(Subscribe)数据变更。
组件间的逻辑关系与数据流向
组件之间呈现出一种 "一对多" 和 "线性流水线" 的关系:
- Server 与 Instance:你可以在一台服务器上启动一个 Canal 服务,同时监控(Instance)多个不同的数据库。
- Instance 内部结构 :
每个 Instance 内部都包含了一套完整的流水线:- Parser → \rightarrow → Sink → \rightarrow → Store
- 数据流向:MySQL → \rightarrow → Parser (获取/解析) → \rightarrow → Sink (过滤) → \rightarrow → Store (缓存) → \rightarrow → Client (消费)。
Canal安装步骤
第一步:准备工作配置Mysql
- 创建MySQL。
- 创建挂载目录。
shell
# 数据目录
mkdir -p /home/osboxes/mysql_data
# 配置目录
mkdir -p /home/osboxes/mysql/conf
- 启动容器。
shell
docker run -d \
--name mysql \
-p 3306:3306 \
-v /home/osboxes/mysql/conf:/etc/mysql/conf.d \
-v /home/osboxes/mysql_data:/var/lib/mysql \
-e MYSQL_ROOT_PASSWORD=123456 \
-e MYSQL_DATABASE=balloon_atelier \
mysql:latest
- 修改
my.cnf(或docker.cnf)
执行MySQL命令,是否开启log-bin:
show variables like '%log_bin%';ON:表示开启。直接执行第三步。
在 MySQL 的配置文件中添加以下内容:
ini
[mysqld]
# 必须开启 binlog
log-bin=mysql-bin
# 选择 ROW 模式(Canal 必须要求)
binlog-format=ROW
# 配置 MySQL repliation 需要的 server-id,不能和 Canal 的 slaveId 重复
server-id=1
- 命令一键执行:
shell
cat > /home/osboxes/mysql/conf/my.cnf <<EOF
[mysqld]
log-bin=mysql-bin
binlog-format=ROW
server-id=1
binlog_row_image=FULL
EOF
注意: 修改后需要重启 MySQL 容器:docker restart mysql。
- 创建 Canal 专属账号
进入 MySQL 命令行,执行以下 SQL:
sql
-- 创建账号
CREATE USER canal IDENTIFIED BY 'canal';
-- 授予从库权限
GRANT SELECT, REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'canal'@'%';
-- 刷新权限
FLUSH PRIVILEGES;
第二步:使用 Docker 安装 Canal-Server
- 拉取镜像。
shell
docker pull canal/canal-server:v1.1.8
- 启动临时容器并获取配置文件
由于我们需要修改 instance.properties,建议先运行一个临时容器把配置文件拷贝出来。
shell
# 1. 创建目录(如果不存在)
mkdir -p /home/osboxes/canal_data/conf
mkdir -p /home/osboxes/canal_data/logs
# 2. 启动一个临时容器,复制默认配置到宿主机
docker run -d --name canal-temp canal/canal-server:v1.1.8
# 3. 将容器内的 conf 目录内容复制到宿主机目录
docker cp canal-temp:/home/admin/canal-server/conf/. /home/osboxes/canal_data/conf/
# 4. 删除临时容器
docker rm -f canal-temp
第三步:修改配置文件instance.properties
编辑 /home/osboxes/canal_data/conf/example/instance.properties,设置你的 MySQL 连接信息:
shell
# MySQL 数据库地址
canal.instance.master.address = 192.168.58.129:3306
# 连接 MySQL 的用户名和密码(刚才创建的数据库账号)
canal.instance.dbUsername = canal
canal.instance.dbPassword = canal
# 需要监听的数据库和表(正则表达式)
canal.instance.filter.regex = .*\\..*
# slaveId 不能与 MySQL 的 server-id 重复
# slaveId是 Canal 在伪装成 MySQL 从库时使用的 **唯一标识 ID**
# 如果未手动配置,Canal 会随机生成一个 ID(通常范围在 1~2^31-1),但强烈建议手动指定,以便管理且避免冲突。
canal.instance.mysql.slaveId = 1234
过滤规则配置:canal.instance.filter.regex
标准定义
该参数通过 正则表达式 精确定义 Instance 需要监听的库和表。
- 格式规范 :
数据库名\.表名。多个规则使用 逗号 分隔。
常见配置示例与含义
| 配置值 | 含义说明 |
|---|---|
.*\\..* |
全库全表:监听实例内所有数据库的所有变更(不推荐在生产使用)。 |
db_exam\\..* |
单库全表 :只监听 db_exam 库下的所有数据表。 |
db_exam\\.t_question |
精确单表 :只监听 db_exam 库中的题目表。 |
.*\\..*,!mysql\\..* |
排除模式 :监听所有库,但排除 mysql 系统库(使用 ! 引导)。 |
多 Instance 独立监控 (解耦)
如果你的多个数据库属于不同的业务模块(例如一个是"题目库",一个是"用户权限"),建议为它们分别创建不同的 Instance。
目录结构
在 Canal 的 conf 目录下,默认只有 example 文件夹。你可以创建新的文件夹:
conf/
├── example/ # 监听 A 库
│ └── instance.properties
└── question_bank/ # 监听 B 库
└── instance.properties
第四步:启动 Canal 容器
shell
docker run -d \
--name canal-server \
-p 11111:11111 \
-v /home/osboxes/canal_data/conf:/home/admin/canal-server/conf \
-v /home/osboxes/canal_data/logs:/home/admin/canal-server/logs \
--restart unless-stopped \
canal/canal-server:v1.1.8
-p 11111:11111:暴露 TCP 端口,供客户端连接。-v:挂载宿主机目录到容器内的对应路径。--restart unless-stopped:自动重启,提高稳定性。
| 端口号 | 默认名称 | 职责描述 (Primary Purpose) |
|---|---|---|
| 11111 | RPC 端口 | 数据传输通道。后端 Java 客户端通过此端口连接,订阅并拉取增量数据。 |
| 11110 | Admin 端口 | 管理运维通道。用于远程管理、配置动态下发或通过 Web 界面(Canal Admin)控制实例。 |
| 11112 | Metrics 端口 | 监控观测通道。提供 Prometheus 接口,用于导出解析延迟、吞吐量等性能指标。 |
第五步:验证启动情况
- 查看容器日志。
shell
docker logs canal-server
- canal启动成功:
start canal successful
shell
osboxes@osboxes:~$ docker logs canal-server
DOCKER_DEPLOY_TYPE=VM
==> INIT /alidata/init/02init-sshd.sh
==> EXIT CODE: 0
==> INIT /alidata/init/fix-hosts.py
==> EXIT CODE: 0
==> INIT DEFAULT
==> INIT DONE
==> RUN /home/admin/app.sh
==> START ...
start canal ...
start canal successful
==> START SUCCESSFUL ...
osboxes@osboxes:~$
- 查看详细日志。
- 直接查询挂载的日志文件。
shell
cat /home/osboxes/canal-server/logs/example/example.log
-
canal成功连接到MySQL。
2026-04-01 13:13:46.019 [destination = example , address = /192.168.58.129:3306 , EventParser] WARN c.a.o.c.p.inbound.mysql.rds.RdsBinlogEventParserProxy - ---> begin to find start position, it will be long time for reset or first position
2026-04-01 13:13:46.020 [destination = example , address = /192.168.58.129:3306 , EventParser] WARN c.a.o.c.p.inbound.mysql.rds.RdsBinlogEventParserProxy - prepare to find start position just show master status
2026-04-01 13:13:46.055 [destination = example , address = /192.168.58.129:3306 , EventParser] WARN c.a.otter.canal.parse.inbound.mysql.MysqlConnection - load MySQL @@version_comment : MySQL Community Server - GPL
2026-04-01 13:13:50.044 [destination = example , address = /192.168.58.129:3306 , EventParser] WARN c.a.o.c.p.inbound.mysql.rds.RdsBinlogEventParserProxy - ---> find start position successfully, EntryPosition[included=false,journalName=mysql-bin.000001,position=15395,serverId=1,gtid=,timestamp=1775049165000] cost : 4000ms , the next step is binlog dump
2026-04-01 13:13:50.083 [destination = example , address = /192.168.58.129:3306 , EventParser] WARN c.a.otter.canal.parse.inbound.mysql.MysqlConnection - load MySQL @@version_comment : MySQL Community Server - GPL
SpringBoot使用Canal
第一步:引入依赖
- 注意:Canal 1.1.5及之前的版本,仅支持jdk1.8
pom
<dependency>
<groupId>com.alibaba.otter</groupId>
<artifactId>canal.client</artifactId>
<version>1.1.8</version>
</dependency>
<!--消息模型-->
<dependency>
<groupId>com.alibaba.otter</groupId>
<artifactId>canal.protocol</artifactId>
<version>1.1.8</version>
</dependency>
第二步:定义配置类
- yaml配置文件
yaml
canal:
server:
host: 192.168.58.129
port: 11111
destination: example # 实例名称
username: # 默认可为空
password: # 默认可为空
batch-size: 1000 # 每次拉取条数
# subscribe-filter: '.*\..*' # 拦截规则,所有库所有表
subscribe-filter: 'balloon_atelier\..*' # 订阅整个 balloon_atelier 库,具体表由处理器再过滤
target-table: user # 当前由 user 处理器消费的目标表
- canal配置文件
java
@Data
@ConfigurationProperties(prefix = "canal.server")
public class CanalProperties {
private String host;
private Integer port;
private String destination;
private String username;
private String password;
private Integer batchSize;
private String subscribeFilter;
private String targetTable;
}
- canal配置类
java
@Configuration
@EnableConfigurationProperties(value = CanalProperties.class)
@Slf4j
public class CanalConfiguration {
// Spring 销毁 Bean 时会自动调用 CanalConnector.disconnect() 关闭连接。
@Bean(destroyMethod = "disconnect")
public CanalConnector canalConnector(CanalProperties canalProperties) {
// 监听规则
String subscribeFilter = canalProperties.getSubscribeFilter();
CanalConnector connector = CanalConnectors.newSingleConnector(
// HOST和端口号
new InetSocketAddress(canalProperties.getHost(), canalProperties.getPort()),
// 实例名称:destination 的名称确实直接对应 Canal Server 配置目录下的一个文件夹名称。
canalProperties.getDestination(),
// 用户名
canalProperties.getUsername(),
// 密码
canalProperties.getPassword()
);
try {
// 获取链接
connector.connect();
// 设置过滤器,监听规则
connector.subscribe(subscribeFilter);
log.info("Canal client connected, host={}, port={}, destination={}, subscribeFilter={}",
canalProperties.getHost(),
canalProperties.getPort(),
canalProperties.getDestination(),
subscribeFilter);
// 回滚到未使用的位置
connector.rollback();
}catch (Exception e) {
throw new RuntimeException(e);
}
return connector;
}
}
第三步:创建启动类监听数据
- 启动类:
java
@Component
public class CanalMessageRunner implements CommandLineRunner {
@Autowired
private CanalConnector canalConnector;
@Autowired
private UserCanalMessageHandler userCanalMessageHandler;
@Autowired
private CanalProperties canalProperties;
@Override // Spring Boot 启动完成后自动执行
public void run(String... args) { // 启动独立线程消费 Canal 消息
log.info("Starting canal listener thread, destination={}, filter={}, batchSize={}",
canalProperties.getDestination(),
canalProperties.getSubscribeFilter(),
canalProperties.getBatchSize()); // 启动时打印关键配置,先确认监听目标是否正确
Thread worker = new Thread(this::processMessages, "canal-listener-thread"); // 创建后台消费线程
worker.setDaemon(true); // 设置为守护线程,主进程退出时自动结束
worker.start(); // 启动消费线程
}
private void processMessages() { // 持续轮询 Canal 消息
int batchSize = canalProperties.getBatchSize() == null ? 1000 : canalProperties.getBatchSize(); // 读取批量大小,没有配置时默认 1000
while (!Thread.currentThread().isInterrupted()) { // 线程未中断时持续消费
long batchId = -1L; // 先给批次号默认值,异常场景下也能安全打印和回滚
try { // 处理当前批次消息
Message message = canalConnector.getWithoutAck(batchSize); // 拉取一批消息但暂不确认
batchId = message.getId(); // 记录当前批次 ID,后续 ack 或 rollback 要用
int size = message.getEntries().size(); // 获取当前批次内的 entry 数量
if (batchId == -1 || size == 0) { // 没有拿到有效消息时进入短暂休眠
log.debug("No canal message received, batchId={}, size={}", batchId, size); // 输出空轮询日志,便于判断是否一直没有数据
Thread.sleep(1000L); // 避免空轮询过于频繁
continue; // 继续下一轮拉取
}
log.info("Received canal batch, batchId={}, size={}", batchId, size); // 记录收到的批次信息,确认客户端已经拿到消息
for (CanalEntry.Entry entry : message.getEntries()) { // 遍历批次中的每条 entry if (entry.getEntryType() != CanalEntry.EntryType.ROWDATA) { // 只处理行数据变更,跳过事务开始结束等类型
continue; // 非行变更数据直接忽略
}
CanalEntry.RowChange rowChange = CanalEntry.RowChange.parseFrom(entry.getStoreValue()); // 反序列化出行变更对象
log.info("Received canal event, schema={}, table={}, eventType={}, batchId={}",
entry.getHeader().getSchemaName(),
entry.getHeader().getTableName(),
rowChange.getEventType(),
batchId); // 打印收到的原始事件信息,确认到底是哪个库表的变更
userCanalMessageHandler.handle(entry, rowChange); // 交给 user 表处理器执行业务逻辑
}
canalConnector.ack(batchId); // 整批处理成功后确认消费
} catch (InterruptedException e) { // 线程休眠被中断时进入这里
Thread.currentThread().interrupt(); // 重新设置中断标记,便于外层循环退出
if (batchId != -1L) { // 只有拿到有效批次号时才回滚
canalConnector.rollback(batchId); // 回滚当前批次,避免消息丢失
}
} catch (Exception e) { // 处理消息过程中的其他异常
log.error("Canal listener failed, batchId={}", batchId, e); // 把拉取和处理两阶段的异常都记录下来,避免线程静默退出
if (batchId != -1L) { // 只有拿到有效批次号时才回滚
canalConnector.rollback(batchId); // 回滚当前批次,等待后续重试
}
}
}
}
}
- 消息模型:
java
@Component
public class UserCanalMessageHandler {
private static final String CACHE_KEY_PREFIX = "user:"; // Redis 用户缓存 key 前缀
@Autowired // 注入 Redis 操作模板
private StringRedisTemplate stringRedisTemplate;
@Autowired
private CanalProperties canalProperties;
public void handle(CanalEntry.Entry entry, CanalEntry.RowChange rowChange) { // 按表名和事件类型处理 binlog 数据
String schemaName = entry.getHeader().getSchemaName(); // 取出当前变更对应的库名,便于排查是否连错库
String tableName = entry.getHeader().getTableName(); // 取出当前变更对应的表名
String targetTable = canalProperties.getTargetTable();
if (!targetTable.equalsIgnoreCase(tableName)) { // 不是目标表时直接跳过
log.debug("Skip canal event, schema={}, table={}, eventType={}, targetTable={}",
schemaName,
tableName,
rowChange.getEventType(),
targetTable); // 打印被过滤的事件,避免误以为客户端完全没收到消息
return; // 当前处理器只负责目标表
}
log.info("Handling user table event, schema={}, table={}, eventType={}",
schemaName,
tableName,
rowChange.getEventType()); // 进入 user 表处理逻辑时打印事件摘要
CanalEntry.EventType eventType = rowChange.getEventType(); // 获取本次行变更的事件类型
for (CanalEntry.RowData rowData : rowChange.getRowDatasList()) { // 遍历本次消息中的每一行数据
switch (eventType) { // 按事件类型分发处理逻辑
case INSERT: // 新增时读取变更后的列
handleInsert(rowData.getAfterColumnsList()); // 执行新增场景的专门处理
break; // 当前行处理完成
case UPDATE: // 更新时也读取变更后的列
handleUpdate(rowData.getAfterColumnsList()); // 执行更新场景的专门处理
break; // 当前行处理完成
case DELETE: // 删除时读取变更前的列
deleteUserCache(rowData.getBeforeColumnsList()); // 删除 Redis 中对应缓存
break; // 当前行处理完成
default: // 其他事件类型暂不处理
break; // 保持无操作
}
}
}
private void handleInsert(List<CanalEntry.Column> columns) { // 处理 user 表新增事件
Map<String, String> userData = cacheUser(columns, CanalEntry.EventType.INSERT); // 先把新增后的最新数据写入 Redis if (userData == null) { // 没有拿到有效数据时直接返回
return; // 跳过新增后续逻辑
}
String id = userData.get("id"); // 获取新增用户 id stringRedisTemplate.opsForValue().set(CACHE_KEY_PREFIX + id + ":created", "1"); // 单独写一个新增标记,方便验证 INSERT 事件已命中
log.info("Handled user INSERT event, created marker key={}", CACHE_KEY_PREFIX + id + ":created"); // 记录新增专属处理日志
}
private void handleUpdate(List<CanalEntry.Column> columns) { // 处理 user 表更新事件
cacheUser(columns, CanalEntry.EventType.UPDATE); // 更新时只覆盖 Redis 中的用户最新数据
}
private Map<String, String> cacheUser(List<CanalEntry.Column> columns, CanalEntry.EventType eventType) { // 把用户最新数据写入 Redis Map<String, String> userData = toColumnMap(columns); // 把 Canal 列列表转换成 Map String id = userData.get("id"); // 读取主键 id,用于拼接缓存 key if (id == null || id.trim().isEmpty()) { // 没有主键时无法建立缓存 key log.warn("Skip {} event for user table because id is missing", eventType); // 记录跳过原因
return null; // 返回空表示本次未执行缓存写入
}
String cacheKey = CACHE_KEY_PREFIX + id; // 生成 Redis 缓存 key stringRedisTemplate.opsForHash().putAll(cacheKey, userData); // 使用 Hash 结构写入全部字段
log.info("Handled user {} event, cacheKey={}, data={}", eventType, cacheKey, userData); // 记录成功处理日志
return userData; // 返回写入 Redis 的用户数据,便于上层继续处理
}
private void deleteUserCache(List<CanalEntry.Column> columns) { // 删除 Redis 中的用户缓存
Map<String, String> userData = toColumnMap(columns); // 转成 Map 以便读取主键
String id = userData.get("id"); // 获取被删除用户的 id if (id == null || id.trim().isEmpty()) { // 没有 id 时无法定位缓存
log.warn("Skip DELETE event for user table because id is missing"); // 记录异常情况
return; // 直接跳过
}
String cacheKey = CACHE_KEY_PREFIX + id; // 生成待删除的缓存 key stringRedisTemplate.delete(cacheKey); // 删除 Redis 中对应用户缓存
log.info("Handled user DELETE event, removed cacheKey={}", cacheKey); // 记录删除日志
}
private Map<String, String> toColumnMap(List<CanalEntry.Column> columns) { // 把 Canal 列对象列表转成普通 Map Map<String, String> map = new LinkedHashMap<>(); // 使用有序 Map 保存列数据
for (CanalEntry.Column column : columns) { // 遍历每一个列对象
map.put(column.getName(), column.getValue()); // 以列名为 key、列值为 value 存入 Map }
return map; // 返回转换后的列数据 Map }
}