使用Canal同步数据到ES组件实战

626 阅读15分钟

一、背景

系统运行过程中日积月累产生大量业务数据,并且查询条件也随着业务细分更加精细。通过传统关系型数据库查询严重拖慢系统响应速度,尤其是模糊查询时更是力不从心。

为了解决查询条件繁多、模糊查询等无法有效使用索引通过关系型数据库解决的问题,需要找到一种可以存储大量数据、无需事务支持、支持模糊查询、读多写少、响应快速的搜索引擎。所以分布式可扩展的实时搜索和分析引擎Elasticsearch呼之欲出。

使用Elasticsearch,随之而来的问题就是如何把数据存储到Elasticsearch中。

同步方案:同步双写、异步双写、基于SQL抽取、基于Binlog实时同步。

同步工具:Canal、DTS、Databus、Flink、CloudCanal、Maxwell、DRD、yugong。

二、方案选型

本文所述采用通过Canal开源框架基于Binlog实时同步的方案。

Canal向master节点发送dump协议,伪装成MySQL的从节点,订阅master节点的Binlog日志。在Canal服务端解析Binlog对象转成人类可以阅读的Java对象,通过TCP或者MQ发送到Canal客户端,由Canal客户端处理之后同步到Elasticsearch中存储。

本文分析讲解基于Canal客户端实现的组件。

三、架构设计

同步方案已经确定,那么只需要做两件事情。

一、安装部署Canal服务端

二、安装部署Canal客户端

数据同步逻辑流程

架构图简略

数据同步逻辑流程组件简略

Goods服务

Goods服务是业务服务,主要产生业务数据。业务数据的CURD都会产生相应的Binlog数据。

中间件(Cannal服务端)

Cannal服务端通过监听Binlog字节文件,将Biglog字节文件转化成Java对象,通过AMQP协议发送到RocketMQ服务中。

Sync-Data服务(Cannal客户端)

Canal客户端监听RocketMQ服务消息,整理数据同步到Elasticsearch中。

同步失败可以开启失败记录功能,记录失败的信息,定时发送通知到开发人员处理。

存储

Elasticsearch存储查询数据的文档记录,通过倒排索引提供搜索引擎的数据结构支撑。

存量数据同步

业务表增加version字段,触发Binlog的产生。

数据一致性保证

数据不一致的场景:Canal服务端故障、MQ消息丢失、Canal客户端服务故障。

  1. 三个服务中间件都有相应的HA方案。
  2. 业务表version字段修改,触发Binlog的产生,重新同步数据。

四、技术要点分解

@Import

@Import注解用于添加一些自定义的bean到Spring容器中。与其他注入方式不同的是,@Import更加灵活,可以自定义实现各种处理逻辑来决定是否需要添加bean到容器中。

SpringBoot的自动化装配@EnableAutoConfiguration,就是@Import所扩展出来的注解,通过SPI机制把spring.factories中配置的类注入到Spring容器中。

@Import的四种使用方式:

  • 声明普通类
  • 声明@Configuration
  • 引入ImportSelector的实现类
  • 引入ImportBeanDefinitionRegister的实现类

本项目通过@Import注解引入ImportSelector实现类CanalTunnelImportSelector,通过CanalTunnelImportSelectorselectImports方法声明项目中需要的类。

添加@EnableDataTunnel注解,继承@Import注解,在项目中通过@EnableDataTunnel注解可以开启Date Tunnel的能力。类似@EnableAutoConfiguration@EnableAsync@EnableCaching@EnableScheduling等启动注解。

CanalTunnelImportSelector

EnableDataTunnel

@ConfigurationProperties

@ConfigurationPropertiesSpring Boot 框架中提供的一个非常有用的注解,它允许开发者将外部的配置属性(如 application.propertiesapplication.yml 文件中的属性)自动绑定到 JavaBean 对象上。这使得从配置文件中读取属性并注入到应用程序的组件中变得非常简单和直观。

