网上看到的一篇文章,对Lucene的倒排索引是如何执行的,说的比较易懂,就转过来分享下。
Elasticsearch是通过Lucene的倒排索引技术实现比关系型数据库更快的过滤。特别是它对多条件的过滤支持非常好,比如年龄在18和30之间,性别为女性这样的组合查询。倒排索引很多地方都有介绍,但是其比关系型数据库的b-tree索引快在哪里?到底为什么快呢?
笼统的来说,b-tree索引是为写入优化的索引结构。当我们不需要支持快速的更新的时候,可以用预先排序等方式换取更小的存储空间,更快的检索速度等好处,其代价就是更新慢。要进一步深入的化,还是要看一下Lucene的倒排索引是怎么构成的。
这里有好几个概念。我们来看一个实际的例子,假设有如下的数据:
docid |
年龄 |
性别 |
1 |
18 |
女 |
2 |
20 |
女 |
3 |
18 |
男 |
这里每一行是一个document。每个document都有一个docid。那么给这些document建立的倒排索引就是:
年龄
性别
可以看到,倒排索引是per field的,一个字段由一个自己的倒排索引。18,20这些叫做 term,而[1,3]就是posting list。Posting list就是一个int的数组,存储了所有符合某个term的文档id。那么什么是term dictionary 和 term index?
假设我们有很多个term,比如:
Carla,Sara,Elin,Ada,Patty,Kate,Selena
如果按照这样的顺序排列,找出某个特定的term一定很慢,因为term没有排序,需要全部过滤一遍才能找出特定的term。排序之后就变成了:
Ada,Carla,Elin,Kate,Patty,Sara,Selena
这样我们可以用二分查找的方式,比全遍历更快地找出目标的term。这个就是 term dictionary。有了term dictionary之后,可以用 logN 次磁盘查找得到目标。但是磁盘的随机读操作仍然是非常昂贵的(一次random access大概需要10ms的时间)。所以尽量少的读磁盘,有必要把一些数据缓存到内存里。但是整个term dictionary本身又太大了,无法完整地放到内存里。于是就有了term index。term index有点像一本字典的大的章节表。比如:
A开头的term ……………. Xxx页
C开头的term ……………. Xxx页
E开头的term ……………. Xxx页
如果所有的term都是英文字符的话,可能这个term index就真的是26个英文字符表构成的了。但是实际的情况是,term未必都是英文字符,term可以是任意的byte数组。而且26个英文字符也未必是每一个字符都有均等的term,比如x字符开头的term可能一个都没有,而s开头的term又特别多。实际的term index是一棵trie 树:
例子是一个包含 "A", "to", "tea", "ted", "ten", "i", "in", 和 "inn" 的 trie 树。这棵树不会包含所有的term,它包含的是term的一些前缀。通过term index可以快速地定位到term dictionary的某个offset,然后从这个位置再往后顺序查找。再加上一些压缩技术(搜索 Lucene Finite State Transducers) term index 的尺寸可以只有所有term的尺寸的几十分之一,使得用内存缓存整个term index变成可能。整体上来说就是这样的效果。
现在我们可以回答“为什么Elasticsearch/Lucene检索可以比mysql快了。Mysql只有term dictionary这一层,是以b-tree排序的方式存储在磁盘上的。检索一个term需要若干次的random access的磁盘操作。而Lucene在term dictionary的基础上添加了term index来加速检索,term index以树的形式缓存在内存中。从term index查到对应的term dictionary的block位置之后,再去磁盘上找term,大大减少了磁盘的random access次数。
额外值得一提的两点是:term index在内存中是以FST(finite state transducers)的形式保存的,其特点是非常节省内存。Term dictionary在磁盘上是以分block的方式保存的,一个block内部利用公共前缀压缩,比如都是Ab开头的单词就可以把Ab省去。这样term dictionary可以比b-tree更节约磁盘空间。
所以给定查询过滤条件 age=18 的过程就是先从term index找到18在term dictionary的大概位置,然后再从term dictionary里精确地找到18这个term,然后得到一个posting list或者一个指向posting list位置的指针。然后再查询 gender=女 的过程也是类似的。最后得出 age=18 AND gender=女 就是把两个 posting list 做一个“与”的合并。
这个理论上的“与”合并的操作可不容易。对于mysql来说,如果你给age和gender两个字段都建立了索引,查询的时候只会选择其中最selective的来用,然后另外一个条件是在遍历行的过程中在内存中计算之后过滤掉。那么要如何才能联合使用两个索引呢?有两种办法:
PostgreSQL 从 8.4 版本开始支持通过bitmap联合使用两个索引,就是利用了bitset数据结构来做到的。当然一些商业的关系型数据库也支持类似的联合索引的功能。Elasticsearch支持以上两种的联合索引方式,如果查询的filter缓存到了内存中(以bitset的形式),那么合并就是两个bitset的AND。如果查询的filter没有缓存,那么就用skip list的方式去遍历两个on disk的posting list。
以上是三个posting list。我们现在需要把它们用AND的关系合并,得出posting list的交集。首先选择最短的posting list,然后从小到大遍历。遍历的过程可以跳过一些元素,比如我们遍历到绿色的13的时候,就可以跳过蓝色的3了,因为3比13要小。
整个过程如下
Next -> 2
Advance(2) -> 13
Advance(13) -> 13
Already on 13
Advance(13) -> 13 MATCH!!!
Next -> 17
Advance(17) -> 22
Advance(22) -> 98
Advance(98) -> 98
Advance(98) -> 98 MATCH!!!
最后得出的交集是[13,98],所需的时间比完整遍历三个posting list要快得多。但是前提是每个list需要指出Advance这个操作,快速移动指向的位置。什么样的list可以这样Advance往前做蛙跳?skip list:
从概念上来说,对于一个很长的posting list,比如:
[1,3,13,101,105,108,255,256,257]
我们可以把这个list分成三个block:
[1,3,13] [101,105,108] [255,256,257]
然后可以构建出skip list的第二层:
[1,101,255]
1,101,255分别指向自己对应的block。这样就可以很快地跨block的移动指向位置了。
Lucene自然会对这个block再次进行压缩。其压缩方式叫做Frame Of Reference编码。示例如下:
考虑到频繁出现的term(所谓low cardinality的值),比如gender里的男或者女。如果有1百万个文档,那么性别为男的posting list里就会有50万个int值。用Frame of Reference编码进行压缩可以极大减少磁盘占用。这个优化对于减少索引尺寸有非常重要的意义。当然mysql b-tree里也有一个类似的posting list的东西,是未经过这样压缩的。
因为这个Frame of Reference的编码是有解压缩成本的。利用skip list,除了跳过了遍历的成本,也跳过了解压缩这些压缩过的block的过程,从而节省了cpu。
Bitset是一种很直观的数据结构,对应posting list如:
[1,3,4,7,10]
对应的bitset就是:
[1,0,1,1,0,0,1,0,0,1]
每个文档按照文档id排序对应其中的一个bit。Bitset自身就有压缩的特点,其用一个byte就可以代表8个文档。所以100万个文档只需要12.5万个byte。但是考虑到文档可能有数十亿之多,在内存里保存bitset仍然是很奢侈的事情。而且对于个每一个filter都要消耗一个bitset,比如age=18缓存起来的话是一个bitset,18<=age<25是另外一个filter缓存起来也要一个bitset。
所以秘诀就在于需要有一个数据结构:
Lucene使用的这个数据结构叫做 Roaring Bitmap。
其压缩的思路其实很简单。与其保存100个0,占用100个bit。还不如保存0一次,然后声明这个0重复了100遍。
这两种合并使用索引的方式都有其用途。Elasticsearch对其性能有详细的对比(https://www.elastic.co/blog/frame-of-reference-and-roaring-bitmaps)。简单的结论是:因为Frame of Reference编码是如此 高效,对于简单的相等条件的过滤缓存成纯内存的bitset还不如需要访问磁盘的skip list的方式要快。
一种常见的压缩存储时间序列的方式是把多个数据点合并成一行。Opentsdb支持海量数据的一个绝招就是定期把很多行数据合并成一行,这个过程叫compaction。类似的vivdcortext使用mysql存储的时候,也把一分钟的很多数据点合并存储到mysql的一行里以减少行数。
这个过程可以示例如下:
12:05:00 |
10 |
12:05:01 |
15 |
12:05:02 |
14 |
12:05:03 |
16 |
可以看到,行变成了列了。每一列可以代表这一分钟内一秒的数据。
Elasticsearch有一个功能可以实现类似的优化效果,那就是Nested Document。我们可以把一段时间的很多个数据点打包存储到一个父文档里,变成其嵌套的子文档。示例如下:
{timestamp:12:05:01, idc:sz, value1:10,value2:11}
{timestamp:12:05:02, idc:sz, value1:9,value2:9}
{timestamp:12:05:02, idc:sz, value1:18,value:17}
可以打包成:
{
max_timestamp:12:05:02, min_timestamp: 1205:01, idc:sz,
records: [
{timestamp:12:05:01, value1:10,value2:11}
{timestamp:12:05:02, value1:9,value2:9}
{timestamp:12:05:02, value1:18,value:17}
]
}
这样可以把数据点公共的维度字段上移到父文档里,而不用在每个子文档里重复存储,从而减少索引的尺寸。
在存储的时候,无论父文档还是子文档,对于Lucene来说都是文档,都会有文档Id。但是对于嵌套文档来说,可以保存起子文档和父文档的文档id是连续的,而且父文档总是最后一个。有这样一个排序性作为保障,那么有一个所有父文档的posting list就可以跟踪所有的父子关系。也可以很容易地在父子文档id之间做转换。把父子关系也理解为一个filter,那么查询时检索的时候不过是又AND了另外一个filter而已。前面我们已经看到了Elasticsearch可以非常高效地处理多filter的情况,充分利用底层的索引。
使用了嵌套文档之后,对于term的posting list只需要保存父文档的doc id就可以了,可以比保存所有的数据点的doc id要少很多。如果我们可以在一个父文档里塞入50个嵌套文档,那么posting list可以变成之前的1/50。
前面提及了字段过滤缓存,那么与之相反的清楚缓存策略
单一索引缓存,多索引缓存和全部缓存的清理
1.清空全部缓存
curl localhost:9200/_cache/clear?pretty
{
"_shards" : {
"total" : 72,
"successful" : 72,
"failed" : 0
}
}
2.清除单一索引缓存
curl localhost:9200/index/_cache/clear?pretty
{
"_shards" : {
"total" : 2,
"successful" : 2,
"failed" : 0
}
}
3.清除多索引缓存
curl localhost:9200/index1,index2,index3/_cache/clear?pretty
{
"_shards" : {
"total" : 12,
"successful" : 12,
"failed" : 0
}
}
当然了清楚缓存时也可以添加参数使之清楚对用的缓存并非所有的
filter:此类缓存可以设置filter参数为true来清理,相反的不需要清楚此类缓存那么可以设置参数为false来保留此类缓存
field_data:此类缓存可以设置filter参数为true来清理,相反的不需要清楚此类缓存那么可以设置参数为false来保留此类缓存
bloom:此类缓存可以设置filter参数为true来清理(如果某种倒排索引格式中引用了bloom filter则可能使用此类缓存),相反的不需要清楚此类缓存那么可以设置参数为false来保留此类缓存
fields:清楚字段相关的缓存,可以为单个或者多个字段,多个字段的时候用逗号隔开(英文)
上述参数使用格式(可以使用一个或者多个参数)
curl localhost:9200/index/_cache/clear?pretty&filter=false&field_data=true&bloom=false&fields=tag,name
Query Cache也称为Filter Cache,顾名思义它的作用就是对一个查询中包含的过滤器执行结果进行缓存。
比如我们常用的term,terms,range过滤器都会在满足某种条件后被缓存,注意,这里的bool过滤器是不会被缓存的,但bool过滤器包含的子query clause会被缓存,我们可以用下面的命令来查询Query Cache的情况。
http://192.168.0.109:9200/_stats/query_cache?pretty&human
举个栗子,看下面的查询
{
"from": 0,
"size": 5,
"query": {
"bool": {
"filter": {
"bool": {
"must": [
{
"term": {
"productID": "JODL-X-1937-#pV7"
}
},
{
"range": {
"price": {
"from": 20,
"to": null,
"include_lower": true,
"include_upper": true
}
}
}
]
}
}
}
}
}
上面有两个过滤器一个Term过滤器用来过滤productID为“JODL-X-1937-#pV7” 的产品,一个range过滤器用来过滤价格在20以上的产品,在这个例子中这两个过滤器执行的结果会分别作为一个BitSet(位图)缓存,返回的查询结果则是这两个位图交集。
上面提到Filter Cache只会在满足某种条件下才会被缓存,至于是哪些条件这里就不介绍了,想了解的童鞋戳下面链接。
关于Filter执行流程及缓存原理 ,请参看此文:《Elasticsearch2.X Filter执行流程及缓存原理》
当一个查询发送到ES集群的某个节点上时,这个节点会把该查询扩散到其他节点并在相应分片上执行,我们姑且把在分片上执行的结果叫“本地结果集“,这些本地结果集最终会汇集到最初请求到达的那个协调节点,这些“分片级”的结果集会合并成“全局”结果集返回给调用端。
Request Cache模块就是为了缓存这些“分片级”的本地结果集,但是目前只会缓存查询中参数size=0的请求,所以就不会缓存hits 而是缓存 hits.total,aggregations和suggestions
缓存失效
Request Cache是非常智能的,它能够保证和在近实时搜索中的非缓存查询结果一致。这句话读起来很难懂,简单解释下。
我们都知道ES是一个“near real-time”(近实时)搜索引擎,为什么是近实时搜索呢,那是因为当我们向ES发送一个索引文档请求到这个文档变成Searchable(可搜索)默认的时间是1秒,我们可以通过index.refresh_interval参数来设置刷新时间间隔,也就是说我们在执行一个搜索请求时实际上数据是有延迟的。回到刚才的问题,刚才那句话其实指的就是:ES能保证在使用Request Cache的情况下的搜索结果和不使用Request Cache的近实时搜索结果相同,那ES是如何保证两者结果相同的呢?继续……
Request Cache缓存失效是自动的,当索引refresh时就会失效,也就是说在默认情况下Request Cache是每1秒钟失效一次(注意:分片在这段时间内确实有改变才会失效)。也就是说当一个文档被索引到该文档变成Searchable之前的这段时间内,不管是否有请求命中缓存该文档都不会被返回,正是是因为如此ES才能保证在使用Request Cache的情况下执行的搜索和在非缓存近实时搜索的结果一致。
如果我们把索引刷新时间设置得越长那么缓存失效的时间越长,如果缓存被写满将采用LRU策略清除。当然我们也可以手动设置参数indices.request.cache.expire指定失效时间,但是基本上我们没必要去这样做,因为缓存在每次索引refresh时都会自动失效。
下面的命令可以手动清除缓存
curl -XPOST 'localhost:9200/kimchy,elasticsearch/_cache/clear?request_cache=true'
缓存使用
在默认情况下Request Cache是关闭的,我们需要手动开启
curl -XPUT localhost:9200/my_index/_settings -d'
{ "index.requests.cache.enable": true }
'
开启缓存后,我们需要在那些需要缓存的搜索请求上加上request_cache=true这个参数才能使我们的查询请求被缓存,比如:
curl 'localhost:9200/my_index/_search?request_cache=true' -d'
{
"size": 0,
"aggs": {
"popular_colors": {
"terms": {
"field": "colors"
}
}
}
}'
注意1:上面的参数size:0非常重要必须强制指定才能被缓存,否则请求是不会缓存的。
注意2(重要):在使用script执行查询时一定要指定request_cache=false,因为脚本的执行结果是不确定的(比如使用random函数或使用了当前时间作为参数),这种情况下应该避免使用缓存
缓存的Cache Key
对于Request Cache来说,它的Cache Key就是整个查询的DSL语句,所以如果要命中缓存查询生成的DSL一定要一样,这里的一样是指DSL这个字符串一样。只要有一个字符或者子查询的顺序变化都不会命中缓存。
通过下面的参数我们可以设置缓存的大小,默认情况下是JVM堆的1%大小,当然我们也可以手动设置在elasticsearch.yml文件里
indices.requests.cache.size: 1%
Request Cache总结:
一谈到Fielddata我们不得不提到doc_values,这两者的作用都是一样:能够让我们在inverted index(倒排索引)的基础之上做aggregation、sort或者在查询中通过script访问doc属性,这里我们不讨论doc_values,主要讲下Fielddata,doc values相关知识请戳:http://blog.csdn.net/chennanymy/article/details/52555055
想必大家都知道倒排索引这种结构,如果我们仅仅依靠倒排是很难在查询中做到排序和统计的,因为它并不是像关系型数据库那样采用“列式存储”,而是基于一个“词”到“文档”的倒排。
Fielddata是专门针对分词的字段在query-time(查询期间)的数据结构的缓存。当我们第一次在一个分词的字段上执行聚合、排序或通过脚本访问的时候就会触发该字段Fielddata Cache的加载,这种缓存是“segment”级别的,当有新的segment打开时旧的缓存不会重新加载,而是直接把新的segement对应的Fielddata Cache加载到内存。
加载Fielddata Cache是一个非常昂贵的操作,一旦Fielddata被加载到内存,那么在该Fielddata Cache对应的Segement生命周期范围内都会驻留在内存中。也就是说当段合并时会触发合并后更大段的Fielddata Cache加载。
Fielddata会消耗大部分的JVM堆内存,特别是当加载“高基数”的分词字段时(那些分词后存在大量不同词的字段),针对这种字段的聚合排序其实是非常没有意义的,我们更多的要去考虑是否能用not_analyzed代替(这样就可以使用doc_values实现聚合、排序)。
默认情况下Fielddate Cache是默认开启的,我们可以通过下面的设置来关闭,关闭后就无法对分词字段执行聚合、排序操作了。
PUT my_index
{
"mappings": {
"my_type": {
"properties": {
"text": {
"type": "string",
"fielddata": {
"format": "disabled"
}
}
}
}
}
}
在ES1.X里面除了string类型其他类型也是使用Fieldata的,在ES2.X中除了分词的String类型字段,其他类型都使用doc_values。
Fielddata的加载方式有3种:
1.lazy: 懒加载是默认的加载方式,当第一次使用时加载
2.eager:预加载模式是当一个新的索引段变成Searchable之前会被加载
3.eager_global_ordinals:全局序数预加载模式,这种方式能生成一份全局序数表,可降低内存使用。
如下设置为eager_global_ordinals
PUT my_index
{
"mappings": {
"my_type": {
"properties": {
"text": {
"type": "string",
"fielddata": {
"loading": "eager_global_ordinals"
}
}
}
}
}
}
Fielddata也可指定满足某些条件的term才被加载进内存
1.通过词频加载
PUT my_index
{
"mappings": {
"my_type": {
"properties": {
"tag": {
"type": "string",
"fielddata": {
"filter": {
"frequency": {
"min": 0.001,
"max": 0.1,
"min_segment_size": 500
}
}
}
}
}
}
}
}
上面的设置表示词频在0.001到0.1之间的且段持有的文档数在500以上的term会被加载到内存。
2.通过正则表达式加载
PUT my_index
{
"mappings": {
"my_type": {
"properties": {
"tweet": {
"type": "string",
"analyzer": "whitespace",
"fielddata": {
"filter": {
"regex": {
"pattern": "^#.*"
}
}
}
}
}
}
}
}
上面的设置表示只有满足pattern的词才会被加载到内存。
Fielddata Cache设置
1.indices.fielddata.cache.size:此参数设置缓存大小(默认是不限制)。可设置百分数如30%,或者数字12GB
2.indices.breaker.fielddata.limit:此参数设置Fielddata断路器限制大小(公式:预计算内存 + 现有内存 <= 断路器设置内存限制),默认是60%JVM堆内存,当查询尝试加载更多数据到内存时会抛异常(以此来阻止JVM OOM发生)
3.indices.breaker.fielddata.overhead:一个常数表示内存预估值系数,默认1.03,比如预计算加载100M数据,那么100*1.03=103M会用103M作为参数计算是否超过断路器设置的最大值。
原文出处:http://www.infoq.com/cn/articles/database-timestamp-02