双写一致性的探讨

281 阅读22分钟

前提概要

关于在完成缓存与数据库进行数据同步时候,是否有完美的银弹方案,没有!!!,不过是在平时的交流中还是去真实的面试当中,如果同学被问到这种类似的问题,我们首先要告诉面试官的是:没有完美的强一致性方案,只有结合实际业务,与产品经理进行商讨,对于当前业务来说我们最低能够接受的数据一致性延迟是在多少左右?比如:1 分钟、10 分钟、20 分钟……。这个是站在时效性的角度来看的。同时,我们也要关注到我们在做数据双写的时候,数据是否出现了不一致性的情况,比如,数据库中真实的数据是 A,但是缓存上的数据是 B 的,这种缓存与数据库数据不一致的情况。如果数据都不一致的,那开始说的时效性的就无从谈起了。

那么本篇文章,我们就要先要基于缓存与数据库一致性的角度去探究如何保证数据的可见性安全问题。

缓存模型

概述

Redis 由于性能高效,通常可以做数据库存储的缓存,比如给 MySQL 当缓存就是常见的玩法,具体而言,就是将 MySQL 的热点数据存储在 Redis 中,通常业务都满足二八原则,80% 的流量在20% 的热点数据之上,所以缓存是可以很大程度提升系统的吞吐量。

实践

一般而言,缓存分为服务器端缓存和客户端缓存:

服务器端缓存

  • 服务端将数据存入 Redis, 可以在访问 DB 之后,将数据缓存,或者在回包时,将回包内容以请求参数为 Key 缓存。

客户瑞缓存

  • 服务端 PC 调用之后,将结果存储在客户端,这样下次请求相同数据时就能直接拿到结果,不会再远程调用,提高性能节省网络带宽。

用服务端还是客户端呢?

  • 其实是需要分析具体瓶颈在哪里,当然,如果按通常的经验,从服务角度来看,在目前的微服务架构下,每个服务其实都应该缓存一些热点数据,以减轻热点数据频繁请求给自己带来的压力,毕竟微服务也要有一定的互不信任原侧。至于客户端缓存,这个就更看场景了,频繁请求的数据,就有必要做缓存。下面我们以服务端缓存的视角,来进行缓存分析。

四种缓存策略的分析

Cache-Aside-Pattern 旁侧缓存模式(缓存在返回,缓存不在数据库查询后写回缓存)

Cache Aside,即旁侧缓存模式是最常见的模式,应用服务把缓存当作数据库的旁路,直接和缓存进行交互。

应用服务收到查询请求后,先查询数据是否在缓存上,如果在,就用缓存数据直接打包返回,如果不存在,就去访问数据库,从数据库查询,并放到缓存中,除了查库后加载这种模式,如果业务有需要,还可以预加载数据到缓存。在写操作的时候 Cache Aside 模式是一般是先更新数据库,然后直接删除缓存,为什么不直接更新呢?

  • 因为更新相比删除会更容易造成时序性问题。Cache Aside适用于读多写少的场景,比如用户信息、新闻报道等,一旦写入缓存,几乎不会进行修改。该模式的缺点是可能会出现缓存和数据库双写不一致的情况。

Read-Through-Cache-Pattern 读穿透模式(Cache-Aside-Pattern 的变种,采用单独的查询服务来维持缓存的一致性)

Read-Through-Cache-Pattern读穿透模式Cache-Aside-Pattern旁侧缓存模式的区别主要在于应用服务不再和缓存直接交互,而是直接访问数据服务,数据服务自己来根据情况查询缓存或者数据库和 Cache Aside 一样,也是缓存中有,就用从缓存中获得的数据,没有就查DB,只不过这些由数据服务托管保存,而对应用服务是透明的。 相比 Cache Aside,Read Through 的优势是缓存对业务透明,业务代码更简洁。缺点是缓存命中时性能不如 Cache Aside,相比直接访问缓存,还会多一次服务间调用。

Write-Through-Cache-Pattern 写穿透模式(先从缓存入手)

