众所周知,Elasticsearch提供的高效而丰富的查询分析能力,是基于Lucene提供的字段存储、倒排索引以及doc values等特性。通过倒排索引,可以快速过滤出符合查询条件的文档集合;结合doc values,可以高效地获取文档特定列的值信息,以便进行排序以及各种聚合操作;而字段存储则允许获取文档的原始内容。不难看出,字段存储、倒排索引以及doc values之间,是存在一定数据冗余的(即一份数据存放在多个不同的结构之中),Lucene之所以选择这样做,是为了加快分析和查询的效率(即以空间换时间),避免不同数据结构之前的转换(如基于倒排来构建类似doc values的列存)。
然而,有时为了节省成本(或者加快文档写入Lucene的速度),对于查询频率不高的字段,我们并不想构建倒排索引以及doc values,但我们仍然希望这些字段能够支持查询和分析。当然,这些字段的查询和分析性能差是我们能够接受的,因为他们本身并不经常使用。那么Elasticsearch(或者Lucene)是否支持这种方式的查询和分析呢?答案是肯定的。Elasticsearch提供的runtime字段,正可以完美地解决这种场景的需求(当然,runtime字段还有很多其他用途)。
Runtime字段
通过这种字段的名称不难理解,runtime字段是可以由ES在运行时阶段计算出来,即这类字段不必存在于source中,允许用户基于特定的信息(如source、自定义的params)动态创建任意字段,比如根据距离和时间动态计算出速度信息。另外,runtime类型的字段同样可以用于查询、聚合等请求中,但这类字段是不占磁盘空间的。同时,source中原有的字段也是可以定义为runtime类型。通过runtime类型字段的引入,可以实现无索引字段的查询和聚合,只不过相对于索引字段而言,性能会差很多(需要进行全文档集扫描)。但针对不常用的查询字段,这个还是有很大价值的(节省存储成本、加快写入速度)。
让我们来看一个实例,这里我创建一个cars索引,其定义如下所示。通过mappings定义可以知道,Weight是runtime类型的字段,其他3个字段则是非runtime类型的字段。另外,mapping中定义的4个字段都是原始文档中存在的(见下面的批量索引操作)。
PUT /cars
{
"settings": {
"number_of_shards": "1",
"number_of_replicas": "0"
},
"mappings": {
"dynamic": "false",
"runtime": {
"Weight": {
"type": "keyword"
}
},
"properties": {
"Type": {
"type": "keyword"
},
"Distance": {
"type": "long"
},
"Duration": {
"type": "long"
}
}
}
}
POST cars/_bulk?refresh=true
{"index":{}}
{"Type": "toyouta", "Distance": 80, "Duration": 15, "Weight": 1200}
{"index":{}}
{"Type": "volvo", "Distance": 200, "Duration": 25, "Weight": 2200}
{"index":{}}
{"Type": "benz", "Distance": 100, "Duration": 10, "Weight": 2500}
{"index":{}}
{"Type": "bwm", "Distance": 120, "Duration": 15, "Weight": 2000}
{"index":{}}
{"Type": "van", "Distance": 100, "Duration": 25, "Weight": 1000}
无索引字段查询
通过上面实例可以知道,文档中虽然包含了Weight字段,但因为其类型是runtime,不会为它创建倒排索引和doc values信息。我们可以通过_disk_usage接口进行确认,以下是kibana中执行POST /cars/_disk_usage?run_expensive_tasks=true时的输出。
可以看到,_disk_usage接口输出中不存在Weight字段相关的信息(注意,Weight字段的原始内容其实是承载于Lucene的_source字段中),这就证实了没有为Weight字段创建倒排索引以及doc values等。另外,通过Type字段对应的内容可以看到,它占用了40字节的倒排索引空间以及31字节的doc values空间,这也是符合我们预期的。
上文说过,虽然runtime字段不存在索引和doc values,但仍然可以使用它进行查询和分析操作,这里让我们验证下。
GET cars/_search
{
"query": {
"range": {
"Weight": {
"gte": 1500
}
}
},
"sort": [
{
"Weight": {
"order": "desc"
}
}
]
}
{
"took" : 7,
"timed_out" : false,
"_shards" : {
"total" : 1,
"successful" : 1,
"skipped" : 0,
"failed" : 0
},
"hits" : {
"total" : {
"value" : 3,
"relation" : "eq"
},
"max_score" : null,
"hits" : [
{
"_index" : "cars",
"_id" : "H5KoT4MBA7BZwWhNO13G",
"_score" : null,
"_source" : {
"Type" : "benz",
"Distance" : 100,
"Duration" : 10,
"Weight" : 2500
},
"sort" : [
"2500"
]
},
{
"_index" : "cars",
"_id" : "HpKoT4MBA7BZwWhNO13G",
"_score" : null,
"_source" : {
"Type" : "volvo",
"Distance" : 200,
"Duration" : 25,
"Weight" : 2200
},
"sort" : [
"2200"
]
},
{
"_index" : "cars",
"_id" : "IJKoT4MBA7BZwWhNO13G",
"_score" : null,
"_source" : {
"Type" : "bwm",
"Distance" : 120,
"Duration" : 15,
"Weight" : 2000
},
"sort" : [
"2000"
]
}
]
}
}
通过上面的结果可以看到,通过Weight字段,我们过滤出了我们需要的文档集合并按照预期的顺序进行了输出。因此,这就验证了在没有索引和doc values的情况下,我们仍然使用runtime字段进行查询和分析操作。
合成字段查询
接下来让我们看一个runtime类型的合成字段。通过上面的索引操作指导,索引中仅仅记录了汽车的类型(Type)、行驶距离(Distance)以及行驶时间(Duration)信息,如果我们要按照汽车的速度进行排名,该怎么操作?原始索引中并没有速度相关信息的。通过runtime类型字段的定义,我们可以很容易实现这个需求。
定义runtime类型的字段时,我们可以选择在创建索引的时候在mappings中进行配置(如上面Weight字段的定义),也可以在查询请求中通过runtime_mappings进行定配置。前一种方式的好处是runtime字段对所有查询都是可见的(即便于复用),而后一种方式则是当前请求可见的,适用于临时runtime字段的查询。这里我们以后一种方式进行说明,具体请求和查询结果如下所示。
GET cars/_search
{
"runtime_mappings": {
"Speed": {
"type": "long",
"script": {
"source": "emit(doc['Distance'].value/doc['Duration'].value)"
}
}
},
"sort": [
{
"Speed": {
"order": "desc"
}
}
]
}
{
"took" : 0,
"timed_out" : false,
"_shards" : {
"total" : 1,
"successful" : 1,
"skipped" : 0,
"failed" : 0
},
"hits" : {
"total" : {
"value" : 5,
"relation" : "eq"
},
"max_score" : null,
"hits" : [
{
"_index" : "cars",
"_id" : "H5KoT4MBA7BZwWhNO13G",
"_score" : null,
"_source" : {
"Type" : "benz",
"Distance" : 100,
"Duration" : 10,
"Weight" : 2500
},
"sort" : [
10
]
},
{
"_index" : "cars",
"_id" : "HpKoT4MBA7BZwWhNO13G",
"_score" : null,
"_source" : {
"Type" : "volvo",
"Distance" : 200,
"Duration" : 25,
"Weight" : 2200
},
"sort" : [
8
]
},
{
"_index" : "cars",
"_id" : "IJKoT4MBA7BZwWhNO13G",
"_score" : null,
"_source" : {
"Type" : "bwm",
"Distance" : 120,
"Duration" : 15,
"Weight" : 2000
},
"sort" : [
8
]
},
{
"_index" : "cars",
"_id" : "HZKoT4MBA7BZwWhNO13G",
"_score" : null,
"_source" : {
"Type" : "toyouta",
"Distance" : 80,
"Duration" : 15,
"Weight" : 1200
},
"sort" : [
5
]
},
{
"_index" : "cars",
"_id" : "IZKoT4MBA7BZwWhNO13G",
"_score" : null,
"_source" : {
"Type" : "van",
"Distance" : 100,
"Duration" : 25,
"Weight" : 1000
},
"sort" : [
4
]
}
]
}
}
上述请求中,我们通过runtime_mappings属性额外定义了一个runtime类型的Speed字段,同时定义中我们指定了通过painless脚本来生成这个字段的值,即采用距离和时间计算速度。通过和之前Weight字段的定义对比可以发现,Weight字段的定义是没有指定script属性的,这种情况其实就是告诉Elasticsearch直接从_source中提取Weight属性的内容。
性能说明
对于runtime类型的字段,Elasticsearch不会为其构建索引,这就意味着其查询性能肯定是比较差的。在查询过程中,Elasticsearch需要遍历索引中的所有文档,然后根据runtime字段的定义来计算值并同查询条件进行匹配。因此,对runtime字段进行查询分析的时间,同遍历的文档数量、runtime字段值计算的复杂度有关,下面我们通过简单的实例加以说明。
测试准备
这里我创建了两个索引,其定义如下所示,其中oplogs-with-index的AliUid字段定义为写入时构建索引,oplogs-without-index的AliUid字段则定义为runtime类型。为了对比查询性能,我们需要保证往oplogs-with-index和oplogs-without-index写入相同的文档集。我的做法是先往oplogs-with-index写入一定数量(具体为6078060)的文档,然后通过_reindex接口(kibana操作为POST _reindex?wait_for_completion=false)将数据copy到oplogs-without-index。
PUT /oplogs-with-index
{
"settings": {
"number_of_shards": "4",
"number_of_replicas": "0"
},
"mappings": {
"dynamic": "false",
"properties": {
"AliUid": {
"type": "keyword"
}
}
}
}
PUT /oplogs-without-index
{
"settings": {
"number_of_shards": "4",
"number_of_replicas": "0"
},
"mappings": {
"dynamic": "false",
"runtime": {
"AliUid": {
"type": "keyword"
}
}
}
}
查询对比
对于oplogs-with-index而言,字段AliUid在写入过程中已经构建好了索引。因此,根据AliUid进行查询时,请求响应时间将会非常短。通过下面的操作可以看到,找到包含特定AliUid的所有文档数量花费的时间仅1毫秒。
接下来让我们对oplogs-without-index进行相同类型的操作,其结果如下所示。这次请求花费的时间为14秒多,比查询oplogs-with-index的时间多出好几个数量级。这其实是符合预期的,因为在开启倒排索引的情况下,可以快速定位包含查询字段的所有文档。而对于runtime类型的字段,则只能通过遍历所有的文档来进行查询过滤。
另外,如果我们限定track符合查询请求的文档数量的话,可以看到,将会有效降低runtime字段查询所需要的时间。很明显,之所以能达到这种效果,是因为遍历的文档数量显著减少了(查询到track要求的匹配数量后,可以提前退出)。
超时问题
通过上面的对比可以知道,runtime类型字段查询所耗费的时间同索引中的文档数量是线性相关的。当索引中文档数据量较多时,将很容易导致查询超时。针对runtime字段查询的场景,需要采用非同步类型的查询方式,允许客户端主动轮询查询结果是否完成。Elasticsearch提供的_async_search接口,在很大程度上,正是为了支持这种场景。如果在1秒内完成查询,则_async_search会立即返回查询结果,否则返回一个查询ID,允许客户端后续进行结果轮询。
异步查询操作如下:
轮询异步查询结果如下:
实现介绍
我们知道,Lucene查询涉及到几个核心的概念:Query,Weight以及Scorer。对于索引字段和runtime字段的查询,Elasticsearch都会封装成对应的Query、Weight和Scorer。对于runtime类型字段查询,其涉及到的核心基类如下所示:
public abstract class AbstractScriptFieldQuery<S extends AbstractFieldScript> extends Query {
@Override
public Weight createWeight(IndexSearcher searcher, ScoreMode scoreMode, float boost) throws IOException {
return new ConstantScoreWeight(this, boost) {
@Override
public boolean isCacheable(LeafReaderContext ctx) {
return false; // scripts aren't really cacheable at this point
}
@Override
public Scorer scorer(LeafReaderContext ctx) {
S scriptContext = scriptContextFunction.apply(ctx);
DocIdSetIterator approximation = DocIdSetIterator.all(ctx.reader().maxDoc());
TwoPhaseIterator twoPhase = new TwoPhaseIterator(approximation) {
@Override
public boolean matches() {
return AbstractScriptFieldQuery.this.matches(scriptContext, approximation.docID());
}
@Override
public float matchCost() {
return MATCH_COST;
}
};
return new ConstantScoreScorer(this, score(), scoreMode, twoPhase);
}
};
}
protected abstract boolean matches(S scriptContext, int docId);
}
通过AbstractScriptFieldQuery的createWeight方法可以知道,其创建Scorer内部采用了TwoPhaseIterator进行查询过滤,而TwoPhaseIterator内部引用的DocIdSetIterator对应的是Lucene segment的文档全集,即会遍历segment中所有的文档。另外,TwoPhaseIterator的matches方法允许对遍历的文档进行进一步过滤(这也是TwoPhaseIterator为啥叫two phase的原因)。对于runtime类型的字段而言,TwoPhaseIterator的matches方法实现交给AbstractScriptFieldQuery具体子类,比如上面cars索引实例中,Weight字段和Speed字段对应的实现逻辑是不一样的。
因此,通过AbstractScriptFieldQuery可以清楚地看到,对于runtime类型字段的查询,会遍历索引中的所有文档。这也就解释了,runtime类型字段的查询性能同索引文档集合的大小紧密相关的原因。
总结
本文主要是对Elasticsearch中的runtime字段的应用场景、特性及其实现做了简单介绍。runtime字段为查询和分析提供了更为灵活的手段,不要求查询的字段存在倒排索引、doc values等信息,甚至不要求存在于原始文档中。对于不常用的查询字段,非常适合定义为runtime类型,一方面这有助于降低保存索引数据的磁盘空间,另一方面可以加快文档写入Elasticsearch的速度。但需要注意的是,runtime类型的字段查询性能远远不如索引字段,而且查询时间是和索引中文档集的大小线性相关的。