黑马点评这个项目做到缓存优化这一段很多人卡在了“缓存一致性”上。数据库里商铺数据改了Redis里还是旧值用户查到的就是脏数据。手动删缓存、定时任务刷缓存这些方案要么代码侵入大要么时效性差。我最近在做黑马点评的优化正好把Canal这套东西完整跑了一遍用Canal监听MySQL的binlog变更自动同步Redis再配合Caffeine做本地缓存把原来的单级缓存升级成了三级缓存架构。这篇文章就把我的完整实践过程、踩坑记录和核心代码都整理出来给正在做类似缓存优化的人一个参考。这个方案解决的核心问题有两个一是热点数据的读取性能Caffeine本地缓存能把单次查询的响应时间压到微秒级二是缓存更新的时效性Canal通过解析binlog能在数据库发生变更后的毫秒级时间内把Redis里的缓存同步过来。对比手动删缓存或者定时刷新的方式这套机制对业务代码零侵入也不用关心缓存key的删除逻辑散落在多少个service里。标题里写的Canel对应的正式组件是阿里的Canal下面所有内容统一用Canal。1. 为什么需要多级缓存黑马点评缓存方案的一次复盘1.1 原始单级Redis缓存的问题黑马点评项目里商铺查询这块是最典型的缓存场景。按照课程原始方案就是查询时先查RedisRedis没有就查数据库然后把数据回填到Redis设置一个TTL。这个方案在低并发下没什么问题但一旦遇到热点商铺问题就非常明显。第一个问题是网络IO开销。Redis虽然快但每次查询都要走一次网络往返即使本机部署也要经过TCP协议栈单次查询的耗时大概在0.1到1毫秒这个区间。如果QPS到了几千甚至上万这些网络开销累计起来就很可观了。Caffeine这类本地缓存直接从JVM堆内存里取数据单次查询是微秒级快了至少一个数量级。第二个问题是热点key的并发压力。某个商铺成为热点后大量请求同时打到Redis上虽然Redis本身能扛住但这种压力完全可以被本地缓存消化掉让Redis只承受一小部分流量穿透。更危险的是缓存击穿——一个热点key的缓存恰好过期大量请求同时去数据库查询数据库瞬间就可能被打挂。多级缓存能有效减少这种穿透概率因为本地缓存层已经拦截了绝大部分请求。第三个问题才是核心缓存和数据库的一致性。黑马点评原始方案中更新商铺数据时做法是先更新数据库再删除Redis缓存。这个思路本身没错但问题在于“删除Redis缓存”这个动作需要业务代码手动触发而且散落在各个service里。如果某个地方漏删了或者删除操作因为网络原因失败了数据不一致的问题就出现了。更麻烦的是如果项目里有多个服务实例每个实例都必须执行删除操作任何一个实例漏掉这个实例的用户就会一直读到旧数据。1.2 多级缓存架构的演进思路多级缓存的核心思路就是在请求链路的不同位置设置缓存层逐层拦截。访问顺序是Caffeine本地缓存 → Redis分布式缓存 → MySQL数据库。Caffeine位于应用进程内部速度最快但没有共享能力适合放单个实例的热点数据Redis是分布式共享缓存适合存放全站共享的数据数据库是最终数据源保证数据不丢。但引入了Caffeine之后一致性问题的范围从Redis一层扩展到了两层。Redis的删除还能靠业务代码手动触发Caffeine分布在每个服务实例的内存里总不可能在每个service里都写一遍“遍历所有实例删除本地缓存”的代码吧。这个时候就需要一个统一的、自动的同步机制——这就是Canal的价值所在。Canal做的事情是伪装成一个MySQL从节点从主节点拉取binlog日志解析出增删改的具体数据变更然后推送给消费者。我们只需要在Canal的消费者里写上“更新Redis缓存 广播本地缓存失效事件”就能实现整条链路的自动同步。这样业务代码只需要关心CRUD缓存更新完全由Canal异步完成。2. 技术选型与整体架构设计Canal、Redis、Caffeine如何各司其职2.1 三个核心组件的分工这套架构里三个组件各管一段职责非常清晰。Caffeine的责任是挡住绝大部分读请求。我把它设计成一级缓存只存放查询频率最高的热点数据比如商铺详情。Caffeine内置了基于W-TinyLFU的淘汰算法相比LRU它能更好地识别“频率高但最近没被访问”的热点数据所以这块直接无脑用Caffeine而不是自己实现一个简单的LRU Map。Redis的责任是作为共享缓存层也是整个架构的兜底。当一个请求在Caffeine里没命中就去Redis里查Redis里也没有才查数据库。同时Redis承担了“缓存同步中枢”的角色Canal监听到数据库变更后先更新Redis里的缓存再通过Redis的Pub/Sub能力广播一条缓存失效消息各个服务实例收到消息后把自己的Caffeine缓存也删掉。为什么选Redis做广播而不是直接用消息队列因为Redis本身就是架构里的必选组件不用额外引入MQ而且缓存失效这种轻量级通知Pub/Sub完全够用。Canal的责任是解决“数据库变更如何通知到缓存层”。它通过解析binlog拿到数据库的增量变更数据然后把变更内容同步给Redis保证缓存与数据库的一致性。2.2 缓存一致性方案对比为什么选Canal做缓存同步这件事方案其实不少我列个表格对比一下方案时效性代码侵入性可靠性适用场景业务代码手动删Redis缓存即时高每个写操作都要处理低容易漏删、删错小项目、方法少的场景定时任务扫表更新缓存分钟级中需要写扫描逻辑低存在明显延迟窗口对实时性要求不高的统计类数据基于AOP切面自动删缓存即时低但无法感知字段级变化中切面逻辑难以处理复杂场景简单的单表操作Canal监听binlog同步毫秒级极低业务代码零感知高基于MySQL主从复制协议读多写少、实时性要求高的系统Canal最大的优势在于完全脱离了业务代码。它不关心你的service层怎么写的也不关心你操作的是哪张表只要MySQL的binlog里产生了变更它就能捕捉到。这意味着即使未来项目里新增了表、新增了字段只要Canal的配置里匹配到了同步逻辑自动生效不需要去改任何业务代码。另外补充一点Canal同步的是增量binlog不是全量扫描所以对数据库的性能影响非常小。你可以把它理解成一个只读的从库在拉取binlogMySQL本身对binlog的复制机制已经非常成熟多一个消费者并不会带来多大压力。2.3 整体数据流与架构说明我把完整的数据流画成文字版描述一下方便对照理解。读请求路径浏览器请求进入Controller → Service查询本地Caffeine缓存 → 未命中则查询Redis → 未命中则查询MySQL → 结果回填Redis → 结果回填Caffeine → 返回客户端。写请求路径Service更新MySQL数据库 → MySQL生成binlog → Canal拉取并解析binlog → Canal客户端同步更新Redis中的缓存 → Canal客户端通过Redis Pub/Sub广播缓存失效消息 → 所有服务实例收到消息后失效本地Caffeine缓存 → 下一次读取请求从Redis加载最新数据并重新填充Caffeine。这里有一个延迟窗口需要心里有数数据库更新完成到Redis更新完成这中间隔着binlog拉取、解析、同步三个步骤正常情况下是几十毫秒级别。在这个窗口期内读取请求可能还是旧数据。所以对数据一致性要求不是100%严格的场景比如商铺详情、商品信息这类这套架构是完全够用的但如果是账户余额、库存扣减这类强一致场景不能依赖缓存必须直接操作数据库。3. 环境准备MySQL开启binlog Canal服务端部署3.1 MySQL端配置开启binlog并设置ROW格式Canal工作的前提是MySQL开启了binlog而且格式必须是ROW。因为Canal需要拿到每一行数据变更前后的具体内容用来构造缓存更新语句。修改MySQL配置文件在[mysqld]段下加入以下配置[mysqld] # 开启binlog建议指定log-bin前缀 log-binmysql-bin # 必须是ROW模式Canal才能解析到行级数据 binlog-formatROW # 每个MySQL实例的server-id必须唯一不能和Canal的slaveId冲突 server-id1 # 建议设置binlog保留时间避免磁盘被写满 expire_logs_days7配置完成后重启MySQL。注意修改binlog-format需要重启服务才能生效不是像SET GLOBAL那样即时生效的。重启后用下面这个SQL验证是否生效SHOW VARIABLES LIKE binlog_format; SHOW VARIABLES LIKE log_bin;如果binlog_format显示为ROW且log_bin显示为ON说明配置成功。顺便说一下binlog有三种格式STATEMENT记录的是SQL语句本身ROW记录的是行的实际变更前后值MIXED是自动混用。我们这里必须用ROW因为只有ROW格式下Canal才能拿到UPDATE之后的最新字段值才能准确地构造出“更新Redis缓存”所需要的完整数据结构。3.2 创建Canal专用账号并授权Canal连接MySQL时需要用到REPLICATION SLAVE和REPLICATION CLIENT权限。这两个权限分别对应主从复制和复制状态查询。不建议直接用root账号连接因为Canal的账号信息是明文配置在Canal服务端的单独创建一个最小权限账号更安全。CREATE USER canal% IDENTIFIED BY canal; GRANT SELECT, REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO canal%; FLUSH PRIVILEGES;这里有个坑我要特别提醒如果MySQL开了skip-grant-tables模式或者配置了validate_password插件账号创建可能会失败。另外如果MySQL版本是8.0以上密码加密方式默认是caching_sha2_password旧版本的Canal1.1.4以下可能不兼容建议给Canal账号指定mysql_native_passwordCREATE USER canal% IDENTIFIED WITH mysql_native_password BY canal;3.3 使用Docker部署Canal ServerCanal的部署方式有几种直接下载安装包、Docker运行、Kubernetes部署。我这边最推荐Docker方式因为Canal的启动参数比较多Docker可以非常方便地通过环境变量来覆盖默认配置不用手工去改instance.properties文件。docker run -d --name canal \ -p 11111:11111 \ -e canal.instance.mysql.slaveId1234 \ -e canal.instance.master.address127.0.0.1:3306 \ -e canal.instance.dbUsernamecanal \ -e canal.instance.dbPasswordcanal \ -e canal.instance.connectionCharsetUTF-8 \ -e canal.instance.filter.regexhmdp\\\\.tb_shop \ canal/canal-server:v1.1.6参数说明canal.instance.mysql.slaveIdCanal伪装成MySQL从节点时使用的ID这个值不能和MySQL主节点的server-id相同默认值是1234我这里特意改成1234也是为了避免和常见的server-id1冲突。canal.instance.master.addressMySQL主节点地址格式是ip:端口。canal.instance.filter.regex只监听哪些表的binlog正则格式是数据库名\\.表名。如果不配置默认监听所有库所有表。我这里只监听hmdp库的tb_shop表减少无谓的解析开销。-p 11111:11111暴露Canal Server默认的TCP端口客户端通过这个端口连接。启动后可以用docker logs -f canal查看启动日志如果出现success to connect mysql server说明连接成功如果出现权限错误、鉴权失败之类的信息往前面的配置排查。4. 核心代码实现Canal客户端与缓存同步核心逻辑4.1 Maven依赖配置Java服务端需要引入两个核心依赖Canal的Client端依赖以及Caffeine依赖。如果你用的是Spring Boot项目Caffeine直接通过Spring Cache的starter引入也行但这里我选择手动构建Caffeine对象因为我们的用法和Spring Cache抽象不是完全契合。!-- Canal客户端 -- dependency groupIdcom.alibaba.otter/groupId artifactIdcanal.client/artifactId version1.1.6/version /dependency !-- Caffeine本地缓存 -- dependency groupIdcom.github.ben-manes.caffeine/groupId artifactIdcaffeine/artifactId version3.1.8/version /dependency4.2 Caffeine本地缓存初始化Caffeine的配置有几个关键参数需要根据实际场景来设定我特别说明一下我的选择逻辑。核心配置代码如下Configuration public class CacheConfig { Bean public CacheString, Object shopLocalCache() { return Caffeine.newBuilder() // 最多缓存一万条数据超出后按W-TinyLFU策略淘汰 .maximumSize(10_000) // 写入后10分钟过期作为最终一致性兜底 .expireAfterWrite(Duration.ofMinutes(10)) // 缓存的key统一加前缀避免和其他业务缓存混淆 .build(); } }几个参数的解释maximumSize(10_000)本地缓存不能无限增长10万条商铺数据已经非常大了实际上一个线上服务实例的热点商铺可能只有几百条。设置这个值是为了防止内存泄漏缓存对象在堆内存里得不到释放会导致频繁Full GC。expireAfterWrite(Duration.ofMinutes(10))写入10分钟后强制过期。为什么要设过期时间因为Canal同步是异步的虽然正常情况下几十毫秒就能完成但极端情况下比如Canal挂掉了、网络抖动Redis或Caffeine可能长时间没收到更新。有一个过期时间兜底保证缓存数据最多只有10分钟的延迟不至于一直脏下去。4.3 Canal客户端连接、订阅与binlog解析Canal客户端的核心流程是建立TCP连接 → 订阅需要监听的数据库表 → 循环拉取binlog变更消息 → 解析每条变更记录 → 投递到同步逻辑 → 确认消息处理完成。下面这段是核心代码可以直接套用Component public class CanalSyncService { private static final String DESTINATION example; private static final String FILTER hmdp\\.tb_shop; private final StringRedisTemplate stringRedisTemplate; private final CacheString, Object shopLocalCache; // 构造器注入省略 PostConstruct public void start() { // 在实际项目里建议放到线程池中执行不要把主线程阻塞 new Thread(this::run).start(); } private void run() { CanalConnector connector CanalConnectors.newSingleConnector( new InetSocketAddress(127.0.0.1, 11111), DESTINATION, canal, canal ); try { connector.connect(); // 订阅hmdp库的tb_shop表 connector.subscribe(FILTER); connector.rollback(); while (true) { // 一次拉取100条消息提高吞吐量 Message message connector.getWithoutAck(100); long batchId message.getId(); int size message.getEntries().size(); if (batchId -1 || size 0) { Thread.sleep(1000); continue; } for (CanalEntry.Entry entry : message.getEntries()) { // 只处理行数据变更跳过事务头和事务尾 if (entry.getEntryType() ! CanalEntry.EntryType.ROWDATA) { continue; } handleEntry(entry); } // 确认这批次消息已处理完成 connector.ack(batchId); } } catch (Exception e) { log.error(Canal连接或解析异常, e); // 异常时回滚消息不会丢失稍后会重新拉取 connector.rollback(); } finally { connector.disconnect(); } } private void handleEntry(CanalEntry.Entry entry) throws Exception { // 获取表名比如tb_shop String tableName entry.getHeader().getTableName(); // 解析存储的实际变更数据 CanalEntry.RowChange rowChange CanalEntry.RowChange.parseFrom(entry.getStoreValue()); CanalEntry.EventType eventType rowChange.getEventType(); // 根据事件类型决定同步策略 if (eventType CanalEntry.EventType.INSERT || eventType CanalEntry.EventType.UPDATE || eventType CanalEntry.EventType.DELETE) { for (CanalEntry.RowData rowData : rowChange.getRowDatasList()) { handleRowData(tableName, eventType, rowData); } } } }这里有几个关键动作我展开解释一下。getWithoutAck(100)是一次拉取100条变更记录处理完统一调用ack(batchId)确认。如果处理过程中抛异常调用rollback()让Canal重新推送这批消息。这是一个消费确认机制保证消息不会因网络闪断而丢失。entry.getEntryType() ! ROWDATA这个判断很重要。Canal推送给客户端的消息里除了真正的行变更数据还有一些TRANSACTIONBEGIN和TRANSACTIONEND类型的事务标记以及HEARTBEAT类型的心跳消息这些都必须过滤掉。4.4 行数据解析与Redis缓存更新拿到RowData之后要通过getAfterColumnsList()获取变更后的字段列表。注意对于INSERT和UPDATE事件用afterColumns能拿到最新值但DELETE事件只有beforeColumns因为记录已经删掉了没有after这一说。private void handleRowData(String tableName, CanalEntry.EventType eventType, CanalEntry.RowData rowData) { MapString, Object dataMap new HashMap(); ListCanalEntry.Column columns; if (eventType CanalEntry.EventType.DELETE) { columns rowData.getBeforeColumnsList(); } else { columns rowData.getAfterColumnsList(); } for (CanalEntry.Column column : columns) { dataMap.put(column.getName(), column.getValue()); } Long shopId Long.valueOf(dataMap.get(id).toString()); String redisKey cache:shop: shopId; if (eventType CanalEntry.EventType.DELETE) { // 删除事件不仅Redis删了还要广播本地缓存失效 stringRedisTemplate.delete(redisKey); publishInvalidateMessage(redisKey); return; } // 把行数据转成商铺对象这里直接用Map简化实际项目建议转成DTO Object shopData dataMap; // 更新Redis缓存商品信息是读多写少设置一个较长的TTL stringRedisTemplate.opsForValue().set(redisKey, JSON.toJSONString(shopData), 30, TimeUnit.MINUTES); // 广播本地缓存失效事件 publishInvalidateMessage(redisKey); } private void publishInvalidateMessage(String redisKey) { stringRedisTemplate.convertAndSend(cache.invalidate, redisKey); }我解释一下为什么既要更新Redis又要广播失效事件。Caffeine缓存在每个服务实例的JVM内存里Canal客户端只存在于一个同步服务中它没法直接操作其他服务实例的本地缓存。所以必须通过Redis的Pub/Sub广播一条消息其他服务实例都订阅了这个频道收到key之后调用shopLocalCache.invalidate(key)把本地缓存删掉。这样各个实例的Caffeine就能在下一次读取时从Redis拉取最新数据并重新填充。细心的读者会问为什么不直接让Canal客户端去更新其他节点的Caffeine这个问题的答案是技术上限——本地缓存天然是进程内隔离的跨进程只能靠消息通知各自处理这是多级缓存架构的固有设计。4.5 Redis Pub/Sub订阅与Caffeine失效逻辑订阅端相对简单用Spring Data Redis的RedisMessageListenerContainer注册一个监听器收到消息后调用Caffeine的invalidate方法Component public class CacheInvalidateListener implements MessageListener { private final CacheString, Object shopLocalCache; public CacheInvalidateListener(CacheString, Object shopLocalCache) { this.shopLocalCache shopLocalCache; } Override public void onMessage(Message message, byte[] pattern) { String redisKey new String(message.getBody()); String caffeineKey convertRedisKeyToLocalKey(redisKey); shopLocalCache.invalidate(caffeineKey); log.info(本地缓存失效: {}, caffeineKey); } private String convertRedisKeyToLocalKey(String redisKey) { // Redis key是 cache:shop:1和Caffeine的key保持一致 // 如果你的Caffeine key用了不同格式在这里做转换 return redisKey; } }配置监听容器Configuration public class RedisPubSubConfig { Bean public RedisMessageListenerContainer redisMessageListenerContainer( RedisConnectionFactory connectionFactory, CacheInvalidateListener listener) { RedisMessageListenerContainer container new RedisMessageListenerContainer(); container.setConnectionFactory(connectionFactory); container.addMessageListener(listener, new PatternTopic(cache.invalidate)); return container; } }注意一个细节Caffeine的key和Redis的key最好统一格式这样失效时可以免去转换逻辑。我这边统一用cache:shop:1这种格式保持命名一致性。还需要配一个查询逻辑把三级缓存串联起来。这里直接用业务代码示例Service public class ShopService { Resource private CacheString, Object shopLocalCache; public Shop queryShopById(Long id) { String key cache:shop: id; // 一级缓存Caffeine Object localValue shopLocalCache.getIfPresent(key); if (localValue ! null) { return (Shop) localValue; } // 二级缓存Redis String redisValue stringRedisTemplate.opsForValue().get(key); if (StringUtils.isNotBlank(redisValue)) { Shop shop JSON.parseObject(redisValue, Shop.class); shopLocalCache.put(key, shop); return shop; } // 三级缓存MySQL Shop shop shopMapper.selectById(id); if (shop ! null) { stringRedisTemplate.opsForValue().set(key, JSON.toJSONString(shop), 30, TimeUnit.MINUTES); shopLocalCache.put(key, shop); } return shop; } }到这里整个多级缓存的闭环已经跑通了。5. 缓存同步机制深入binlog与Canal的工作原理5.1 binlog的基本原理MySQL的binlog二进制日志记录了所有数据库结构变更和表数据变更主要用于主从复制和数据恢复。每次对数据库的INSERT、UPDATE、DELETE操作都会生成对应的binlog事件。Canal之所以选择面向binlog做文章是因为它天生就对业务代码“零侵入”。业务系统不需要关心“数据变了之后要通知谁”这些变更在MySQL层面就已经被记录下来了Canal只是作为一个外部消费者去读取这些记录不改变数据库本身的任何行为。最核心的概念是Canal把自己伪装成一个MySQL从节点向主节点发送复制协议请求主节点就会源源不断地把binlog推送给它。这个机制和正式的主从复制架构是一模一样的只不过真正的从节点会用binlog去重放数据而Canal拿到binlog之后做的是解析、转换、投递。5.2 Canal的关键机制位点管理与投递确认Canal有两个机制是正常工作的重要保障位点管理和消息确认。位点管理解决的是“Canal重启后从哪里开始继续读”的问题。binlog是有序的Canal会记录自己已经消费到的binlog文件名和位置position。比如上次消费到mysql-bin.000003的position 1200重启后它会从这个位置继续往后拉不会重复消费也不会丢失。Canal把这个位点信息持久化在meta.dat文件中Docker部署时要注意挂载数据卷否则容器重建后位点会丢失可能造成全量重放或者跳过一部分数据。消息确认是我在代码里已经演示过的ack(batchId)机制。Canal把一批消息推给客户端后客户端处理完并确认Canal才会把这一批消息的位点往后推进。如果客户端处理失败了调用rollback()Canal会重新推送。这套机制保证了“至少一次”的消息投递所以我们的消费逻辑必须是幂等的。我举个例子如果同步Redis的过程中成功了但广播消息时网络异常导致整体抛异常rollback后Canal重新推送同一条消息此时Redis里的数据已经被更新过了重复执行一次set操作并不会造成问题因为结果是相同的。所以在设计同步逻辑时尽量让每个事件的处理都是可重复执行且结果一致的。5.3 为什么是“先更新Redis再广播失效”我在实现时特意安排了执行顺序先更新Redis里的缓存再广播本地缓存失效消息。这个顺序是有讲究的。假设反向操作——先广播失效再更新Redis。那么各个服务实例收到失效消息后删掉了本地Caffeine缓存此时如果请求进来会去Redis查询。但Redis里的旧缓存可能还没来得及被Canal更新于是返回的是旧数据并且还会把旧数据重新回填到Caffeine里。等到Canal再去更新Redis时Caffeine里的脏数据又已经被填充了不会再自动失效只有等10分钟后过期。这样就产生了一个非常不优雅的不一致窗口。换成“先更新Redis再广播失效”后即使广播消息到达前有请求进来从Redis拿到的是旧数据并填充到Caffeine但这只是暂态的旧值广播消息到达后Caffeine立刻被失效下一次读取会从Redis重新加载此时Redis中已经是最新数据了就恢复了正确状态。整个不一致窗口被压缩到“Redis更新完成 → 广播消息到达”之间的极小时间差内。5.4 仍然需要的兜底策略过期时间不是摆设很多人会觉得用上了Canal缓存过期时间就可以不设了。这是个大误区。Canal同步链路中的任何一个环节都可能出问题Canal服务宕机、网络分区、Redis不可用、代码bug导致消息没发出去等等。所以我在Caffeine和Redis的配置里都保留了过期时间作为最终兜底。在缓存技术里这种策略有一个经典的说法当数据不一致且无法及时修复时过期时间就是用来保证“最终一致性”的最后防线。我推荐的做法是Redis缓存设置30分钟过期Caffeine本地缓存设置10分钟过期Canal负责保证常规情况下的秒级同步即使同步链路整体瘫痪系统最差也能在10分钟内恢复到一致状态。6. 常见问题与排查技巧实录6.1 Canal启动时报鉴权失败或连接超时Canal连接不上MySQL是出现频率最高的问题。如果日志里看到Access denied for user canalxxx优先检查账号权限和密码加密方式。如果看到Communications link failure先确认MySQL地址端口是否可达然后确认canal.instance.master.address格式是否正确。还有一个隐蔽的坑MySQL实例开启了防火墙只允许部分IP访问。Docker容器里的Canal访问宿主机MySQL时源IP是Docker网桥的IP如果你的MySQL配置了bind-address限制需要在MySQL侧放通Docker网桥网段。6.2 Canal能收到消息但Redis缓存没更新这种情况先排查自己的过滤正则。比如订阅时写的是hmdp\\.tb_shop但binlog里表名的实际大小写可能不完全一致。MySQL在Linux下默认是区分大小写的TB_SHOP和tb_shop是两张表。另外如果你监听的表在生产环境是分库分表的正则写法也要匹配物理表名。其次排查消息是否进入了TRANSACTIONBEGIN和TRANSACTIONEND分支被过滤掉了。建议在处理入口加一条日志把entry的type和tableName打出来这样能快速定位。最后检查消息的确认逻辑——如果你在ack(batchId)之前就return了比如某个continue语句跳过了部分消息但没确认Canal会认为这批消息还在处理中不会继续推送新的数据。循环会卡死。这个坑我在开发初期踩过定位了很久才发现是忘了ack。6.3 本地Caffeine缓存没有失效最快的排查路径是这样的先确认Redis里是否收到广播消息可以临时用Redis Desktop Manager订阅cache.invalidate频道看看有没有消息推送。如果频道有消息但Caffeine没失效问题出在监听器配置上检查RedisMessageListenerContainer是否成功启动、PatternTopic是否和发送端一致。如果频道里完全没有消息问题出在Canal同步服务的消费端检查发送广播前是否抛了异常比如JSON序列化失败导致方法中断广播语句没机会执行。还有一个容易被忽略的问题Pub/Sub模式是“发后即焚”的。如果服务实例在广播消息发出后才启动它会错过这条消息。这会导致新增实例的Caffeine里没有最新数据要等10分钟过期兜底。如果要解决这个问题就得引入持久化消息队列或者启动时强制清空本地缓存。我这边处理方式是在服务启动时统一清空一次Caffeine简单有效。6.4 高并发场景下的性能调优建议这套架构跑了一段时间后可以做一些锦上添花的调优。Canal消费端如果同步速度跟不上生产端的binlog生成速度消息会积压。此时可以调大getWithoutAck的大小从100调成500甚至1000减少网络交互次数。同时确保消费逻辑里不要有慢操作比如不要在Canal客户端里直接调用外部接口这会严重拖慢消费速率。Caffeine的maximumSize需要根据实例内存适当调整。一个Caffeine缓存对象如果包含商铺JSON字符串可能占几百字节到几KB1万个对象就是几十MB到几百MB的内存占用。不要盲目设置一个很大的值要根据JVM堆内存和GC表现来调。如果业务上存在极端热点key且数据更新频率较高可以在Caffeine配置里增加refreshAfterWrite在过期前异步刷新。但这个功能需要自己实现CacheLoader调用的也是数据库或Redis用之前先确认自己的场景是否有必要。我建议在监控层面把Canal同步延迟作为核心指标关注。Canal提供了prometheus的监控插件docker logs里也能看到同步耗时。一旦发现延迟超过预期优先检查MySQL的binlog写入情况、Canal消费线程是否阻塞、Redis响应时间是否劣化。整个过程跑下来我最大的体会是多级缓存本身不难写难的是把更新链路的每个环节想清楚。Canal解决的是“从数据库到Redis”这一环Redis Pub/Sub解决的是“从Redis到所有本地缓存”这一环过期时间解决的是“整个链路失控时”的最后兜底三者缺一不可。如果只引入Canal而不管本地缓存失效多级缓存升级就成了半吊子工程如果只做本地缓存而不引入自动同步机制那缓存一致性就全靠人肉删迟早会出问题。这套方案目前在我这边跑得很稳日常场景下Canal的同步延迟基本在100毫秒以内热点商铺的查询响应时间从原来的20毫秒左右降到了1毫秒以内。后续如果你想继续扩展同一条Canal链路还能用来同步数据到Elasticsearch做全文检索或者投递到消息队列做数据审计架构上是非常灵活的。