先认识几个基本概念:
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"
}
流程图:
在索引数据过程中,每一次的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 个 segmentindex.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"}
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的机制。
es的倒排索引, 可以根据条件很快定位到是哪个document(能拿到doc_id),如上图,但此时我们并不知道每个doc里面的具体内容是什么,如果需要做排序聚合等,我们不可能把所有doc的内容都查出来再做,那样性能会非常差,因此倒排索引不适合聚合排序(从上面的query机制我们知道,查询是先获取id和得分,排序后取出size个,最终根据id去对应的shard上获取完整的doc内容,所以在最终获取完整doc前,肯定要先做排序)。
如果在获取数据之前,有一种索引中可以知道doc的值,那就可以做排序了。DocValues就是这样的一种正排索引,它存的是doc与对应词项的关系:
在创建索引的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表示存在)。
如下:
如果搜索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"
}
}
]
}
}
}
深度分页问题:
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:
scoll分页分两步,第一步把所有初始化,把所有符合条件的数据缓存,形成快照。