@ConfigurationProperties三种使用场景:

  • 配合@Component
  • 配合@Bean
  • 配合@EnableConfigurationProperties

本项目通过配合@Component注解分别声明 CanalTunnelConfigElasticSearchConfig 两个配置类,实现自动加载配置文件中前缀 tunnelelasticsearch 的配置信息。

  • CanalTunnelConfigSpring容器启动时,扫描配置文件中 tunnel 前缀的配置信息,注入到声明的bean实例中,用于 Canal 客户端实例的启动。

CanalTunnelConfig

tunnel_config_yml

  • ElasticSearchConfigSpring容器启动时,扫描配置文件中 elasticsearch 前缀的配置信息,注入到声明的bean实例中,用于 RestHighLevelClient 客户端的实例化。但是由于本项目是作为组件来设计的,那么就需要考虑到容器本身就已经存在 RestHighLevelClient 的情况了。此种情况我们通过 @ConditionalOnMissingBean 注解来解决。

ElasticSearchConfig

elasticsearch_config_yml

@ConditionalOnMissingBean

@ConditionalOnMissingBean 是用于修饰Bean的一个注解,在项目启动时开始自动化配置注册生成Bean,当某个类型的Bean被注册之后,如果再注册相同类型的Bean就会失败,该注解会保证Spring容器中只有一个Bean类型的实例,当注册多个相同类型的Bean时,会出现异常。

@ConditionalOnMissingBean注解是基于@Conditional注解的扩展。@Conditional元注解与Condition接口配合,根据matches方法的返回结果决定是否创建bean

其它扩展注释及使用场景:

  • @Conditional:源注解。当给定的Condition::matches返回true时,则实例化当前Bean
  • @ConditionalOnBean:当给定的bean存在时,则实例化当前Bean
  • @ConditionalOnMissingBean:当给定的bean不存在时,则实例化当前Bean
  • @ConditionalOnClass:当给定的类名在类路径上存在,则实例化当前Bean
  • @ConditionalOnMissingClass:当给定的类名在类路径上不存在,则实例化当前Bean
  • @ConditionalOnExpression:当给定的表达式为true,则会实例化当前Bean
  • @ConditionalOnProperty:当给定配置的属性为true,则会实例化当前Bean
  • @ConditionalOnResource:当给定的资源存在于 classpath 中,则会实例化当前Bean

ConditionalOnMissingBean

本项目中使用@ConditionalOnMissingBean注解,匹配容器中是否存在 RestHighLevelClient 实例,如果存在那就使用容器中已声明的实例。

RestHighLevelClient

设计模式-工厂模式

工厂模式是一种创建型设计模式,它主要目的是封装对象的创建过程,使客户端代码和具体的对象实现解耦。这种模式通过一个共同的接口来创建不同的对象实例,从而降低了代码之间的耦合度,提高了系统的灵活性和可扩展性。

工厂模式的类型:

  • 简单工厂模式(Simple Factory Pattern):简单工厂模式不是一个正式的设计模式,更确切的说应该是一种编程习惯,但它是工厂模式的基础。它使用一个单独的工厂类来创建不同的对象,根据传入的参数决定创建哪种类型的对象。
  • 工厂方法模式:工厂方法模式定义了一个创建对象的接口,但由子类决定实例化哪个类。工厂方法将对象的创建延迟到子类。
  • 抽象工厂模式:抽象工厂模式提供一个创建一系列相关或互相依赖对象的接口,而无需指定它们具体的类。

简单工厂模式:

  1. 本组件使用简单工厂模式创建组件运行过程中具体的Handler处理器。
  2. 配合@Autowired注解,在HandlerFactory工厂类实例化时自动注入AbstractGeneralEntryHandler implements EntryHandler的所有子类,存储到List集合中。
  3. 通过InitializingBean::afterPropertiesSet方法将List集合转换成Map映射
  4. 客户端通过HandlerFactory::getHandlers获取合适的AbstractGeneralEntryHandler处理器集合

HandlerFactory

InitializingBean

