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 slavemasterbinary log events 拷贝到它的中继日志(relay log)
  • MySQL slave 重放 relay log 中事件,将数据变更反映它自己的数据

Canal 工作原理

  1. 伪装canal 模拟 MySQL slave 的交互协议,伪装自己为 MySQL slave ,向 MySQL master 发送dump 协议
  2. 推送MySQL master 收到 dump 请求,开始推送 binary logslave (即 canal )
  3. 解析canal 解析 binary log 对象(原始为 byte 流)二进制数据后,将其解析成可读性强的对象(如 JSON 或特定的实体类)。
  4. 投递 :Canal 将解析后的数据发送至下游(如消息队列 Kafka/RocketMQElasticsearch 或另一个数据库)。

核心应用场景

在后端开发中,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    }  
}
相关推荐
睡不醒男孩0308231 小时前
CLup篇之数据库传统运维对比
运维·数据库
梓䈑2 小时前
C++大模型统一接入引擎(第三篇):模型管理、会话持久化与SDK门面封装的完整实现
数据库·c++
日取其半万世不竭2 小时前
PostgreSQL 跑在 Docker 里怎么备份?恢复成功才算备份成功
数据库·docker·postgresql
奈斯ing2 小时前
花了三个月,我写了个RDS管控平台(目前进度一半)
数据库·管控平台
Hoxy.R2 小时前
记录一次 Oracle 10g USERS 表空间在线扩容
数据库·oracle
典学长编程2 小时前
Redis分布式缓存超详细教学(微服务版)!
redis·微服务·持久化·主从复制·redis哨兵集群
2601_956743682 小时前
2026 上海软件定制开发公司:依托 D-coding 解析企业级定制开发的技术方案与落地全路径
大数据·数据库·人工智能·软件开发·开发经验·上海
睡不醒男孩0308232 小时前
CLup篇之达梦数据库管理
运维·服务器·数据库
霖霖总总2 小时前
[MongoDB小技巧10]MongoDB 数组查询深度解析:$size、$all 与 $in 的核心机制与避坑指南
数据库·mongodb