Canal搭配RocketMQ同步MySQL和Elasticsearch

一、技术介绍

Elasticsearch 是一个分布式、RESTful 风格的搜索和数据分析引擎,基于 Apache Lucene 构建,提供了强大的全文搜索、结构化搜索和数据分析功能。

Elasticsearch 基础概念:

名称MySQL 对应概念解释
索引(Index)数据库(Database)Elasticsearch 中的索引(Index)类似于数据库(Database),每个索引存储一类相似(Type)的数据。
类型(Type)8.0 及以上删除表(Table)类型是用来定义数据结构,是在索引中对文档进行分类的一种方式。每个类型都有自己的映射(Mapping),定义了文档中包含的字段和它们的类型。
文档(Document)行记录(Row)是存储在索引(Index)中的基本数据单位(最小),一个文档就是一条记录。类似于 MySQL 中的一行。每个文档都是一个 JSON 格式的数据对象,它包含了实际的数据。
字段(Feild)列(Column)一个 Document 里面有多个 Field,类似于 MySQL 中一行记录包含多个列
映射(Mapping)表结构(Schema)每个索引都有一个对应的 Mapping。主要用于定义文档中的字段类型和属性。

二、安装教程

注意:Elasticsearch 需要 JDK1.8 及以上 JAVA 环境。(7.0 后内置了 JAVA 环境,无需再配置)

考虑到 Elasticsearch 对依赖包、springboot 和 jdk 的版本的要求很严格,且 Elasticsearch 版本迭代速度很快,可供 spring 整合的依赖包跟不上速度,因此选用网上推荐的 7.10.0 版本。

2.1 Windows 环境 (Windows11)

2.1.1 下载安装

2.1.2 启动服务

  • 进入 bin 目录下,双击执行 elasticsearch.bat

    在这里插入图片描述

    等待 cmd 中出现 started,浏览器访问 http://localhost:9200/ 测试,如下图则说明启动成功。

    在这里插入图片描述

    若浏览器出现 localhost未发送任何数据,且 cmd 中提示

    [WARN ][o.e.h.n.Netty4HttpServerTransport] [DESKTOP-T619QSF] received plaintext http traffic on an https channel, closing connection Netty4HttpChannel{localAddress=/[0:0:0:0:0:0:0:1]:9200, remoteAddress=/[0:0:0:0:0:0:0:1]:54474}
    

    修改安装目录 config 下的 elasticsearch.yml 文件,将 xpack.security.enabled: true 修改为 xpack.security.enabled: false,关闭 cmd,重启服务。

  • 安装 windows 服务,进入 bin 目录,执行以下命令:

    .\elasticsearch-service.bat install
    

    使用管理员权限打开 cmd,执行下列命令设置服务开机自启

    sc config elasticsearch-service-x64 start=auto
    

2.1.3 配置

Elasticsearch 的配置存放在 config/elasticsearch.yml 中,可配置集群名称、端口号、文件路径等等,除默认配置外,需要额外配置的参数如下:

# 设置集群名称,确保同一集群内节点名称一致
cluster.name: your_cluster_name

# 节点的唯一标识
node.name: node_name_01

# 设置绑定的主机名或IP地址,允许远程访问(如需)
network.host: 0.0.0.0

# 数据文件和日志文件的存储路径
path.data: D:/Elasticsearch/data
path.logs: D:/Elasticsearch/logs

除此之外,Elasticsearch 比较吃内存,还应该根据项目需求规模修改 jvm 配置,更多配置详情可参考这篇博客

2.1.4 安装分词器

Elasticsearch 默认不支持中文分词,因此需要下载安装额外的分词器。

2.2 Linux 环境(CentOS7)

2.2.1 下载安装

  • 下载安装包(注意查看当前官方最新版本)

    wget https://artifacts.elastic.co/downloads/elasticsearch/elasticsearch-7.10.0-x86_64.rpm
    
  • 安装

    sudo rpm --install elasticsearch-7.10.0-x86_64.rpm
    
  • 设置开机自启

    sudo systemctl enable elasticsearch
    sudo systemctl daemon-reload
    

2.2.2 启动服务

  • 启动服务

    sudo systemctl start elasticsearch
    

    执行命令 sudo systemctl status elasticsearch,若出现 Active: active(running),说明服务启动成功。

  • 测试访问

    curl -X GET "localhost:9200"
    

    若出现下图,则说明可以正常访问

    外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

    若提示 curl: (52)Empty reply from server,则执行命令进入 elasticsearch.yml 文件

    vi /etc/elasticsearch/elasticsearch.yml
    

    i 进入编辑模式,将 xpack.security.enabled: true 修改为 xpack.security.enabled: false,按 Esc 退出编辑,输入 :wq 保存并退出。执行命令 sudo systemctl restart elasticsearch 重启服务。

2.2.3 配置

Elasticsearch 的配置存放在 elasticsearch.yml 中,可配置集群名称、端口号、文件路径等等,除默认配置外,需要额外配置的参数如下:

# 设置集群名称,确保同一集群内节点名称一致
cluster.name: your_cluster_name

# 节点的唯一标识
node.name: node_name_01

# 设置绑定的主机名或IP地址,允许远程访问(如需)
network.host: 0.0.0.0

# 数据文件和日志文件的存储路径
path.data: 
path.logs: 

除此之外,Elasticsearch 比较吃内存,还应该根据项目需求规模修改 jvm 配置。根据网上说法,Linux 下可能还需要根据并发数增加可打开文件数量,以及增加线程可用数量,更多配置详情可参考这篇博客

2.2.4 安装分词器

# 进入plugins目录下
cd /usr/share/elasticsearch/plugins

# 创建新目录用于存放分词器
sudo mkdir ik

# 下载ik分词器(注意对应版本)
sudo wget https://github.com/medcl/elasticsearch-analysis-ik/releases/download/v7.10.0/elasticsearch-analysis-ik-7.10.0.zip

# 解压至ik目录中
sudo unzip elasticsearch-analysis-ik-7.10.0.zip -d ik

#重启服务
sudo systemctl restart elasticsearch

三、Spring Boot 整合 Elasticsearch

Spring Boot 整合 Elasticsearch 的几种常见方式包括:

  1. Spring Data Elasticsearch:
    使用 Spring Data 提供的抽象层和注解简化与 Elasticsearch 的交互。
  2. Elasticsearch Rest Client:
    直接使用 Elasticsearch 提供的 Java REST 客户端,与 Elasticsearch 进行低级别的通信。
  3. Elasticsearch Transport Client:
    使用原生的 Transport Client(请注意,此方法在最新版本中已被弃用,不再推荐)。

这里选用第一种方式,使用 Spring Boot 提供的框架。Spring Boot 与 Elasticsearch 整合的官方文档:Spring Data Elasticsearch

但受限于 Springboot2.2.0 的版本,使用的客户端版本低于 ES7.10.0,因此部分较新的 api 不可使用。

3.1 依赖导入与配置

  • maven 仓库复制最新依赖并导入。

    <!-- https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-data-elasticsearch -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-elasticsearch</artifactId>
    </dependency>
    
  • application.yml 配置

    # Elasticsearch
    spring:
      elasticsearch:
        username: elastic # 如果有的话
        password: 123456 # 如果有的话
        uris: http://127.0.0.1:9200
        connectTimeout: 1000 # http连接超时时间
        socketTimeout: 30000 # socket连接超时时间
        connectionRequestTimeout: 500 # 获取连接的超时时间
        maxConnTotal: 100 # 最大连接数
        maxConnPerRoute: 100 # 最大路由连接数
        executeTimeout: 8 # 任务最长可执行时间 (单位:小时)
    