在 Cache-Aside-Pattern 中,应用程序需要维护两个数据存储:缓存、数据库。

  • 如果当前写入的数据缓存存在
    • 先查询要写入的数据在缓存是否存在,如果存在则先更新缓存然后再更新数据库最后返回。
  • 如果当前写入的数据缓存不存在(如果要写入的数据在缓存不存在,有两对应策略)
    • 一:先将数据写入缓存,然后由缓存组件将数据同步更新到数据库。
    • 二:不写缓存直接将数据写入数据库,等读的时候再加载进去。

tips:可以理解为应用程序只有一个单独的访问源,而存储服务自己维护访问逻辑。

当使用 Write-Through-Cache-Pattern 时,一般都配合使用 Read-Through-Cache-Pattern 来使用,Write-Through-Cache-Pattern 潜在使用场景是银行系统。

Write-Through-Cache-Pattern 使用场景:

  • 需要频繁读取相同数据。
  • 不能忍受数据丢失(相对 Write-Behind-Pattern 而言)和数据不一致。

在使用 Write-Through-Cache-Pattern 时要特别注意的是缓存的有效性管理,否则会导致大量的缓存占用内存资源,极端情况下甚至有些有效的缓存数据被无效的缓存数据给清除掉。

Write-Behind-Pattern(Write Back)异步缓存写入模式(见名知意,异步写入)

Write-Behind-Pattern 和 Write-Through-Cache-Pattern 在程序只和缓存交互,并且只能通过缓存写数据这方面很相似。不同的点在于 Write-Through-Cache-Pattern 会立刻的把数据同步到数据库中,但是 Write-Behind-Pattern 是在一段时间之后(通过某种触发机制,如定时任务)再将数据写入到数据库中,这里我们说的异步写入是 Write-Behind-Pattern 的最大特点。数据库写操作可以用不同的方式完成,其中一个方式就是收集所有的写操作并在某一时间点(如,数据库负载低的时候)批量写入。另一种方式就是合并几个写操作成为一个小批次操作,接着缓存收集写操作一起批量写入。异步写操作极大地降低了请求延迟并减轻了数据库的负担,但是也放大了数据不一致性的问题。比如,有人此时直接从数据库中查间数据,但是更新的数据还未被写入数据库,此时查间到的数据就不是最新的数据。但是对于服务器的资源分配以及利用可以做到较好。

实际中不同的方案会导致的问题

如果项目业务处于起步阶段,流量非常小,那无论是读请求还是写请求,直接操作数据库即可,这时架构模型是这样的:

但随着业务量的增长,项目业务请求量越来越大,这时如果每次都从数据库中读数据,那肯定会有性能问题。这个阶段通常的做法是,引入缓存来提高读性能,架构模型就变成了这样:

在实际开发过程中,缓存的使用频率是非常高的,只要使用缓存和数据库存储,就难免会出现双写时数据一致性的问题,就是 Redis 缓存的数据和数据库中保存的数据出现不相同的现象。

如上图所示,大多数人的很多业务操作都是根据这个图来做缓存的,这样能有效减轻数据库压力。但是一旦设计到双写或者数据库和缓存更新等操作,就很容易出现数据一致性的问题。无论是先写数据库,在删除缓存,还是先删除缓存,再写入数据库,都会出现数据一致性的问题。

例如:

  • 先删除了 Redis,但是因为其他什么原因还没来得及写入 MySQL,另外一个线程就来读取,发现缓存为空,则去 MySQL 读取到之前的数据并写入缓存,此时 Redis 中为脏数据。

总的来说,写和读在多数情况下都是并发的,不能绝对保证先后顺序,就会很容易出现缓存和数据库数据不一致的情况,那我们又该如何解决呢?

一致性的基本介绍

  • 强一致性:如果你的项目对缓存的要求是强一致性的,那么请不要使用缓存。这种一致性级别是最符合用户直觉的,它要求系统写入什么,读出来的也会是什么,用户体验好,但实现起来往往对系统的性能影响大。读请求和写请求会串行化,串到一个内存队列里去,这样会大大增加系统的处理效率,吞吐量也会大大降低。
  • 弱一致性:这种一致性级别约束了系统在写入成功后,不承诺立即可以读到写入的值,也不承诺多久之后数据能够达到一致,但会尽可能地保证到某个时间级别(比如秒级别)后,数据能够达到一致状态。
  • 最终一致性:最终一致性是弱一致性的一个特例,系统会保证在一定时间内,能够达到一个数据一致的状态。这里之所以将最终一致性单独提出来,是因为它是弱一致性中非常推崇的一种一致性模型,也是业界在大型分布式系统的数据一致性上比较推崇的模型。一般情况下,高可用只确保最终一致性,不确保强一致性。

场景分析

针对读场景

A 请求查询数据,如果命中 Redis,那么直接取 Redis 数据返回即可。如果请求中不存在,MySQL 中存在,那么直接取 MySQL 数据返回,然后将数据同步到 Redis 中。不会存在数据不一致的情况。

在高并发的情况下,A 请求和 B 请求一起访问某条数据,如果缓存中数据存在,直接返回即可,如果不存在,直接取数据库数据返回即可。无论 A 请求 B 请求谁先谁后,本质上没有对数据进行修改,数据本身没变,只是从缓存中取还是从数据库中取的问题,因此不会存在数据不一致的情况。因此,单独的读场景是不会造成 Redis 与 MySQL 不一致的情况,因此我们不用关心这种情况。

针对写场景

如果该数据在 Redis 中不存在,那么直接修改 MySQL 中的数据即可,不会存在数据不一致的情况。如果该数据在 Redis 中和 MySQL 中都存在,那么就需要既修改缓存中的数据又修改数据库中的数据。如果写数据库的值与更新到缓存值是一样的,可以马上更新缓存;如果写数据库的值与更新缓存的值不一致,在高并发的场景下,还存在先后关系,这就会导致数据不一致的问题。

例如:

  • 当更新数据时,如更新某商品的库存,当前商品的库存是 100,现在要更新为 99,先更新数据库为 99,然后删除缓存,发现删除缓存失败了,这意味着数据库存的是99,而缓存是100,这导致数据库和缓存不一致。
  • 在高并发的情况下,当删除完缓存的时候,这时去更新数据库,但还没有更新完,另外一个请求来查询数据,发现缓存里没有,就去数据库里查,还是以上面商品库存为例,如果数据库中产品的库存是 100,那么查询到的库存是 100,然后插入缓存,插入完缓存后,原来那个更新数据库的线程把数据库更新为了 99,导致数据库与缓存不一致的情况。

同步策略

先更新缓存,再更新数据库

问题一:更新缓存成功,更新数据库失败?

问题描述:

  • 更新 Redis 缓存成功,但更新数据库出现异常时,会导致 Redis 缓存数据与数据库数据完全不一致,而且这很难察觉,因为 Redis 缓存中的数据一直都存在。只要缓存进行了更新,后续的读请求基本上就不会出现缓存未命中的情况。
  • 但在某些业务场景下,更新数据的成本较大,并不是单纯将数据的数据查询出来丢到缓存中即可,而是需要连接很多张表组装对应数据存入缓存中,并且可能存在更新后,该数据并不会被使用到的情况。

先更新数据库,再更新缓存

问题一:如果更新数据库成功,但是更新缓存失败呢?

问题描述:

  • 原因是当数据同步时 MySQL 更新成功,但 Redis 缓存更新失败,那么此时 MySQL 中是最新值,Redis 缓存中是旧值。之后的应用系统的读请求读到的都是 Redis 缓存中旧数据。只有当 Redis 缓存数据失效后,才能从数据库中重新获得正确的值。

问题二:并发一致性问题

该方案还存在并发引发的一致性问题,假设同时有两个线程进行数据更新操作,如下图所示:

问题描述:

  • 目前,我们有两个线程同时在更新同一个数据,假设该数据为 A。
  • 时间线一: 线程-1线程-2一起去更新 MySQL 中的数据 A,此时可以看到上图中,是线程-1先完成了数据 A 的更新动作(A = B),紧接着线程-2也完成了数据 A 的更新(A = C)。
  • 时间线二: 线程-2在更新完 MySQL 后立刻就去更新 Redis 了,完成了数据的写入。此时线程-1还在慢慢悠悠更新自己在 Redis 中 C 值 B。

问题出现:

  • 从上述两个线程的执行过程中,我们发现最后写入数据库的是线程-2,那么也就意味着本次请求中,数据 A 的最后到底是什么是以线程-2给的值来确定的,也就是说本次请求中,不管是 MySQL 还是 Redis,数据 A 的值预期应该是 C。
  • 但是,我们最终会发现,在线程-2完成 Redis 的写入之后,线程-1他迟迟的才开始去写入 Redis,导致本次请求,最终实际上数据 A 的值是 B(旧数据)。

先删除缓存,后更新数据库

问题一:删除 Redis 失败了,MySQL 更新成功。

问题描述:

  • 假如,我们现在有两个线程 T-1、T-2,那么执行的过程中,T-1 线程先开始执行,就会先去删除 Redis 中的旧数据,但是在删除的过程中出现了问题,所以导致 Redis 中的旧数据没有被去除掉,但是 T-1 线程更新 MySQL 成功了,导致目前的结果为:Redis 中是旧数据,MySQL 中是新数据。这个时候如果 T-2 线程来读取数据的时候从 Redis 中读取到了旧数据就会直接返回。
  • 这种情况下如果想要从 Redis 中获取到正确的数据,只有当 Redis 缓存数据失效后,才能从数据库中重新获得正确的值。由于缓存被删除,下次查询无法命中缓存,需要在查询后将数据写入缓存,增加查询逻辑。同时在高并发的情况下,同一时间大量请求访问该条数据,第一条查询请求还未完成写入缓存操作时,这种情况,大量查询请求都会打到数据库,加大数据库压力(缓存击穿)。

问题二:并发修改问题

问题描述:

  • 假设现在还是有两个线程 T-1、T-2,这个两个线程中是 T-1,先访问到了 Redis,并且将对应的数据进行删除。
  • 此时,线程 T-2 请求到了,他先去请求 Redis 中发现 Redis 中没有存在数据(被线程 T-1 删除了),所以线程 T-2 去 MySQL 中获取数据,但是此时获取的这个数据是一个旧数据,然后线程 T-2 也把他同步到 Redis 中了。
  • 最后当线程 T-2 将旧数据同步到 Redis 中之后,我们的线程 T-1 才迟迟把新数据写入到 MySQL 中,但是最后没有将新数据写入到 Redis 中。
  • 最后就导致了,Redis 中是旧数据,MySQL 中是新数据。

延时双删策略

先删除缓存、再写数据库、休眠后再次淘汰缓存。这样做的目的,就是确保读请求结束,写请求可以删除读请求造成的缓存脏数据。

/**
 * 延迟双删_Demo
 */
@Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRED)
public void delayDelDoubleSync() {
    // 1、模拟数据发生了修改
    SysDict exited = Optional.ofNullable(dictService.getById(1819740448848158722L))
                             .orElseThrow(()->new RuntimeException("data not exited!"));
    exited.setDictName("");

    // 2、删除缓存
    redisUtils.del(
            RedisUtils.joinUnicode(
                    RedisUtils.RedisByKeyNameCommon.dict_key,
                    exited.getId()
            )
    );

    // 3、更新数据库
    dictService.updateById(exited);

    // 4、睡眠(这里做睡眠,本质上是在等其他线程将旧数据进行写入,好在下面 step.5 进行旧数据的删除)
    try {
        Thread.sleep(1000);
    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    }


    // 5、删除缓存中的数据(防止其他线程将旧数据同步到 Redis 中,所以将其删除)
    redisUtils.del(
            RedisUtils.joinUnicode(
                    RedisUtils.RedisByKeyNameCommon.dict_key,
                    exited.getId()
            )
    );

    // 6、将当前最新的缓存更新上去
    redisUtils.set(
            RedisUtils.joinUnicode(
                    RedisUtils.RedisByKeyNameCommon.dict_key,
                    exited.getId()
            ),
            new Gson().toJson(
                    Optional.ofNullable(dictService.getById(1819740448848158722L))
                            .orElseThrow(() -> new RuntimeException("data not exited!"))
            )
    );
}

延时双删就能彻底解决不一致吗?

  • 当然不一定来。首先,我们评估的延时时间并不能完全代表实际运行过程中的耗时,运行过程如果因为系统压力过大,我们评估的耗时就是不准确,仍然会导致数据不一致的出现。其次,延时双删虽然在保证事务提交完以后再进行删除缓存,但是如果使用的是 MySQL 的读写分离的架构呢?主从同步之间其实也会有时间差。

先更新数据库,后删除缓存

问题一:如果删除缓存失败?

问题描述:当前我们采取的策略是,先更新数据库,再去修改缓存对应的值,和先删除缓存,后更新数据库策略的问题是一样的,万一缓存中的数据删除失败呢???

问题结果:那也就意味着说,当用户进行数据的读取后 Redis 中的数据就都是旧数据了,直到缓存数据到期,将最新数据更新上去才行。

问题二:并发引发的一致性?

一:当数据库的数据被更新后,如果此时缓存还没有被删除,那么缓存中的数据仍然是旧值。如果此时有新的读请求(查询数据)发生,由于缓存中的数据是旧值,这个读请求将会获取到旧值。

二:当缓存刚好失效,这时有请求来读缓存(线程一),未命中缓存,然后到数据库中读取,在要写入缓存时,线程二来修改了数据库,而线程一写入缓存的是旧的数据,导致了数据的不一致。

一些常见的解决方案

使用 Kafka 发送异步消息,完成数据更新

在更新数据库数据时,同时发送一个异步通知给 Redis,让 Redis 知道数据库数据已经更新,需要更新缓存中的数据。这个过程是异步的,不会阻塞数据库的更新操作。当 Redis 收到异步通知后,会立即删除缓存中对应的数据,确保缓存中没有旧数据。这样,即使在这个过程中有新的读请求发生,也不会读取到旧数据。等到数据库更新完成后,Redis 再次从数据库中读取最新的数据并缓存起来。

@Slf4j
@Component
public class SyncService {

    @Resource
    private IDictService dictService;
    @Resource
    private KafkaUtils kafkaUtils;
    /**
    * 使用Kafka同步消息
    * @param name
    */
    public void syncWriteRedis(String name){
        // 1、模拟数据发生了修改
        SysDict exited = Optional
        .ofNullable(dictService.getById(1819740448848158722L))
        .orElseThrow(() -> new RuntimeException("data not exited!"));
        exited.setDictName(name);

        // 2、如果数据库更新成功则发送异步消息,完成缓存数据同步
        if (TxHelper.exec(() -> {
            // 3、更新数据库
            return dictService.updateById(exited);}))
        {
            // 4、发送更新缓存消息
            kafkaUtils.sendGeneralMsg(
                MessageData.builder()
                .topic(KafkaConstant.GENERA_TOPIC)
                .key(null)
                .data(exited)
                .build()
            );
        }
    }
}
package com.part.time.common.config.kafka.listeners;

import com.alibaba.fastjson.JSONObject;
import com.google.gson.Gson;
import com.part.time.common.config.kafka.pojo.KafkaConstant;
import com.part.time.common.config.redis.RedisByKeyNameCommon;
import com.part.time.common.utils.RedisUtils;
import com.part.time.infrastructure.entity.SysDict;
import org.apache.kafka.clients.consumer.Consumer;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.apache.kafka.clients.consumer.ConsumerRecords;
import org.apache.kafka.common.TopicPartition;
import org.springframework.kafka.listener.BatchMessageListener;
import org.springframework.kafka.support.Acknowledgment;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;

/**
 * @Date:2024/10/27
 * @Author:LuKp
 * @Remark:
 */
@Component
public class GeneralListener implements BatchMessageListener<String,String> {

