黑马点评缓存优化:基于Canal的Caffeine+Redis多级缓存架构实战
黑马点评这个项目每个做过的人都知道缓存部分是最能体现基本功的地方。原版项目里Redis缓存直接顶在前面业务代码里手动删缓存当时的思路没问题但放到真实的并发场景下单靠Redis一层扛热点流量很快会发现瓶颈不在数据库而是Redis本身成了兜底的那个口子。标题里写的Canel其实是阿里开源的Canal拼写很容易记岔用它监听MySQL的binlog来驱动Redis和Caffeine两级缓存同步这一套落地下来性能提升是实打实的。这篇文章我会把优化动机、整体架构、部署配置、核心代码、压测数据和踩过的坑一次讲透适合正在做黑马点评项目优化的同学也适合准备面试时被问到“多级缓存”“缓存一致性”“Canal原理”的人。1. 黑马点评的缓存痛点与这次优化的目标1.1 原版Redis缓存方案在并发场景下的三个隐患原版黑马点评的缓存逻辑很简单查询商铺时先查Redis命中直接返回没命中就去数据库捞捞到后回填Redis并设置过期时间更新商铺时先改数据库再手动删除Redis里对应的key。这套Cache-Aside模式单机跑没问题但放在两个典型场景下就开始露馅。第一个隐患是热点key场景。一个爆款店铺的详情页在营销活动期间被集中访问所有请求都打到同一个Redis key上。Redis本身的处理能力很强但每次查询都是一次网络IO到了千万级日活的体量Redis的网卡和单线程处理队列会被打满响应时间从1毫秒慢慢膨胀到几十毫秒。第二个隐患是缓存更新逻辑散落在业务代码里。每写一个增删改接口都要记得调用删除缓存的代码。黑马点评这个项目里涉及Shop、ShopType、Voucher等好几类数据漏删一个key缓存里的旧数据就要等到过期时间才会被刷新期间用户看到的一直是脏数据。第三个隐患是缓存击穿和穿透的兜底能力弱。Redis一旦没有命中所有请求会同时穿透到MySQL即使原项目里加了互斥锁或者逻辑过期方案MySQL在极端流量下依然会成为被击穿的那层。这三个隐患指向同一个结论在Redis前面再加一层更快的缓存并且把“删缓存”这个动作从业务代码里彻底剥离出来变成由数据变更自动触发。1.2 为什么是“RedisCaffeine”而不是只用一层的方案Caffeine是一个基于Java 8的高性能本地缓存库底层用ConcurrentHashMap加一系列异步加载和淘汰策略实现单机读写性能是纳秒级的比走网络IO的Redis快一到两个数量级。但本地缓存有个天然限制数据存在JVM堆内存里只有当前这个服务实例自己能读到。如果服务是多节点部署节点A的本地缓存更新了节点B的本地缓存还是旧的。所以多级缓存的标准做法是Caffeine作为一级缓存缓存热点数据服务本地直接读Redis作为二级缓存存全量缓存数据多个服务实例共享MySQL是最底层的数据源。查询链路先走本地本地没有走RedisRedis没有才走数据库再逐级回填。这样组合的好处很明显——热点key的绝大部分请求在一级缓存就被拦住了Redis的压力断崖式下降。更重要的是Caffeine的淘汰策略、过期策略都是可以单独调的不同业务场景可以用不同的缓存参数比单一Redis灵活得多。1.3 为什么引入Canal而不是简单手动删缓存既然原项目已经有手动删缓存的代码为什么不沿用非要引入Canal这个新组件答案是手动删缓存注定是“尽力而为”不是“必然达成”。任何一条写数据的分支没有执行删缓存逻辑缓存就脏了。而且业务系统里经常有非业务代码改数据的情况比如数据库管理后台直接改一条记录、运营跑了一个批量修正SQL、定时任务更新了状态字段这些操作业务代码完全感知不到手动删缓存根本无从下手。Canal的原理是从底层解决这个问题它把自己伪装成MySQL的从库向MySQL主库发送dump协议请求主库的binlog只要有变动Canal就能实时收到并解析成结构化的数据变更事件。这样一来不管数据是被谁改的只要进了binlogCanal就能感知到缓存同步就有了一个确定的、不可绕过的触发源。这里需要有个清晰的认知Canal并不是来替代Cache-Aside模式的。Cache-Aside的读取逻辑不动Canal接管的是“缓存失效”这个动作取代原先散落在业务代码里的各种delete缓存调用。2. 多级缓存架构Caffeine本地缓存、Redis分布式缓存与binlog同步链路的分工2.1 读链路一级一级查下去优化后的读链路非常直观。以查询商铺详情为例代码逻辑从上到下走三层请求进来后第一站是Caffeine本地缓存用cache:shop:{id}作为key命中就直接返回结果整个过程不涉及网络通信。这是最快的路径也是热数据待的地方。没有命中Caffeine进入第二站Redis。Redis里存的还是原来那份JSON字符串和原项目一样。查到了就反手把数据放进Caffeine——这一步叫“逐级回填”下次同样的请求直接打到第一层。如果Redis也没有那就只能回源数据库。查出来的数据先回填Redis并设置过期时间再回填Caffeine然后返回给前端。整体流程并不复杂但真正决定缓存效率的是数据回填之后的一致性保障。读链路只负责“把新的数据填进来”数据一旦发生变化老的数据需要被清掉或刷新这个动作由写链路驱动。2.2 写链路Canal如何把数据库变更变成缓存失效指令写链路的核心链路是应用程序或任何客户端写MySQL —— MySQL记录binlog —— Canal伪装成从库拉取并解析binlog —— 解析出变更的表名、主键ID、事件类型 —— 拼出对应的Redis key和Caffeine key —— 执行删除。这个设计把“Redis和Caffeine里存的数据”与“MySQL里的真实数据”之间的时差压缩到了毫秒级。业务代码不再需要关心缓存的清理只需要保证MySQL写成功剩下的交给Canal去通知。在Canal客户端消费这一侧处理逻辑里要特别注意事件类型。DELETE事件只能从beforeColumns里取数据因为记录已经被删了没有“更新后”的字段。INSERT和UPDATE事件则应该从afterColumns取拿到的才是最新数据。这里一旦取反缓存删除的key就会变成cache:shop:null删了个寂寞。2.3 为什么“删缓存”比“更新缓存”更安全很多第一次做多级缓存的同学会问既然Canal都拿到变更后的最新数据了为什么不直接把新值写进Redis和Caffeine还要多此一举地删除两个原因。第一个是并发覆盖问题。假设缓存更新逻辑是“读binlog得到新值然后写入Redis”。在binlog产生的瞬间可能已经有并发请求把这个key的旧值回填到了Redis或Caffeine。如果Canal延迟几十毫秒回填动作和Canal的写操作之间无法保证顺序可能出现旧值覆盖新值也就是缓存里最新的数据反而被老数据覆盖掉。第二个是复杂业务场景下行记录无法简单地“翻译”成新缓存。一个订单的变更可能涉及多张表联动一个商铺的更新可能连带着商铺列表也要刷新。删除操作则简单得多——下次查询时按需回源数据库重新组装数据这个过程天然是安全的。所以“删除”永远比“更新”安全这个取舍值得成为你下次面试时说出的标准答案。3. 环境准备MySQL binlog开启、Canal部署与连通性验证3.1 MySQL端必须改的3个binlog参数Canal工作的前提是MySQL开启binlog并且binlog格式必须满足Canal的解析要求。我第一次搭的时候就是栽在这里Canal日志显示连接成功但数据库不管怎么增删改消息就是不来。排查到最后发现binlog_format还是STATEMENT。需要修改的配置核心就三行在my.cnfLinux或my.iniWindows的[mysqld]段下server-id1 log-binmysql-bin binlog_formatROW binlog_row_imageFULL expire_logs_days7逐行解释一下为什么这样配。server-id必须是一个唯一值因为Canal会把自己当成从库去拉取binlogMySQL主库需要用它来区分不同的复制来源同一个集群里不能重复。binlog_formatROW是硬性要求。ROW模式下binlog记录的是每一行数据的变化Canal能解析出“哪一行被更新更新前是什么值更新后是什么值”。STATEMENT模式下binlog只记录执行的SQL语句Canal无法可靠地推断出变更影响的具体行。binlog_row_imageFULL的作用是保证ROW模式下每一条binlog里都包含完整的行镜像也就是beforeColumns和afterColumns都能拿到完整的数据列避免只记录主键而导致解析出来的缓存key不完整。配置改完后需要重启MySQL。验证是否生效show variables like binlog_format; show variables like log_bin;看到binlog_format的值为ROW、log_bin的值为ON就说明配置到位了。3.2 Canal连接账号与instance配置MySQL会为Canal单独建一个账号权限不需要太大但必须包括主从复制相关的权限。实测下来最稳的授权组合是这样CREATE USER canal% IDENTIFIED BY canal; GRANT SELECT, REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO canal%; FLUSH PRIVILEGES;SELECT权限允许Canal在初始化时读取表结构REPLICATION SLAVE和REPLICATION CLIENT是复制协议要求的。不建议给这个账号ALL权限万一Canal的客户端代码有安全问题影响面要控制住。部署Canal有两种主流方式直接用官方压缩包deployer模式或者用Docker。我个人的建议是本地开发用Docker测试环境和生产环境用压缩包部署因为压缩包部署时调整JVM参数和日志配置更直接。以Canal 1.1.6的压缩包部署为例解压后目录结构是conf/、lib/、logs/。最重要的配置在conf/canal.properties和conf/example/instance.properties两个文件。canal.properties里需要确认端口配置canal.port11111默认端口是11111Java客户端连接时要用到这个端口。conf/example/instance.properties里配置MySQL连接信息和订阅规则canal.instance.master.address127.0.0.1:3306 canal.instance.dbUsernamecanal canal.instance.dbPasswordcanal canal.instance.connectionCharsetUTF-8 canal.instance.filter.regexhmdp\\..*canal.instance.master.address填MySQL的主库地址。canal.instance.filter.regex填监听规则hmdp\\..*表示监听hmdp库下的所有表其中\\.是正则里的点号转义。如果你项目的库名不叫hmdp按实际库名改。启动Canalsh bin/startup.sh日志输出在logs/example/example.log正常启动的标志是出现类似successful的字样。这里有一个值得注意的细节Canal启动时会去连接的其实就是它配置的master.address但canal.instance.master.address里如果配的是127.0.0.1代表Canal和MySQL在同一台机器上。跨机器部署时记得改成MySQL的真实IP。3.3 验证Canal真正收到binlog的三种方法不能等到业务代码写完才发现Canal没接入环境装完之后要先做联通性验证。第一种方法查看Canal日志。在logs/example/example.log里如果出现dump address相关的日志说明Canal已经成功连接上MySQL主库并且进入binlog dump状态。第二种方法通过MySQL端观察复制状态。在MySQL执行show slave hosts;如果Canal正常连接这里能看到一行记录Host字段指向运行Canal的那台机器。看到这行记录说明Canal已经成功冒充了从库开始接收binlog。第三种方法也是最实用的——手动执行一条INSERT语句然后观察Canal日志。假如监听的库里表名叫tb_shop随便插入一条记录日志里会出现binlog相关解析记录。没有日志输出基本可以确定是filter.regex写错了或者binlog_format没改过来。4. Caffeine层实现配置、序列化与两级缓存读取改造4.1 Caffeine依赖和缓存实例配置工程里加Caffeine依赖用Maven的话在pom.xml里加dependency groupIdcom.github.ben-manes.caffeine/groupId artifactIdcaffeine/artifactId version2.9.3/version /dependency版本号这里提一句Caffeine 2.x基于JDK83.x要求JDK11以上黑马点评如果用的是JDK8环境老老实实用2.9.x。Caffeine的实例创建和配置集中在配置类里Configuration public class CaffeineConfig { Bean public CacheString, Object localCache() { return Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(Duration.ofMinutes(10)) .recordStats() .build(); } }maximumSize(10_000)控制的是本地缓存最多存多少个key超过之后按W-TinyLFU算法淘汰低频使用的key。这个参数不能拍脑袋乱填要结合服务实例的内存规格来看。黑马点评的Shop数据平均一个对象序列化后几百字节到1KB1万个key大约占10MB内存4G堆内存的服务开10万也扛得住但如果是那种聚合了很多信息的复杂对象就要往保守了配。expireAfterWrite(Duration.ofMinutes(10))是写后10分钟过期。这个过期时间其实是本地缓存的一个兜底保障正常情况下Canal会在数据变更后主动删除key过期时间的存在主要是防止Canal宕机或者消息丢失后本地缓存无限期地保留脏数据。recordStats()打开统计信息生产环境建议打开。压测或者线上排查时可以随时查看命中率。4.2 查询业务的读取链路实现查询接口的实现核心在改查询方法。以黑马点评的商铺查询为例改造后的代码逻辑分三步走public Result queryShopById(Long id) { String cacheKey RedisConstants.CACHE_SHOP_KEY id; // 1. 优先查Caffeine本地缓存 Shop localShop (Shop) localCache.getIfPresent(cacheKey); if (localShop ! null) { return Result.ok(localShop); } // 2. 本地缓存未命中查Redis String shopJson redisTemplate.opsForValue().get(cacheKey); if (StrUtil.isNotBlank(shopJson)) { Shop shop JSONUtil.toBean(shopJson, Shop.class); localCache.put(cacheKey, shop); return Result.ok(shop); } // 3. Redis也未命中查数据库 Shop dbShop getById(id); if (dbShop null) { return Result.fail(店铺不存在); } // 4. 回填Redis和本地缓存 redisTemplate.opsForValue().set(cacheKey, JSONUtil.toJsonStr(dbShop), 30L, TimeUnit.MINUTES); localCache.put(cacheKey, dbShop); return Result.ok(dbShop); }这里有两个小细节值得展开。第一localCache.getIfPresent(cacheKey)返回的是Object类型强转成Shop时要注意Caffeine缓存里存的对象一定是完整对象不能是JSON字符串。如果第2步从Redis取出来的数据是JSON字符串回填到Caffeine前必须先反序列化成对象再put否则第1步的强转会直接ClassCastException。第二Redis的过期时间设的是30分钟Caffeine是10分钟这里故意让Redis的过期时间更长这样即使Caffeine的10分钟到了Redis的数据还在回源数据库的频率被大幅压低。两级缓存的过期时间要错开这是很多人容易忽略的点。4.3 null值难题Caffeine不能存nullRedis缓存穿透怎么办使用Caffeine时第一个碰到的异常十有八九是NullPointerException——Caffeine压根不允许缓存值为nullConcurrentHashMap同样不允许。但业务查询的时候数据库没查到数据返回一个null再正常不过了。所以代码里不能直接localCache.put(cacheKey, dbShop)得加判空if (dbShop null) { return Result.fail(店铺不存在); } localCache.put(cacheKey, dbShop);这里的取舍是数据库和两级缓存都查不到时这个请求不会往缓存里回填任何东西。问题在于如果某个不存在的ID被恶意地高频访问每次都穿透到数据库数据库的压力会直线上升。黑马点评原项目里针对这个问题用的是“缓存空值”或“布隆过滤器”思路。在多级缓存场景下我的建议是保留Redis层的空值缓存策略但Caffeine层就放弃空值缓存——因为Caffeine存不了null而且空值缓存的数据必须靠过期时间来清理放本地缓存里容易积累大量无效key得不偿失。5. Canal客户端实现binlog解析、缓存失效与多机部署的同步问题5.1 客户端建立连接与订阅Canal服务器的数据需要有一个消费客户端去拉取这个客户端同时也是最容易被忽视的一环。黑马点评这种单体项目最直接的写法是实现ApplicationRunner接口在Spring Boot启动完成后自动运行一个Canal客户端线程。加入Canal客户的依赖dependency groupIdcom.alibaba.otter/groupId artifactIdcanal.client/artifactId version1.1.6/version /dependency客户端核心代码Component Slf4j public class CanalClient implements ApplicationRunner { Resource private StringRedisTemplate stringRedisTemplate; Resource private CacheString, Object localCache; private static final int BATCH_SIZE 1000; Override public void run(ApplicationArguments args) { CanalConnector connector CanalConnectors.newSingleConnector( new InetSocketAddress(127.0.0.1, 11111), example, , ); while (true) { try { connector.connect(); connector.subscribe(hmdp\\..*); connector.rollback(); log.info(Canal客户端已连接并订阅); while (true) { Message message connector.getWithoutAck(BATCH_SIZE); long batchId message.getId(); if (batchId -1 || message.getEntries().isEmpty()) { Thread.sleep(1000); continue; } handleEntries(message.getEntries()); connector.ack(batchId); } } catch (Exception e) { log.error(Canal连接异常5秒后重连, e); try { Thread.sleep(5000); } catch (InterruptedException ex) { Thread.currentThread().interrupt(); } } finally { connector.disconnect(); } } } }这段代码有几个容易踩的点。newSingleConnector的第二个参数example对应Canal服务端conf/example目录下的instance名称。如果服务端配置的instance名不叫example客户端这里就要改成对应的名字。connector.subscribe(hmdp\\..*)和Canal服务端的canal.instance.filter.regex是两套过滤。服务端是上游在拉binlog时就过滤客户端是订阅时再过滤两处都写上最稳妥。getWithoutAck和ack是Canal客户端消费的核心机制getWithoutAck拿到的数据和内置的batchId绑定处理成功后再调用ack(batchId)告诉服务端这批消息已经处理完了。如果处理了一半程序崩溃Canal服务端会认为这批消息没被确认客户端重连后可以rollback()重新消费。这个过程保证的是“至少一次”投递所以消费端处理逻辑必须幂等删除缓存天然幂等删不存在的key也不会报错这一点是删除型缓存同步方案的又一个优势。5.2 binlog Entry解析代码详解Canal拿到的是序列化后的二进制数据核心的解析逻辑在handleEntries方法里。每一个Entry对应一条binlog事件里面包含表名、事件类型以及具体的数据行变化。private void handleEntries(ListEntry entries) throws InvalidProtocolBufferException { for (Entry entry : entries) { if (entry.getEntryType() ! EntryType.ROWDATA) { continue; } RowChange rowChange RowChange.parseFrom(entry.getStoreValue()); EventType eventType rowChange.getEventType(); String tableName entry.getHeader().getTableName(); // 表名过滤只处理需要缓存同步的表 if (!tb_shop.equals(tableName) !tb_shop_type.equals(tableName)) { continue; } for (RowData rowData : rowChange.getRowDatasList()) { if (eventType EventType.INSERT || eventType EventType.UPDATE) { String id getColumnValue(rowData.getAfterColumnsList(), id); syncCache(tableName, id); } else if (eventType EventType.DELETE) { String id getColumnValue(rowData.getBeforeColumnsList(), id); syncCache(tableName, id); } } } } private String getColumnValue(ListColumn columns, String columnName) { for (Column column : columns) { if (column.getName().equals(columnName)) { return column.getValue(); } } return null; }解析binlog这里有个很重要的概念Entry和RowChange是Canal定义好的protobuf协议客户端拿到的entry.getStoreValue()是一段序列化后的字节流必须用RowChange.parseFrom手动解出来。EventType就是INSERT、UPDATE、DELETE这些枚举值和SQL语义一一对应。表名过滤这步我提一句entry.getHeader().getTableName()拿到的表名不带库名前缀比如tb_shop。如果库名也想过滤可以看getSchemaName()但实际使用中一般只订阅了一个库所以表名判断足够了。同步逻辑syncCache根据表名拼出对应的两级缓存keyprivate void syncCache(String tableName, String id) { String cacheKey; if (tb_shop.equals(tableName)) { cacheKey RedisConstants.CACHE_SHOP_KEY id; } else if (tb_shop_type.equals(tableName)) { cacheKey RedisConstants.CACHE_SHOP_TYPE_KEY; } else { return; } // 先删Redis再删本地缓存 stringRedisTemplate.delete(cacheKey); localCache.invalidate(cacheKey); log.info(缓存同步删除, table{}, key{}, tableName, cacheKey); }注意这里tb_shop_type表的结构比较特殊它是一整个列表存放在一个key里的所以无需拼接ID。5.3 删缓存顺序与多实例场景下的本地缓存失效方案删缓存的顺序为什么必须先删Redis再删Caffeine模拟一个并发场景就明白了。请求A刚查完Redis并准备回填Caffeine这时Canal收到binlog先删了Caffeine后删Redis。请求A等Canal删完Caffeine后把自己手里的旧数据写进了Caffeine——请求A老数据成功复活。反过来先删Redis再删Caffeine请求A的旧数据回填Caffeine后马上会被Canal的invalidate删掉脏数据在Caffeine里停留的时间只有毫秒级。这个顺序问题直接决定了多级缓存系统的最终一致性窗口有多大值得在代码注释里写清楚。还有一个单机环境下意识不到、一旦多机部署就立刻翻车的问题Canal客户端在哪个机器上收到binlog消息就只会清理那台机器本地JVM里的Caffeine。另外几台机器的Caffeine缓存照样留着旧数据要靠各自的过期时间兜底。如果对一致性要求没那么苛刻比如黑马点评这种业务10分钟的Caffeine过期时间完全能兜住。但如果服务实例多而且对数据新鲜度要求高有三种升级路径第一种启用Redis Pub/Sub。Canal消费端收到binlog后除了删除本地缓存同时往Redis的某个topic发一条清缓存指令。其他服务实例订阅这个topic收到指令后清理自己的Caffeine。第二种把binlog变更投递到MQ所有服务实例都消费MQ消息各自清理本地缓存。第三种直接用Canal的MQ模式服务端把binlog变更发到Kafka或RocketMQ客户端从MQ消费。这三条路成本依次递增收益也递增。但黑马点评这种单体应用单实例部署用Canal客户端直接同步就完全够用不推荐一开始就上MQ架构复杂度会陡增。6. 压测数据、线上一致性与踩坑实录6.1 单热点key压测纯Redis与多级缓存的数据差异我把优化前后的方案做了一个简单的压测对比测试环境是4核8G的虚拟机Jmeter开50个线程持续压一个商铺详情的热点key数据是提前预热好的。指标纯Redis缓存RedisCaffeine多级缓存P99响应时间毫秒241.8每秒请求处理数QPS842048300Redis每秒请求次数84301326CPU使用率62%41%这个结果非常能说明多级缓存的威力响应时间从几十毫秒降到几毫秒不是重点重点是Redis的请求量被打了下来——50个线程压测时Redis每秒收到的请求从8430次降到了1326次。真正高并发的生产环境里这个数字差距会直接决定Redis集群要不要扩容。当然这个数据跟机器配置、热点key的数据大小都有关系不要当绝对值看但趋势是稳定复现的一级缓存命中率越高Redis压力越小响应时间越快。这个命中率可以用localCache.stats().hitRate()拿到Caffeine自带统计功能压测时盯这个指标最有意义。6.2 踩坑一binlog_format没改对Canal连上但收不到消息这应该是所有人玩Canal时踩的第一个坑。Canal连接日志显示一切正常show slave hosts也能看到Canal的虚拟从库记录但不管数据库怎么INSERT和UPDATECanal日志一个消息都刷不出来。排错链路是这样走的先检查Canal的instance.properties里filter.regex的正则发现没问题再检查MySQL的binlog有没有开启show variables like log_bin的结果也是ON最后随手敲了一句show variables like binlog_format才发现值是STATEMENT。binlog_format决定binlog里装的是什么格式的数据。STATEMENT模式装的是SQL语句canal解析完不知道具体哪行变了自然没法产生行级变更事件。改配置、重启MySQL后Canal立即就开始出数据了。6.3 踩坑二UPDATE和DELETE事件取错字段缓存key失效同步代码里一开始我只用了一套取列的逻辑统一从getAfterColumnsList()取id。INSERT和UPDATE确实没问题但DELETE事件一发生getAfterColumnsList()返回空得到的id是null拼出来的key变成cache:shop:nullRedis里的真实key根本删不掉。这个错误的隐蔽性在于数据库的DELETE操作本身不会报错Canal客户端日志也没有任何异常但缓存就是一直不更新。后来我在handleEntries里把每种EventType的before和after列都打了出来才发现DELETE事件的after全是空的。正确的逻辑在5.2节的代码里已经写了INSERT和UPDATE从afterColumns取DELETE从beforeColumns取。并建议你在解析时打日志至少把表名、事件类型、id这几个字段打出来排查问题会快很多。6.4 踩坑三消费端异常断开导致缓存永久不更新Canal客户端在拉消息的时候如果MySQL宕机、网络抖动、或者binlog里有某条数据解析失败客户端线程会直接抛出异常。我第一版代码里把connect()、subscribe()、rollback()都放在try块外面一旦出现网络异常整个线程终止日志里只有一行堆栈然后缓存同步就静静地“死”了——不是报错是无声地再也不更新。解决思路是给整个连接和消费逻辑套上一层死循环加异常恢复。5.1节里最终版本的代码就是在最外层套了一个while (true)连接断开后立即disconnect()然后等待5秒重连重连后重新subscribe()和rollback()。Canal服务端会保留未ack的消息客户端重新rollback()之后之前没有处理完的binlog变更会再次推送过来不会丢数据。如果没有这层重连机制本地缓存和Redis缓存会一直保留旧数据直到过期时间兜底。对于Redis里30分钟过期、Caffeine里10分钟过期的配置最多也就10分钟的不一致但会频繁出现“MySQL数据已经更新页面数据还是旧的”的投诉。所以重连逻辑必须要有宁可消费重复也不能消费遗漏。6.5 关于一致性的最后判断引入Canal之后系统的缓存同步链路从“可能漏删”变成了“大概率及时删”但不要神话这个方案。Canal同步本身是有延迟的binlog产生、Canal拉取、客户端解析、删除Redis、删除Caffeine这一串动作走下来几十毫秒是有的。极端情况下Canal如果挂掉了重连之前的缓存更新就完全依赖过期时间兜底。所以这套方案的核心适用场景是黑马点评这种典型的读多写少、允许秒级短暂不一致的业务。下单、支付、库存这类强一致场景光靠多级缓存同步是不够的要么接受读取侧有延迟要么在写路径上增加版本校验或者读多写多的策略。从工程实现的角度讲我做这套优化时最后悔的其实不是技术选型而是没从一开始就把日志和监控加上。Canal客户端消费了多少条binlog、删除了多少个缓存key、Caffeine的命中率是多少这些指标如果上线前就埋好后面排查问题的时间能省一半。建议你在开发时就把recordStats()的指标和Canal消费条数指标接入监控哪怕只是先打到日志里也比出了问题再盲猜强得多。多级缓存这套方案本身不复杂Canal的部署也谈不上难真正的门槛在于你是否理解每一层缓存存在的意义以及在缓存不一致时如何快速定位到底是哪一环出了问题。把上面的踩坑链路自己亲手走一遍你对Redis、Caffeine和Canal的认知会完全不一样。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →