一:近实时搜索原理

 

先认识几个基本概念:

1、segment

es基本存储单元是shard,index分散在多个shard上。 而每个shard由多个段-segment组成,每次创建一个新Document(一条新数据),就会归属于一个新的segment。 删除数据时,也不会直接删除当前segment,只是标记为已删除状态,后续在合适时机删除。

2、translog

操作日志,用来记录操作动作,防止数据丢失。 每个shard中对应一个translog文件。

3、commit

提交,意味着将多个segment,合并成新的更大的segment,并刷入磁盘。

4、refresh

es索引数据时,先是写入到内存buffer中,默认1s执行一次refresh操作,刷新到一个新的segment中,在segment中数据才具备被检索的结构,才能被查询。当写入segment后,会清空内存buffer。 所以近实时搜索通常指的是: 写入数据1s后才能被检索。

当然,可以改变默认时长(时长为-1代表关闭刷新):

PUT /mytest/_settings 
 {
   "refresh_interval": "20s"
 }

或者直接调用refresh的api:

POST /_refresh    刷新所有索引
POST /mytest/_refresh     刷新某个索引
PUT /mytest/_doc/1?refresh=true      //刷新具体文档数据
 {
   "test": "test"
 }


5、flush

数据清洗,将内存缓冲区、segments中、translog等全部刷盘,成功后清空原数据和translog。

默认每30分钟执行一次,或者translog变大超过设定值后触发。

commit需要一个fsync同步操作来保证数据物理的被成功刷盘,假如每一个写操作都这样,那么性能会大大下降。 es在内存buffer与磁盘之间,引入了文件系统缓存。 refresh将数据刷到新的segment,这些segment其实是先存在于文件系统缓存,后续再刷盘。

整体流程:

 当es收到写请求后,数据暂时写入内存buffer,并添加translog。默认1s后,refresh数据到file system cache,并清空内存buffer。 30分钟后,执行flush刷数据到磁盘(tanslog大小超过设定阈值也会执行flush)。

分片默认会30分钟执行一次flush,也可手动调用api:

POST /mytest/_flush        刷新某一个索引

POST /_flush?wait_for_ongoin         刷新所有索引直到成功后返回

(手动调用flush情况很少,不过要关闭索引或者重启节点时,最好执行一下。因为es恢复索引或者重新打开索引时,它必须要先把translog里面的所有操作给恢复,所以也就是说translog越小,recovery恢复操作就越快)

上面说了数据的流程,现在看看translog是如何工作的?

当数据被refresh期间,新的操作日志会继续追加到translog,默认每次写请求(如 index, delete, update, bulk)都会刷盘。 这样会有很大的性能问题,所以如果能容忍5s内的数据丢失情况,还是使用每5s异步刷盘的方式。

配置如下:

PUT /mytest/_settings 
 {
   "index.translog.durability": "async",
   "index.translog.sync_interval": "5s"
 }

要保证完全可靠,还是使用默认配置:

PUT /mytest/_settings 
 {
   "index.translog.durability": "request"
 }

流程图: 

es 删除数据很慢 es删除数据原理_数据

二: 段合并机制 

 在索引数据过程中,每一次的refresh都会创建新的segment,数量会越来越多,影响内存和CPU运行,查询也会在多个段中,影响性能。

所以,es会使用一定的策略,将segment不断的合并为更大的segment,最终被flush刷新到磁盘。

当然,合并会消耗大量IO和CPU,所以要对执行归并任务的线程作限速控制,默认是20MB,如果磁盘转速高,或者SSD等,可以适当调高:

PUT /_cluster/settings 
 { 
   "persistent" : 
   { 
     "indices.store.throttle.max_bytes_per_sec" : "100mb"
   } 
 }

线程数也可以调整,比如为CPU核心数的一半:

index.merge.scheduler.max_thread_count

下面来看看合并是依据什么策略来执行的:

主要有以下几条:

index.merge.policy.floor_segment     默认 2MB ,小于这个大小的 segment ,优先被归并。

index.merge.policy.max_merge_at_once      默认一次最多归并 10 个 segment

index.merge.policy.max_merge_at_once_explicit       默认 optimize 时一次最多归并 30 个

segment 。

