1.ElasticSearch简述
ElastiaSearch(以下简称ES)是一个基于Lucene的搜索服务器,它提供了一个分布式多用户能力的全文搜索引擎,支持RESTful web接口。Elasticsearch是用Java开发的,并作为Apache许可条款下的开放源码发布,是当前流行的企业级搜索引擎。ES设计用于云计算中,能够进行实时搜索,支持PB级搜索,具有稳定,可靠,快速,安装使用方便等优点。从2016年开始,使用量已经超越solr。目前京东互联网医院对医院、医生、问诊单的搜索;京东多药城b2c处方药订单的搜索等均已依赖ES进行。
本文从倒排索引、相关度分数计算、分布式架构、JavaAPI常见用法等几个方面简要解析ES底层原理及基本用法,希望给读者提供有益帮助。
2.倒排索引
2.1理解倒排索引
ES使用倒排索引的结构进行全文快速搜索,一个倒排索引由文档中所有不重复的列表构成,对于每一个单词,有一个包含他的文档列表。本小节主要以京东互联网医院医院信息为例介绍倒排索引的存储方式及数据存储标准化规则。
如下表所示,假设文档集合中包含5个文档,左边对应文档编号,右边文档内容,我们的任务就是对这个文档集合建立倒排索引。
文档编号 文档内容
1 {“hospitalName”:”北京大学第三附属医院”}
2 {“hospitalName”:”北京协和医院”}
3 {“hospitalName”:”解放军总医院第一附属医院”}
4 {“hospitalName”:”Peking University Third Hospital”}
5 {“hospitalName”:”Peking Union Medical College Hospital”}
(1)首先利用中、英文分词器从所有文档中提取不重复的单词,每一个单词对应有一个ID和含有这个单词的文档ID,这样可以很清晰的看出单词及对应的文档,如下表所示。
单词ID 单词 文档id
1 医院 1、2、3
2 北京 1、2
3 北京大学 1
4 第三 1
5 附属 1、3
6 协和 2
7 解放军 3
8 第一 3
9 总 3
(2)索引系统还可以记录除此之外的很多信息,下图还记录了单词频率信息(TF),即单词在每个文档中出现的次数。这个信息是用户为词条信息在搜索时,计算查询和文档相似程度(相关度分数)是一个很重要的计算因子。
单词ID 单词 文档Id:出现次数
1 医院 (1:1)、(2:1)、(3:2)
2 北京 (1:1)、(2:1)
3 北京大学 (1:1)
4 第三 (1:1)
5 附属 (1:1)、(3:1)
6 协和 (2:1)
7 解放军 (3:1)
8 第一 (3:1)
9 总 (3:1)
(3)还可以记录单词在文档中出现的位置
例如:(1,<8>,1)代表“医院”这个单词在ID为1、位置为8的文档中的出现了1次。
单词ID 单词 文档id,<位置>,出现次数
1 医院 (1,<8>,1)、(2<5>1)、(3<5,11>2)
… … …
显然,利用倒排索引,我们可以很快定位到文档,从而提高用户对词条的检索速度。
2.2标准化规则(normalization)
为解决词条检索时词条命中率问题,ES在建立倒排索引时运用标准化规则即针对存储的索引词条进行一些相关预处理再作为索引进行存储。
为了便于理解,此部分利用英文文档解释倒排索引的标准化规则。
例如:通常情况下,在搜索“Third”、“Hospital”这两个单词时候,文档4两个单词都出现了,计数为2;文档5只有“Hospital”这个单词出现了,计数为1,所以文档4命中率高,排名靠前。
Term Doc_4 Doc_5
Third 1 0
Hospital 1 1
Peking 1 1
Total 3 2
但是这样搜索就会存在下列问题:
(1)”Third”与”third” 用户认为是相同单词,但是首字母小写可能搜不到内容。
(2)“hospitals”与”hospital”有相同的词根,如果存储了”hospitals”,那么”hospital”可能检索不到 。
(3)“piking”与”beijing”为相同意思的词,”beijing”可能检索不到。
基于以上问题,ES在建立倒排索引时,会对拆分的各个单词进行相应处理,以提升后面搜索的时候能够搜索到相关联的文档的概率,这就是标准化规则转换,主要包括:时态的转换、单复数的转换、同义词的转换、大小写的转换
最终文档在es中的可能存储结构就变为
Term Doc_4 Doc5 Term Doc_4 Doc_5 Attention
Peking 1 1 peking 1 1 同义词互转
beijing 1 1 beijing 1 1
University 1 1 university 1 1 同义词互转
College 1 1 college 1 1
Third 1 0 third 1 0 大小写转换
Hospital 1 1 hospital 1 1 大小写转换
Union 0 1 union ; 0 1 大小写转换
Medical 0 1 medical 0 1 大小写转换
有了标准化规则,搜索是不区分大小写,不区分同义词,不区分单复数,这样就可以大大提升用户对词条搜索的命中率。
3.相关度分数的计算
当利用ES进行查询时,查询结果都会返回一个对应词条的相关度分数(score)。相关度分数的计算基于TF/IDF算法(Term Frequence&Inverse Doucument Frequency),翻译大意为:词条在文档中出现的频率及在倒排索引中出现的频率。
1.Term Frequence:我们查询的词条在文本中出现多少次,出现次数越多,相关度越高。
TF(t in f)=
例如:前面所列医院信息文档中,“医院”,“北京大学”这两个单词,在第1文档中都出现了,但是第2和第3个文档只出现了“医院”,所以第1个相关度分数高。
2.Inverse Doucument Frequency:查询词条在所有文本中出现的次数,出现次数越高,相关度越低。
例如:“医院”在3个文档中出现4次,而“北京大学”出现1次,所以“医院”这个单词的相关度越低。
3.Field-length(字段长度规约):字段的长度越长,相关度越低
例如:“北京大学第三附属医院”长度大于“北京协和医院”,那么在检索“医院”这个单词时,第二个文档中分数要大于第一个文档的分数。
总结:ES相关度分数计算结果直接影响搜索排名顺序,对用户检索命中率有极大的影响
4.自定义相关度分数计算
实际搜索过程中,会出现一种情况,当用户群体对某个文档的搜索次数越多,我们认为这个文档的关注度高,那么这个文档排名理应适当靠前,但是当前的计算方法无法完成这个需求。此时我们可以做到自定义一个function_score函数,自己将某个field的值,跟es内置算出来的分数进行运算,然后由自己指定的field来进行分数的增强
注:new_score代表新分数;_score代表旧分数;factor代表权重;number_of_votes代表搜索次数
4.ES分布式架构
ES集群通过自定义的一些机制来保证集群条件下的扩展性,效率及事务性等特性。本小节主要从ES分片和副本机制、扩容机制、数据路由机制、增删改查机制、写一致性原理和Quorum机制这几个方面来介绍ES分布式架构。为便于理解需先解释一些相关专业用语。
(1)分片机制(shard):ES决定特定的数据存到特定的分片中去。
(2)分片副本:主分片的备份。
(3)集群发现机制:新加入的es进程会作为一个节点(node)会自动发现集群并加入进去。
(4)分片负载均衡:例如现在有10个shard,集群中3个node,ES会均衡分配shard,保证node负载均衡。
(5)数据路由:某条数据最终根据什么路径进行存储。
(6)ES透明隐藏特性:ES针对集群的操作对用户进行隐藏,用户面对的只是数据而不是相关的机器。
4.1分片和副本机制
ES利用分片机制将用户数据分节点存储,保证存储效率以及数据安全性。重要性不言而喻。在介绍ES分片机制之前,首先需要说明ES主节点(master)主要职责。ES集群主节点主要进行与集群操作相关内容,比如删除或者创建索引,跟踪哪些节点是集群的一部分,决定分片(shard)的分配情况,因为稳定的节点对集群健康非常重要。
ES分片机制主要基于以下规则进行。
(1)ES根据将一个索引下的数据根据数据大小以及分片个数分布式存储,所有主分片共同组成一个索引。
假设一个索引大小为3T,每个分片存储为1T,每个分片有一个副本则单节点下分片存储机制如图所示,其中每个文档只能存在于一个分片下,3个分片共同组成一个索引。
(2)副本和主分片上的数据是同步的,承担集群数据的容错功能。
(3)主分片的个数一旦确认不可改变(原因请阅数据路由小节),但是副本的数量是可以随意改变的。
(4)某个分片与他的副本不在同一个节点上,但是与其他副本可以在一个节点上。
总结:ES分片以及副本机制在保证数据存储效率以及数据安全性方面提供了有力的支持,也是ES能够快速检索数据的架构基础。
4.2扩容机制
在介绍ES扩容机制之前,需要先需要介绍ES的【集群发现机制】:新加入的es进程作为一个节点(node)会自动发现集群并加入进去。集群发现机制为ES扩容提供了后续操作的先决条件。
(1)水平扩容:增加服务器个数。
自平衡机制(rebalance): ES在增加减少节点时对shard进行自动均衡,如下图当ES集群server2有两个分片,当集群中再加入一个节点server4,server2会自动将一个分片迁移到server4中。
水平扩容极限:当每个节点上只存在一个shard,则不可再进行扩容。
容错性能:当某台服务器发送宕机,ES集群能够正常数据的存取。
例:如下图所示,若集群有3个节点,6个shard,则按照ES的shard 分配机制3个节点分别存【主分片1,主分片2】,【主分片3,副本1】,【副本3,副本1】,若其中1个节点宕机,其他两台能够照常提供服务。
集群中接受服务器宕机台数越多,说明容错性越好。事例中,只能接受1台服务器宕机。可以看出,增加副本个数可以提高集群容错性能。
(2)垂直扩容:在服务器台数不变情况下,单个服务器容量扩大。
总结:扩容机制首先可提高集群吞吐量,其次也可以提高机器的容错性能。
4.3数据路由机制
一个索引由多个分片构成,当添加(删除、修改)一个文档时,ES就需要决定这个文档存储在哪个分片上,这个过程就成为数据路由(routing)。
路由算法:shard=hash(routing)%number_of_primary_shards
解释:对文档路由值取hash后对ES集群中主分片数取余数。
例:一个索引,3个主分片
(1)当每次增删改查时,都有一个routing,默认是文档ID值。
(2)对这个routing进行哈希函数计算。
(3)计算出的值再跟主分片个数取余数。文档就在对应的shard上。
总结:由路由算法快速定位数据位置,并兼有负载均衡的作用。同时可理解若主分片个数发生改变,我们就不能正确计算出的对应分片位置,造成查询故障,这就是分片数不可变的原因。
4.4数据增删改查机制
ES针对数据进行不同操作所用的策略略有不同,本小节分为数据操作(增删改)与数据查询两部分进行详述。
(1)增删改:
当用户发送请求进行增删改时,并不是首先发送到master节点,而是任何节点都有可能接收到请求(节点对等原则),如果数据在请求的节点上,那么直接返回,如果数据不在节点上,那么请求转发到相应节点,再返回数据。接受用户请求的节点被称为协调节点,负责根据路由算法将数据转发至相应节点,最终处理请求的节点负责将信息的正删改,并同步到对应的副本上。
(3)查询操作
与正删改操作不同,查询不需要进行数据操作,所以在转发的时候运用轮询算法,将请求依次发送到数据对应的对主、副分片,从而减小查询对单个节点的压力。
总结:ES增删改查策略给我们查询和修改数据提供了一条最简化的路径,同时保证了ES集群的负载均衡,保证了搜索的稳定和效率。
4.5写一致性原理和quorum机制
本小节主要介绍用户操作ES集群进行数据的增删改操作时,集群会针对不同的操作类型(ES条件),做出不同的响应(执行操作或者不执行操作)。主要分为通过指定参数和quorum算法两种机制进行指定。
(1)consistency参数指定
任何一个增删改操作都可以跟上一个参数consistency,可以给该参数指定值
例如:PUT /myindex/mytype/myid?consistency=one
one:只要有一个主分片是活跃的就可以执行。
all:所有主分片和副本都是活跃的才可以执行。
(3)quorum(ES默认机制):大部分分片是活跃的才可以执行。
算法:int((primary+number_of_replica)/2)+1
例如:集群中有3个主分片,每个分片有1个副本,那么计算公式为int((3+1)/2)+1=3及至少有3个分片是活跃的才能进行增删改操作。
5.JavaAPI常见用法
本小结以ES 6.2.4版本为例,主要总结ES条件查询及聚合查询相关接口并解释相关规则及其特点。ES服务端搭建及客户端配置不再赘述。
5.1条件查询
(1)term查询:主要用于精确匹配单(多)个词条。
QueryBuilder builder=QueryBuilders.termQuery("hospitalName","协和");
QueryBuilder builder=QueryBuilders.termsQuery("hospitalName","北京",”协和”);
(2)match查询:匹配短语查询要求查询字符串中的terms要么都出现Document中、要么terms按照输入顺序依次出现在结果中。在默认情况下,查询输入的terms必须在搜索字符串紧挨着出现,否则将查询不到。不过我们可以指定slop参数,来控制输入的terms之间有多少个单词仍然能够搜索到。
QueryBuilder builder = QueryBuilders.matchQuery("hospitalName","协和");
QueryBuilder builder=QueryBuilders.multiMatchQuery("hospitalName","北京",”协和”);
现将match匹配规则总结如下:
词条(含搜索顺序) 是否匹配
“北京”,“协和” 匹配doc_1
“协和”,“北京” 匹配doc_1
“北京”,“协和”,“附属” 匹配doc_1
“北京”,“附属”,“协和” 无匹配
(3)range查询:主要是对字段进行过滤筛选,可以是数字和字符串类型。
QueryBuilder builder=QueryBuilders.rangeQuery("postDate")
.from("2017-01-01").to("2019-02-02").format("yyyy-MM-dd");
(4)prefix查询:匹配字段的前缀
QueryBuilder builder=QueryBuilders.prefixQuery("hospitalName","北京协和");
匹配结果:“北京协和医院”
(5)wildcard查询:模糊查询,?匹配单个字符,*匹配多个字符
QueryBuilder builder=QueryBuilders.wildcardQuery("hospitalName","协和*");
匹配结果:“北京协和医院”
(6)fuzzy查询:分词模糊查询,通过增加fuzziness 模糊属性,来匹配短语。例:能够匹配hospitalName为Medical前或后加一个字母的term的文档Fuzziness的含义是检索的term 前后增加或减少n个单词的匹配查询。
QueryBuilder builder=QueryBuilders.fuzzyQuery("hospitalName","Medica")
.fuzziness(Fuzziness.ONE);
匹配结果:Peking Union Medical College Hospital;
(7)type查询:查询指定type的文档
QueryBuilder builder=QueryBuilders.typeQuery("hospital");
(8)ids查询:根据文档id匹配文档
QueryBuilder builder = QueryBuilders.idsQuery().addIds("1", "3");
匹配结果:“北京大学第三附属医院”、“解放军总医院第一附属医院”
(9)commonTerms查询:常用词查询,含有Piking的医院
QueryBuilder builder=QueryBuilders.commonTermsQuery("hospitalName","piking");
(10)queryString查询:字符串匹配查询,例如查询含有Peking但是不含Medical的文档
QueryBuilder builder=QueryBuilders.queryStringQuery("+Peking -Medical");
匹配结果:Peking University Third Hospital
(11)constantScore查询:不计算相关度分数查询
QueryBuilder builder=QueryBuilders
.constantScoreQuery(QueryBuilders.termQuery("hospitalName","piking"));
(12)bool组合查询:组合查询主要有must、mustNot、should、filter等几个关键词用来匹配查询条件。例如,查询必须包含Peking且必须不含有Medical,可以含有University,且时间大于等于2018年1月1日的医院。
QueryBuilder builder=QueryBuilders.boolQuery()
.must(QueryBuilders.matchQuery("hospitalName","Peking"))
.mustNot(QueryBuilders.matchQuery("hospitalName","Medical"))
.should(QueryBuilders.matchQuery("hospitalName","University"))
.filter(QueryBuilders.rangeQuery("postDate").gte("2018-01-01")
.format("yyyy-MM-dd"));
5.2聚合查询
聚合查询主要用于针对文档某一指标进行相关统计,主要涉及客户端AggregationBuilders类进行处理。本小节结合互联网医院医生表展示聚合查询的用法,需要注意的是聚合查询需先指定查询结果对应的字段。
文档ID 文档内容
1 {“name”:”赵大保”,”age”:28}
2 {“name”:”孟小飞”,”age”:26}
3 {“name”:”苏小玲”,”age”:18}
(1)获取最大值。
AggregationBuilder agg= AggregationBuilders.max("aggMax").field("age");
结果:{”aggMax”:28}
(2)获取最小值:
AggregationBuilder agg = AggregationBuilders.min("aggMin").field("age");
结果:{”aggMin”:18}
(3)获取平均值:
AggregationBuilder agg = AggregationBuilders.avg("aggAvg").field("age");
结果:{”aggAvg”:24}
(4)求和:
AggregationBuilder agg = AggregationBuilders.sum("aggSum").field("age");
结果:{"aggSum":72}