InitializingBean是Spring提供的拓展性接口,InitializingBean接口为bean提供了属性初始化后的处理方法,它只有一个afterPropertiesSet方法,凡是继承该接口的类,在bean的属性初始化后都会执行该方法。

Spring Bean 的生命周期的扩展点:

life_cycle

本组件正是利用 Spring Bean 生命周期的 InitializingBean 扩展点,在@Autowired注解将entryHandlers属性填充之后通过afterPropertiesSet方法转换AbstractGeneralEntryHandler集合为映射,为简单工厂模式提供实例集合。

afterPropertiesSet

@MapperScan

@MapperScan注解用于指定Mapper接口所在的包路径。当Mybatis启动时,它会扫描指定包路径下的所有类,并自动识别出实现了Mapper接口的类。这样,我们就可以避免手动创建SqlSessionFactorySqlSession,简化开发过程。

但是在组件中使用会存在包路径不一致的问题。在本例中组件中Mappercenter.leon.data.tunnel.mapper 包下,而主项目的启动包是 center.leon.biz。对于组件引用方,肯定是不知道组件的Mapper路径的,所以项目中必定不会自动注入Mapper

这个问题可以通过定义一个开放类CanalTunnelFailureRecordMapperOpen,通过@Import声明注入到容器中。在CanalTunnelFailureRecordMapperOpen注入到容器的过程中,会解析该类上的@MapperScan注解,自动扫描指定的Mapper类包,将Mapper接口通过MyBatis框架构造代理对象注入到容器中。

CanalTunnelFailureRecordMapperOpen

CanalTunnelFailureRecordMapper

ApplicationRunner

ApplicationRunnerSpring Boot 提供的一个接口,与 CommandLineRunner 类似,用于在 Spring Boot 应用程序启动后执行一些任务。通过实现 ApplicationRunner 接口,我们可以在应用程序启动时执行一些初始化操作,例如加载初始数据、建立连接,或者执行其他的启动任务。

ApplicationRunner的主要作用:

  • 初始化数据:在应用启动时加载必要的初始数据,保证应用的正常运行。
  • 启动任务:在应用启动时启动一些后台任务,如监控服务、数据同步等。
  • 检查配置:在应用启动时检查配置的正确性,避免因配置错误导致应用运行异常。

CommandLineRunnerApplicationRunner的作用是相同的。不同之处在于CommandLineRunner接口的run()方法接收String数组作为参数,即是最原始的参数,没有做任何处理;而ApplicationRunner接口的run()方法接收ApplicationArguments对象作为参数,是对原始参数做了进一步的封装。

Spring启动时,容器刷新完成之后,提供了扩展接口CommandLineRunner或者ApplicationRunner, 执行最后的逻辑。SpringApplication在启动完成后,会执行一次所有实现了这些接口类的run方法;

本文通过实现ApplicationRunner接口,在run方法中构建RocketMQ Client、连接RocketMQ服务、启动调度线程定时拉取RocketMQ处理。

ApplicationRunner

本项目CanalClientRocketMQRunner类,加载时会通过@ConditionalOnMissingBean注解判断是否已存在CanalClientRocketMQFlatMessageRunner的bean实例,如果已经存在则优先使用,否则再将CanalClientRocketMQRunner类交给Spring容器管理。

相应地CanalClientRocketMQFlatMessageRunner类同样实现了ApplicationRunner接口,在容器刷新完成之后执行run方法,实现和CanalClientRocketMQRunner类一样的创建连接器、连接MQ服务、启动调度线程定时拉取MQ消息处理。不同的是,连接器对MQ消息的封装不同。一个是处理非扁平化消息,一个是处理扁平化消息。

当然在CanalClientRocketMQFlatMessageRunner中,同样使用@Conditional注解的扩展注解@ConditionalOnProperty,当指定的属性值和havingValue匹配时才将CanalClientRocketMQFlatMessageRunner注入到容器中。

CanalClientRocketMQFlatMessageRunner

DisposableBean

