让人又爱又恨的spring-data-elasticsearch

822 阅读10分钟

1、背景

项目需要使用elasticSearch做业务功能,基本链路是使用binlog监听组件例如精卫或者canal等收集数据库变动消息,当数据库表的关键字段数据发生变动时对对应的文档索引进行数据更新,例如数据库里面站点名称发生了修改,通过监听数据库变动可以及时的将es索引库里面的文档数据的站点名称也进行近乎同步的修改。
简单来说就是要对es进行数据操作和数据查询。
笔者本人算是很早就接触过elasticSearch这个中间件,早期还曾使用过elastic-header这种上古插件做es的数据可视化,可是不得不说elasticSearch版本更新是真的快,之前好多学过的知识点以及很多API的使用,都已经随着版本更迭而产生重大变化。

2、spring-data-elasticsearch简介

在介绍本文主角spring-data-elasticsearch之前,先介绍下目前操作es的java客户端到底有哪些,目前的同一种Java客户端不同版本差异也是非常大的,有时候这个版本能用的api接口在升级版本以后,下一个版本这个API就被移动或者废除了,这就导致如果我们需要了解一个API的使用只能去官网查询或者就是去问ChatGPT。

(1)Java Rest Client

maven依赖:

<dependency>
    <groupId>org.elasticsearch.client</groupId>
    <artifactId>elasticsearch-rest-high-level-client</artifactId>
    <version>7.6.2</version>
</dependency>
<dependency>
    <groupId>org.elasticsearch</groupId>
    <artifactId>elasticsearch</artifactId>
    <version>7.6.2</version>
</dependency>
<dependency>
    <groupId>org.elasticsearch.client</groupId>
    <artifactId>elasticsearch-rest-client</artifactId>
    <version>7.6.2</version>
</dependency>

Java Rest Client目前分为两类一类是 RestHighLevelClient 一类是 RestClient,这两个一个被称之为highLevel一个被称之为lowLevel,此外可以通过RestHighLevelClient的getLowLevelClient()方法获取RestClient客户端 目前这个API可以说是elasticSearch8.0以前使用的最多的API,但是确实也是比较难用,个人觉得有的API设计让人比较费解,此外使用时强烈推荐 Rest High Level Client和 rest client的版本和Elasticsearch的版本保持一致,否则存在各种不兼容问题。但是注意8.0以后官网开始推新的API了,也就是下文要说的 Java API Client

(2)Java API Client

maven依赖:

<project>
  <dependencies>

    <dependency>
      <groupId>co.elastic.clients</groupId>
      <artifactId>elasticsearch-java</artifactId>
      <version>8.12.0</version>
    </dependency>

    <dependency>
      <groupId>com.fasterxml.jackson.core</groupId>
      <artifactId>jackson-databind</artifactId>
      <version>2.12.3</version>
    </dependency>

  </dependencies>
</project>

这个Java es客户端也是被官网推荐的最新API,这个API确实相比原来做了大量调整,但是在使用之前最好确保你的es版本高于7.5版本。使用这个API确实好用许多,采用了全链式调用,比如我要执行一个match-query我们可以这样写代码:

SearchResponse<Product> response = esClient.search(s -> s
        .index("products")
        .query(q -> q
            .match(t -> t
                .field("name")
                .query(searchText)
            )
        ),
    Product.class
);

篇幅原因不再详细介绍这个API的各种使用,详细可以参考官方文档www.elastic.co/guide/en/el… 进行了解学习

(3)spring-data-elasticsearch

有了前面几个客户端自然就有人想要是咱们操作elasticSearch文档像我们操作数据库一样简单就好了,于是乎spring-data-elasticsearch横空出世,Spring Data Elasticsearch提供了一组抽象,例如repositories和entities,使得操作Elasticsearch更加简洁,有点类似Hibernate的解决方案一样,直接使用接口就能完成增删改查,下面介绍一个例子:

S1: 引入依赖(注意springBoot、es、spring-data-elasticsearch三者版本的对应)

<dependencies>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-elasticsearch</artifactId>
    </dependency>

    <dependency>
        <groupId>org.elasticsearch.client</groupId>
        <artifactId>elasticsearch-rest-high-level-client</artifactId>
        <version>您的Elasticsearch版本对应的客户端版本</version>
    </dependency>
</dependencies>

从导入的依赖你也可以看出其实spring-data-elasticsearch还是封装了elasticSearch的JavaRestClient的能力。

