ElasticSearch搜索引擎

本笔记分析研究对于ES的search功能的原理

一、查询分页

通过之前的学习,我们可以知道搜索时可以通过from和size字段来配置分页信息

1
2
3
4
5
6
7
8
POST /ecommerce/product/_search
{
"query": {
"match_all": {}
},
"from": 0,
"size": 2
}

1571034663141

我们先进行结果分析,

  • took:查询使用12毫秒
  • timed_out:没有超时
  • _shards:查询请求了多少个分片,成功请求多少个分片
  • hits:命中数量,最大匹配分数,具体命中数据

当我们看数据时,发现_id为2的在前,_id为1的在后,这是es底层分页导致的。

es查询分页原理:举例3个shard,3000条数据,查询from=100,size=10

  1. 因为from100,size10,即需要100-109的数据
  2. es协调节点向3个shard发出请求,要求获取每个shard的0-109的数据注意是0-109
  3. 每个shard接收到请求后,会创建一个from+size大小的priority queue,一个优先队列,将查询出的数据放入队列再全部返回给协调节点。
  4. 协调节点获取到这些数据,总数据量为330条,将这些数据放到自己的priority queue然后对这些数据进行排序
  5. 排序后将序号为100-109的数据返回给客户端

这时我们会发现,如果数据量增加到30万条,而用户需要最后10条,那么es的协调节点需要临时存储30万条数据且进行排序,会导致大量资源耗费,所以对于ES的分页,要尽量避免DEEP PAGING的情况。

二、全域搜索

ES提供了一种query string的字段,可以将关键词分词再进行搜索,并且可以全域搜索,即不指定查询哪个field

1
2
3
4
5
6
7
8
9
10
POST /ecommerce/product/_search
{
"query":{
"query_string": {
"query": "gaolujie yagao"
}
}
}

GET /ecommerce/product/_search?q=gaolujie yagao

问题提出:不指定field,ES底层是如何处理并查询所有域呢?难道是对每个field都进行查询吗?

如果对每个field都进行一次查询,太繁琐且性能低,ES的底层不是这样的,它有一个元数据_all field,在建立索引保存document的时候,会讲所有域的值串成一个字符串并赋值给元数据_all field。

例如:

1
2
3
4
5
6
{
"name": "jack",
"age": 26,
"address": "guangzhou"
}
_all field = "jack 26 guangzhou"

当全域搜索的时候只需搜索_all field元数据即可

三、mapping

我们之前了解mapping就类似一张表的数据结构,定义了多个字段,我们回过头来再研究一下

1)在es里直接插入数据时PUT shop/book/1及数据信息时,es会自动建立索引、type和mapping

2)mapping会根据数据自动定义field的数据类型,如2018-01-01日期格式的数据类型为date,100数字格式的为long

3)不同的数据类型可能有的是exact value精准匹配,有的是full text全文检索。比如text就为全文检索,date就为精准匹配

4)exact value在建立倒排索引时,是整个词进行建立,full text是使用分词器分词后再对不同的词进行建立。所以在搜索时也不同

5)mapping的建立可以让es自动建,或者手动设置mapping的field,包括数据类型,分词器,是否存储,是否索引等等

这里我们举个例子来看一看精准匹配与全文检索

1571043610271

创建两条数据,website/article,因为是直接插入的,所以mapping是es自动创建的,我们看下mapping格式

1571043677772

可以看到author_id是long,post_date是date,和我们上面说的一样。

我们通过上面说的全域搜索query string进行查询一下试一试:

1571043897206

可以看到我们全域搜索2017可以查询出2条数据

1571043986752

如果我们指定域post_date查询,只能查到一条,为什么呢?

全域搜索:查询的是_all field的字段,其类型text属于全文检索,即对2017-01-01这个数据分词后再查询,所以有2条

指定域:查询的是post_date字段,其类型date属于精准搜索,即对日期没有分词,又因为es5.x之后的优化,所以能查出一条记录,其实在5.x之前的版本是一条都查不出来的。即只能用2017-01-01这个搜索关键词才能查出。

四、Filter和Query

在前面已经学习了如何过滤数据。

1571057595341

这样一看,发现filter和query的功能好像差不多,能相互转化,那么到底应该用什么呢?

query:每次查询需要进行计算匹配值并排序,无法缓存结果