DisposableBean在Spring框架中,DisposableBean是一个接口,它定义了一个单一的方法,用于在Spring容器关闭时或一个由Spring管理的Bean不再需要时执行特定的清理操作。当一个Bean实现了DisposableBean接口,Spring容器会在销毁该Bean之前调用其destroy()方法。这样设计的主要目的是为了确保那些在Bean生命周期内分配的系统资源能够得到适当的释放,避免内存泄漏或其他类型的资源浪费。

本组件配合volatile boolean running在容器销毁时调用的destroy接口中释放连接器资源。

DisposableBean

设计模式-模板方法模式

模板方法模式是一种行为设计模式,它定义了一个操作中的算法框架,将某些步骤的具体实现留给子类。通过模板方法模式,我们可以在不改变算法结构的情况下,允许子类重新定义某些步骤,从而实现代码复用和扩展。

适用场景:

  • 存在一组相似的操作,具有相同的算法结构,实现细节各不相同。
  • 不改变算法的整体结构,由子类扩展或修改某些步骤。
  • 封装算法的实现细节,暴露抽象接口供调用者使用。

怎么把大象装到冰箱里就是一个典型的模版方法模式,JUC 模块的 AQS 就使用到了模板方法模式,其中 acquire() 是模板方法。tryAcquire() 方法的具体实现去交给子类完成。

以可重入锁为例:实例化ReentrantLock并调用 lock 方法。

lock

ReentrantLock::lock默认构造的是非公平锁,调用lock时,会先通过CAS由0修改成1。

如果修改成功,意味着当前锁还没有被线程使用,直接将当前锁的独占所属线程设置成当前线程即可。

如果修改失败,也就是锁state不为0,那么就意味着有线程在使用当前锁,因为state的值表示内存语义是读的计数。

加锁失败的话,就需要调用AbstractQueuedSynchronizer#acquire 方法,通过AQS机制控制线程对锁的获取。

正式进入AQS的模版方法模式

通过AbstractQueuedSynchronizer#acquire方法进入模版方法设计模式的世界。

非公平锁NonfairSync#tryAcquire方法就是AbstractQueuedSynchronizer#tryAcquire抽象方法的具体实现。最终NonfairSync#tryAcquire方法执行的尝试获取锁逻辑其实是父类 ReentrantLock.Sync#nonfairTryAcquire尝试获取锁的逻辑。

NonfairSync

在AQS定义的加锁算法定式中:

  1. 首先通过tryAcquire尝试获取
  2. 如果获取失败,将当前抢占锁的线程封装成Node节点,放入到队列中。
  3. 最后再次尝试在队列中为当前Node节点获取锁,如果当前节点的前驱节点是head节点的话。
  4. 如果获取失败,将会park当前线程,直到其他线程释放锁然后unpark当前线程。

acquire

此处非公平尝试获取方法ReentrantLock.Sync#nonfairTryAcquire,本质是将当前线程设置为当前锁的独占所属线程,如果锁状态不为零但是锁的独占所属线程就是当前线程,那么锁状态加acquires,记录当前锁状态的读计数。

nonfairTryAcquire

如果模版方法tryAcquire方法获取锁失败,那么就会把当前线程封装成EXCLUSIVE模式的Node,加入到waiter队列中。 在将当前线程节点加入到waiter队列的过程中,会判断队列是否为空。如果waiter是空队列,直接就将当前线程节点作为tail节点放入waiter队列中,如果waiter非空队列,就通过enq方法,将node放入到队列中。

addWaiter

AbstractQueuedSynchronizer#enq方法会继续判断tail节点是否为空。如果为空就设置一个EXCLUSIVE模式的节点作为tail节点,将当前线程节点作为tail节点的后继节点放入到waiter队列中。

enq

在当前线程节点放入到waiter队列之后,会通过AbstractQueuedSynchronizer#acquireQueued方法,尝试再次获取锁或者被阻塞。 如果在进入队列之后由于waiter队列中线程节点不停的解锁释放,有可能在此处时当前线程节点即是tail节点的后继节点,那么就直接获取锁。 如果不是tail节点的后继节点或者获取锁失败,那么就需要根据AbstractQueuedSynchronizer#shouldParkAfterFailedAcquire的返回结果去调用AbstractQueuedSynchronizer#parkAndCheckInterrupt方法park当前线程,直到其他线程释放锁时unpark当前线程,继续进入争抢锁的过程中。

acquireQueued

在AQS机制中,除了tryAcquire是由子类实现的AQS中的抽象方法,其他方法及调用顺序也就是算法逻辑,统一都在AQS中,就像是AQS是一个模版,定义一套算法,算法可扩展或是可变更的细节由具体的子类实现。这种类结构关系构建的思想就是模版方法设计模式。

Canal消息处理模版方法设计模式

本项目中通过定义EntryHandler接口暴露调用方法handle,在抽象类AbstractGeneralEntryHandler中实现算法逻辑,将需要扩展的算法步骤延迟到具体类中实现。

EntryHandler#handle定义Canal消息体的统一处理入口,通过AbstractGeneralEntryHandler封装算法逻辑,然后抽象出算法自定义步骤。

EntryHandler

通过AbstractGeneralEntryHandler抽象模版类,将算法封装起来。根据消息类型选择处理消息的逻辑,可以在消息处理前和处理后进行额外的消息处理,将消息真正的处理延迟到具体类中实现。

AbstractGeneralEntryHandler

五、接入组件

maven引入同步组件依赖

<dependency>
    <groupId>center.leon.componet</groupId>
    <artifactId>data-tunnel</artifactId>
    <version>1.0.0-SNAPSHOT</version>
</dependency>

主启动程序

启动类使用@EnableDataTunnel注解,启用数据同步能力。

/**
 * @author leon
 */
@EnableDataTunnel
@SpringBootApplication
public class EasyhomeGoodsTunnelCenterApplication {

    public static void main(String[] args) {
        SpringApplication.run(EasyhomeGoodsTunnelCenterApplication.class, args);
    }

}

实现处理器

实现AbstractGeneralEntryHandler抽象模版类,实现相应的抽象方法即可。

处理器示例。id表示所有库表都能处理,Order注解将它加入到第一个处理项中,增删改方法提供日志打印。


/**
 * @author : Leon on XXM Mac
 * @since : create in 2024/4/15 11:48 AM
 */
@Slf4j
@Order(1)
@Component
public class TunnelLogHandler extends AbstractGeneralEntryHandler {

    @Override
    protected String getId() {
        return "*.*.*";
    }

    @Override
    protected void handleInsert(CanalEntry.Header header, List<CanalEntry.Column> afterColumns) throws Exception {
        log.info("TunnelLogHandler insert schema : {}, table : {}", header.getSchemaName(), header.getTableName());
    }

    @Override
    protected void handleUpdate(CanalEntry.Header header, List<CanalEntry.Column> beforeColumns, List<CanalEntry.Column> afterColumns) throws Exception {
        log.info("TunnelLogHandler update schema : {}, table : {}", header.getSchemaName(), header.getTableName());
    }

    @Override
    protected void handleDelete(CanalEntry.Header header, List<CanalEntry.Column> beforeColumns) throws Exception {
        log.info("TunnelLogHandler delete schema : {}, table : {}", header.getSchemaName(), header.getTableName());
    }
}

告警任务

AlarmProcessorJob

实现AbstractFailureAlarmProcessorBasic抽象模版类,实现相应的抽象方法即可。

开启数据同步失败告警通知能力,实现processorName方法,提供告警名称。

/**
 * @author : Leon on XXM Mac
 * @since : create in 2024/4/18 12:07 PM
 */
@Component
public class AlarmProcessorJob extends AbstractFailureAlarmProcessorBasic {
    @Override
    public String processorName() {
        return "GoodsTunnel";
    }
}

六、结语

数据同步组件github地址:github.com/EASYHOME-DO…