S2: 配置连接信息

spring.data.elasticsearch.cluster-name=您的集群名称 
spring.data.elasticsearch.cluster-nodes=您的Elasticsearch服务器地址:端口  
spring.elasticsearch.rest.uris=http://您的Elasticsearch服务器地址:端口

S3:创建文档对应的实体类,使用注解标记mapping

@Document(indexName = "station_map_index",createIndex = true, shards = 3, replicas = 1)
public class StationEntity {

    @Id
    @Field(name="id",type=FieldType.Keyword)
    private String id;

    @Field(name="id",type=FieldType.Keyword)
    private String stationId;

    @Field(name = "station_name", type = FieldType.Text, searchAnalyzer = "ik_max_word", analyzer = "ik_max_word")
    private String stationName;
}

S4: 编写类实现ElasticsearchRepository接口

public interface StationEntityRepository extends ElasticsearchRepository<StationEntity, String> {
    
}

除此以外如果有接口无法满足的查询,可以在这个类中继续定义方法,然后使用ElasticsearchRestTemplate自己写查询逻辑。

S5:使用

后面在需要使用es查询的服务内注入StationEntityRepository实例,即可进行增删改查。


@Service
public class StationEntityService {
    
    private final StationEntityRepository repository;

    @Autowired
    public StationEntityService(YourEntityRepository repository) {
        this.repository = repository;
    }

    public void saveStationEntity(StationEntity entity) {
        repository.save(entity);
    }

}

此外部分版本要求在springBoot启动类上添加注解:@EnableElasticsearchRepositories(basePackages = "repository包路径")

3、spring-data-elasticsearch使用踩坑记录

前面介绍了spring-data-elasticsearch 的简单使用,不由得感觉spring-data-elasticsearch真的好啊,封装了底层各种查询语句,直接按照面向对象的思维去存数据写数据查数据,优雅,太优雅了,让程序员直呼太爱啦。

但是其实封装的太厚,在不清楚底层细节的情况下,也很容易踩坑,下面介绍几种实战中遇到的真实踩坑记录。

(1)无法删除数据

先看下下面的代码,看着毫无毛病,传入id集合 然后删除文档id是传入id值的数据

@Autowired
StationEntityRepository repository;

@PostMapping("/testMethod")
public String testMethod(@RequestBody List<String> ids){
    repository.deleteAllById(ids);
    return "success";
}

事实上,根本无法删除...

而且控制台还报出来这样的异常错误

image.png

但是你把上面的代码改成下面这样,竟然又能成功删除

@PostMapping("/testMethod")
public String testMethod(@RequestBody List<String> ids){
    // stationMapIndexRepo.deleteAllById(ids);
    for (String id : ids) {
        stationMapIndexRepo.deleteById(id);
    }
    return "success";
}

这到底是咋回事?按照异常的报错位置先把断点打到此位置,

image.png

然后你就会发现之所以我们代码中实现一个接口就能直接进行增删改查的真正原因是有 SimpleElasticsearchRepository这个实现类在帮我们“负重前行”...

咱们观察下这个类的继承关系

然后再看看这个类的实现方法清单

你会发现根本就没有deleteAllById这个方法 自然就无法实现删除功能

所以版本匹配很重要你会发现: org.springframework.data.repository.CrudRepository隶属于spring-data-common 依赖 org.springframework.data.elasticsearch.repository.support.SimpleElasticsearchRepository又隶属于spring-data-elasticsearch 依赖

如果版本不一致,就会导致CrudRepository中定义的接口在SimpleElasticsearchRepository竟然没有对应实现。

有的同学估计也会好奇,为什么没有实现方法,却在程序启动的时候不会报错,事实上这个方法调用是通过JDK代理调用的,所以问题比较隐蔽,不会在项目一启动的时候就体现出来。

所以建议在使用 spring-data-elasticsearch 要注意版本适配,此外还需要注意是否存在依赖冲突,最好去观察下 我们调用的方法是否都在 SimpleElasticsearchRepository 类有对应的实现。

(2)自动创建索引的大坑

spring-data-elasticsearch默认支持自动创建索引,在前面的实体类定义中我们使用了 @Field(name="id",type=FieldType.Keyword) 这个注解来定义字段mapping 然后启动的时候spring-data-elasticsearch会去扫描这些注解,如果发现索引库还不存在,这时候就会按照我们定义的mapping来去创建索引