filter:每次只按搜索条件过滤,不排序计算,会缓存结果

这样一看,使用场景显而易见了,这里要说下,看着filter不计算,效率肯定比query高,其实不然,在第一次查询时,query的效率可能还会比filter高,因为filter需要缓存结果,但是除了第一次查询,filter的速度比query快几十上百倍。

五、ES的相关度评分

在进行查询操作时,前面介绍说排序默认是按_score分数来排的,且es底层的计算排序也是如此,那么到底是如何排序计算呢?就是我们要讲的相关度评分,TF/IDF(词频/逆向文档频率)

官方文档告诉我们,分数的由来主要有三个因素决定

5.1.词频(TF)

词频即词在文档中出现的频率,频率越高,权重越高,计算公式为:
$$
tf(td) = √frequency
$$
t在文档d的频率是:该词在文档中出现次数的平方根

例如:搜索hello world

doc1:hello you, and world is very good
doc2:hello, how are you

那么分词后在doc1出现2次,doc2出现1次,即doc1分数高

如果不在意词在某个字段中出现的频次,而只在意是否出现过,则可以在字段映射中禁用词频统计:

1
2
3
4
5
6
7
8
9
10
11
12
13
PUT /my_index
{
"mappings": {
"doc": {
"properties": {
"text": {
"type": "text",
"index_options": "docs"
}
}
}
}
}

设置其field的index_options为docs,即计算分数时不会再计算词频TF

5.2.逆向文档频率(IDF)

即词在总文档中出现的频率,频率越高,分数越低
$$
idf(t) = 1 + log ( numDocs / (docFreq + 1))
$$
t 的逆向文档频率( idf )是:索引中文档数量除以所有包含该词的文档数,然后求其对数。

例如:搜索hello world

doc1:world is very good,bro(world在总document中出现1000次)
doc2:hello, how are you(hello在总)

因为doc1的出现次数更多,所以doc2的分数更高

5.3.字段长度归一值(flnorm)

Field-length norm:字段的长度越长,其分数越低
$$
norm(d) = 1 / √numTerms
$$
字段长度归一值( norm )是字段中词数平方根的倒数

例如:搜索hello world

doc1:world is very good,bro
doc2:hello, how are you

因为doc2的长度更短,所以doc2的分数更高

Norm和TF一样也可以禁用的

1
2
3
4
5
6
7
8
9
10
11
12
13
PUT /my_index
{
"mappings": {
"doc": {
"properties": {
"text": {
"type": "text",
"norms": { "enabled": false }
}
}
}
}
}

设置其field的norms为false,即计算分数时不会再计算Norm

六、SearchType

ES是分布式的全文检索系统,其特点就是分片存储数据,ES如何获取用户需要的数据且排序呢?ES搜索排序分为两步:

1)ES向5个分片发出请求,叫Scatter

2)5个分片独立搜索,将符合条件的数据返回,ES将数据集中起来再进行重新排序,返回给用户,即Gather

这样就会出现问题:

1)数量问题:用户需要10条数据,ES向5个分片发出前10条数据的请求,得到结果会有50条数据,ES再进行排序返回,用户就会获得50条数据,不符合请求数量。

2)排序问题:ES的排序是基于分片传来的分数的,即每个分片有不同的使用频率,举个例子:分片1的name字段的mike使用200次,allie使用100次,用户查询1条最高使用数据,当然分片1返回mike,这时分片2的name字段bob使用10次,jasper使用5次,就会返回bob,这样的话明显allie的使用频率更高。所以如果要解决这种情况,ES要先获取分片上频率,然后统一计算后再排序查询。

这两个问题,估计ES也没有什么较好的解决方法,最终把选择的权利交给用户,方法就是在搜索的时候指定query type。

有三种SearchType:

  • Query and fetch:即ES请求所有分片,返回n*5的数据整合排序,就会出现上面的问题,返回数量是用户要求的n倍。这种方法以及被弃用了
  • query then fetch:ES请求所有分片,获取排名相关的信息,而不是数据,es获取后进行计算然后再根据计算结果向不同分片请求数据,这样可以解决上面数量问题和排序问题。默认使用,也最常用
  • DFS query then fetch:同样多了初始化散发过程。

七、零停机重建索引

前提条件:Java应用中使用的别名查询而不是索引名