注意,当在项目中同时启动 Redis 和 Elasticsearch 时,会出现获取不到 Netty 处理器的冲突,解决方案:在启动类中添加如下代码:

@SpringBootApplication
@EnableAutoConfiguration
@EnableScheduling
public class DemoApiApplication {

    // 解决ES和Redis同时启动,获取不到Netty处理器的冲突
    @PostConstruct
    public void init() {
        System._setProperty_("es.set.netty.runtime.available.processors", "false");
    }

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

3.2 使用说明

3.2.1 注意事项

  • 在插入数据时,spring 会根据插入的对象自动创建索引和映射。

  • IK 分词器有两种类型:
    ik_max_word:会尽可能多地将词语进行切分,适用于对精确性要求不高的场景。
    ik_smart:会尽可能少地进行切分,更倾向于将词语切分为较为完整的词组。

  • text 类型不支持排序,需要对字符串类型的字段进行排序可选用 keyword 类型。使用 keyword 类型时,不应该设置 analyzersearch_analyzer 参数。

  • Spring Data ElasticSearch 有下边这几种方法操作 Elasticsearch:

    1. ElasticsearchRepository(传统的方法,可以使用)
    2. ElasticsearchRestTemplate(推荐使用。基于 RestHighLevelClient)
    3. ElasticsearchTemplate(ES7 中废弃,不建议使用。基于 TransportClient)
    4. RestHighLevelClient(推荐度低于 ElasticsearchRestTemplate,因为 API 不够高级)
    5. TransportClient(ES7 中废弃,不建议使用)
      下面将基于 ElasticsearchRepositoryElasticsearchRestTemplate 进行使用说明讲解。
  • 索引在创建之后无法修改 Mapping,只能重新给索引重新追加字段,或者删除索引(会同时删除数据)后重新创建。

3.2.2 ElasticsearchRepository 方式

ElasticsearchRepository 是 Spring Data Elasticsearch 提供的一个接口,用于与 Elasticsearch 进行交互。它扩展自 ElasticsearchCrudRepositoryPagingAndSortingRepository,提供了对 Elasticsearch 索引中数据的基本 CRUD(创建、读取、更新、删除)操作以及分页和排序功能。

  1. 首先创建一个数据类。(首次插入数据时,会自动创建索引和映射)

    /**
     * indexName:索引的名称。
     * createIndex:是否在启动时自动创建索引,默认是 true。
     * shards:分片数量。
     * replicas:副本数量。
     */
    @Document(indexName = "question")
    @Data
    public class QuestionDO implements Serializable {
    
        /**
         * type:字段的类型,如 Text, Keyword, Integer, Date等。
         * name:在索引中对应的字段名,默认是属性名。
         * store:是否将字段单独存储在 Elasticsearch 中,可以单独检索,默认是false。
         * index:是否索引该字段,默认是 true。
         * analyzer:用于存入文本的分析器。
         * searchAnalyzer:用于搜索时的分析器。
         */
    
        //此项作为id,不会写到_source里边
        @Id
        private Long id;
    
        @Field(store = true, type = FieldType.Keyword)
        private String type;
    
        @Field(store = true, analyzer = "ik_max_word", searchAnalyzer = "ik_smart", type = FieldType.Text)
        private String question;
    
        @Field(store = true, analyzer = "ik_max_word", searchAnalyzer = "ik_smart", type = FieldType.Text)
        private String answer;
    
        @Field(store = true, analyzer = "ik_max_word", searchAnalyzer = "ik_smart", type = FieldType.Text)
        private String tag;
    
        @Field(store = true, type = FieldType.Date, format = DateFormat.custom, pattern = "yyyy-MM-dd HH:mm:ss || ||yyyy-MM-dd'T'HH:mm:ssZ")
        @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
        private Date time;
    
    }
    

    ES7.10.0 中(不同的版本文档的格式可能不同),插入的文档格式如下:

    在这里插入图片描述

  2. 编写 Dao 层

    public interface QuestionMapper extends ElasticsearchRepository<QuestionDO, Long> {
        // 自定义查询方法可以在这里定义
    
        // 简单的组合、范围等查询可直接通过属性名命中,无需实现直接调用
        List<QuestionDO> findByAnswerContaining(String answer);
    
        List<QuestionDO> findByQuestionAndTag(String question, String tag);
    
        List<QuestionDO> findByQuestionOrTag(String question, String tag);
    
        List<QuestionDO> findByIdBetween(long start, long end);
    
        // 复杂的可使用注解(无法实现动态查询),或java代码构建QueryBuilder和SearchQuery来实现
        @Query("{\"bool\": {\"must\": [],\"filter\": [{\"term\": {\"id\": \"?0\"}}]}}")
        List<QuestionDO> findById(int id);
    }
    

    注意,跟 Spring Data JPA 类似,spring data elsaticsearch 提供了自定义方法的查询方式:在 Repository 接口中自定义方法,spring data 根据方法名,自动生成实现类,方法名必须符合一定的规则,如下表所示:
    在这里插入图片描述

  3. 编写测试类

  • 添加数据

    @Autowired
    private QuestionMapper mapper;
    
    /**
     * 测试增加数据
     */
    @org.junit.Test
    public void add() {
    
        // 添加一条数据
        QuestionDO questionDO = new QuestionDO();
        questionDO.setId(0L);
        questionDO.setQuestion("理科学科第0题");
        questionDO.setAnswer("第0题答案解析");
        questionDO.setType("数学");
        questionDO.setTag("数学科目应用题");
        questionDO.setTime(new Date());
        mapper.save(questionDO);
    
        // 与第0题id相同,覆盖插入
        questionDO = new QuestionDO();
        questionDO.setId(0L);
        questionDO.setQuestion("理科学科第0题");
        questionDO.setAnswer("第0题答案解析");
        questionDO.setType("物理");
        questionDO.setTag("物理科目应用题");
        questionDO.setTime(new Date());
    
        mapper.save(questionDO);
    
        // 批量添加
        String[][] tags = new String[][]
                {{"数学科目应用题", "数学科目选择题"}, {"物理科目应用题", "物理科目选择题"}};
        List<QuestionDO> questionDOList = new ArrayList<>();
        for (int i = 0; i < 100; i++) {
            questionDO = new QuestionDO();
            questionDO.setId((long) i + 1);
            questionDO.setQuestion("理科学科第" + (i + 1) + "题");
            questionDO.setAnswer("第" + (i + 1) + "题答案");
            questionDO.setType(i % 2 == 0 ? "数学" : "物理");
            questionDO.setTag(tags[i % 2][new Random().nextInt(2)]);
            questionDO.setTime(getRandomDate(2024, i % 12 + 1));
            if (questionDO.getTag().contains("应用")) questionDO.setAnswer(questionDO.getAnswer() + "解析");
            questionDOList.add(questionDO);
        }
        mapper.saveAll(questionDOList);
    }
    
  • 查找数据

    • 查询全部内容

      @Autowired
      private QuestionMapper mapper;
      
      /**
       * 查询全部内容
       */
      @org.junit.Test
      public void find1() {
          // 查询全部内容并根据id和type字段升序排序
          Iterable<QuestionDO> result = mapper.findAll(Sort.by("id", "type").ascending());
          for (QuestionDO dto : result) System.out.println(dto);
      }
      
    • 根据字段进行简单的查询

        @Autowired
        private QuestionMapper mapper;
        
        /**
         * 根据字段进行简单的命中查询
         */
        @org.junit.Test
        public void find2() {
        
            // 筛选出所有answer包含"解析"的记录
            List<QuestionDO> result = mapper.findByAnswerContaining("解析");
            for (QuestionDO dto : result) System.out.println(dto);
        
            // 筛选出question包含"理科",同时tag包含"学科"的记录
            result = mapper.findByQuestionAndTag("理科", "学科");
            for (QuestionDO dto : result) System.out.println(dto);
        
        
            // 筛选出question包含"学科",或者tag包含"理科"的记录
            result = mapper.findByQuestionOrTag("学科", "理科");
            for (QuestionDO dto : result) System.out.println(dto);
        
            // 筛选出id在[5, 10]区间内的记录
            result = mapper.findByIdBetween(5L, 10L);
            for (QuestionDO dto : result) System.out.println(dto);
        }
        ```
      
    • 通过ElasticsearchRepository的以下三个方法进行查询,即构建QueryBuilderSearchQuery进行复杂的查询:
      在这里插入图片描述

      @Autowired
      private QuestionMapper mapper;
      
      /**
       * 使用QueryBuilders对象进行复杂的查询
       */
      @org.junit.Test
      public void find3() {
      
          // 模糊查询type包含理的记录
          WildcardQueryBuilder queryBuilder = QueryBuilders.wildcardQuery("type", "*理*");
          Iterable<QuestionDO> search = mapper.search(queryBuilder);
          for (QuestionDO dto : search) System.out.println(dto);
      
          // 查询条件
          BoolQueryBuilder queryBuilders = QueryBuilders.boolQuery();
          queryBuilders.must(QueryBuilders.matchPhraseQuery("question", "题"));
          // 分页,每页2两记录,返回第一页
          Pageable pageable = PageRequest.of(0, 2, Sort.Direction.ASC, "id");
          search = mapper.search(queryBuilders, pageable);
          for (QuestionDO dto : search) System.out.println(dto);
      }
      

      关于QueryBuilderSearchQuery更详细的介绍可参考3.2.3中的梳理和使用

  • 修改数据

    修改es中存在的数据 使用的仍然是save方法,当id一样时,会覆盖原来的数据,因此可用于修改数据。

  • 删除数据

    @Autowired
    private QuestionMapper mapper;
    /**
     * 测试删除数据
     */
    @org.junit.Test
    public void delete() {
        // 删除指定一条数据
        QuestionDO questionDO = new QuestionDO();
        questionDO.setId(0L);
        questionDO.setQuestion("理科学科第0题");
        questionDO.setAnswer("第0题答案解析");
        questionDO.setType("数学");
        questionDO.setTag("数学科目应用题");
        mapper.delete(questionDO);
    
        // 根据id值删除一条数据
        mapper.deleteById(0L);
    
        // 删除多条或全部数据
        List<QuestionDO> list = new ArrayList<>();
        mapper.deleteAll(list);
        mapper.deleteAll();
    }
    

关于ElasticsearchRepository的更多使用方法可参考:
最完整的springboot2.2.x.RELEASE整合springDataElasticsearch 7.6.2

3.2.3 ElasticsearchRestTemplate 方式

ElasticsearchRestTemplate 是 Spring Data 提供的一个模板类,用于以编程方式与 Elasticsearch 进行交互。它类似于 Spring 的 JdbcTemplateMongoTemplate,提供了对 Elasticsearch 的低级别操作。

  • 添加数据

    注意ElasticsearchRepository 中的 saveAll 方法适合批量添加少量数据,要完成超大数据的插入就要用 ES 自带的 bulk 了(即这里的 bulkIndex()),可以迅速插入百万级的数据。

    @Autowired
    private ElasticsearchRestTemplate template;
    
    /**
     * 添加数据
     */
    @Test
    public void add() {
    
        // 插入一条数据,id相同会覆盖
        QuestionDO questionDO = new QuestionDO();
        questionDO.setId(0L);
        questionDO.setQuestion("文科学科第0题");
        questionDO.setAnswer("第0题答案解析");
        questionDO.setType("地理");
        questionDO.setTag("地理科目应用题");
        questionDO.setTime(new Date());
        IndexQuery query = new IndexQueryBuilder()
                .withObject(questionDO)
                .build();
        template.index(query);
    
        // 批量插入数据
        List<QuestionDO> questionDOS = new ArrayList<>();
        for (int i = 100; i < 200; i++) {
            questionDO = new QuestionDO();
            questionDO.setId((long) i + 1);
            questionDO.setQuestion("文科学科第" + (i + 1) + "题");
            questionDO.setAnswer("第" + (i + 1) + "题答案解析");
            questionDO.setType("地理");
            questionDO.setTag("地理科目应用题");
            questionDO.setTime(new Date());
            questionDOS.add(questionDO);
        }
        List<IndexQuery> indexQueries = new ArrayList<>();
        for (QuestionDO dto : questionDOS) {
            IndexQuery indexQuery = new IndexQueryBuilder()
                    .withId(dto.getId().toString())
                    .withObject(dto)
                    .build();
            indexQueries.add(indexQuery);
        }
        template.bulkIndex(indexQueries);
    }
    
  • 查询数据

    ElasticsearchRepository 构建 QueryBuilderSearchQuery 类似,ElasticsearchRestTemplate 同样是通过构建各种 SearchQuery 条件查询。

    这里,我们梳理一下这些查询构建类之间的关系。
    在这里插入图片描述

    从这里可以看出 SearchQuery 是一个接口,有一个实现类叫 NativeSearchQuery,实际使用中,我们的主要任务就是构建 NativeSearchQuery 来完成一些复杂的查询。

    要构建 NativeSearchQuery,主要需要以下几个构造参数:

    public NativeSearchQuery(QueryBuilder query, QueryBuilder filter, List<SortBuilder> sorts, Field[] highlightFields) {
            this.query = query;
            this.filter = filter;
            this.sorts = sorts;
            this.highlightFields = highlightFields;
        }
    

    可以看出来,大概是需要 QueryBuilderfilter,和排序的 SortBuilder 以及高亮的字段。一般情况下,我们不是直接使用 new NativeSearchQuery() 的方法,而是通过构建 NativeSearchQueryBuilder 来完成 NativeSearchQuery 的构建。如下:

    NativeSearchQueryBuilder
        .withQuery(QueryBuilder1)
        .withFilter(QueryBuilder2)
        .withSort(SortBuilder1)
        ...
        ...
        .withXXXX()
        .build();
    

    在这里面,QueryBuilder 主要用来构建查询条件、过滤条件,SortBuilder 主要是构建排序。下面列举部分实现类:

    在这里插入图片描述
    在这里插入图片描述

    要构建 QueryBuilder,我们可以使用工具类 QueryBuilders,里面有大量的方法用来完成各种各样的 QueryBuilder 的构建,字符串的、Boolean 型的、match 的、地理范围的等等。

    要构建 SortBuilder,可以使用 SortBuilders 来完成各种排序。

    然后就可以通过 NativeSearchQueryBuilder 来组合这些 QueryBuilderSortBuilder,再组合分页的参数等等,最终就能得到一个 SearchQuery 了。

    下面列举部分使用方法:

    • 通过question和answer查询记录。

      @Autowired
      private ElasticsearchRestTemplate template;
      
      @Test
      public void find1() {
      
          NativeSearchQueryBuilder query = new NativeSearchQueryBuilder();
      
          // 创建一个 BoolQueryBuilder 对象,用于构建布尔查询
          BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery();
      
          // 添加一个 must 子查询,对“文科学科第10题”进行分词后匹配question进行搜索
          boolQueryBuilder.must(QueryBuilders.matchQuery("question", "文科学科第10题"));
      
          // 添加一个 must 子查询,对“第10题答案解析”进行分词后匹配answer进行搜索
          boolQueryBuilder.must(QueryBuilders.matchQuery("answer", "第10题答案解析"));
      
          query.withQuery(boolQueryBuilder).build();
      
          List<QuestionDO> questionDOS = template.queryForList(query.build(), QuestionDO.class);
          for (QuestionDO questionDO : questionDOS) System.out.println(questionDO);
      }
      
    • 通过tag和type来查找记录,分页,并根据time倒序排序。

      @Test
      public void find2() {
      
          NativeSearchQueryBuilder query = new NativeSearchQueryBuilder();
          BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery();
      
          // 根据内容匹配
          boolQueryBuilder.must(QueryBuilders.matchQuery("type", "物理"));
      
          boolQueryBuilder.must(QueryBuilders.matchQuery("tag", "应用题"));
      
          query.withQuery(boolQueryBuilder);
      
          // 分页
          PageRequest pageRequest = PageRequest.of(0, 2);
          query.withPageable(pageRequest);
      
          // 降序排序
          query.withSort(SortBuilders.fieldSort("time").order(SortOrder.DESC));
      
          AggregatedPage<QuestionDO> qaDtos = template.queryForPage(query.build(), QuestionDO.class);
          for (QuestionDO questionDO : qaDtos) System.out.println(questionDO);
          // AggregatedPage<T>除了总页数外还包含更多的分页信息
          System.out.println(qaDtos.getTotalPages());
      }
      
    • 去重。根据tag查询题目,根据type去重。

      /**
       * 根据type去重
       */
      @Test
      public void find3() {
      
          NativeSearchQueryBuilder query = new NativeSearchQueryBuilder();
          BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery();
      
          //根据内容匹配
          boolQueryBuilder.must(QueryBuilders.matchQuery("tag", "应用题"));
      
          query.withQuery(boolQueryBuilder);
      
          //分页
          PageRequest pageRequest = PageRequest.of(0, 3);
          query.withPageable(pageRequest);
      
          //排序
          query.withSort(SortBuilders.fieldSort("time").order(SortOrder.DESC));
      
          // 去重的字段不能是text类型。所以,mapping要有一个类型为keyword的字段,这里通过type去重。
          query.withCollapseField("type");
      
          AggregatedPage<QuestionDO> qaDtos = template.queryForPage(query.build(), QuestionDO.class);
          for (QuestionDO questionDO : qaDtos) System.out.println(questionDO);
      }
      
    • 聚合统计。查询所有科目(type)中题型是选择题(tag)的数量。

      /**
       * 聚合,统计同一type的数量
       */
      @Test
      public void find4() {
          // 创建一个查询构建器
          NativeSearchQueryBuilder query = new NativeSearchQueryBuilder();
      
          // 创建一个布尔查询构建器
          BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery();
      
          // 添加一个must子查询,要求 "tag" 字段必须匹配 "选择题"
          boolQueryBuilder.must(QueryBuilders.matchQuery("tag", "选择题"));
      
          // 设置布尔查询构建器到查询构建器中
          query.withQuery(boolQueryBuilder);
      
          // 添加聚合操作:按照 "type" 字段分组统计文档数量
          query.addAggregation(AggregationBuilders.terms("count").field("type"));
      
          // 设置查询结果的源过滤器为空,即返回所有字段
          query.withSourceFilter(new FetchSourceFilterBuilder().build());
      
          // 执行查询并获取聚合结果的分页对象
          AggregatedPage<QuestionDO> qaDtos = template.queryForPage(query.build(), QuestionDO.class);
      
          // 获取聚合结果
          Aggregations aggregations = qaDtos.getAggregations();
          assert aggregations != null;
      
          // 获取名为 "count" 的聚合
          ParsedStringTerms count = aggregations.get("count");
      
          // 创建一个Map来存储每个类型(type)及其文档数量的映射关系
          Map<String, Long> map = new HashMap<>();
          for (Terms.Bucket bucket : count.getBuckets()) {
              // 将每个桶(bucket)的类型(type)和文档数量(docCount)存入map中
              map.put(bucket.getKeyAsString(), bucket.getDocCount());
          }
              // 遍历输出每个类型(type)及其文档数量
          for (Map.Entry<String, Long> stringLongEntry : map.entrySet()) {
              System.out.println(stringLongEntry.getKey());   // 类型(type)
              System.out.println(stringLongEntry.getValue()); // 对应的文档数量
          }
      }
      
    • 嵌套聚合。查询所有科目(type)中题型是选择题(tag)的数量,再查出所有科目的最新的题目时间。

      @Test
      public void find5() {
          // 创建一个查询构建器
          NativeSearchQueryBuilder query = new NativeSearchQueryBuilder();
      
          // 创建一个布尔查询构建器
          BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery();
      
          // 添加一个must子查询,要求 "tag" 字段必须匹配 "选择题"
          boolQueryBuilder.must(QueryBuilders.matchQuery("tag", "选择题"));
      
          // 设置布尔查询构建器到查询构建器中
          query.withQuery(boolQueryBuilder);
      
          // 添加聚合操作:按照 "type" 字段分组统计文档数量,并计算每组的最大时间
          query.addAggregation(AggregationBuilders.terms("count").field("type")
                  .subAggregation(AggregationBuilders.max("latest_time").field("time"))
          );
      
          // 设置查询结果的源过滤器为空,即返回所有字段
          query.withSourceFilter(new FetchSourceFilterBuilder().build());
      
          // 执行查询并获取聚合结果的分页对象
          AggregatedPage<QuestionDO> qaDtos = template.queryForPage(query.build(), QuestionDO.class);
      
          // 获取聚合结果
          Aggregations aggregations = qaDtos.getAggregations();
          assert aggregations != null;
      
          // 获取名为 "count" 的聚合
          ParsedStringTerms count = aggregations.get("count");
      
          // 创建一个Map来存储每个类型(type)及其对应的文档数量和最大时间的映射关系
          Map<String, Map<String, Object>> map = new HashMap<>();
          for (Terms.Bucket bucket : count.getBuckets()) {
              // 创建一个Map来存储当前桶(bucket)的数据
              Map<String, Object> objectMap = new HashMap<>();
      
              // 存储当前桶(bucket)的文档数量
              objectMap.put("docCount", bucket.getDocCount());
      
              // 获取并存储当前桶(bucket)的最大时间聚合结果
              ParsedMax latestTime = bucket.getAggregations().get("latest_time");
              objectMap.put("latestCreateTime", latestTime.getValueAsString());
      
              // 将当前类型(type)及其对应的数据放入最终的Map中
              map.put(bucket.getKeyAsString(), objectMap);
      
              // 输出当前桶(bucket)的文档数量和最大时间
              System.out.println(objectMap.get("docCount"));
              System.out.println(objectMap.get("latestCreateTime"));
          }
      }
      

      关于更多的QueryBuilders方法可参考这篇文章Elasticsearch的Java客户端库QueryBuilders查询方法大全

  • 修改数据

    • 修改单个文档数据
      相同 id 的记录会覆盖,重新插入相同 id 文档即可。

    • 修改单个文档的部分数据,根据 id 修改文档的 type 和 tag。

      /**
       * 修改id=0的文档的type和tag
       */
      @Test
      public void update1() {
          long id = 0L;
          // 构建要更新的文档数据
          Map<String, Object> updateObject = new HashMap<>();
          updateObject.put("type", "历史");
          updateObject.put("tag", "历史科目应用题");
          UpdateRequest request = new UpdateRequest();
          request.doc(updateObject);
          // 使用 UpdateQueryBuilder 构建更新请求
          UpdateQuery updateQuery = new UpdateQueryBuilder()
                  .withId(Long.toString(id))                    // 文档的 ID
                  .withUpdateRequest(request)   // 更新的内容
                  .withClass(QuestionDO.class)
                  .build();
          // 执行更新操作
          UpdateResponse update = template.update(updateQuery);
          System.out.println(update.status());
      }
      
    • 修改多条数据,根据id修改多条数据的type和tag。

      /**
       * 根据id修改多条文档的type和tag
       */
      @Test
      public void update2() {
      
          List<UpdateQuery> list = new ArrayList<>();
          for (int i = 100; i < 200; i++) {
      
              // 构建要更新的文档数据
              Map<String, Object> updateObject = new HashMap<>();
              updateObject.put("type", "历史");
              updateObject.put("tag", "历史科目应用题");
              UpdateRequest request = new UpdateRequest();
              request.doc(updateObject);
      
              // 使用 UpdateQueryBuilder 构建更新请求
              UpdateQuery updateQuery = new UpdateQueryBuilder()
                      .withId(Long.toString(i))                    // 文档的 ID
                      .withUpdateRequest(request)   // 更新的内容
                      .withClass(QuestionDO.class)
                      .build();
      
              list.add(updateQuery);
          }
      
          template.bulkUpdate(list);
      }
      
  • 删除数据

    • 根据 id 删除文档。

      /**
       * 根据id删除单个文档
       */
      @Test
      public void delete1(){
          String delete = template.delete(QuestionDO.class, Long.toString(199L));
          System.out.println(delete);
      }
      
    • 根据条件删除文档,通过构建DeleteQuery。

      /**
       * 根据tag删除文档
       */
      @Test
      public void delete2(){
          DeleteQuery deleteQuery = new DeleteQuery();
          deleteQuery.setQuery(QueryBuilders.termQuery("type", "地理"));
          template.delete(deleteQuery, QuestionDO.class);
      }
      

关于ElasticsearchRestTemplate的更多使用方法可参考:
ElasticsearchTemplate常用API使用,看这一篇就够了SpringBoot中ElasticsearchRestTemplate的使用示例

四、MySQL 同步 Elasticsearch

在搜索业务中,一般使用 ES 进行模糊搜索,而数据存放在 MySQL 中,所以需要将 MySQL 数据同步到 ES 中,以保证数据的一致性。

保证数据同步的方法一般包括同步双写异步双写定时任务数据订阅,下面是这几种方案的介绍和优劣。

  • 同步双写

    在写入MySQL的同时,直接也同步往ES里写一份数据。
    优点:

    • 占用资源少、不用引入第三方中间件

    缺点:

    • 业务耦合,业务中耦合大量数据同步代码
    • 影响性能,写入两个存储,响应时间变长
    • 不便扩展:搜索可能有一些个性化需求,需要对数据进行聚合,这种方式不便实现
  • 异步双写

    先把数据写入数据库,再将数据存入消息队列中进行解耦。
    优点:

    • 低解耦,实现难度一般

    缺点:

    • 引入了新的组件和服务,依赖MQ的可靠性
  • 定时任务

    通过定时任务间隔型的将 MySQL 数据同步至 ES 中。

    优点:

    • 实现比较简单

    缺点

    • 实时性难以保证
    • 对存储压力较大
  • 数据订阅

    通过binlog订阅实现主从同步,例如canal将client组件伪装成从库,来实现数据订阅。
    优点:

    • 业务入侵较少

    • 实时性较好

    • 完全解除服务间耦合, 可靠性较强

    缺点:

    • MySQL需要开启binlog,降低MySQL效率(忽略不计)
    • 实现复杂度高

这里选用数据订阅的方法。采用 canal 组件同步 MySQL 和 ES 的数据。

canal 会模拟 MySQL 主库和从库的交互协议,从而伪装成 MySQL 的从库,然后向 MySQL 主库发送 dump
协议,MySQL 主库收到 dump 请求会向 canal 推送 binlog,canal 通过解析 binlog 将数据同步到其他存储中去。

在这里插入图片描述

前置工作,创建示例数据库test和表格tb_demo_question:

CREATE TABLE `tb_demo_question` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `question` text NOT NULL COMMENT '问题',
  `answer` text NOT NULL COMMENT '答案',
  `type` varchar(20) NOT NULL COMMENT '类型',
  `tag` varchar(200) DEFAULT NULL COMMENT '标签',
  `time` VARCHAR(50) NOT NULL COMMENT '时间',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8 COMMENT='问答demo表格';

4.1 canal 安装与配置

在版本选择上,由于 canal1.6 及以上版本需要 jdk11,而 canal1.4 不支持 Elasticsearch7.0 版本及以上,因此选用 canal1.5 版本。

4.1.1 开启 binlog

由于 canal 是通过订阅 MySQL 的 binlog 来实现数据同步的,所以需要开启 MySQL 的 binlog 写入功能,并设置 binlog-format 为 ROW 模式。

  • 进入 MySQL 的配置文件。可通过服务,属性,查看路径,复制路径至文件管理器打开。

在这里插入图片描述

在这里插入图片描述

  • 修改 mysql 的配置文件 my.ini。修改以下属性。

    	# mysql 实例id,集群时用于区分实例
    	server-id = 1
    	# binlog日志文件路径和名称
    	log-bin = C:/ProgramData/MySQL/MySQL Server 8.0/binlogs/mysql-bin.log
    	# binlog_format:binlog日志数据保存格式
    	binlog_format = row
    	# binlog-do-db:指定开启binlog日志数据库
    	binlog-do-db = canal-demo
    

注意:一般根据情况进行指定需要同步的数据库,如果不配置则表示所有数据库均开启 Binlog。

  • 验证 binlog 是否开启成功。重启 MySQL 服务,并执行以下 SQL 语句。

    show VARIABLES like 'log_bin'
    

    在这里插入图片描述

  • 接下来需要创建一个拥有从库权限的账号,用于订阅 binlog,这里创建的账号为 canal:canal。

    CREATE USER canal IDENTIFIED BY 'canal';  
    GRANT SELECT, REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'canal'@'%';
    FLUSH PRIVILEGES;
    

4.1.2 安装配置 canal 服务端

  • 下载 canal.deployer 服务端。通过网址 https://github.com/alibaba/canal/releases 下载 1.5 版本,并解压。

    在这里插入图片描述

  • 修改 conf 目录下 canal.properties 的配置。

    # canal.port:默认端口 11111
    canal.port = 11111
    # canal.serverMode:服务模式,tcp 表示输入客户端,xxMQ输出到各类消息中间件
    canal.serverMode = tcp
    # canal.destinations:用于配置需要监控数据的数据库。如果有多个,使用,隔开
    canal.destinations = example
    

    注意:canal 可以收集多个 MySQL 数据库数据,每个 MySQL 数据库都有独立的配置文件控制。具体配置规则: conf/目录下,使用文件夹放置,文件夹名代表一个 MySQL 实例。canal.destinations 用于配置需要监控数据的数据库。如果有多个需要监控的数据库,使用 , 隔开。

  • 修改 example 目录下的文件 instance.properties 的配置。

    # canal.instance.master.address:数据库ip端口
    canal.instance.master.address=127.0.0.1:3306
    # canal.instance.dbUsername:连接mysql账号
    canal.instance.dbUsername=root
    # canal.instance.dbPassword:连接mysql密码
    canal.instance.dbPassword=root
    
  • 启动,双击启动 bin 目录下的 startup.bat 文件。可通过查看 logs/example 目录下的 log 日志查看是否与数据库连接成功。

    在这里插入图片描述

4.2 同步方式

4.2.1 canal.adapter 方式

从 1.1.1 版本开始,canal 实现了一个配套的落地模块,实现对 canal 订阅的消息进行消费,就是 client.adapter。目前的最新稳定版 1.1.5 版本中,client.adapter 已经实现了同步数据到 RDS、ES、HBase 的能力。

使用 client.adapter 的优点包括:无需编码:通过配置文件即可实现数据同步;扩展性好:支持多种目标数据源,方便扩展和应用。缺点包括:配置复杂:对于复杂的同步需求,配置文件可能较为复杂和难以维护;灵活性有限:只能实现通过配置文件支持的同步逻辑,不能处理特殊需求。

下面是 canal.adapter 的使用方式:

  • 下载 canal.adapter。通过网址 https://github.com/alibaba/canal/releases 下载 1.5 版本,并解压。
    在这里插入图片描述

  • 修改配置文件 conf/application.yml

    canal.conf:
      # 客户端的模式,可选tcp kafka rocketMQ
      mode: tcp 
      # 扁平message开关, 是否以json字符串形式投递数据, 仅在kafka/rocketMQ模式下有效
      flatMessage: true
      # 对应集群模式下的zk地址
      zookeeperHosts:    
      # 每次同步的批数量
      syncBatchSize: 1000
      # 重试次数, -1为无限重试 
      retries: 0
      # 同步超时时间, 单位毫秒 
      timeout: 
      accessKey:
      secretKey:
      consumerProperties:
        # canal tcp consumer
        #设置canal-server的地址
        canal.tcp.server.host: 127.0.0.1:11111 
        canal.tcp.zookeeper.hosts:
        canal.tcp.batch.size: 500
        canal.tcp.username:
        canal.tcp.password:
    
      # 源数据库配置
      srcDataSources: 
        defaultDS:
          url: jdbc:mysql://127.0.0.1:3306/test?useUnicode=true
          username: root
          password: root
      canalAdapters: 
        # canal实例名或者MQ topic名
        - instance: example 
          # 分组列表
          groups: 
            # 分组id, 如果是MQ模式将用到该值
            - groupId: g1 
              outerAdapters:
              # 日志打印适配器
              - name: logger
              # ES同步适配器 
              - name: es7 
                # ES连接地址
                hosts: 127.0.0.1:9200 
                properties:
                  # 模式可选transport(9300) 或者 rest(9200)
                  mode: rest 
                  # security.auth: test:123456 #  only used for rest mode
                  # ES集群名称
                  cluster.name: demo
    
  • 修改(新增) conf/es7/{数据库表名}.yml,用于同步数据库表。

    在这里插入图片描述

    # 源数据源的key, 对应上面配置的srcDataSources中的值
    dataSourceKey: defaultDS
    # canal的instance或者MQ的topic 
    destination: example  
    # 对应MQ模式下的groupId, 只会同步对应groupId的数据
    groupId: g1 
    esMapping:
      # es 的索引名称
      _index: question
      # es 的_id, 如果不配置该项必须配置下面的pk项_id则会由es自动分配
      _id: id  
      # sql映射
      sql: "select q.id, q.question, q.answer, q.type, q.tag, q.time from tb_demo_question q"
      #etl的条件参数        
      etlCondition: ""   
      # 提交批大小
      commitBatch: 3000
    
  • 双击 bin 目录下的 startup.bat,启动服务。

    注意,若启动时报错 com.alibaba.druid.pool.DruidDataSource cannot be cast to com.alibaba.druid.pool.DruidDataSource,需要下载 canal 源码重新打包。原因是 druid 包冲突,具体步骤包括,首先下载源码(使用阿里云 Maven 仓库),修改 client-adapter/escore/pom.xml:

    <dependency>
        <groupId>com.alibaba</groupId>
        <artifactId>druid</artifactId>
        <scope>provided</scope>
    </dependency>
    

    打包过程中若出现 You are using ‘tasks’ which has been removed from the maven-antrun-plugin. 修改对应报错包的 pom 文件:

    <artifactId>maven-antrun-plugin</artifactId>
    <!-- 新增 -->
    <version>1.8</version>
    

    更多打包问题可通过百度解决。将 client-adapter/es7x/target/client-adapter.es7x-1.1.5-jar-with-dependencies.jar 替换 adataper/plugin 下的同名 jar 文件。文中有已修改打包的 jar。

  • 在 ES 中提前创建与数据库结构对应的索引

    PUT question
    {
        "mappings": {
            "properties": {
                "answer": {
                    "type": "text",
                    "store": true,
                    "analyzer": "ik_max_word",
                    "search_analyzer": "ik_smart"
                },
                "question": {
                    "type": "text",
                    "store": true,
                    "analyzer": "ik_max_word",
                    "search_analyzer": "ik_smart"
                },
                "tag": {
                    "type": "text",
                    "store": true,
                    "analyzer": "ik_max_word",
                    "search_analyzer": "ik_smart"
                },
                "time": {
                    "type": "date",
                    "store": true,
                    "format": "yyyy-MM-dd HH:mm:ss || ||yyyy-MM-dd'T'HH:mm:ssZ"
                },
                "type": {
                    "type": "keyword",
                    "store": true
                }
            }
        }
    }
    
  • MySQL 中新增数据,此时发现 ES 中已同步更新数据。

    INSERT INTO tb_demo_question ( question, answer, tag, type )
    VALUES ( '第九题', '第九题答案', '化学应用题', '化学', NOW());
    

更多canal.adapter的使用参考: Canal实时同步MySQL数据到ES canal数据同步到ES
配置常见报错: Elastic: canal数据同步到ES配置常见报错

4.2.2 Spring 整合 Canal 方式

使用官方提供的客户端进行监听。

  • 导入依赖

    <dependency>
        <groupId>com.alibaba.otter</groupId>
        <artifactId>canal.client</artifactId>
        <version>1.1.5</version>
    </dependency>
    
  • 编写监听的代码

    /**
     * canal监听binlog
     */
    @Component
    public class CanalClient implements InitializingBean {
        private final static int BATCH_SIZE = 1000;
    
        @Override
        public void afterPropertiesSet() throws Exception {
            // 创建链接
            CanalConnector connector = CanalConnectors.newSingleConnector(
                    new InetSocketAddress(AddressUtils.getHostIp(), 11111), "example", "", "");
            int batchSize = 1000;
            int emptyCount = 0;
            try {
                //建立连接
                connector.connect();
                //设置监听的表 如果这里配置了,那么配置文件里配置的就失效了
                //test是数据库,tb_demo_question是表,如果是全部,那就不用配置了,因为配置文件已经配置
                connector.subscribe("test.tb_demo_question");
                connector.rollback();
                //设置监控次数,如果监控超过次数后就自动关闭,如果想一直监控 while就设置为true
                int totalEmptyCount = 99999;
                while (emptyCount < totalEmptyCount) {
                    // 获取指定数量的数据
                    Message message = connector.getWithoutAck(batchSize);
                    long batchId = message.getId();
                    int size = message.getEntries().size();
                    if (batchId == -1 || size == 0) {
                        emptyCount++;
                        System.out.println("empty count : " + emptyCount);
                        try {
                            //休眠一秒
                            Thread.sleep(1000);
                        } catch (InterruptedException ignored) {
                        }
                    } else {
                        emptyCount = 0;
                        //数据有更新
                        printEntry(message.getEntries());
                    }
                    // 提交确认
                    connector.ack(batchId);
                }
                System.out.println("empty too many times, exit");
            } finally {
                connector.disconnect();
            }
        }
    
        /**
         * 打印canal server解析binlog获得的实体类信息
         */
        private static void printEntry(List<CanalEntry.Entry> entrys) {
            for (CanalEntry.Entry entry : entrys) {
                if (entry.getEntryType() == CanalEntry.EntryType.TRANSACTIONBEGIN
                        || entry.getEntryType() == CanalEntry.EntryType.TRANSACTIONEND) {
                    continue;
                }
    
                CanalEntry.RowChange rowChage;
                try {
                    rowChage = CanalEntry.RowChange.parseFrom(entry.getStoreValue());
                } catch (Exception e) {
                    throw new RuntimeException("ERROR ## parser of eromanga-event has an error , data:" + entry.toString(), e);
                }
    
                //获取操作类型
                CanalEntry.EventType eventType = rowChage.getEventType();
                System.out.println(String.format("================binlog[%s:%s] , name[%s,%s] , eventType : %s",
                        entry.getHeader().getLogfileName(), entry.getHeader().getLogfileOffset(),
                        entry.getHeader().getSchemaName(), entry.getHeader().getTableName(),
                        eventType));
    
                for (CanalEntry.RowData rowData : rowChage.getRowDatasList()) {
                    // 此处调用ESAPI执行同步数据操作
                    if (eventType == CanalEntry.EventType.DELETE) {
                        //删除
                        printColumn(rowData.getBeforeColumnsList());
                    } else if (eventType == CanalEntry.EventType.INSERT) {
                        //插入
                        printColumn(rowData.getAfterColumnsList());
                    } else {
                        //更新
                        System.out.println("-------before");
                        printColumn(rowData.getBeforeColumnsList());
                        System.out.println("-------after");
                        printColumn(rowData.getAfterColumnsList());
                    }
                }
            }
        }
    
        private static void printColumn(List<CanalEntry.Column> columns) {
            for (CanalEntry.Column column : columns) {
                System.out.println(">>>" + column.getName() + "=" + column.getValue());
            }
        }
    }
    

五、Canal 配合 RocketMQ 实现数据同步

在开发环境下,往往将 Canal 与 RocketMQ 搭配进行使用,流程为:Canal 将 MySQL 变更的数据推送到 RocketMQ 中,RocketMQ 消费消息并将最新数据同步到 ES 中。

5.1 RocketMQ 的安装与配置

  • 下载

    因为我的 SpringBoot 的版本为 2.2.0,故选用 4.9.0 版本的 RocketMQ。从官网下载对应版本的 Binary 压缩包,并解压至指定文件夹中。下载地址:https://rocketmq.apache.org/zh/download

    在这里插入图片描述

  • 配置环境变量并启动

    RocketMQ 依赖于 jdk1.8.0,因此必须在安装 RocketMQ 之前,安装配置好 java 的环境变量(注意目录不要有空格)。
    在这里插入图片描述
    在这里插入图片描述
    接着配置 RocketMQ 的环境变量。

在这里插入图片描述
在这里插入图片描述
启动 nameserver 和 broker 服务。在 cmd 中使用 cd 命令进入 RocketMQ 的 bin 目录,先执行 start mqnamesrv.cmd

用来开启 nameserver 服务,再执行 start mqbroker.cmd -n 127.0.0.1:9876 开启 broker,其中 -n 后面为 nameserver 的 IP 和端口号。

在这里插入图片描述
在这里插入图片描述

  • 设置 Canal 配置文件

    进入 canal.deployer 的 conf 目录,编辑 canal.properties:

    # 配置模式
    canal.serverMode = rocketMQ
    # 生产者群组
    rocketmq.producer.group = default_producer_group
    

    进入 example 目录,编辑 instance.properties:

    # 订阅名称
    canal.mq.topic=example
    

    重启 canal 服务器。此时 Canal 会将监听到的 binlog 推送到 RocketMQ 中。

5.2 SpringBoot 整合 RocketMQ

在 pom 文件中添加依赖。

<dependency>
    <groupId>org.apache.rocketmq</groupId>
    <artifactId>rocketmq-spring-boot-starter</artifactId>
    <version>2.1.0</version>
</dependency>

<dependency>
    <groupId>org.apache.rocketmq</groupId>
    <artifactId>rocketmq-client</artifactId>
    <version>4.6.0</version>
</dependency>

rocketmq-spring-boot-starterrocketmq-client 的版本需要对应,否则会出问题。
application.yml中添加配置

rocketmq:
  name-server: 127.0.0.1:9876
  producer:
    # 发送同一类消息的设置为同一个group,保证唯一
    group: springboot_producer_group
  consumer:
    group: springboot_consumer_group

5.3 使用 canal-glue-core 消费 RocketMQ 中的 binlog

  • 打包 canal-glue-core

    进入 https://gitee.com/throwableDoge/canal-glue,拉取项目并打包。

  • 导入依赖

    将 jar 包放到项目的 lib 目录下,并 add as Library

    在这里插入图片描述
    在 pom 文件中引用

    <dependency>
        <groupId>cn.throwx</groupId>
        <artifactId>canal-glue</artifactId>
        <version>1.0-SNAPSHOT</version>
    </dependency>
    
  • 编写 CanalGlueAutoConfiguration

    @Configuration
    public class CanalGlueAutoConfiguration implements SmartInitializingSingleton, BeanFactoryAware {
    
        private ConfigurableListableBeanFactory configurableListableBeanFactory;
    
        @Bean
        @ConditionalOnMissingBean
        public CanalBinlogEventProcessorFactory canalBinlogEventProcessorFactory() {
            return InMemoryCanalBinlogEventProcessorFactory._of_();
        }
    
        @Bean
        @ConditionalOnMissingBean
        public ModelTableMetadataManager modelTableMetadataManager(CanalFieldConverterFactory canalFieldConverterFactory) {
            return InMemoryModelTableMetadataManager._of_(canalFieldConverterFactory);
        }
    
        @Bean
        @ConditionalOnMissingBean
        public CanalFieldConverterFactory canalFieldConverterFactory() {
            return InMemoryCanalFieldConverterFactory._of_();
        }
    
        @Bean
        @ConditionalOnMissingBean
        public CanalBinLogEventParser canalBinLogEventParser() {
            return DefaultCanalBinLogEventParser._of_();
        }
    
        @Bean
        @ConditionalOnMissingBean
        public ParseResultInterceptorManager parseResultInterceptorManager(ModelTableMetadataManager modelTableMetadataManager) {
            return InMemoryParseResultInterceptorManager._of_(modelTableMetadataManager);
        }
    
        @Bean
        @Primary
        public CanalGlue canalGlue(CanalBinlogEventProcessorFactory canalBinlogEventProcessorFactory) {
            return DefaultCanalGlue._of_(canalBinlogEventProcessorFactory);
        }
    
        @Override
        public void setBeanFactory(BeanFactory beanFactory) throws BeansException {
            this.configurableListableBeanFactory = (ConfigurableListableBeanFactory) beanFactory;
        }
    
        @SuppressWarnings({"rawtypes", "unchecked"})
        @Override
        public void afterSingletonsInstantiated() {
            ParseResultInterceptorManager parseResultInterceptorManager
                    = configurableListableBeanFactory.getBean(ParseResultInterceptorManager.class);
            ModelTableMetadataManager modelTableMetadataManager
                    = configurableListableBeanFactory.getBean(ModelTableMetadataManager.class);
            CanalBinlogEventProcessorFactory canalBinlogEventProcessorFactory
                    = configurableListableBeanFactory.getBean(CanalBinlogEventProcessorFactory.class);
            CanalBinLogEventParser canalBinLogEventParser
                    = configurableListableBeanFactory.getBean(CanalBinLogEventParser.class);
            Map<String, BaseParseResultInterceptor> interceptors
                    = configurableListableBeanFactory.getBeansOfType(BaseParseResultInterceptor.class);
            interceptors.forEach((k, interceptor) -> parseResultInterceptorManager.registerParseResultInterceptor(interceptor));
            Map<String, BaseCanalBinlogEventProcessor> processors
                    = configurableListableBeanFactory.getBeansOfType(BaseCanalBinlogEventProcessor.class);
            processors.forEach((k, processor) -> processor.init(canalBinLogEventParser, modelTableMetadataManager,
                    canalBinlogEventProcessorFactory, parseResultInterceptorManager));
        }
    }
    
  • 编写与数据库对应的实体类
    这里使用了 @CanalModel 注解绑定了数据库和表格

    	@Data
    	@CanalModel(database = "test", table = "tb_demo_question", fieldNamingPolicy =         FieldNamingPolicy._LOWER_UNDERSCORE_)
    	public class QuestionPO {
    	
    	    private Long id;
    	
    	    private String question;
    	
    	    private String answer;
    	
    	    private String tag;
    	
    	    private String type;
    	
    	    private Date time;
    	}
    
  • 定义一个处理器和自定义异常处理器
    在这里,canal-glue 会根据执行的不同类别 SQL 将 binlog 转为对象,我们可以在这里将数据同步到 ES 中。

    @Component
    @RequiredArgsConstructor
    public class MyProcessor extends BaseCanalBinlogEventProcessor<QuestionPO> {
    
        static Logger log = LoggerFactory.getLogger(MyProcessor.class);
    
        private final EsService esService;
    
        @Override
        protected void processInsertInternal(CanalBinLogResult<QuestionPO> result) {
            QuestionPO model = result.getAfterData();
            log.info("insert model = {}", model);
    
            QuestionDO questionDO = new QuestionDO();
            BeanUtils.copyProperties(model, questionDO);
            esService.insert(questionDO);
        }
    
        @Override
        protected void processUpdateInternal(CanalBinLogResult<QuestionPO> result) {
            QuestionPO oldModel = result.getBeforeData();
            log.info("oldModel = {}", oldModel);
            QuestionPO curModel = result.getAfterData();
            log.info("curModel = {}", curModel);
    
            QuestionDO questionDO = new QuestionDO();
            BeanUtils.copyProperties(curModel, questionDO);
            esService.update(questionDO);
        }
    
        @Override
        protected void processDeleteInternal(CanalBinLogResult<QuestionPO> result) {
            QuestionPO model = result.getAfterData();
            log.info("delete model = {}", model);
    
            QuestionDO questionDO = new QuestionDO();
            BeanUtils.copyProperties(model, questionDO);
            esService.delete(questionDO);
        }
    
    
        @Override
        protected ExceptionHandler exceptionHandler() {
            return EXCEPTION_HANDLER;
        }
    
        /**
         * 覆盖默认的ExceptionHandler.NO_OP
         */
        private static final ExceptionHandler EXCEPTION_HANDLER = (event, throwable)
                -> log.error("解析binlog事件出现异常,事件内容:{}", JSON.toJSONString(event), throwable);
    }
    

    EsService

    @Service
    public class EsService {
    
        @Autowired
        private ElasticsearchRestTemplate template;
    
        /**
         * 更新ES中指定数据
         * @param questionDO
         */
        public void update(QuestionDO questionDO) {
            // 构建要更新的文档数据
            Map<String, Object> updateObject = JSONObject.parseObject(JSONObject.toJSONString(questionDO), Map.class);
    
            UpdateRequest request = new UpdateRequest();
            request.doc(updateObject);
    
            // 使用 UpdateQueryBuilder 构建更新请求
            UpdateQuery updateQuery = new UpdateQueryBuilder()
                    .withId(Long.toString(questionDO.getId()))                    // 文档的 ID
                    .withUpdateRequest(request)   // 更新的内容
                    .withClass(QuestionDO.class)
                    .build();
            // 执行更新操作
            UpdateResponse update = template.update(updateQuery);
            System.out.println(update.status());
        }
    
        /**
         * ES新增数据
         * @param questionDO
         */
        public void insert(QuestionDO questionDO) {
            IndexQuery query = new IndexQueryBuilder()
                    .withObject(questionDO)
                    .build();
            template.index(query);
        }
    
        /**
         * 根据id删除ES里的数据
         * @param questionDO
         */
        public void delete(QuestionDO questionDO) {
            template.delete(QuestionDO.class, Long.toString(questionDO.getId()));
        }
    
    
    }
    
  • 最后,对接 Canal 投放到 RocketMQ 的 Topic,作为 canal-glue 的消息来源。

    @Component
    @RequiredArgsConstructor
    @RocketMQMessageListener(topic = "example", consumerGroup = "default_consumer_group")
    public class CanalListener implements RocketMQListener<String> {
        Logger log = LoggerFactory._getLogger_(CanalListener.class);
        private final CanalGlue canalGlue;
    
        @Override
        public void onMessage(String s) {
            log.info("s = {}", s);
            canalGlue.process(s);
        }
    
    }
    

六、总结

在这里插入图片描述
MySQL同步ES的整个流程为:
1、MySQL开启binlog日志,canal伪装成MySQL从库向MySQL发送dump请求,MySQL收到dump请求后向canal发送binlog。
2、canal设置serveMode=mq,以及topic和consumer等配置,此时canal会作为生产者向RocketMQ推送binlog。
3、使用springboot服务作为RocketMQ的消费者,订阅指定topic获取binlog数据。
4、canal-glue消费来自RocketMQ的binlog数据,并根据不同的DML将数据解析成对应的数据对象。
5、使用easyES等框架将canal-glue解析后的数据同步到ES中。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值