一切看起来很好不是嘛?

但是 如果我们在程序运行的过程中 通过devTools删除掉这个索引会咋样呢?

我们先在kibanna上执行这个命令:

DELETE station_map_index

然后让程序触发一次文档写入,再来看看会不会自动创建索引 你会发现确实索引又被创建了,但是别急着高兴,使用下面的命令先看看mapping还对不对先:

GET /station_map_index/_mapping

你会发现你定义的keyword类型都变成了Text类型......这问题可就太大了 大家都知道Text类型你不能用termQuery去查询的,因为text是默认分词的 如果想要精准查询需要使用xxx.keyword 的方式去查询,这个自动创建的索引竟然把类型改了,会使所有的查询接口会直接查不到任何数据,喜提生产事故一次。

原因是什么呢? 原因是对于索引的存在性检查只会在应用启动的时候进行,也可以理解,注解的扫描肯定只能进行一次,你程序启动之后把索引删除,再次插入数据,那个索引实际上是es帮你自动创建的,es有一套自己的类型推断策略(称之为dynamic-Mapping),可以参考官网: www.elastic.co/guide/en/el… 这个动态mapping会把默认文本都给设定成了Text类型。

那应该咋解决呢? 事实上,正常场景下我们很少会去删除索引,但是有的场景就会,比如我们定义一套名字叫做 log-index-01 log-index-02 log-index-03 的多个索引库然后我想每隔7天删除一个索引,并重新建立一个索引,这时候就会出现删除索引的操作了。

正确的方法是什么呢,es提供了“索引模板”功能。这个索引模板通过pattern匹配的方式来创建索引,也就是说你比如你定义一个log-index-* 的索引模板 这时候需要创建log-index-04 这个索引还不存在,但是这个索引名称和我们的pattern是匹配的,这时候就会按照你给的模板去创建索引,从而不会出现类型映射错误的问题

创建索引模板的命令可以参考下面的内容

PUT /_index_template/template_1
{
  "index_patterns" : ["te*"],
  "priority" : 1,
  "template": {
    "settings" : {
      "number_of_shards" : 2
    }
    "mapping": {
      // 定义你的mapping
    }
  }
}

(3)可怕的字段类型推断

某次因为需求需要,需要往es中新增三个字段,因为es不像mysql数据库一样,新增字段需要指定字段类型和字段名称,es存在自动的mapping规则(前文已经有所涉及),然后同样使用的是spring-data-elasticsearch,小伙伴的代码还“贴心”的在文档类中使用了 @Field 注解指定了字段类型是keyword类型,如下面的代码所示:

@JSONField(name = "gd_province_code")
@Field(name = "gd_province_code", type = FieldType.Keyword)
private String gdProvinceCode;

@JSONField(name = "gd_city_code")
@Field(name = "gd_city_code", type = FieldType.Keyword)
private String gdCityCode;

@JSONField(name = "gd_district_code")
@Field(name = "gd_district_code", type = FieldType.Keyword)
private String gdDistrictCode;

然后直接上线,当插入第一条文档数据的时候,查看下mapping,果然可怕的事情又双叒叕发生了:

image.png

字段又被映射成为Text了....

那么正确的做法应该是怎么处理呢?可以考虑使用dynamicMapping:

PUT my-index-000001
{
  "mappings": {
    "dynamic_templates": [
      {
        "strings_as_keywords": {
          "match_mapping_type": "string",
          "mapping": {
            "type": "keyword"
          }
        }
      }
    ]
  }
}

match_mapping_type基于 Elasticsearch 推断出的字段数据类型进行匹配。上面这段设置的含义就是将所有类型是String类型都给映射成为keyword。但是需要注意的是这种动态映射配置需要在新增字段之前就要进行配置,一旦文档字段类型确定了,对于存量的文档就无法再去设定了。 一旦需要修改存量的文档字段类型就只能执行“重建索引”,费时费力害还容易出事故,所以这点还是需要注意下的。

总结

spring-data-elasticsearch采用面向对象的思路使得我们操作es的文档数据变得非常简单,避免了使用es官方API去构造各种查询条件,确实是一个非常优秀的封装框架。 但是对于任何一个高度封装的框架,我们在使用的时候都需要额外多考虑其中的实现原理并做深度的接口测试,否则就有可能直接踩坑,本文总结了两种踩坑场景,希望对正在使用spring-data-elasticsearch的读者有所帮助。