ES 底层(或者说内核)是基于 Lucene,本文从 ES 查询流程以及 Lucene 底层的一些存储结构设计设计, 来分析 ES 的一些查询优化方向
上图是 ES 的完整查询流程, ES 的任意节点可作为写入请求的协调节点(Coordinating Node),接收用户请求。协调节点将请求转发至对应一个或多个数据分片的主或者从分片进行查询,各个分片查询结果最后在协调节点汇聚,返回最终结果给客户端。 从查询流程可以看出 ES 的查询主要有2个阶段: Query 和 Fetch
当然如果 ES 只有一个分片, 那么整个流程将合并成 QueryAndFetch 一个阶段。
ES 底层是 Lucene, 说到索引设计, 大部分同学都知道 ES 是基于倒排索引来进行文档检索, 即一个分词(term)对应一个 docsList。 Lucene 的设计中, 倒排索引并非只是简单的一个 term-> docsList 的结构, 主要是采取了这几种数据结构:
除了索引外,ES 同时提供了行存(stored
)、列存(doc_value
)来进行业务字段的存储
stored 是 ES 的行存储模式, 类似 innodb 的存储, 用于字段值的展示,特点如下:
存储结构如下:
需要注意的是从上图可以看出 _source 是 stored field 的第一个字段, 会优先读取
doc_value 是 ES 的列存储模式, 类似大数据的存储,用于聚合排序等分析场景, 特点如下:
"docvalue_fields": ["tag1"]
存储结构如下:
了解了 ES/Lucene 索引的一些底层设计, 那来看看一些优化方法论
在 ES 6.6 或以上的版本, 官方提供了索引生命周期管理(ILM index-lifecycle-manager)功能, 可以通过 kibana 或 API 来配置索引生命周期。实现索引数据的自动滚动跟过期,并结合冷热分离架构进行数据的降冷跟删除。
为了让分片查询性能发挥到最优,需要对规模进行限制,通常有以下使用原则:
ES 的 Mapping 类似于传统关系型数据库的表结构定义。 在ES 中,一旦一个字段被定义在了 mapping中,是无法被修改的(新增字段除外),所以一般我们需要修改索引的话,都会滚动或者重建索引,并采用 reindex 或 logstach 来迁移数据。 为了高效发挥 mapping 的性能并降低存储成本,介绍一些常见的使用技巧:
enabled: false
来防止其下面创建子字段 mapping ,但是能被行存查询出来。dynamic=runtime
,虽然加入新字段也会更新 mapping,但是新加入的字段不会被索引,也就是不会使得索引变大,不过虽然不被索引,但是新加入的字段依然可以被查询,只是查询的代价会更大(运行时构建)。所以这种类型一般不建议用在经常查询的条件字段上,而更适合用在一些不确定数据结构的日志类索引中。dynamic=strict
(不允许新增一个不在 mapping中的字段,一旦新增的字段不在 mapping 定义中,则直接报错)或者dynamic=false
(新字段不会被索引,不能作为查询条件,但是能被行存查询出来)ES 在查询的时候会将请求下发到所有分片, 特殊情况下会造成很多分片空转(并不命中数据), 这里引申一个概念 查询裁剪, 通常有这几种:
preference=_shards:0
或者 routing
来指定查某一个分片进行查询)需要注意的是, 1,2两种都可以在用户程序级做到(通过查询 API), segment 级别的裁剪需要对 Lucene 内核进行修改适配
ES 在 6.0 以上版本提供 Index Sorting 功能
通过数据排序(类似 mysql 的二级索引能力, Elasticsearch会结合索引排序和查询条件对结果进行排序。如果查询条件与索引排序顺序一致,查询性能将得到显著提升),通过牺牲少量的写入性能,在写入时将文档归类放置存储,非常有利于查询裁剪
ES 的写入模型采用的是类似 LSM-Tree 的存储结构。ES 实时写入的数据都在 lucene 内存 buffer 中,同时依赖写入 translog 保证数据的可靠性。当积攒到一定程度后,将他们批量写入一个新的 Segment。 这样,数据写入都是 Batch 和 Append(顺序追加),能达到很高的吞吐量。 但是这种方式,也会产生大量的小Segment,查询时会产生非常多的随机IO,导致查询效率低下。
ES后台会进行 segment merge(段合并)
操作,但是默认段合并非常缓慢。这是因为 merge 操作比较吃IO,为了避免跟写入争抢IO,所以默认 merge 得非常慢。所以我们可以通过强制的 forcemerge (使用 _forcemerge
API)来大幅降低Segment 数量,减少函数空转跟随机IO,极限压测通常大约能提升20%~30%的查询性能。
特别是业务刚迁移到新集群的热数据,一开始写入时产生的segment较多,导致查询性能相对于老集群反而变弱,需要等待一段时间让ES做merge 后性能才会变好。这种情况下,如果能做强制一把 forcemerge 就最好
Force Merge 是非常占用系统资源的, 尽量避免在线上业务期间使用
上面也提到了 Merge 是非常吃IO的操作。 通常在搜索场景下,merge 可以很好的提升查询性能,但是在日志场景下,写多读少,merge 并非十分必要,甚至可以放到深夜低峰期去做也是可以的。所以通过限制白天 merge 的线程数跟size限制, 可以有效降低集群负载
减少 Merge 可以通过调整集群配置中索引刷新间隔
index.refresh_interval
来实现, 不过会影响数据的实时性
在文章开头介绍过 ES 的查询流程, 整个查询流程可以分为汇总后, 可以分为四个阶段的 cache:
ES 可以在这些地方配置缓存使用:
"index.store.type": "mmapfs"
让ES 尽可能将数据全部装入缓存。(ES 默认使用 NIOFS 读行存,所以默认读行存一定会读盘。)indices.queries.cache.count
调节,或者通过 index.queries.cache.enabled
关闭。ES 批量拉取数据的场景下通常有以下几种方式:
scroll_id
,在此之后的增删改查等操作不会影响到这个快照的结果。后续的查询只需要根据这个游标去取数据,直到结果集中返回的 hits 字段为空,就表示遍历结束。需要注意的是,每一个结果快照保存了全部的查询结果 doc 列表,所以会占用大量的资源,同时存在游标过多或者保存时间过长,会非常消耗内存。当不需要 scroll 数据的时候,尽可能快的把 scroll_id 显式删除掉(Delete scroll_id)。 Search_Scroll 在深度翻页场景有如下缺点:
_sort
中当前响应的最后一个结果search_after
查询, 值为 _sort
返回的结果相比 scroll api, search_after 会更加的高效和实时, 但是 search_after 参数使用上一页中的一组排序值来检索下一页的数据。(增加一个条件查询 排序值 > 上一页排序值 )使用 search_after 需要具有相同查询和排序值的多个搜索请求。 如果在这些请求之间发生刷新,结果的顺序可能会发生变化,从而导致跨页面的结果不一致。
pit API(point in time,轻量化视图)
参数来支持一个有状态的翻页查询,能够解决上述的实时性问题
整体流程如下:point_in_time
参数, 例如 "point_in_time": {"keep_alive": "1m"}
代表 pit 保持打开 1minx-elastic-id
, 后续的请求中要使用这个值search_after
中添加 x-elastic-id
来进行翻页总结: