前言
之前的几篇elasticsearch和canal的搭配文章,基本把一个项目算是搭起来了。这个架构总算是支撑到项目的第一版上线了。在投入生产环境之后,必然会面对一系列的bug修复、需求变动、版本迭代。这个相信也是大家都会遇到的情况,尤其是需求和功能的变动,对于后端来说,往往会导致数据库表的变动。在这类需要通过canal同步mysql到elasticsearch的项目架构中,会面对几个问题点:1、复杂结构的同步可行性 2、表结构的同步 3、表结构变动带来的兼容问题。
由于elasticsearch的索引结构在创建的时候就已经指定,elasticsearch可以任意的扩展索引的字段,但是需要注意的是,当一个索引已经投入运行,已经存在数据时;如果增加了索引的字段,对于旧的文档,是不会有影响的。那么在未来的查询中,就需要有个解决方案来兼顾到旧文档和新文档。
表结构的修改
首先,我们需要验证elasticsearch可以在运行中不删除原索引的前提下,增加字段。
首先我们通过spring data elasticsearch依赖以正向工程的方法创建索引。
package com.zjl.es_demo.es;
import lombok.Data;
import org.springframework.data.annotation.Id;
import org.springframework.data.elasticsearch.annotations.*;
import org.springframework.data.elasticsearch.core.join.JoinField;
import java.util.Date;
import java.util.List;
@Data
@Document(indexName = "forum_post")
public class ForumPostES {
/**
* 话题id
**/
@Id
@Field(type = FieldType.Long, name = "post_id")
private Long postId;
/**
* 标题
**/
@Field(type = FieldType.Text, analyzer = "ik_max_word", searchAnalyzer = "ik_smart")
private String title;
/**
* 标题关键字 keyword类型可以做去重聚合
**/
@Field(type = FieldType.Keyword, name = "title_keyword")
private String titleKeyword;
/**
* 内容
**/
@Field(type = FieldType.Text, analyzer = "ik_max_word", searchAnalyzer = "ik_smart")
private String content;
/**
* 内容不分词
**/
@Field(type = FieldType.Keyword, value = "content_keyword", ignoreAbove = 256)
private String contentKeyword;
/**
* 版块id
**/
@Field(type = FieldType.Integer, name = "plate_id")
private Integer plateId;
/**
* 版块中文名称
**/
@Field(type = FieldType.Keyword, name = "plate_name_cn")
private String plateNameCn;
/**
* 版块英文名称
**/
@Field(type = FieldType.Keyword, name = "plate_name_en")
private String plateNameEn;
/**
* 内容类型
**/
@Field(type = FieldType.Short, name = "content_type")
private Short contentType;
/**
* 发帖日期
**/
@Field(type = FieldType.Keyword, name = "gmt_created")
private String gmtCreated;
/**
* 回复日期
**/
@Field(type = FieldType.Keyword, name = "gmt_reply")
private String gmtReply;
/**
* 是否置顶
**/
@Field(type = FieldType.Boolean, name = "is_top")
private Boolean isTop;
/**
* 是否存档
**/
@Field(type = FieldType.Boolean, name = "is_archive")
private Boolean isArchive;
/**
* 置顶日期
**/
@Field(type = FieldType.Keyword, name = "gmt_top")
private String gmtTop;
/**
* 操作人
**/
@Field(type = FieldType.Keyword, name = "created_by")
private String createdBy;
/**
* 用户名
**/
@Field(type = FieldType.Text, analyzer = "ik_max_word", name = "username", searchAnalyzer = "ik_smart")
private String userName;
/**
* 用户名不分词
**/
@Field(type = FieldType.Keyword, name = "username_keyword")
private String userNameKeyword;
/**
* 头像
**/
@Field(type = FieldType.Keyword, name = "head_icon")
private String headIcon;
/**
* 浏览量
**/
@Field(type = FieldType.Integer, name = "read_count")
private Integer readCount;
/**
* 评论量
**/
@Field(type = FieldType.Integer, name = "reply_count")
private Integer replyCount;
/**
* 点赞量
**/
@Field(type = FieldType.Integer, name = "like_count")
private Integer likeCount;
/**
* 语言类型
**/
@Field(type = FieldType.Short)
private Short languageType;
@JoinTypeRelations(
relations = {
@JoinTypeRelation(parent = "forum_post", children = {"forum_post_like", "forum_reply", "forum_reply_like"})
}
)
private JoinField<Long> post_join;
/**
* 评论id
**/
@Field(type = FieldType.Long, name = "reply_id")
private Long replyId;
/**
* 话题点赞id
**/
@Field(type = FieldType.Long, name = "post_like_id")
private Long postLikeId;
/**
* 要搜索的类型
**/
@Field(type = FieldType.Short, name = "search_type")
private Short searchType;
/**
* 回复点赞id
**/
@Field(type = FieldType.Long, name = "reply_like_id")
private Long replyLikeId;
}
大概就是创建一个实体类,具体的可以翻看之前elasticsearch的相关文章,同时创建一个对应的repository用于对elasticsearch的数据进行操作。
完成之后我们运行这个spring boot工程,可以通过elasticsearch-head插件发现这个索引已经被创建了。
然后我们可以试验下如果直接在实体类中修改或添加字段,对应的elasticsearch的mapping是否会有变化呢?
我们增加一个成员变量
@Field(type = FieldType.Integer,name = "flag")
private Integer flag;
然后我们再次运行工程
2022-02-18 11:39:28.596 DEBUG 2168 --- [ main] org.elasticsearch.client.RestClient : request [GET http://127.0.0.1:9200/] returned [HTTP/1.1 200 OK]
2022-02-18 11:39:28.642 INFO 2168 --- [ main] o.s.d.elasticsearch.support.VersionInfo : Version Spring Data Elasticsearch: 4.1.12
2022-02-18 11:39:28.642 INFO 2168 --- [ main] o.s.d.elasticsearch.support.VersionInfo : Version Elasticsearch Client in build: 7.9.3
2022-02-18 11:39:28.642 INFO 2168 --- [ main] o.s.d.elasticsearch.support.VersionInfo : Version Elasticsearch Client used: 7.6.2
2022-02-18 11:39:28.643 WARN 2168 --- [ main] o.s.d.elasticsearch.support.VersionInfo : Version mismatch in between Elasticsearch Clients build/use: 7.9.3 - 7.6.2
2022-02-18 11:39:28.643 INFO 2168 --- [ main] o.s.d.elasticsearch.support.VersionInfo : Version Elasticsearch cluster: 7.10.0
2022-02-18 11:39:28.643 WARN 2168 --- [ main] o.s.d.elasticsearch.support.VersionInfo : Version mismatch in between Elasticsearch Client and Cluster: 7.6.2 - 7.10.0
2022-02-18 11:39:28.872 INFO 2168 --- [ main] o.s.s.concurrent.ThreadPoolTaskExecutor : Initializing ExecutorService 'applicationTaskExecutor'
2022-02-18 11:39:29.592 DEBUG 2168 --- [ main] org.elasticsearch.client.RestClient : request [HEAD http://127.0.0.1:9200/forum_post?ignore_throttled=false&ignore_unavailable=false&expand_wildcards=open%2Cclosed&allow_no_indices=false] returned [HTTP/1.1 200 OK]
2022-02-18 11:39:29.613 DEBUG 2168 --- [ main] org.elasticsearch.client.RestClient : request [HEAD http://127.0.0.1:9200/forum_reply?ignore_throttled=false&ignore_unavailable=false&expand_wildcards=open%2Cclosed&allow_no_indices=false] returned [HTTP/1.1 200 OK]
2022-02-18 11:39:29.738 INFO 2168 --- [ main] o.a.coyote.http11.Http11NioProtocol : Starting ProtocolHandler ["http-nio-8080"]
2022-02-18 11:39:29.755 INFO 2168 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat started on port(s): 8080 (http) with context path '/person'
2022-02-18 11:39:29.766 INFO 2168 --- [ main] com.zjl.es_demo.EsDemoApplication : Started EsDemoApplication in 11.965 seconds (JVM running for 15.847)
日志中看到我们的elasticsearch client并没有向elasticsearch发起索引修改的请求。所以通过正向工程去实现表结构的变动是不可行的,这一点在spring data jpa上似乎也同样得到了验证。
那么在已经处于生产环境的项目,如果需要变动elasticsearch的索引字段结构,可以采取预先执行脚本的方式。
修改字段的请求是PUT请求。
curl --location --request PUT 'http://127.0.0.1:9200/forum_post/_mapping' \
--header 'Authorization: Basic ZWxhc3RpYzplbGFzdGlj' \
--header 'Content-Type: application/json' \
--data-raw '{
"properties": {
"post_type": {
"type": "short"
},
"post_status": {
"type": "short"
},
"check_user": {
"type": "keyword"
},
"gmt_solution": {
"type": "keyword"
},
"gmt_check": {
"type": "keyword"
},
"labels": {
"type": "text"
},
"label_cn_names": {
"type": "text"
},
"label_en_names": {
"type": "text"
}
}
}'
首先是定义method,跟着的是${host}/${索引名}/_mapping(url的通用格式模板)
请求头上加Authorization是因为我们之前elasticsearch已经开启了安全认证,获取的方式我介绍两种:
1、直接访问你elasticsearch服务的9200端口,然后输入账号密码,然后回车能够获取到elasticsearch信息后。打开浏览器的控制台,刷新页面可以在请求头中看到这个信息;
2、访问elasticsearch-head,一般是9100端口,url后面跟上账号密码,然后回车,能够获取到索引信息。打开浏览器的控制台,刷新页面可以在请求头中看到这个信息;
请求体中在properties中定义字段名和类型。
执行后,我们可以在elasticsearch-head的控制台中查看到索引的结构已经发生了变化。
我们在修改之前已经往elasticsearch中添加了数据
可以发现并没有我们后续新增的字段post_type。我们再新增一条数据
可以发现post_type是出现了。论证,elasticsearch新增字段对旧的文档是没有影响的,但是对于新的文档是有效的。
复杂结构同步
之前的同步中我们使用了join类型,这个在canal中是可以支持的。这次是使用array类型。elasticsearch中可以支持array类型(同一数据类型所组成的)。
我们首先查询canal的文档
canal中提到了可以通过group_concat来操作,但是这里需要考虑一个长度的问题,不是极其长的可用。这里同步过去在elasticsearch中是一个用;拼接的字符串。
另外要注意canal在关联从表如果是子查询不能有多张表。
上面是我写的案例,需要在objFields字段中添加字段名: arrays:;。
将改动的文件保存在canal.adapter的es7文件夹下,重启adapter,然后添加数据出发canal的同步。可以看到我们定义的数组字段在elasticsearch中拼接成了字符串。
同样,在我们的实体类中需要加上对应的映射mapping
/**
* 标签ids
**/
@Field(type = FieldType.Text, name = "labels")
private List<Integer> labelList;
兼容查询
在开头我们提到了,由于elasticsearch可以在运行中动态添加字段,同时新字段对旧的文档不产生影响。这对于后续的查询会引入一个兼容性的问题。在mysql中,新增的字段会对整张表有效,无论是旧的记录还是新的记录,所以兼容性的问题相对就小很多。但是elasticsearch中就会出现一个查询会针对到不存在新字段的旧文档和存在新字段的新文档。
通过查询资料,elasticsearch中存在一种missing query。它可以查询索引中不存在该字段的文档。需要注意在较新版本的elasticsearch中missing query由exists query替代。exists query指的是存在该字段的文档,如果我们需要查询不存在该字段的文档,就需要在exists query之前加上must not query。
下面我们可以在spring boot elasticsearch中看下这个语法的操作。代码片段如下
@Override
public Page<ForumPostES> queryForumPostPage(ForumPostQuery forumPostQuery) {
SearchRequest searchRequest = new SearchRequest("forum_post");
//分页
PageRequest pageRequest = PageRequest.of(forumPostQuery.getPageNum() - 1, forumPostQuery.getPageSize());
SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
String[] include = {"post_id","search_type","post_status","title","content",""};
String[] exclude = {};
searchSourceBuilder.fetchSource(include,exclude);
BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery();
boolQueryBuilder.must(QueryBuilders.termQuery("search_type",1));
if (null != forumPostQuery.getPostStatus()){
BoolQueryBuilder postStatusQuery = new BoolQueryBuilder();
if (forumPostQuery.getPostStatus().equals(PostStatusEnum.SUCCESS.getCode())){
postStatusQuery.should(QueryBuilders.termQuery("post_status",PostStatusEnum.SUCCESS.getCode()));
BoolQueryBuilder boolQuery = new BoolQueryBuilder();
//查询
postStatusQuery.should(boolQuery.mustNot(QueryBuilders.existsQuery("post_status")));
boolQueryBuilder.must(postStatusQuery);
}
}
searchSourceBuilder.query(boolQueryBuilder);
int from = pageRequest.getPageNumber() * pageRequest.getPageSize();
searchSourceBuilder.from(from).size(pageRequest.getPageSize());
searchRequest.source(searchSourceBuilder);
SearchResponse searchResponse = elasticsearchDao.getSearchResponse(searchRequest);
List<ForumPostES> forumPostESList = new ArrayList<>(pageRequest.getPageSize());
for (SearchHit hit : searchResponse.getHits().getHits()) {
JSONObject jsonObject = JSONObject.parseObject(hit.getSourceAsString());
ForumPostES forumPostES = JSON.toJavaObject(jsonObject, ForumPostES.class);
forumPostESList.add(forumPostES);
}
return new PageImpl<ForumPostES>(forumPostESList,pageRequest,searchResponse.getHits().getTotalHits().value);
}
这里的场景是新增一个功能需求发布的帖子需要审核,通过审核后才能展现(我们把通过的状态认为是2)。而之前所有帖子是发布后即可直接展现的,如果是mysql,那么我们可能会新增字段后,把旧的记录的新增字段的值手动批量update成我们需要的。但是在elasticsearch中我们无法这么操作。
所以当我们需要查询所有可以展现的帖子时,就需要获取到没有状态字段的文档(旧文档)和状态字段值为2的文档(新文档) 的并集。
设计思路如下:
首先我们把并集这个操作等价到should查询;
然后我们分类设计
用一个布尔查询来获取状态值为2的文档
用另一个布尔查询加上mustNot的条件,再传入existsQuery要查询的状态字段名获取没有状态字段的旧文档。
将上述两个布尔查询用should逻辑拼接包装成一个并集逻辑。
这样就完成设计。
到此,文章先前提出的几个问题已经有了初步的解决方案。目前在项目中会先使用上面的方案投入使用,后续有更优的解决方案也会及时和大家共享交流