Elasticsearch 是一个开源的搜索引擎,建立在一个全文搜索引擎库 Apache Lucene 基础之上。Elasticsearch 也是使用 Java 编写的,它的目的是使全文检索变得简单,通过隐藏 Lucene 的复杂性,取而代之的是提供一套简单一致的 RESTful API。
1.分布式特性
ES 支持集群模式,是一个分布式系统,每个 ES 实例本质上是一个 JVM 进程。
这里推荐一款 ES 开源监控工具:cerebro
一些概念:
概念 | 说明 |
---|---|
Cluster State | ES集群相关的数据称为Cluster State,主要记录节点信息(节点名称、连接地址),索引信息(索引名称、配置)等。 |
master节点 | 可以修改cluster state的节点,一个集群只能有一个;cluster state存储在每个节点上,master维护最新版本并同步给其他节点;master节点是通过集群中所有节点选举产生的,可以被选举的节点称为master-eligible节点(配置node.master:true,默认true) |
Coordinating Node | 处理请求的节点称为Coordinating Node,该节点为所有节点的默认角色,不能取消。Coordinating的作用就是路由请求到正确的节点处理,比如创建索引的请求到master节点 |
Data Node | 存储数据的节点称为data节点(配置node.data:true,默认true) |
1.副本与分片
- 分片存储了部分数据,可以分布于任意节点上
- 分片数在索引创建时指定且后续不允许再更改,默认为5个
- 分片有主分片和副本分片之分,以实现数据的高可用
- 副本分片的数据由主分片同步,可以有多个,可以动态调整,从而提高读取的吞吐量
创建索引时我们指定了 3 个分片和 1 个副本(总共会是 6 个分片):
PUT /book
{
"settings": {
"number_of_shards": "3",
"number_of_replicas": "1"
}
}
其中,node-1 是 master 节点,P0、P1、P2 是主分片,R1、R2、R0 是副本分片。
问题1:此时增加节点是否能提高索引 book 的数据容量?
答:不能,因为创建索引 book 时,你只设置了 3 个分片(分片数在索引创建时指定且后续不允许再更改),这 3 个分片已经分布在 3 台节点上了,新增的节点无法利用。
问题2:此时增加副本数是否能提高 book 的读取吞吐量?
答:不能,因为新增的副本也是分布在这 3 个节点上,还是利用了同样的资源,意味着当一个请求过来的时候,其实还是在 node-1、node-2、node-3 上做读取,在并行的做读请求的时候,同一时刻占用的还是同一资源,它是不会提升读取吞吐量的。如果要增加吞吐量,还需要新增节点。
通过这两个问题,可以看出,分片数的设定很重要,需要提前规划好,过小会导致后续无法通过增加节点实现水平扩容;过大会导致一个节点上分布过多分片,造成资源浪费,同时会影响查询性能。
2.集群状态
ES 可以通过如下命令查看集群的健康状态:
GET _cluster/health
- green:健康状态,指所有主副分片都正常分配
- yellow:指所有主分片都正常分配,但是有副本分片未正常分配
- red:有主分片未分配
2.故障转移
故障转移指当集群其中有节点发生问题的时候,集群如何去修复,自动的去修复这些问题。
问题1:如果 node-1(master 节点)所在机器宕机导致服务终止,此时集群会如何处理?
答:node-2 和 node-3 会定时去 ping node-1 的,发现 node-1 无法响应一段时间后会发起 master 选举:
比如这里选举出 node-2 为新的 master 节点。此时由于主分片 P0 下线,集群状态变为 Red:
node-2 发现主分片 P0 未分配,将 R0 提升为 主分片,此时由于所有主分片都正常分配,集群状态变为 Yellow:
然后 node-2 会为 P0 和 P1 生成新的副本,集群状态变为绿色:
3.文档分布式存储
文档最终会存储在分片上。
我们假设文档 doc1 最终存储在分片 P1 上,在 P1 和 R1 副本都会有这个文档。
问题1:doc1 是如何存储到分片 P1 的?选择 P2 的依据是什么?
答:其实是有一个文档到分片的映射算法,这个算法使得文档均匀分布在所有分片上,以充分利用资源。ES 是通过如下的公式计算文档对应的分片:
shard = hash(routing) % number_of_primary_shards
hash 算法可以保证可以将数据均匀地分散在分片中,routing 是一个关键参数,默认是文档 id,也可以自行指定,number_of_primary_shards 是主分片数。该算法与主分片数相关,这也是分片数一旦确定后便不能更改的原因。
一个完整的文档创建流程如下:
- client 向 node-3 发起创建文档的请求;
- node-3 通过 routing 计算该文档应该存储在 P1 上,查询 cluster state 后确认主分片 P1 在 node-2 上,然后转发创建文档的请求到 node-2;
- P1 接收并执行创建文档请求后,将同样的请求发送到副本分片 R1;
- R1 接收并执行创建文档请求后,通知 P1 成功的结果;
- P1 接收副本分片结果后,通知 node-3 创建成功;
- node-3 返回结果到 client。
一个完整的文档读取流程如下:
- client 向 node-3 发起获取文档 doc1 的请求;
- node-3 通过 routing 计算该文档在 P1 上,查询 cluster state 后获取 P1 的主副分片列表,然后以轮询的机制获取一个分片,比如这里获取到了 R1,然后转发读取文档的请求到 node-1;
- R1 接收并执行读取文档请求后,将结果返回 node-3;
- node-3 返回结果给 client。
文档批量创建的流程如下:
- client 向 node-3 发起批量创建文档的请求 (bulk);
- node-3 通过 routing 计算所有文档对应的分片,然后按照主分片分配对应执行的操作,同时发送请求到涉及的主分片,比如这里 3 个主分片都需要参数;
- 主分片接收并执行请求后,将同样的请求同步到对应的副本分片;
- 副本分片执行结果后返回结果到主分片,主分片再返回 node-3;
- node-3 整合结果后返回 client。
文档批量读取的流程如下:
- client 向 node-3 发起批量获取文档的请求 (mget);
- node-3 通过 routing 计算所有文档对应的分片,然后以轮询的机制获取要参与的分片,按照分片构建 mget 请求,同时发送请求到涉及的分片,比如这里有 2 个分片需要参与;
- R1、R2 返回文档结果;
- node-3 返回结果给 client。
4.脑裂问题
脑裂问题(split-brain)是分布式系统中的经典网络问题,如下图所示,3 个节点组成的集群,突然 node-1 的网络和其他两个节点中断:
这个时候,node-2 和 node-3 会重新选举 master,比如 node-2 成为了新 master,此时会更新 cluster state,node-1 自己组成集群后,也会更新 cluster state。这就出现了一个问题,同一个集群有两个 master,而且维护不同的 cluster state,网络恢复后无法选择正确的 master。
脑裂问题解决方案:
仅在可选举 master-eligible 节点数大于等于 quorum 时才可以进行 master 选举。
- quorum = master - eligible 节点数 / 2 + 1,例如 3 个 master-eligible 节点时,quorum 为 2;
- 设定 discovery.zen.minimum_master_nodes 为 quorum 即可避免脑裂。
发生网络隔离后,node-1 可选举数未达到 2,不选举,node-2 和 node-3 可选举数为 2,重新选举 master。待网络恢复后,node-1 可正常加入集群
5.倒排索引的不可变更
倒排索引一旦生成,不能更改,其好处如下:
- 不用考虑并发写文件的问题,杜绝了锁机制带来的性能问题;
- 由于文件不再更改,可以充分利用文件系统缓存,只需载入一次,只要内存足够,对该文件的读取都会从内存读取,性能高;
- 利于生成缓存数据;
- 利用对文件进行压缩存储,节省磁盘和内存存储空间。
坏处:
- 需要写入新文档时,必须重新构建倒排索引文件(全部文档),然后替换老文件后,新文档才能被检索,导致文档实时性差。
6.文档搜索实时性
写入新文档时,必须重新构建倒排索引文件(全部文档),导致文档实时性差。
这个问题是有解决方案的:
新文档直接生成新的倒排索引文件(只是新文档),查询的同时查询所有的倒排文件,然后做结果的汇总计算即可。
- Lucene 便是采用了这种方案,它构建的单个倒排索引称为 segment,合在一起称为 Index,与 ES 中的 Index 概念不同。ES 中的一个分片对应一个 Lucene Index。
- Lucene 会有一个专门的文件来记录所有的 segment 信息,称为 commint point。
1.refresh
- segment 写入磁盘的过程依然很耗时,可以借助文件系统缓存的特性,先将 segment 在缓存中创建并开放查询来进一步提升实时性,该过程在 ES 中被称为 refresh;
- 在 refresh 之前文档会先存储在一个 buffer 中,refresh 时将 buffer 中的所有文档清空并生成 segment;
- ES 默认每一秒执行一次 refresh,因此文档的实时性被提高到 1 秒,这也是 ES 被称为近实时 (Near Real Time) 的原因。
refresh 发生的时机主要有如下几种情况:
- 间隔时间达到时,通过 index.settings.refresh_interval 来设定,默认是1秒;
- index.buffer 占满时,其大小通过 indices.memory.index_buffer_size 设置,默认为 jvm heap 的 10%,所有分片共享;
- flush 发生时也会发生 refresh。
2.translog
如果在内存中的 segment 还没有写入磁盘前发生了宕机,那么其中的文档就无法恢复了,如何解决这个问题?
- ES 引入 translog 机制。写入文档到 buffer 时,同时将该操作写入 translog;
- translog 文件会即时写入磁盘 (fsync),ES 6.x 版本默认每个请求都会落盘,可以修改为每 5 秒写一次,这样风险便是丢失 5 秒内的数据,相关配置为 index.translog.*;
- ES 启动时会检查 translog 文件,并从中恢复数据。
3.flush
flush 负责将内存中的 segment 写入磁盘,主要做如下的工作:
- 将 translog 写入磁盘;
- 将 index buffer 清空,其中的文档生成一个新的 segment,相当于一个 refresh 操作
- 更新 commit point 并写入磁盘;
- 执行 fsync 操作,将内存中的 segment 写入磁盘
- 删除旧的 translog 文件。
flush 发生的时机主要有如下几种情况:
- 间隔时间达到时,默认是30分钟,ES 5.x 之后版本无法修改;
- translog 占满时,其大小可以通过 index.translog.flush_threshold_size 控制,默认是 512MB,每个 index 有自己的 translog。
4.删除与更新文档
segment 一旦生成就不能更改,那么如果你要删除文档该如何操作?
- Lucene 专门维护一个 .del 的文件,记录所有已经删除的文档,注意 .del 上记录的是文档在 Lucene 内部的 id;
- 在查询结果返回前会过滤 .del 中的所有文档。
更新文档如何进行呢?
- 首先删除文档,然后再创建新文档。
整体视角:
5.Segment Merging
- 随着 segment 的增多,由于一次查询的 segment 数增多,查询速度会变慢;
- ES 会定时在后台进行 segment merge 的操作,减少 segment 的数量;
- 通过 force_merge api 可以手动强制做 segment merge 的操作。
3.search运行机制
3.Match Query的流程
Match Query(全文匹配 )的流程:
1.Query-Then-Fetch
search 执行的时候实际分两个步骤运作的:
- Query 阶段;
- Fetch 阶段。
node-3 在接收到用户的 search 请求后,会先进行 Query 阶段(此时是 Coordinating Node 角色):
- node-3 在 6 个主副分片中随机选择 3 个分片(涵盖了 Index 完整的数据),发送 search request;
- 被选中的 3 个分片会分别执行查询并排序,返回 from + size 个文档 id 和排序值给 node-3;
- node-3 整合 3 个分片返回的 form + size 个文档 id,根据排序值排序后选取 from 到 from + size 的文档 id;
Fetch 阶段,node-3 根据 Query 阶段获取的文档 id 列表去对应的 shard 上获取文档详情数据;
- node-3 向相关的分片发送 multi_get 请求;
- 3 个分片返回文档详细数据;
- node-3 拼接返回的结果并返回给客户。
2.相关性算分
相关性算分(relevance)是指文档与查询语句间的相关度。
通过倒排索引可以获取与查询语句相匹配的文档列表,那么如何将最符合用户查询需求的文档放在前列呢?本质上是一个排序问题,排序的依据是相关性算分。相关性算分的几个重要概念如下:
概念 | 说明 |
---|---|
Term Frequency(TF,词频) | 单词在该文档中出现的次数,TF 越高,相关度越高 |
Document Frequency(DF,文档频率) | 单词出现的文档数 |
Inverse Document Frequency(IDF,逆向文档频率) | 与 DF 相反,简单理解为 1/DF,即单词出现的文档数越少,相关度越高 |
Field-length Norm | 文档越短,相关度越高 |
ES 目前主要有两个相关性算分模型:
概念 | 说明 |
---|---|
TF/IDF 模型 | Lucene 的经典模型 |
BM25 模型 | ES 5.x 版本之后的默认模型,BM指Best Match,25指迭代了25次才计算方法,是针对TF/IDF的一个优化。BM25相比TF/IDF的一大优化是降低了TF在过大时的权重 |
我们在进行查询时,可以通过 explain 参数来查看具体的计算方法,但要注意:
- ES 的算分是按照 shard (分片)进行的,即 shard 的分数计算是相互独立的,所以在使用 explain 的时候注意分片数。可以通过设置自索引的分片数为 1(这样所有文档就存在一个 shard 上了)来避免这个问题。
比如可以使用 Kibana DevTools 输入命令:
POST /book/_search
{
"explain":true,
"query":{
"match": {"title": "细说kibana"}
}
}
- 相关性算分在 shard 与 shard 间是相互独立的,也就意味着同一个 Term 的 IDF 等值在不同 shard 上是不同的。文档的相关性算分和它所处的 shard 相关;
- 在文档数量不多时,会导致相关性算分严重不准的情况发生。
解决思路有两个:
- 一是设置分片数为 1,从根本上排除问题,在文档数量不多的时候可以考虑该方案,比如百万到千万级别的文档数量;
- 二是使用 DFS Query-Then-Fetch 查询方式。
DFS Query-Then-Fetch 是在拿到所有文档后再重新完整的计算一次相关性算分,耗费更多的 cpu 和内存,执行性能也比较低下,一般不建议使用。使用方式如下:
POST /book/_search?search_type=dfs_query_then_fetch
{
"query":{
"match": {"title": "细说kibana"}
}
}
3.排序
ES 默认会采用相关性算分排序,用户可以通过设定 sorting 参数来自行设定排序规则。
- 排序的过程实质上是对字段原始内容排序的过程,这个过程中倒排索引无法发挥作用,需要用到正排索引也就是通过文档 id 和字段可以快速得到字段原始内容;
- ES 对此提供了两种实现方式:fielddata 默认禁用;doc values 除了text类型默认启用。
对比 | fielddata | doc values |
---|---|---|
创建时机 | 搜索时即时创建 | 索引时创建,与倒排索引创建时机一致 |
创建位置 | JVM Heap | 磁盘 |
优点 | 不会占用额外的磁盘资源 | 不会占用 Heap 内存 |
缺点 | 文档过多时,即时会花过多时间,占用过多 Heap 内存 | 减慢索引的速度,占用额外的磁盘空间 |
fielddata 默认是关闭的,可以通过如下 API 开启:
PUT /book/_mapping/doc
{
"properties":{
"title": {"type":"text", "fielddata":true}
}
}
- 此时字符串是按照分词后的 term 排序,往往结果很难符合预期;
- 一般是在对分词做聚合分析的时候开启。
doc values 默认是启用的,可以在创建索引的时候关闭:
PUT /book
{
"mappings":{
"doc":{
"properties":{
"title":{"type":"text", "doc_values":false}
}
}
}
}
- 如果后面要再开启 doc values,需要做 reindex 操作。
可以通过 docvalue fields 字段获取 fielddata 或 doc values 中存储的内容:
POST /book/_search
{
"docvalue_fields":[
"title.keyword",
"desc.keyword",
"country"
]
}
4.分页与遍历
ES 提供了 3 种方式来解决分页与遍历的问题:
- from/size
- scroll
- search_after
类型 | 场景 |
---|---|
from/size | 最常用的分页方案,需要实时获取顶部的部分文档,且需要自由翻页 |
scroll | 需要全部文档,如导出所有数据的功能 |
search_after | 需要全部文档,不需要自由翻页 |
1.from/size
from/size 是最常用的分页方案,from 表示开始位置,size 表示大小。
深度分页是一个经典的问题:在数据分片存储的情况下如何获取前 1000 个文档?
- 获取从 990-1000 的文档时,会在每个分片上都先获取 1000 个文档,然后再由 Coordinating Node 聚合所有分片的结果后再排序选取前 1000 个文档;
- 页数越深,处理文档越多,占用内存越多,耗时越长。尽量避免深度分页,ES 通过 index.max_result_window 限定最多到 10000 条数据。
2.scroll
scroll 是遍历文档的 API,以快照的方式来避免深度分页的问题。
- 不能用来做实时搜索,因为数据不是实时的;
- 尽量不要使用复杂的 sort 条件,使用 _doc 最高效;
- 使用稍嫌复杂。
第一步需要发起 1 个 scroll search,如下所示:
- ES 在收到该请求后会根据查询条件创建文档 id 合集的快照
POST /book/_search?scroll=5m //scroll=5m 指该scroll快照的过期时间
{
"size":1 //指明每次scroll返回的文档数
}
ES 返回参数:
{
"_scroll_id" : "DnF1ZXJ5VGhlbkZldGNoBQAAAAAAAyqNFjlHdVJROUFQUkN5MndRQ3NfRHpWaVEAAAAAAAJi2BYzM2VaaUt6N1RuQzF0UGFKUHlBNW9nAAAAAAAE8SwWZEpJeWpfcnJSWkNYcS10cXc5MTdXQQAAAAAAAmLZFjMzZVppS3o3VG5DMXRQYUpQeUE1b2cAAAAAAATxKxZkSkl5al9yclJaQ1hxLXRxdzkxN1dB",
...
}
_scroll_id 就是后续调用 scroll api 时的参数。
第二步调用 scroll search 的 api,获取文档集合,如下所示:
- 不断迭代调用直到返回 hits.hits 数组为空时停止
POST /_search/scroll
{
"scroll":"5m", //快照的过期时间
"scroll_id":"DnF1ZXJ5VGhlbkZldGNoBQAAAAAAAyqNFjlHdVJROUFQUkN5MndRQ3NfRHpWaVEAAAAAAAJi2BYzM2VaaUt6N1RuQzF0UGFKUHlBNW9nAAAAAAAE8SwWZEpJeWpfcnJSWkNYcS10cXc5MTdXQQAAAAAAAmLZFjMzZVppS3o3VG5DMXRQYUpQeUE1b2cAAAAAAATxKxZkSkl5al9yclJaQ1hxLXRxdzkxN1dB" //第一步返回的_scroll_id
}
ES 返回参数:
{
"_scroll_id" : "DnF1ZXJ5VGhlbkZldGNoBQAAAAAAAyqNFjlHdVJROUFQUkN5MndRQ3NfRHpWaVEAAAAAAAJi2BYzM2VaaUt6N1RuQzF0UGFKUHlBNW9nAAAAAAAE8SwWZEpJeWpfcnJSWkNYcS10cXc5MTdXQQAAAAAAAmLZFjMzZVppS3o3VG5DMXRQYUpQeUE1b2cAAAAAAATxKxZkSkl5al9yclJaQ1hxLXRxdzkxN1dB",
"hits" : {
"total" : 10,
"max_score" : 1.0,
"hits" : [
...
]
},
...
}
_scroll_id 下一次调用时使用到的 scroll_id。
过多的 scroll 调用会占用大量内存,可以通过 clear api 删除过多的 scroll 快照:
DELETE /_search/scroll
{
"scroll_id":[
"DnF1ZXJ5VGhlbkZldGNoBQAAAAAAAyqNFjlHdVJROUFQUkN5MndRQ3NfRHpWaVEAAAAAAAJi2BYzM2VaaUt6N1RuQzF0UGFKUHlBNW9nAAAAAAAE8SwWZEpJeWpfcnJSWkNYcS10cXc5MTdXQQAAAAAAAmLZFjMzZVppS3o3VG5DMXRQYUpQeUE1b2cAAAAAAATxKxZkSkl5al9yclJaQ1hxLXRxdzkxN1dB"
]
}
DELETE /_search/scroll/_all
3.search_after
避免深度分页的性能问题,提供实时的下一页文档获取功能。
- 缺点是不能使用 from 参数,即不能指定页数;
- 只能下一页,不能上一页
- 使用简单
第一步为正常的搜索,但要指定 sort 值,并保证值唯一:
POST /book/_search
{
"size":1,
"sort":{
"word_count":"desc", //要保障sort值唯一
"_id":"desc"
}
}
ES 返回参数:
{
"hits" : {
"hits" : [
{
"_id" : "100021",
...,
"sort" : [
20000,
"100021"
]
}
], ...
}, ...
}
第二步使用上一步最后一个文档的 sort 值进行查询:
POST /book/_search
{
"size":1,
"search_after":[20000,"100021"],
"sort":{
"word_count":"desc",
"_id":"desc"
}
}
search_after 是如何避免深度分页问题的?
- 通过唯一排序值定位将每次要处理的文档数都控制在 size 内。
参考文档:
https://www.elastic.co/guide/en/elasticsearch/reference/6.8/search.html