    @Resource
    private RedisUtils redisUtils;


    @Override
    public void onMessage(List<ConsumerRecord<String, String>> data) {
        // 1、将消息数据转换为集合数据
        List<SysDict> convertData = data.stream()
                .map(a -> JSONObject.parseObject(a.value(), SysDict.class))
                .collect(Collectors.toList());
        // 2、将数据同步到Redis中
        convertData.forEach(a ->{
            // 3、先删除缓存,保证在更新缓存之前没有其他的线程请求获取到旧数据
            redisUtils.del(RedisByKeyNameCommon.joinUnicode(
                    RedisByKeyNameCommon.dict_key,
                    a.getId()
            ));
            // 4、真正的更新缓存
            redisUtils.set(
                    RedisByKeyNameCommon.joinUnicode(
                            RedisByKeyNameCommon.dict_key,
                            a.getId()
                    ), new Gson().toJson(a)
            );
        });
    }

    @Override
    public void onMessage(ConsumerRecords<String, String> records, Acknowledgment acknowledgment, Consumer<String, String> consumer) {
        // 1、指定当前监听器的监听的是那个Topic?
        consumer.partitionsFor(KafkaConstant.GENERA_TOPIC);

        // 2、获取指定Topic中Partition中的数据
        Set<TopicPartition> partitions = records.partitions();
        List<ConsumerRecord<String, String>> messages = partitions.stream()
                .map(records::records)
                .flatMap(List::stream)
                .collect(Collectors.toList());

        // 3、将由具体的处理逻辑
        onMessage(messages);

        // 4、手动ACK
        acknowledgment.acknowledge();
    }
}

基于 Bin_Log 使用 Canal 进行数据变更监听

通过 Canal 提供的 Bin_Log 日志监听能力,关于 Canal 的介绍:juejin.cn/post/698942…

原生的 Canal 是存在一些问题的,需要我们自己去编写通用组件,问题描述:

1、如果目前,你使用的 ORM 框架是 MyBatis 那么 Canal 在进行数据监听的时候,是接受不到相关数据的信息的,你会发现数据接受到全部是 null,必须使用 JPA 提供的注解 @Column来标明字段,如:

private String dictName;

需要添加 @Column注解进行标识,且注意 name 必须使用 _ 进行分隔:

@Column(name = "dict_name")
private String dictName;

这个时候,可以配合使用 Canal 提供的接口EntryHandler的三个方法:update、insert、delete,来在方法实现自定义逻辑:

@CanalTable(value = "table_name")
@Component
@Slf4j
public class SyncDataByCanal implements EntryHandler<?> {
    @Override
    public void delete(? sysDict) {
    }

    @Override
    public void update(? before, ? after) {
    }

    @Override
    public void insert(? sysDict) {
    }
}

2、如果要使用 Canal,那么我们需要开启 MySQL 的 Bin_Log 模式,需要注意的是,开启的方式很简单,只需要在 MySQL 配置文件中,添加高光处代码,注意必须添加在 [mysqld] 下,否则不生效。

# For advice on how to change settings please see
# http://dev.mysql.com/doc/refman/5.7/en/server-configuration-defaults.html

[mysqld]
#Give mysql a unique id
server-id=123
# open binlog model
log-bin=/var/lib/mysql/mysql_bin
# chose ROW model
binlog_format=ROW

# Remove leading # to set options mainly useful for reporting servers.
# The server defaults are faster for transactions and fast SELECTs.
# Adjust sizes as needed, experiment to find the optimal values.
# join_buffer_size = 128M
# sort_buffer_size = 2M
# read_rnd_buffer_size = 2M
skip-host-cache
skip-name-resolve
datadir=/var/lib/mysql
socket=/var/run/mysqld/mysqld.sock
secure-file-priv=/var/lib/mysql-files
user=mysql

# Disabling symbolic-links is recommended to prevent assorted security risks
symbolic-links=0

#log-error=/var/log/mysqld.log
pid-file=/var/run/mysqld/mysqld.pid
[client]
socket=/var/run/mysqld/mysqld.sock