index.merge.policy.max_merged_segment      默认 5 GB ,大于这个大小的 segment ,不用参与归 并, optimize 除外。


optimize api:

optimize代表手动强制执行合并,它可以通过参数max_num_segments指定,把某个index在每个分片上的segments最终合并为几个(最小是1个),比如日志是按天创建索引存储,可以合并为一个segment,查询就会很快。

POST /logstash-2022-10/_optimize?max_num_segments=1

三:数据的写一致性如何保证

ES如何才能成功写入数据:

5.0版本以前,使用参数consistency来保证,consistency有三个值可选:

one:只要有一个primary shard是可用的即可

all:要求所有的primary shard和replica shard都是可用的

quorum:默认选项。满足可用的shard数量才可写入成功。  它的计算方式是:

quorum  =  int((主分片总数 + 单个主分片的副本数)/ 2) + 1

5.0之后:

wait_for_active_shards:

指定多少个分片的数据都成功写入,才算成功。 默认是1,代表主分片成功即可(一条数据肯定只存在于一个主分片)。  最大值是 1+number_of_replicas,代表主分片和所有副本分片都成功。

timeout:

在多少时间内没有成功,就返回失败。 结合上面 只要在timeout时间内, wait_for_active_shards数满足都能成功(比如超时时间内,shard从不可用到可用,最终也会成功)。

 wait_for_active_shards可在索引的setting属性中全局设置,也可对某个document设置:

put /mytest/_doc/1?wait_for_active_shards=2&timeout=10s 
 
 
{ 
 
 
"name" 
  : 
  "xiao mi" 
 
 
}

四:query机制

es的SearchType有两个选项:

 query then fetch(默认方式):搜索时分两步进行:

1、向所有的shard发出请求,各shard只返回文档id和排名相关信息(即文档的得分,该分值只是在当前shard中比较后统计出来的),然后在请求节点上,对所有返回的文档按分值重新排序,取前size个文档id。

2、拿着id去相关shard获取完整的document信息(数据)。

优点:size数量和用户要求的一致。

缺点:排序不准确,因为不是全局打分排序。

DFS  query then fetch:

在上面的步骤之前,多了一个DFS步骤: 先对所有的shard发送请求,把所有shard中的词频和文档频率等进行全局打分,再执行上面的操作。

优点:返回的size和排序都是准确的

缺点:性能会差一些。

五:写和读的流程

写操作:

客户端选择某一个node发送请求(如果是协调节点,从新路由到对应的node),由primary shard进行处理,并同步replica shard,当shard数满足wait_for_active_shards后返回成功。

读操作:

客户端选择某一个node发送请求(如果是协调节点,从新路由到相关node: 含有primary 或者 replica的都可以)。  根据上面SearchType执行query的机制。

六:DocValues

es 删除数据很慢 es删除数据原理_java_02

es的倒排索引, 可以根据条件很快定位到是哪个document(能拿到doc_id),如上图,但此时我们并不知道每个doc里面的具体内容是什么,如果需要做排序聚合等,我们不可能把所有doc的内容都查出来再做,那样性能会非常差,因此倒排索引不适合聚合排序(从上面的query机制我们知道,查询是先获取id和得分,排序后取出size个,最终根据id去对应的shard上获取完整的doc内容,所以在最终获取完整doc前,肯定要先做排序)。

如果在获取数据之前,有一种索引中可以知道doc的值,那就可以做排序了。DocValues就是这样的一种正排索引,它存的是doc与对应词项的关系:

es 删除数据很慢 es删除数据原理_java_03

在创建索引的mapping映射时,doc_values属性默认为true开启,也可以在不需要的字段中指定为false,则该字段不能用作聚合排序。

PUT /mytest
 {
   "settings": {
     "number_of_shards": 3, 
     "number_of_replicas": 1
   },
   "mappings": {
     "properties": {
       "name":{
         "type": "keyword",
         "doc_values": false
       },
       "age":{
         "type": "keyword"
       }
     }
   }
 }

对于不需要聚合排序的字段,就禁用掉,减少索引成本。

DocValues与倒排索引是同时创建的,也是同样基于segment的操作,最终会序列化到磁盘。 

对于es来说,就是运用倒排索引搜索,并且用DocValues来实现聚合排序,所以es快。当然,对于快es在很多方面都做了细节的体现,比如filter查询就比普通的query快。

