Canal解决MySQL与Redis数据一致性问题

Canal解决MySQL与Redis数据一致性问题

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 工作原理

  1. 伪装 :canal 模拟 MySQL slave 的交互协议,伪装自己为 MySQL slave ,向 MySQL master 发送dump 协议
  2. 推送 :MySQL master 收到 dump 请求,开始推送 binary log 给 slave (即 canal )
  3. 解析 :canal 解析 binary log 对象(原始为 byte 流)二进制数据后,将其解析成可读性强的对象(如 JSON 或特定的实体类)。
  4. 投递 :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数据一致性的方法。其主要流程包括:先删除缓存,然后更新数据库。这个过程完成后,大约在数据库从库更新后再次删除缓存。具体步骤如下:
  1. 先执行redis.del(key)操作删除缓存;
  2. 然后执行写数据库的操作;
  3. 休眠一段时间(例如:500毫秒),根据具体的业务时间来定;
  4. 在执行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)数据变更。


组件间的逻辑关系与数据流向

组件之间呈现出一种 "一对多" 和 "线性流水线" 的关系:

  1. Server 与 Instance:你可以在一台服务器上启动一个 Canal 服务,同时监控(Instance)多个不同的数据库。
  2. Instance 内部结构 :
    每个 Instance 内部都包含了一套完整的流水线:
    • Parser → \rightarrow → Sink → \rightarrow → Store
    • 数据流向:MySQL → \rightarrow → Parser (获取/解析) → \rightarrow → Sink (过滤) → \rightarrow → Store (缓存) → \rightarrow → Client (消费)。

Canal安装步骤

第一步:准备工作配置Mysql

  1. 创建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
  1. 修改 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。

  1. 创建 Canal 专属账号
    进入 MySQL 命令行,执行以下 SQL:
sql 复制代码
-- 创建账号
CREATE USER canal IDENTIFIED BY 'canal';
-- 授予从库权限
GRANT SELECT, REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'canal'@'%';
-- 刷新权限
FLUSH PRIVILEGES;

第二步:使用 Docker 安装 Canal-Server

  1. 拉取镜像。
shell 复制代码
docker pull canal/canal-server:v1.1.8
  1. 启动临时容器并获取配置文件

由于我们需要修改 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 接口,用于导出解析延迟、吞吐量等性能指标。

第五步:验证启动情况

  1. 查看容器日志。
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:~$ 
  1. 查看详细日志。
  • 直接查询挂载的日志文件。
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    }  
}
相关推荐
小白男神5 天前
MySQL进阶学习四(存储过程、游标、触发器)
mysql
这个DBA有点耶5 天前
MVCC深入:Read View、版本链与快照读——InnoDB并发控制的内核
数据库·mysql·架构
DBA_G5 天前
从地面到云霄:GBase数据库在民航三大场景的落地实践
数据库
自由能燃气设备5 天前
商用全预混低氮冷凝锅炉免费方案vs付费方案对比+选型避坑指南
大数据·数据库·人工智能
科创致远5 天前
科创致远 ESOP 系统核心效能与实战价值展示
大数据·数据库·人工智能·精益工程
IT大白鼠5 天前
Redis 系列 · 第 01 篇——认知入门:Redis 是什么
redis·nosql
彧azz5 天前
Linux 环境下 Redis 学习总结:数据类型、持久化、锁、事务、主从与缓存问题
linux·redis·笔记·学习·面试
kybs19915 天前
全球灾害数据分析可视化 毕业设计-附源码66794
vue.js·spring boot·mysql·安全·django·c#·asp.net
2601_962218615 天前
万象生鲜系统称重自动多退少补算法解决生鲜非标品痛点
大数据·数据库·人工智能·python·算法
程序猿_极客5 天前
【免费】分享一套优质的基于SSM的校园失物招领系统的设计与实现,源码+文档+视频详解(讲解)
java·mysql·ssm·课程设计·失物招领系统