!includedir /etc/mysql/conf.d/
!includedir /etc/mysql/mysql.conf.d/

查看是否开启 Bin-Log:

show variables like '%log_bin%'

看到 log_bin 为 ON 代表开启了 Bin_Log 模式。

同时,我们自己编写通用的 Canal 数据同步工具,来对数据的变化进行监听:

1、初始化 Canal:

@Slf4j
@Configuration
public class CanalInitializing {
    @Value("${canal.ip}")
    private String canal_ip;
    @Value("${canal.port}")
    private Integer canal_port;
    @Value("${canal.destination}")
    private String canal_destination;
    @Value("${canal.subscribe}")
    private String canal_subscribe;

    /**
     * init canal configuration
     */
    @Bean
    public CanalConnector getCanalConnector() {
        log.warn("<<<<<<<<<<<< init canal start >>>>>>>>>>>>");
        CanalConnector connector;
        try {
            connector = CanalConnectors.newSingleConnector(
                new InetSocketAddress(canal_ip, canal_port),
                canal_destination, "", ""
            );
            connector.connect();
            connector.subscribe(canal_subscribe);
        }catch (Exception e){
            log.error("<<<<<<<<<<<< init canal error >>>>>>>>>>>>");
            throw new BusException("init canal error"+e.getMessage(),e);
        }
        log.warn("<<<<<<<<<<<< init canal end >>>>>>>>>>>>");
        return connector;
    }
}

2、编写通用 Canal 组件:如果采用,请标明出处!!!

package com.part.time.common.utils;

import cn.hutool.core.date.DateUtil;
import com.alibaba.fastjson2.JSONObject;
import com.alibaba.otter.canal.client.CanalConnector;
import com.alibaba.otter.canal.protocol.CanalEntry;
import com.google.gson.FieldNamingPolicy;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonDeserializer;
import com.google.protobuf.InvalidProtocolBufferException;
import com.part.time.common.excetion.BusException;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.CollectionUtils;

import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.stream.Collectors;

/**
 * @Date:2024/10/30
 * @Author:LuKp
 * @Remark: 提供 Update 、Delete 、Insert 三种操作的Canal解析操作
 */
@Slf4j
public class CanalUtils {

    @AllArgsConstructor
    @Getter
    @Setter
    public static class CanalParseEntity<T> {
        private T data;
        private CanalEntry.EventType eventType;
    }

    /**
     * 通过提供如下参数,来获取指定数据库实体的变更数据
     *
     * @param connector Canal 链接
     * @param batchSize 批量拉取大小
     * @param tableName 实体对应表名
     * @param clazz     实体类型
     * @param <T>       实体类型
     * @return 解析后的变化数据
     */
    public static <T> List<CanalParseEntity<T>> sync(
            CanalConnector connector, Integer batchSize, String tableName, Class<T> clazz) {
        log.warn("Canal Parse Start ...");
        List<CanalEntry.Entry> canalEntries = connector.get(batchSize).getEntries();

        List<CanalParseEntity<T>> result = new ArrayList<>();
        for (CanalEntry.Entry entry : canalEntries) {
            boolean isNeedSyncTable = entry.getHeader().getTableName().equals(tableName);
            if (isNeedSyncTable) {
                boolean isRow = entry.getEntryType().equals(CanalEntry.EntryType.ROWDATA);
                if (isRow) {
                    CanalEntry.RowChange rowChange = null;
                    try {
                        rowChange = CanalEntry.RowChange.parseFrom(entry.getStoreValue());
                    } catch (InvalidProtocolBufferException e) {
                        log.error("Canal Parse Update Error:{}", rowChange);
                        throw new BusException(
                                "The Canal failed to parse binLog data: " + e.getMessage(), e);
                    }
                    result.addAll(doProcessor(rowChange, clazz));
                }
            }
        }
        log.warn("Canal Parse End ...");
        return result;
    }