七:filter

bitset机制和caching机制来提高搜索效率。

bitset:

先根据条件在倒排索引中查找字符串,获取到document list,然后根据list,对每个搜索的结果构建一个bitset(二进制数组),来表示哪些doc中存在(0表示不存在,1表示存在)。

如下:

es 删除数据很慢 es删除数据原理_es 删除数据很慢_04

  如果搜索date为2020-02-02,那么对该条件构建出来的bitset为: [0,1,1]表示在doc2和doc3中存在。

如果多个条件,那么就会构建多个bitset,然后先过滤稀疏的bitset(就是1比较少的数组),先过滤掉大多数数据,如再增加一个条件:

userId=4,创建的bitset为: [0,1,0]。那么会先过滤userId的bitset,再去和date的bitset一起过滤,最终返回doc2。

caching: 

有了bitset之后,最好还能将其缓存起来。 这样避免同一个条件每一次都去扫描索引,频繁创建bitset ,以便达到最佳性能。  什么情况下会缓存bitset呢?

1、在最近使用的256个filter中,如果某个filter超过一定的次数,它对应的bitset就会被缓存。 该次数不固定,使用越多越容易被缓存。

2、我们知道,一个document就对应一个segment,然后segment会逐步合并为更大的segment。   caching机制对于小的segment的情况是不缓存的,因为很快就会被合并。

小segment比如: document记录数少于1000,或者大小少于index总大小的3%。

3、当有写操作时,也会更新缓存。

filter比query快,就是因为它不要聚合排序,不需要去关系分值,只需要做简单的过滤,并使用了缓存。 

八:精准度控制

boost 

es搜索的时候,会根据词项在文档中的匹配度打分。我们可以通过boost来修改权重(默认是1),比如想要name中含有java的doc分高一点,排在前面:

GET /mytest/_search
 {
   "query": {
     "bool": {
       "should": [
         {
           "term": {
             "name": {
               "value": "java",
              "boost": 3
             }
           }
         },
         {
           "term": {
             "name": {
               "value": "elastic",
              "boost": 2
             }
           }
         },
         {
           "term": {
             "name": {
               "value": "mysql"
             }
           }
         }
       ]
     }
   }
 }

dis_max

除了boost,有一种情况,如下:

GET /mytest/_search
 {
   "query": {
     "bool": {
       "should": [
         {"match": {
           "title": "java solution"
           }
         },
         {
           "match": {
             "content": "java solution"
           }
         }
       ]
     }
   }
 }

上面的查询,要求title中有java solution或者content中有java solution能被查出来,此时:

文档1可能在title和content字段都有java一词, 针对两个字段的得分分别是:1和1.3,那么文档1的得分为2.3。

文档2的title中一个词没匹配到,得分0, content中有java solution,完全匹配,得分1.5,文档2的得分为1.5。

然而,文档2更符合预期,但是它的分却比较低,查询不会被优先返回,这时候就需要使用dis_max: 不管有多少个字段匹配,取分值最高的字段得分,作为文档的得分。

GET /mytest/_search
 {
   "query": {
     "dis_max": {
       "queries": [
         {
           "match": {
           "title": "java solution"
           }
         },
         {
           "match": {
             "content": "java solution"
           }
         }
       ]
     }
   }
 }

九:deep paging性能问题 和 解决方案

深度分页问题:

es默认使用from+size的方式分页,类似于mysql的limit。 假如from:990 ,size:10, 那么es会所有shard上各取出1000条数据,返回给协调节点,然后从这1w条数据中根据分值找出最符合的10条(从之前的query流程知道,这里只是返回了文档id和score的值,但是数据量也很大了),假如查询更多,更深,量更大呢?显然性能是很差的!

es有个设置index.max_result_window,默认10000。 代表查询的数据,超过符合条件的第1w条,就不支持。 如果es性能好,可以根据业务需要改大。 当然了,也不是很推荐from+size的方式做深度查询。 这时候要从业务考虑,是不是真的需要查询很后面的数据。 如果确实需要,则要换分页方式:

深度分页方式:

scoll分页分两步,第一步把所有初始化,把所有符合条件的数据缓存,形成快照。