我们都知道Elasticsearch是高效的搜索神器,为什么他会这么快呢?本文浅谈ES几点基本的设计理念,相信会对ES为什么这么快有进一步的认识。仅供参考。
注意,本文档适用于ES 2.x
ES数据存储底层使用了Lucene,其中最重要的一个设计就是倒排索引。一个倒排索引由doc中所有不重复的词构成。下面是一个简单的示例:
Term Doc_1 Doc_2 Doc_3
------------------------------------
brown | X | X |
dog | X | | X
dogs | | X | X
fox | X | | X
foxes | | X |
in | | X |
jumped | X | | X
lazy | X | X |
leap | | X |
over | X | X | X
quick | X | X | X
summer | | X |
the | X | | X
------------------------------------
从以上例子中,我们可以对倒排索引有一个直观的认识。最左一列中,是文档分词拆分后的单词。右侧若干列,是包含左侧某些词的列,即打“X”代表包含该文档包含改词。
如果我们要搜索包含brown这个term的文档,那么,搜索的时候,因为倒排索引是根据term
来排序的,所以我们首先在terms列表中找到 brown ,然后扫描右侧所有docs,快速找到包含 brown 的doc1和doc2。
再考虑,如果还要按某个字段来进行聚合,那就需要找到 Doc_1 和 Doc_2 里所有唯一的词项, 如果用倒排索引做这件事代价是高昂的: 我们需要搜索索引里的每个词项并收集 Doc_1 和 Doc_2 列里面 token。这很慢而且难以扩展:随着词项和文档的数量增加,执行时间也会增加。那么,ES是怎么解决这个问题的呢?答案是doc_values。
索引一个文档时,如果字段分词,那就会对字段进行analyzes,然后使用结果生成倒排索引。否则直接生成倒排索引。
索引文档时,就会生成倒排索引,放入segment,刷入磁盘。这就造成了倒排索引的不可变性。那么,怎么更新倒排索引呢?ES的做法是覆盖。
倒排索引在搜索包含指定term的doc时非常高效,但是在相反的操作时表现很差:查询一个文档中包含哪些term。具体来说,倒排索引在搜索时最为高效,但在排序、聚合等与指定filed相关的操作时效率低下,需要用doc_values。
Doc values通过逆置term和doc间的关系来前面提到的数据聚合的问题。倒排索引将term映射到包含它们的doc,doc values将doc映射到它们包含的所有词项,下面是一个示例:
Doc Terms
-----------------------------------------------------------------
Doc_1 | brown, dog, fox, jumped, lazy, over, quick, the
Doc_2 | brown, dogs, foxes, in, lazy, leap, over, quick, summer
Doc_3 | dog, dogs, fox, jumped, over, quick, the
-----------------------------------------------------------------
当数据被逆置之后,想要收集到 Doc_1 和 Doc_2 的唯一 token 会非常容易。获得每个文档行,获取所有的词项,然后求两个集合的并集。
其实,Doc Values
本质上是一个序列化了的列式存储结构,非常适合排序、聚合以及字段相关的脚本操作。而且这种存储方式便于压缩,尤其是数字类型。压缩后能够大大减少磁盘空间,提升访问速度。下面是一个数字类型的 Doc Values示例:
Doc Terms
-----------------------------------------------------------------
Doc_1 | 100
Doc_2 | 1000
Doc_3 | 1500
Doc_4 | 1200
Doc_5 | 300
Doc_6 | 1900
Doc_7 | 4200
-----------------------------------------------------------------
列式存储意味着有一个连续的数据块: [100,1000,1500,1200,300,1900,4200] 。因为我们已经知道他们都是数字(而不是像文档或行中看到的异构集合),所以可以使用统一的偏移量来将他们紧紧排列。
而且,针对这样的数字有很多种压缩技巧。你会注意到这里每个数字都是 100 的倍数,Doc Values
会检测一个段里面的所有数值,并使用一个最大公约数,方便做进一步的数据压缩。
比如,这个例子中可以用100作为公约数,那么以上数字就变为[1,10,15,12,3,19,42],可用很少的bit就能存储,节约了磁盘空间。一般来说,Doc Values
按顺序来检测以下压缩方案:
以上是数字压缩的例子。下面介绍对String类型数据压缩方式。
String类型使用顺序表,按和数字类型类似的方式编码。String类型去重后排序,然后写入一个表中,并分配一个ID号,然后这些ID号就被当做数字类型的Doc Values
。这意味着字符串享有许多与数字相同的压缩特点。
序数表本身也有一些压缩技巧,例如使用固定、可变或前缀编码的字符串。
Doc Values
是在字段索引时与倒排索引
同时生成。
Doc Values
与倒排索引一样基于Segement
生成并且是不可变的。
Doc Values
的存储是弹性的。因为Doc Values
会被序列化到磁盘,所以我们可以利用操作系统的文件系统缓存来保持快速访问而不是直接用JVM堆内存:
当工作集所需内存小于该节点的可用内存时,操作系统自然将所有Doc Values
存于内存中(堆外内存),这样就可以有超快的访问速度,和在堆上的表现一样;
反之,如果工作集比可用内存大得多的时候,操作系统会按需把Doc Values
从操作系统页缓存中加载或弹出,从而避免发生内存溢出的异常。虽然说这种模式会比完全加载到内存的模式慢,但这样有个好处就是能利用超过服务器内存容量的空间。如果你把所有的这些数据放在java堆里面,那么会直接因为内存不足而崩溃(除非你自己实现一个类似操作系统的页缓存策略)。
所以,当我们大量使用Doc Values
时,可以把更少的内存分配给ES,而把更多的内存留给操作系统。关于此更多的信息可以参考Heap: Sizing and Swapping
Doc Values
默认对除了analyzed String
外的所有字段启用(因为分词后会生成很多token使得Doc Values
效率降低)。但是当你知道某些字段永远不会进行排序、聚合以及脚本操作的时候可以禁用Doc Values
以节约磁盘空间提升索引速度,示例如下:
PUT my_index
{
"mappings": {
"my_type": {
"properties": {
"session_id": {
"type": "string",
"index": "not_analyzed",
"doc_values": false
}
}
}
}
}
以上配置以后,session_id字段就只能被搜索,不能被用于排序、聚合以及脚本操作了。
还可以通过设定doc_values
为true,index为no来让字段不能被搜索但可以用于排序、聚合以及脚本操作:
PUT my_index
{
"mappings": {
"my_type": {
"properties": {
"customer_token": {
"type": "string",
"index": "not_analyzed",
"doc_values": true,
"index": "no"
}
}
}
}
}
Doc Values
的特点就是快速、高效、内存友好,使用由linux kernel管理的文件系统缓存弹性存储。doc values在排序、聚合或与字段相关的脚本计算得到了高效的运用,任何需要查找某个文档包含的值的操作都必须使用它。如果你确定某个filed不会做字段相关操作,可以直接关掉doc_values,节约内存,加快访问速度。
注意,已经设定了分词的String field不支持Doc Values
,而是使用FieldData
,将在下一节介绍。
上文说过,在排序、聚合以及在脚本中访问field
值时需要一个与倒排索引截然不同的数据访问模式:不同于倒排索引中的查找term
->找到对应docs的过程,我们需要直接查找doc然后找到指定某个filed
中包含的terms
。
大多数field
使用索引时、磁盘上的doc_values来支持这种访问模式,但是分词了的String filed不支持Doc Values
,而是使用一种叫FieldData
的数据结构。
FieldData
主要是针对analyzed
String,它是一种查询时(query-time
)的数据结构。
FieldData
缓存主要应用场景是在对某一个field排序或者计算类的聚合运算时。它会把这个field列的所有值加载到内存,这样做的目的是提供对这些值的快速文档访问。为field构建FieldData
缓存可能会很昂贵,因此建议有足够的内存来分配它,并保持其处于已加载状态。
FieldData
是在第一次将该filed用于聚合,排序或在脚本中访问时按需构建。FieldData
是通过从磁盘读取每个段来读取整个反向索引,然后逆置term
↔︎doc
的关系,并将结果存储在JVM堆中构建的。
所以,加载FieldData
是开销很大的操作,一旦它被加载后,就会在整个段的生命周期中保留在内存中。
这了可以注意下FieldData
和Doc Values
的区别。较早的版本中,其他数据类型也是用的FieldData
,但是目前已经用随文档索引时创建的Doc Values
所替代。
FieldData.format
可以配置FieldData
是否开启,它默认是开启的。可以接受的参数是disabled
和paged_bytes
(就是启用)。
PUT my_index
{
"mappings": {
"my_type": {
"properties": {
"text": {
"type": "string",
"fielddata": {
"format": "disabled"
}
}
}
}
}
}
ES默认加载FieldData
的策略是懒加载,那么加载大数据量的时候会很慢(几个GB的数据可能需要几十秒),就会让习惯亚秒级响应的用户必须忍受长时间的等待。要解决这个问题一般有三种策略:
FieldData
Global Ordinals(全局序号)
FieldData.loading
参数可以控制FieldData
加载到内存中的时机,有以下几个可选值:
参数值 | 含义 |
---|---|
lazy | (默认)FieldData 只会在需要用到时加载到内存 |
eager | 创建新段(通过refresh ,flush 或段合并)时,启用了eager loding 的field 将在段对搜索可见之前预先加载其每段的fielddata。如果用户的搜索请求必须触发对一个大型段的延迟加载时,这个选项可以减少延迟。其实说白了,就是把加载FieldData 的时间成本从搜索时转移到了处理段可见时。 |
eager_global_ordinals | 将FieldData 和Global Ordinals 加载提前到一个新段对搜索可见之前。 |
让tags字段的FieldData
提前加载的示例:
PUT /music/_mapping/_song
{
“tags”: {
“type”: “string”,
“fielddata”: {
“loading” : “eager”
}
}
}
当然,你也可以用update-mappingAPI来更新已存在字段的FieldData
加载策略。
更多内容请查看modules-fielddata
加载FieldData
到内存只是所有必须要做的工作中的一部分。在为每个段加载FieldData
后,ES会构建一个称为Global Ordinals(全局序号)的数据结构来构建一个由分片内的所有段中的唯一term
组成的列表。默认的,Global Ordinals
是延迟构建的。如果这个field的基数非常高,那么Global Ordinals
也许会花一些时间来构建,这种情况下你可以使用预加载选项。
详见5.4 FieldData配置预加载章节
FieldData
过滤器可以用来减少加载到内存的term数,因此就能减少内存使用。Terms
可以被frequency(频率)
和正则表达式或是他们的组合过滤,以下为详细说明:
Frequency过滤器可以只加载那些doc频率符合min
和max
之间的值的term。这个值可以在1.0以上或者是用小数表示百分比。
注意,Frequency
是按每个段来计算的。
计算百分比时是基于该filed有值的doc数,而不是该段中的所有doc。
此外,还可以通过min_segment_size
来直接排除一些数据量过少的段。
现在来一个例子。比如一个音乐网站,用户对歌曲都贴了自定义的标签。当需要统计最受欢迎的三个标签时,一些常用的标签如摇滚、情歌、rap之类的会有大概率排名靠前,而一些用户自定义的如“老婆最讨厌的歌”这样的小众标签排名一般都很靠后不具有统计意义,属于长尾项。
如果不用FieldData
过滤器,那么会把这些无意义的数据都加载到内存中。所以,我们可以使用FildData
的Frequency
过滤器来避免这种情况。下面的示例筛选了至少包含500个doc的段,且只加载那些frequency
大于1%且小于50%(过滤如停用词之类的常用词)的term到内存来生成FieldData
:
PUT /music/_mapping/song
{
"properties": {
"tag": {
"type": "string",
"fielddata": {
"filter": {
"frequency": {
"min": 0.01,
"min": 0.5,
"min_segment_size": 500
}
}
}
}
}
}
请注意,前文已经提到过FieldData
是按每个段内来计算的,也就是说,如果一个新风格的歌曲标签迅速蹿红,那么它也会很快排名靠前。因为,这些新的标签会作为高频标签出现在新段内。
如果是采用对这个标签做完整的词频计算,那么这些新标签就会等到和老的流行标签量差不多的时候才会排名靠前,请记住FieldData
的这个按每个段内来计算的特性。
这种方式可以只加载满足正则表达式的term。
注意:正则表达式只会对该field的所有term生效,而不是所有的field。
下面这个例子展示了只加载tweet
filed中hashtags(#号标签)
开头的标签:
PUT my_index
{
"mappings": {
"my_type": {
"properties": {
"tweet": {
"type": "string",
"analyzer": "whitespace",
"fielddata": {
"filter": {
"regex": {
"pattern": "^#.*"
}
}
}
}
}
}
}
}
以上的过滤器可以对现存的mapping filed修改,但是只会在下一次一个段的FieldData
被加载时才会生效。可以使用Clear Cache
API来加载FieldData
,即可使用新的过滤器。
总之,FieldData
过滤器对内存使用有重要的意义,可以在实际使用中排除大量无用的长尾项。
JVM堆内存资源是非常宝贵的,能用好它对系统的高效稳定运行至关重要。FieldData
是直接放在堆内的,所以必须合理设定用于存放它的堆内存资源数。ES中控制FieldData
内存使用的参数是:
# 在ES_HOME/config/elasticsearch.yml中加入
# 控制最大fileldData缓存,可以用x%表示占该节点堆内存百分比,也可以用如12GB这样的数值
indices.fielddata.cache.size: 20%
默认状况下,这个设置是无限制的,ES不会从FieldData
中驱逐数据。
如果生成的fielddata大小超过指定的size
,则将驱逐其他值以腾出空间。使用时一定要注意,这个设置只是一个安全策略而并非内存不足的解决方案。因为通过此配置触发数据驱逐,ES会立刻开始从磁盘加载数据,并把其他数据驱逐以保证有足够空间,导致很高的IO以及大量的需要被垃圾回收的内存垃圾。
举个例子来说明这个配置的意义:
你每天为日志文件建一个新的索引。一般来说你最对最近几天数据感兴趣,很少查询老数据。但是,按默认设置FieldData
中的老索引数据是不会被驱逐的。这样的话,FieldData
就会一直持续增长直到触发熔断机制,这个机制会让你再也不能加载更多的FieldData
到内存。这样的场景下,你只能对老的索引访问FieldData
,但不能加载更多新数据。所以,这个时候就可以通过以上配置来把最近最少使用的FieldData
驱逐以够新进来的数据腾空间。
注意,有一个类似的配置
indices.fielddata.cache.expire
请不要使用该配置,这个是仅凭过期否来判断是否驱逐,开销大,收益低,未来版本会删除掉。
通过以上内容,我们得知,对FieldData
的内存使用和驱逐情况监控十分重要,高驱逐数能指向一系列资源问题以及性能不佳的原因。
关于监控的更多说明请点击这里
FieldData
是在数据被加载后再检查的,那么如果一个查询导致尝试加载超过可用内存的数据就会导致OOM异常。ES中使用了FieldData Circuit Breaker
来处理上述问题,他可以通过
分析一个查询涉及到的字段的类型、基数、大小等来评估所需内存。如果估计的查询大小大于配置的堆内存使用百分比限制,则断路器会跳闸,查询将被中止并返回异常。
断路器是工作是在数据加载前,所以你不用担心遇到FieldData
导致的OOM异常。
ES拥有若干断路器,如下:
indices.breaker.fielddata.limit
默认情况下限制FieldData
最多占堆的60%。因为FieldData
需要和request断路器共享堆内存、索引缓冲内存、过滤器缓存、用来构建索引的Lucene数据结构以及其他许多临时数据结构,所以我们需要为indices.breaker.fielddata.limit
设定一个保守的60%。
过于乐观的设定可能导致潜在的OOM异常,从而使得整个节点挂掉;但是过去保守的设置又会使得你的应用无法处理本可以处理的请求而抛异常。但是异常总比崩溃好,如果遇到异常你就要想办法优化你的请求了。
此外,必须注意断路器设定的***indices.breaker.fielddata.limit
必须大于indices.fielddata.cache.size
,否则会导致数据无法被驱逐***。
indices.breaker.request.limit
request断路器评估完成请求的其他部分所需的结构大小,例如创建聚合桶,并在默认情况下将其限制为堆的40%。
indices.breaker.total.limit
总断路器包裹request和fielddata两种断路器,以确保两者的组合默认不使用超过70%的堆内存。
断路器可以在ES_HOME/config/elasticsearch.yml中指定,也用以下命令动态修改:
PUT /_cluster/settings
{
"persistent" : {
"indices.breaker.fielddata.limit" : "40%"
}
}
最后,要注意断路器评估时是用的总堆内存而不是堆实际用的内存(没有办法准确知道堆真正空闲大小来进行准确估算)。所以说用户在修改以上断路器设定时,务必保守一些。
FieldData
是为分词String而生,它会消耗大量的java 堆空间,特别是加载基数(cardinality
)很大的分词String filed时。但是往往对这种类型的分词Field做聚合是没有意义的(除了Significant Terms Aggregation)。
值得注意的是,FieldData
和Doc Values
的加载时机不同,前者是首次查询时,后者是doc索引时。还有一点,FieldData
是按每个段来缓存的。
在以上的Doc Values
和FieldData
章节中多次提到了Global Ordinals
即全局序号的概念,这个章节我们详细讲一下它。
可以参考详解Elasticsearch的Global Ordinals与High Cardinality
Global Ordinals
是一个在Doc Values
和FieldData
之上的数据结构,它为每个唯一的term
按字典序维护了一个自增的数字序列。每个term
都有自己的一个唯一数字,而且字母A的全局序号小于字母B。特别注意,全局序号只支持String类型的field。
请注意,Doc Values
和FieldData
也有自己的ordinals
序号,这个序号是特定segment
和field
中的唯一编号。通过提供Segment Ordinals
和Global Ordinals
间的映射关系,全局序号只是在此基础上创建,后者(即全局序号)是在整个shard
分片中是唯一的。
一个特定字段的Global Ordinals
跟一个分片中的所有段相关,而Doc Values
和FieldData
的ordinals
只跟单个段相关。因此,只要是一个新段要变得可见,那么就必须完全重建全局序号。
也就是说,跟FieldData
一样,在默认情况下全局序号也是懒加载的,会在第一个请求FieldData
命中一个索引时来构建全局序号。实际上,***在为每个段加载FieldData
后,ES就会创建一个称为Global Ordinals(全局序号)的数据结构***来构建一个由分片内的所有段中的唯一term
组成的列表。
全局序号的内存开销小的原因是它由非常高效的压缩机制。提前加载的全局序号可以将加载时间从第一次搜索时转到全局序号刷新时。
全局序号的加载时间依赖于一个字段中的term
数量,但是总的来说耗时较低,因为来源的字段数据都已经加载到内存了。
全局序号在用到段序号的时候很有用,比如排序或者terms aggregation
,可以提升执行效率。terms aggregations
完全依赖于全局序号来在分片级别执行聚合,然后只是在最终减少(***这个地方看不太懂,也许说的是统计时将多个相同term统计成一个term对应一个count,所以说减少?原文是:A terms aggregation relies purely on global ordinals to perform the aggregation at the shard level, then converts global ordinals to the real term only for the final reduce phase, which combines results from different shards.***)的阶段将全局序号转换为真实的term
,这个阶段将不同分片中的结果组合起来。
我们举个简单的例子。比如有十亿级别的doc,每个doc都有一个status
字段,但只有pending
, published
, deleted
三个状态数据。如果直接存整个String数据到内存,那么就算每个doc有15字节,那么一共就是差不多14GB的数据。怎么减少占用空间呢?首先想到的就是用数字来进行编码,码表如下:
Ordinal | Term
-------------------
0 | status_deleted
1 | status_pending
2 | status_published
这样的话,初始的那三个String就只在码表内被存了一次。FieldData
中的doc就可以直接用编码来指向实际值:
Doc | Ordinal
-------------------------
0 | 1 # pending
1 | 1 # pending
2 | 2 # published
3 | 0 # deleted
这样编码以后,直接把数据量压缩了十倍左右。但有个问题是FieldData
是按每个段来分别加载、缓存的。那么就会出现一个情况,如果一个段内的doc只有deleted
和published
两个状态,那么就会导致该FieldData
算出来的码表只有0和1,这就和拥有3个状态的段算出的FieldData
码表不同。这样的话,聚合的时候就必须一个段一个段的计算,最后再聚合,十分缓慢,开销巨大。
ES的做法是用Global Ordinals
这种构建在FieldData
之上的小巧内存性数据结构,编码会结合所有段来计算唯一值然后存放为一个序号码表。这样依赖,term aggregation
可以只在全局序号上进行聚合,而且只会在聚合的最终阶段来计算从序号到真实的String值一次。这个机制可以提升聚合的性能3-4倍。
默认状态下,全局序号是在搜索时才被加载(懒加载)的,如果你在做文档索引速度优化那么这是一个正确的选项;然而,如果你的优化着重点是搜索速度,那么你可以将eager_global_ordinals
设为true,下面是一个例子:
PUT my_index/_mapping/_doc
{
"properties": {
"tags": {
"type": "keyword",
"eager_global_ordinals": true
}
}
}
上面这样操作以后,就将创建全局序号的开销从搜索时转到了segment refresh时,ES会确保在让索引上的数据更新可见前创建全局序号。当然,如果你不需要再在这个field
上做terms aggregations
,也可以随时将eager_global_ordinals
设为false。
还可以这样设置:
PUT /music/_mapping/_song
{
"song_title": {
"type": "string",
"fielddata": {
"loading" : "eager_global_ordinals"
}
}
}
注意:这样配置隐含的设置了FieldData和Global Ordinals一样是在一个新段对搜索可见之前预加载
此外,全局序号只会为String类型创建,因为数字类型的数据(如integer
,geopoint
,date
)等本身就充当了一个数字映射。所以你只能为String类型配置预加载。
最后,讲一个设置Doc Values
中的全局序号预加载的例子:
PUT /music/_mapping/_song
{
"song_title": {
"type": "string",
"doc_values": true,
"fielddata": {
"loading" : "eager_global_ordinals"
}
}
}
这个例子FieldData
不会被加载到内存,但Doc Values
被加载到文件系统缓存中。
不同于FieldData
预加载,预加载全局索引可以对数据的实时性造成影响。对于基数很高的字段,创建全局序号会将refresh
延迟若干秒。可选的全局序号创建的时间开销一个是在每次refresh
时,或者是在refresh
后的一次查询时。如果你经常索引数据,而查询很少,那么最好是将创建全局序号的时间开销放在索引时。
注意,还有一个调优的小技巧。如果你的某列的文档基数很大,需要很长时间来重建全局序号,那这个时候你可以调大refresh_interval
来让全局序号在更长时间内有效。这样可以降低CPU开销,减少全局序号重建频率。
Global Ordinals
全局序号是构建在FieldData
和Doc Values
之上。
全局序号是跨单个索引中所有段来生成的,所以段的增删都会导致全局序号的重建,重建需要读取每个段中的每个唯一term,重建速度和文档基数、唯一term数负相关。
全局序号默认懒加载,如果某字段数据基数特别大,那么就会在第一次访问FieldData
的时候因为创建导致长时间的延迟。一旦全局序号创建完毕,那么就会一直被重用直到发生段refresh
、flush
或者是merge
。
Elasticsearch Reference
Elasticsearch Definitive Guide