    private static <T> List<CanalParseEntity<T>> doProcessor(
            CanalEntry.RowChange rowChange, Class<T> clazz) {
        return rowChange.getRowDatasList().stream()
                .map(
                        data -> {
                            printDataToJson("before", data.getBeforeColumnsList());
                            printDataToJson("after", data.getAfterColumnsList());

                            // 由于删除数据的时候只存在 Before 数据
                            return parseData(
                                    !CollectionUtils.isEmpty(data.getBeforeColumnsList()) &&
                                     CollectionUtils.isEmpty(data.getAfterColumnsList()) ?
                                            data.getBeforeColumnsList() :
                                            data.getAfterColumnsList()
                                    , rowChange.getEventType(), clazz);
                        }
                ).collect(Collectors.toList());
    }


    private static void printDataToJson(String type, List<CanalEntry.Column> beforeColumnsList) {
        log.info("current processor{} data:{}", type, new JSONObject(beforeColumnsList.stream().collect(
                Collectors.toMap(
                        key -> key.getName(),
                        val -> val.getValue()
                )
        )));
    }

    private static <T> CanalParseEntity<T> parseData(
            List<CanalEntry.Column> columnList, CanalEntry.EventType eventType, Class<T> clazz) {
        Gson gson = new GsonBuilder()
                // 将Json字符串中的所有Key转换为驼峰命名的方式,否则无法进行实体转换
                .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
                // 注册一个Date类型的转换适配器
                .registerTypeAdapter(
                        java.util.Date.class,
                        (JsonDeserializer<Date>) (json, typeOfT, jsonDeserializationContext) -> DateUtil.parse(json.getAsString())
                ).create();
        return new CanalParseEntity<T>(
                gson.fromJson(new JSONObject(columnList.stream().collect(
                        Collectors.toMap(
                                key -> key.getName(),
                                val -> val.getValue()
                        )
                )).toString(), clazz), eventType
        );
    }

}

3、实现定时任务,对 SysDict 表中的数据,进行同步

package com.part.time.common.task;

import com.alibaba.otter.canal.client.CanalConnector;
import com.google.gson.Gson;
import com.part.time.common.config.redis.RedisByKeyNameCommon;
import com.part.time.common.excetion.BusException;
import com.part.time.common.utils.CanalUtils;
import com.part.time.common.utils.RedisUtils;
import com.part.time.infrastructure.entity.SysDict;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;

import javax.annotation.Resource;
import java.util.List;

/**
 * @Date:2024/10/30
 * @Author:LuKp
 * @Remark:
 */
@Slf4j
@Component
public class SyncDataTask {

    @Resource
    private CanalConnector canalConnector;
    @Resource
    private RedisUtils redisUtils;

    @Value("${canal.default-batch-size}")
    private Integer batchSize;

    /**
     * 监听A表中的数据变化
     */
    @Scheduled(cron = "* * * * * *")
    public void syncDictCache() {
        List<CanalUtils.CanalParseEntity<SysDict>> parseData = CanalUtils.sync(
                canalConnector, batchSize,
                "sys_dict", SysDict.class);
        if (!CollectionUtils.isEmpty(parseData)) {

            for (CanalUtils.CanalParseEntity<SysDict> parseDatum : parseData) {
                SysDict data = parseDatum.getData();
                switch (parseDatum.getEventType()) {

                    case INSERT:
                        redisUtils.set(
                                RedisByKeyNameCommon.joinUnicode(
                                        RedisByKeyNameCommon.dict_key,
                                        data.getId()
                                ), new Gson().toJson(data)
                        );
                        break;
                    case UPDATE:
                        redisUtils.set(
                                RedisByKeyNameCommon.joinUnicode(
                                        RedisByKeyNameCommon.dict_key,
                                        data.getId()
                                ), new Gson().toJson(data)
                        );
                        break;
                    case DELETE:
                        redisUtils.del(
                                RedisByKeyNameCommon.joinUnicode(
                                        RedisByKeyNameCommon.dict_key,
                                        data.getId()
                                )
                        );
                        break;
                    default:
                        throw new BusException("Canal data processing failed, event does not exist");
                }
            }
        }
    }
}