文章目录

1.概述
转载并且补充:Elasticsearch的IndexSorting:一种查询性能优化利器
前言
前两周写过一篇《基于Lucene查询原理分析Elasticsearch的性能》,在最后留了一个彩蛋,说下一篇会介绍一种可以极大的优化查询性能的技术。本文就来介绍这种技术——IndexSorting。
因为IndexSorting是在ES6.0之后才作为实验性的功能加入,相关的介绍资料还比较少,所以大部分人对它不够了解。另一方面是,想要理解它为什么能够优化性能、适合哪些场景、内部如何实现、有何副作用等,也需要花一翻功夫。所以本文专门对IndexSorting进行一个介绍,并分析它的作用、实现、适用场景等。如果你的场景能用上IndexSorting,那么它肯定会给你带来一个巨大的性能提升!
2. 什么是IndexSorting?
IndexSorting
是ES的新功能
在Elasticsearch中,IndexSorting
是一个很新的功能,6.0版本才引入,并且标记这个功能是Beta版(6.5版本可能会去掉Beta标记)。
在Lucene中,IndexSorting
其实已经发展了一段时间。最早在10年,Lucene提供了一个IndexSorter的工具,作为一个离线工具可以对Index数据排序后生成一个新的Index。后来13年加入了SortingMergePolicy
,在Segment进行merge的时候可以生成排好序的新Segment,在17年又加入了Sorting on flushed segment
的功能,在Segment最初生成时就进行排序。另一方面是Lucene在查询时也做了很多优化,如果有IndexSorting
,很多地方做了提前中断,后面会讲提前中断对查询性能的巨大作用。经过几次Lucene的改进和优化,IndexSorting这个功能也终于被集成进Elasticsearch
。
3. IndexSorting是一种预排序
与查询时的Sort不同,IndexSorting
是一种预排序,即数据预先按照某种方式进行排序,它是Index的一个设置,不可更改。大家知道,Elasticsearch的底层是Lucene,Lucene中是以Segment为单位进行查询的,这里说的IndexSorting对数据进行预排序也是在每个Segment内有序的。
一个Segment中的每个文档,都会被分配一个docID
,docID从0开始,顺序分配。在没有IndexSorting
时,docID是按照文档写入的顺序进行分配的,在设置了IndexSorting
之后,docID的顺序就与IndexSorting的顺序一致。
举个例子来说,假如文档中有一列为Timestamp
,我们在IndexSorting
中设置按照Timestamp
逆序排序,那么在一个Segment
内,docID越小,对应的文档的Timestamp越大,即按照Timestamp从大到小的顺序分配docID。
4.Index Sorting使用方式
Index Sorting需要在创建索引时在Mappings和Settings里指定,具体使用方式如下:
单Field排序
PUT events
{
"settings" : {
"index" : {
"sort.field" : "timestamp",
"sort.order" : "desc"
}
},
"mappings": {
"properties": {
"timestamp": {
"type": "date"
}
}
}
}
多Field排序
PUT events
{
"settings" : {
"index" : {
"sort.field" : ["timestamp","age"],
"sort.order" : ["desc","desc"]
}
},
"mappings": {
"properties": {
"timestamp": {
"type": "date"
},
"age":{
"type": "integer"
}
}
}
}
当通过配置 index.sort.field,index.sort.order
两个选项后,Elasticsearch在Refresh时,会对当前内存新写入的数据根据这两个配置项作重新排序,然后生成Segment。这几个Field构成的排序机制就类似于Mysql的聚集索引,且可以是复合聚集索引
。当搜索的数据是按照主键索引排序,且不是全表查询,不统计总数时,就能发挥主键索引的所用,能极大的提高检索效率。
检索请求体
GET /events/_search
{
"size": 10,
"sort": [
{ "timestamp": "desc" }
],
"track_total_hits": false
}
4. 为什么IndexSorting可以优化性能?
提前中断
IndexSorting能够优化性能,主要就是靠四个字,“提前中断”。但是提前中断是有条件的,需要查询的Sort顺序与IndexSorting的顺序相同,并且不需要获取符合条件的记录总数(TotalHits)。
Lucene中的倒排链都是按照docID从小到大的顺序排列的,在进行组合条件查询时,也是按照docID从小到大的顺序选出符合条件的doc
。那么当查询时的Sort顺序与IndexSorting的顺序相同时,会发生什么呢?
比如查询时希望按照Timestamp降序排序后返回100条结果,在Lucene中进行查询时,发现docID对应的doc顺序也刚好是Timestamp降序排序的,那么查询到前100个符合条件的结果即可返回,这100个一定也是Timestamp最大的100个,这就做到了提前中断。
提前中断可以极大的提升查询性能,特别是当一个查询条件命中的文档数量非常多的时候。在没有IndexSorting时,必须把所有符合条件的文档的docID扫描一遍,并且读取这些doc的一些字段来排序,选出符合条件的doc。有了IndexSorting之后,只需要选出前Top个doc即可,避免了全部扫描,性能甚至可以提升几个数量级。
5. IndexSorting提高了数据压缩率
Lucene中使用了很多的压缩算法来对数据进行压缩
,压缩一方面减少了磁盘使用量,另一方面也减少了查询时读取磁盘的数据量和IO次数等,对查询性能有很大帮助。
Lucene中同时包括行存(Store)与列存(DocValues)的存储方式,但不管是行存还是列存,当应用IndexSorting后,相邻数据的相似度就会越高,也就越利于压缩。
这不仅仅是体现在排序字段上,也体现在其他字段的相似度上。比如时序场景,当按照时间排序后,各个Metrics的值的近似度也会越大。所以IndexSorting可以提高数据的压缩率。
6. IndexSorting是否适合我的场景?
由排序条件决定是否适合
IndexSorting最大的作用就是优化查询性能,其适用的场景就是能够被其优化的场景
,比如说:
- 查询时需要根据某列或者某几列排序后返回的场景:让IndexSorting顺序跟要查询的Sort顺序一致,让Lucene能够提前中断来提升性能。
- 不关心结果顺序的场景:也可以按照某列IndexSorting,查询时也设置按照这一列Sort即可。
- 有几种排序需求的场景:IndexSorting起码可以优化一种排序需求,其余的几种需求可以考虑是否多建几个索引,用空间换时间。
也有些场景不能被其优化,比如根据文档相似度分数排序的场景,这时候很难进行预排序,因为相似分数是每次查询时才算出来的。
6.1 根据查询原理看是否能优化
上面提到Lucene会按照docID从小到大的顺序选出符合条件的doc,但是有时查询并不是慢在这个筛选过程,而是构造docID列表的过程,这时IndexSorting带来的优化效果会比较有限。
因为Lucene的查询原理是比较复杂的,这里只列举两个例子:
对于字符串进行Range查询,且Range范围内有很多符合条件的Term的场景
。这个场景下,查询可能会慢在两个地方,一个是Range范围内符合条件的Term非常多,扫描FST耗时很大,另一个如果这些Term对应的doc数很多,要构造BitSet也会非常耗时。因为利用IndexSorting的提前中断是发生在BitSet构造好之后,所以并不能优化到这个地方的性能。
对数字类型在BKD-Tree上进行范围查找时,因为BKD-Tree里的docID不是顺序排列的,所以并不像倒排链一样可以顺序读取。如果BKD-Tree上符合条件的docID很多,构造BitSet也很耗时,也不是IndexSorting能够优化到的。
6.2 考虑对写入性能的影响
IndexSorting优化的是查询性能,因为在写入时需要对数据进行排序,所以降低了写性能
。如果写性能是目前的性能瓶颈,或者看重写性能要高于查询性能,那么不适合使用IndexSorting。
6.3 IndexSorting是如何实现的?
本文介绍一下IndexSorting的实现细节,这也有助于大家理解它对写入性能产生的影响。IndexSorting可以保证在每个Segment中,数据都是按照设置的方式进行排序的,这要解决两个问题:
- Lucene的Flush操作会生成Segment,这时候生成的Segment如何保证数据有序。
- 多个Segment进行合并时如何保证有序。
6.4. Flush时保证Segment内数据有序
大家知道,数据写入Lucene后,并不是立即可查的,要生成Segment之后才能被查到。为了保证近实时的查询,ES会每隔一秒进行一次Refresh,Refresh就会调用到Lucene的Flush生成新的Segment。额外说的一点是,Lucene的Flush不同于ES的Flush,ES的Flush保证数据落盘,调用的是Lucene的commit,里面会调用fsync,这里的关系值得额外写一篇文章来说清楚。
我们需要先知道Flush前数据是一个什么样的状态,才能知道Flush时如何对这些数据排序。每个doc写入进来之后,按照写入顺序被分配一个docID,然后被IndexingChain处理,依次要对invert index、store fields、doc values和point values进行处理,有些数据会直接写到文件里,主要是store field和term vector,其他的数据会放到memory buffer中。
在Flush时,首先根据设定的列排序,这个排序可以利用内存中的doc values,排序之后得到老的docID到新docID的映射,因为之前docID是按照写入顺序生成的,现在重排后,生成的是新的排列。如果排序后与原来顺序完全一致,那么什么都不做,跟之前流程一样进行Flush。
如果排序后顺序发生变化,如何排序呢?对于已经写到文件中的数据,比如store field和term vector,需要从文件中读出来,重新排列后再写到一个新文件里,原来的文件就相当于一个临时文件。对于内存中的数据结构,直接在内存中重排后写到文件中。
相比没有IndexSorting时,对性能影响比较大的一块就是store field的重排,因为这部分需要从文件中读出再写回,而其他部分都是内存操作,性能影响稍小一些。这里我们也可以做一些思考,如果将store field和term vector这类数据也buffer在内存中,是否可以提升IndexSorting开启时的写入性能?
6.5. Merge时保证新的Segment数据有序
由于Flush时Segment已经是有序的了,所以在Merge时也就可以采用非常高效的Merge Sort的方式进行。
8.源码解析
8.1 Index Sorting的Settings
public final class IndexSortConfig {
/**
* The list of field names
*/
public static final Setting<List<String>> INDEX_SORT_FIELD_SETTING =
Setting.listSetting("index.sort.field", Collections.emptyList(),
Function.identity(), Setting.Property.IndexScope, Setting.Property.Final);
/**
* The {@link SortOrder} for each specified sort field (ie. <b>asc</b> or <b>desc</b>).
*/
public static final Setting<List<SortOrder>> INDEX_SORT_ORDER_SETTING =
Setting.listSetting("index.sort.order", Collections.emptyList(),
IndexSortConfig::parseOrderMode, Setting.Property.IndexScope, Setting.Property.Final);
/**
* The {@link MultiValueMode} for each specified sort field (ie. <b>max</b> or <b>min</b>).
*/
public static final Setting<List<MultiValueMode>> INDEX_SORT_MODE_SETTING =
Setting.listSetting("index.sort.mode", Collections.emptyList(),
IndexSortConfig::parseMultiValueMode, Setting.Property.IndexScope, Setting.Property.Final);
/**
* The missing value for each specified sort field (ie. <b>_first</b> or <b>_last</b>)
*/
public static final Setting<List<String>> INDEX_SORT_MISSING_SETTING =
Setting.listSetting("index.sort.missing", Collections.emptyList(),
IndexSortConfig::validateMissingValue, Setting.Property.IndexScope, Setting.Property.Final);
}
上述4个Settings对应着 Index Sorting的4个配置:
-
index.sort.field:
对应排序的Field,可指定一个或者多个Field,优先按第一个排序,相同的情况下在按后续的Field排序 -
index.sort.order
:对应Field的排序规则,只能时ASC或者时DESC -
index.sort.mode:
当对应的Field是数组时,取Max或者Min的值作为排序的基准 -
index.sort.missing:
当对应的Field为null时,排第一个还是最后一个
8.2 通过settings构建Field Sort配置
// visible for tests
final FieldSortSpec[] sortSpecs;
public IndexSortConfig(IndexSettings indexSettings) {
final Settings settings = indexSettings.getSettings();
// 提取Field配置
List<String> fields = INDEX_SORT_FIELD_SETTING.get(settings);
this.sortSpecs = fields.stream()
.map((name) -> new FieldSortSpec(name))
.toArray(FieldSortSpec[]::new);
// 提取Order配置
if (INDEX_SORT_ORDER_SETTING.exists(settings)) {
List<SortOrder> orders = INDEX_SORT_ORDER_SETTING.get(settings);
......
for (int i = 0; i < sortSpecs.length; i++) {
sortSpecs[i].order = orders.get(i);
}
}
// 提取Mode配置
if (INDEX_SORT_MODE_SETTING.exists(settings)) {
List<MultiValueMode> modes = INDEX_SORT_MODE_SETTING.get(settings);
......
for (int i = 0; i < sortSpecs.length; i++) {
sortSpecs[i].mode = modes.get(i);
}
}
// 提取Missing配置
if (INDEX_SORT_MISSING_SETTING.exists(settings)) {
List<String> missingValues = INDEX_SORT_MISSING_SETTING.get(settings);
......
for (int i = 0; i < sortSpecs.length; i++) {
sortSpecs[i].missingValue = missingValues.get(i);
}
}
}
8.3 提取ES配置,生成Lucene的Sort
public Sort buildIndexSort(Function<String, MappedFieldType> fieldTypeLookup,
Function<MappedFieldType, IndexFieldData<?>> fieldDataLookup) {
if (hasIndexSort() == false) {
return null;
}
final SortField[] sortFields = new SortField[sortSpecs.length];
for (int i = 0; i < sortSpecs.length; i++) {
FieldSortSpec sortSpec = sortSpecs[i];
final MappedFieldType ft = fieldTypeLookup.apply(sortSpec.field);
if (ft == null) {
throw new IllegalArgumentException("unknown index sort field:[" + sortSpec.field + "]");
}
boolean reverse = sortSpec.order == null ? false : (sortSpec.order == SortOrder.DESC);
MultiValueMode mode = sortSpec.mode;
if (mode == null) {
mode = reverse ? MultiValueMode.MAX : MultiValueMode.MIN;
}
IndexFieldData<?> fieldData;
try {
fieldData = fieldDataLookup.apply(ft);
} catch (Exception e) {
throw new IllegalArgumentException("docvalues not found for index sort field:[" + sortSpec.field + "]");
}
if (fieldData == null) {
throw new IllegalArgumentException("docvalues not found for index sort field:[" + sortSpec.field + "]");
}
sortFields[i] = fieldData.sortField(sortSpec.missingValue, mode, null, reverse);
validateIndexSortField(sortFields[i]);
}
// 生成Lucene的Sort,在创建 IndexWriter 时使用
return new Sort(sortFields);
}
8.4 Lucene源码解析
8.4.1 写入时源码
// To store an index on disk, use this instead,此目录里的文件列表格式可见上一篇博客
Directory directory = FSDirectory.open(Paths.get("D:\\lucene-data"));
IndexWriterConfig config = new IndexWriterConfig(new WhitespaceAnalyzer());
// 设置document在segment里的顺序, 默认是docId, 如果设置成某个Field或者多个Field, 则在检索时能够实现EarlyTerminate
Sort sort = new Sort(new SortField("timestamp", Type.LONG, true), new SortField("age", Type.INT, false));
config.setIndexSort(sort);
IndexWriter indexWriter = new IndexWriter(directory, config);
return indexWriter;
org.apache.lucene.index.IndexWriter 是Lucene在Index数据时的处理类,在生成此对象时,可以指定Index Sort,也就是倒排索引顺序,默认是Index Order,也就是索引顺序。当重新指定Sort时,生成的Segment里的数据顺序就是Sort里指定的顺序了。
8.4.2 检索时源码
private static class SimpleFieldCollector extends TopFieldCollector {
......
// 获取单个Segment的Collector
public LeafCollector getLeafCollector(LeafReaderContext context) throws IOException {
docBase = context.docBase;
final LeafFieldComparator[] comparators = queue.getComparators(context);
final int[] reverseMul = queue.getReverseMul();
// Index Sorting 配置
final Sort indexSort = context.reader().getMetaData().getSort();
// 是否可以提前结束
final boolean canEarlyTerminate = trackTotalHits == false // 不统计总数
&& trackMaxScore == false // 不统计最大评分
&& indexSort != null // Document默认在写入时是没有顺序的,默认是Index顺序, 如果在在写入时是有Sort的, 则indexSort != null
&& canEarlyTerminate(sort, indexSort); // 检索的Sort和Index的Sort是一致的, 则可以提前结束
final int initialTotalHits = totalHits;
return new MultiComparatorLeafCollector(comparators, reverseMul, mayNeedScoresTwice) {
/** 根据倒排索引里的docID顺序,依次查看评分,默认按照Sort排序取最高的TopN的docID; 未指定Sort则默认是Score
* @param doc document在segment里的序号,从0开始,全局序号加上docBase
* @throws IOException
*/
@Override
public void collect(int doc) throws IOException {
......
++totalHits;
// 队列满了, 假设取Top10, 也就是已经取到了10条合适的数据
if (queueFull) {
// 对比当前doc和已经收集到的queue里的bottom的doc的Sort, Score太小或者Field值顺序不合适
if (reverseMul * comparator.compareBottom(doc) <= 0) {
// since docs are visited in doc Id order, if compare is 0, it means
// this document is largest than anything else in the queue, and
// therefore not competitive.
// 如果可以提前结, 收集的数据量足够了,那么提早结束能极大提高性能, es的Index Sorting 使用了这个特性
if (canEarlyTerminate) {
// scale totalHits linearly based on the number of docs
// and terminate collection
totalHits += estimateRemainingHits(totalHits - initialTotalHits, doc, context.reader().maxDoc());
earlyTerminated = true;
// 抛出异常形式结束整个循环
throw new CollectionTerminatedException();
} else {
// just move to the next doc
return;
}
}
......
}
......
};
}
}
通过倒排索引检索数据,判断当前document是否应该被纳入候选者的queue时,如果有Sort,则要判断是通过关联性评分Score,还是Field值来排序。假设要取Top10,当queue里已经取了10条数据,且下一条数据的Score或者Field值都比queue里的小,则判断是否能够Early Terminate,如果可以,那么就不需要在遍历剩下的所有数据,抛出异常中断整个循环,提前结束。 当倒排索引数据量很大时,能够极大的提高性能。
9.总结
Elasticsearch Index Sorting 是通过调整Segment里的数据顺序来契合检索时的顺序,以达到在检索时能够按照Sort里的顺序来对document做处理,当取到足量的满足条件的数据时 Early Terminate,忽略后续所有的document,当指定Field值的倒排索引的数据量很大时,能够极大的提升检索时的效率。
IndexSorting是一种能够极大提高查询效率的技术,它通过预排序和提前中断大大减少了需要扫描的数据量,而且附带的优化是可以提高压缩率,减少存储空间
。对于查询时需要按照某列排序的场景,它非常有用,但对于相关性分数排序的场景则无法通过预排序来优化。IndexSorting的缺点是对写入性能有影响,主要是体现在Segment的Flush和Merge阶段,对于非常看重写入性能的场景也不适合使用。总体上说,这是一项非常有用也很新的技术,相信它在Lucene和ES中的重要性会越来越强,也会有越来越多的业务场景受益于这个功能。
参考文档
https://www.elastic.co/blog/index-sorting-elasticsearch-6-0
https://issues.apache.org/jira/browse/LUCENE-7579
https://zhuanlan.zhihu.com/p/35795070
https://zhuanlan.zhihu.com/p/47951652