一、技术介绍
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 下载安装
- 进入 elasticsearch 官网下载界面,选择 windows 平台并下载 7.10.0 版本。
- 将.zip 压缩包解压至合适的文件夹中。
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 默认不支持中文分词,因此需要下载安装额外的分词器。
- 下载地址:https://github.com/infinilabs/analysis-ik/releases,选择与 Elasticsearch 安装版本相同的 ik(必须相同),下载解压至
plugin
下面的ik
文件夹中(没有文件夹就手动创建)。
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 的几种常见方式包括:
- Spring Data Elasticsearch:
使用 Spring Data 提供的抽象层和注解简化与 Elasticsearch 的交互。 - Elasticsearch Rest Client:
直接使用 Elasticsearch 提供的 Java REST 客户端,与 Elasticsearch 进行低级别的通信。 - 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
类型时,不应该设置analyzer
和search_analyzer
参数。 -
Spring Data ElasticSearch
有下边这几种方法操作 Elasticsearch:- ElasticsearchRepository(传统的方法,可以使用)
- ElasticsearchRestTemplate(推荐使用。基于 RestHighLevelClient)
- ElasticsearchTemplate(ES7 中废弃,不建议使用。基于 TransportClient)
- RestHighLevelClient(推荐度低于 ElasticsearchRestTemplate,因为 API 不够高级)
- TransportClient(ES7 中废弃,不建议使用)
下面将基于ElasticsearchRepository
和ElasticsearchRestTemplate
进行使用说明讲解。
-
索引在创建之后无法修改 Mapping,只能重新给索引重新追加字段,或者删除索引(会同时删除数据)后重新创建。
3.2.2 ElasticsearchRepository
方式
ElasticsearchRepository
是 Spring Data Elasticsearch 提供的一个接口,用于与 Elasticsearch 进行交互。它扩展自 ElasticsearchCrudRepository
和 PagingAndSortingRepository
,提供了对 Elasticsearch 索引中数据的基本 CRUD(创建、读取、更新、删除)操作以及分页和排序功能。
-
首先创建一个数据类。(首次插入数据时,会自动创建索引和映射)
/** * 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
中(不同的版本文档的格式可能不同),插入的文档格式如下: -
编写 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 根据方法名,自动生成实现类,方法名必须符合一定的规则,如下表所示:
-
编写测试类
-
添加数据
@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
的以下三个方法进行查询,即构建QueryBuilder
和SearchQuery
进行复杂的查询:
@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); }
关于
QueryBuilder
和SearchQuery
更详细的介绍可参考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 的 JdbcTemplate
或 MongoTemplate
,提供了对 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
构建QueryBuilder
和SearchQuery
类似,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; }
可以看出来,大概是需要
QueryBuilder
,filter
,和排序的SortBuilder
以及高亮的字段。一般情况下,我们不是直接使用new NativeSearchQuery()
的方法,而是通过构建NativeSearchQueryBuilder
来完成NativeSearchQuery
的构建。如下:NativeSearchQueryBuilder .withQuery(QueryBuilder1) .withFilter(QueryBuilder2) .withSort(SortBuilder1) ... ... .withXXXX() .build();
在这里面,QueryBuilder 主要用来构建查询条件、过滤条件,SortBuilder 主要是构建排序。下面列举部分实现类:
要构建
QueryBuilder
,我们可以使用工具类QueryBuilders
,里面有大量的方法用来完成各种各样的QueryBuilder
的构建,字符串的、Boolean 型的、match 的、地理范围的等等。要构建
SortBuilder
,可以使用SortBuilders
来完成各种排序。然后就可以通过
NativeSearchQueryBuilder
来组合这些QueryBuilder
和SortBuilder
,再组合分页的参数等等,最终就能得到一个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-starter
和 rocketmq-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中。