经过之前的学习知道field属性一旦创建就不能被修改,比如创建时是text,你不能后面再修改成date类型,那么我们模拟一下真实生产场景:

7.1.问题的提出

1)创建document,使用dynamic mapping,但是不小心把title的类型设置成date了,实际是text类型

1
2
3
4
5
PUT my_index/my_type/1
{
"title":"2019-01-01",
"content":"zero gogo"
}

1571122209687

2)向索引添加text类型的title数据,因为类型不匹配报错

1571122263592

3)因为不能直接修改,所以需要重建索引

7.2.问题的解决

1)创建索引,配置正确的field类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
PUT /new_index
{
"mappings": {
"my_type": {
"properties": {
"title": {
"type": "text"
},
"content":{
"type":"text"
}
}
}
}
}

2)设置别名,将新索引加入到旧索引的别名上(Java程序使用的是别名)

1
2
3
4
5
6
7
POST /_aliases
{
"actions": [
{ "add": { "index": "new_index", "alias": "alias_index" }},
{ "add": { "index": "my_index", "alias": "alias_index" }}
]
}

3)使用reindex api同步索引数据

1
2
3
4
5
6
7
8
9
POST _reindex
{
"source": {
"index": "my_index"
},
"dest": {
"index": "new_index"
}
}

4)查看新索引数据是否同步成功

1571123565039

同步成功

5)删除别名中的旧索引

1
2
3
4
5
6
7
POST /_aliases
{
"actions": [
{ "remove": { "index": "my_index_v1", "alias": "my_index" }},
{ "add": { "index": "my_index_v2", "alias": "my_index" }}
]
}

八、深入document读写机制底层

在前面讲document如何写入时,是说协调节点分配通过路由计算分配给不同的节点进行写入,今天我们来讲讲具体节点中primary shard是如何把document写入到磁盘中的。

8.1.写入原理

1571190146881

1)shard将数据先写到内存buffer中,在内存中的数据是搜索不到的

2)buffer每隔一秒将数据刷到file上,即segment file,但是如果这时直接将file写入磁盘,效率会非常慢,而且用户就无法NRT近实时搜索数据,所以会先将segment file数据刷到os cache中,即系统缓存

3)buffer数据进入os cache中后,buffer清空,数据可以被搜索到

4)buffer清空了,数据存在os cache,宕机不就全部丢失了?这时就出现了translog日志文件,数据进入shard后,不止将其写入到buffer中,还将一份写入到translog中,当然也是先保存在translogcache中,然后每隔5s会将translog cache中的数据写入到translog中,即保存到磁盘上(所以这5s如果宕机,可能会丢失5s的数据)

5)重复上面的操作,buffer每隔一秒写入到新的segment file中,然后写入os cache中,并每隔5秒刷数据到translog

6)当translog越来越大,会进行一次commit(flush)操作,默认30分钟flush一次

​ 6.1)将buffer所有数据保存到新的segment file,然后刷到os cache

​ 6.2)将一个commit point写入到磁盘,这个commit point保存了对应所有的segment file

​ 6.3)将os cache中的所有index segment file缓存数据,被fsync强行刷到磁盘上

​ 6.4)清空translog,创建一个新的translog,commit结束

实际上你在这里,如果面试官没有问你 es 丢数据的问题,你可以在这里给面试官炫一把,你说,其实 es 第一是准实时的,数据写入 1 秒后可以搜索到;可能会丢失数据的。有 5 秒的数据,停留在 buffer、translog os cache、segment file os cache 中,而不在磁盘上,此时如果宕机,会导致 5 秒的数据丢失

8.2.更新/删除原理

当数据写入buffer后,并刷入os cache,这时会有一个commit point操作,标志这些数据是属于哪个segment file的,这时就会生成一个.del文件,将里面某个docment标为deleted状态,这样搜索时便会过滤掉。

8.3.再次优化

我们知道,每过一秒写入一个新的segment file,那么segment file会非常庞大,所以一段时间后,es会自动进行merge操作,将一些segment合并,并且会将标记为deleted数据物理删除

merge执行流程

1)选择大小相近的一些segment file,merge成一个大的segment

2)将新的segment file刷到os cache中

3)写一个新的commit point,包括新的segment以及排除旧的segment

4)将新的segment打开提供搜索,删除旧的segment