一、Doc Values介绍
倒排索引在搜索包含指定 term 的文档时效率极高,但在执行相反操作,比如查询一个文档中包含哪些 term,以及进行排序、聚合等与指定字段相关的操作时,表现就很差了,这时候就需要用到 Doc Values。
倒排索引是将 term 映射到包含它们的文档,而 Doc Values 则是将文档映射到它们包含的所有词项,以下是一个示例:
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 按顺序检测以下压缩方案:
如果所有值都相同(或缺失),就设置一个标志并记录该值
如果少于 256 个值,就会使用一个简易码表
如果值个数大于 256,就检查是否存在最大公约数
如果没有最大公约数,就以偏移量的方式从最小值开始对所有值编码
String 类型使用顺序表,按和数字类型类似的方式编码。String 类型去重后排序,然后写入一个表中,并分配一个 ID 号,这些 ID 号就被当做数字类型的 Doc Values。这意味着字符串享有许多与数字相同的压缩特点。
Doc Values 是在字段索引时与倒排索引同时生成,并且生成以后是不可变的。
Doc Value 默认对除了 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 Value 的特点是快速、高效、内存友好,使用由 linux kernel 管理的文件系统缓存弹性存储。doc values 在排序、聚合或与字段相关的脚本计算中得到了高效运用,任何需要查找某个文档包含的值的操作都必须使用它。如果你确定某个 field 不会做字段相关操作,可以直接关掉 doc_values,节约内存,加快访问速度。
二、Fielddata介绍
上文说过,在排序、聚合以及在脚本中访问 field 值时,需要一种与倒排索引截然不同的数据访问模式:不同于倒排索引中的查找 term-> 找到对应 docs 的过程,我们需要直接查找 doc 然后找到指定某个 field 中包含的 terms。
大多数 field 使用索引时、磁盘上的 doc_values 来支持这种访问模式,但是分词了的 String field 不支持 Doc Values,而是使用一种叫 FieldData 的数据结构。
FieldData 主要是针对 analyzed String,它是一种查询时(query-time)的数据结构。
FieldData 缓存主要应用场景是在对某一个 field 排序或者计算类的聚合运算时。它会把这个 field 列的所有值加载到内存,目的是提供对这些值的快速文档访问。为 field 构建 FieldData 缓存可能会很昂贵,因此建议有足够的内存来分配它,并保持其处于已加载状态。
FieldData 是在第一次将该 field 用于聚合、排序或在脚本中访问时按需构建。FieldData 是通过从磁盘读取每个段来读取整个反向索引,然后逆置 term->doc 的关系,并将结果存储在 JVM 堆中构建的。所以,加载 FieldData 是开销很大的操作,一旦它被加载后,就会在整个段的生命周期中保留在内存中。
这里可以注意下 FieldData 和 Doc Values 的区别。较早的版本中,其他数据类型也是用的 FieldData,但是目前已经用随文档索引时创建的 Doc Values 所替代。
JVM 堆内存资源非常宝贵,能用好它对系统的高效稳定运行至关重要。FieldData 直接放在堆内,所以必须合理设定用于存放它的堆内存资源数。ES 中控制 FieldData 内存使用的参数是 indices.fielddata.cache.size,可以用 x% 表示占该节点堆内存百分比,也可以用如 12GB 这样的数值。默认状况下,这个设置是无限制的,ES 不会从 FieldData 中驱逐数据。如果生成的 fielddata 大小超过指定的 size,则将驱逐其他值以腾出空间。使用时一定要注意,这个设置只是一个安全策略而并非内存不足的解决方案。因为通过此配置触发数据驱逐,ES 会立刻开始从磁盘加载数据,并把其他数据驱逐以保证有足够空间,导致很高的 IO 以及大量的需要被垃圾回收的内存垃圾。
举个例子:每天为日志文件建一个新的索引。一般来说我们只对最近几天数据感兴趣,很少查询老数据。但是,按默认设置 FieldData 中的老索引数据是不会被驱逐的。这样的话,FieldData 就会一直持续增长直到触发熔断机制,这个机制会让你再也不能加载更多的 FieldData 到内存。在这种场景下,你只能对老的索引访问 FieldData,但不能加载更多新数据。所以,这个时候就可以通过以上配置来把最近最少使用的 FieldData 驱逐,为新进来的数据腾空间。
FieldData 是在数据被加载后再检查的,那么如果一个查询导致尝试加载超过可用内存的数据,就会导致 OOM 异常。ES 中使用了 FieldData Circuit Breaker 来处理上述问题,它可以通过分析一个查询涉及到的字段的类型、基数、大小等来评估所需内存。如果估计的查询大小大于配置的堆内存使用百分比限制,则断路器会跳闸,查询将被中止并返回异常。
断路器是在数据加载前工作的,所以你不用担心遇到 FieldData 导致的 OOM 异常。ES 拥有多种类型的断路器:
indices.breaker.fielddata.limit
indices.breaker.request.limit
indices.breaker.total.limit
可以根据实际需要进行配置。
FieldData 是为分词 String 而生,它会消耗大量的 java 堆空间,特别是加载基数(cardinality)很大的分词 String field 时。但是往往对这种类型的分词 Field 做聚合是没有意义的。
值得注意的是,FieldData 和 Doc Values 的加载时机不同,前者是首次查询时,后者是 doc 索引时。还有一点,FieldData 是按每个段来缓存的。
三、Doc values 与 Fielddata 对比
doc_values 与 fielddata 一个很显著的区别是,前者的工作地盘主要在磁盘,而后者的工作地盘在内存。
维度 | doc_values | fielddata |
创建时间 | index 时创建 | 使用时动态创建 |
创建位置 | 磁盘 | 内存 (jvm heap) |
优点 | 不占用内存空间 | 不占用磁盘空间 |
缺点 | 索引速度稍低 | 文档很多时,动态创建开销大,且占内存 |
索引速度稍低是相对于 fielddata 方案而言的,其实仔细想想也可以理解。拿排序举例,一个在磁盘排序,一个在内存排序,谁的速度快不言而喻。
而且,随着 ES 版本的升级,对于 doc_values 的优化越来越好,索引的速度已经很接近 fielddata 了,而且我们知道硬盘的访问速度也是越来越快(比如 SSD)。所以 doc_values 现在可以满足大部分场景,也是 ES 官方重点维护的对象。
所以,doc values 相比 field data 还是有很多优势的。因此 ES2.x 之后,支持聚合的字段属性默认都使用 doc_values,而不是 fielddata。
Global Ordinals 全局序号
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,可以提升执行效率。
我们举个简单的例子。比如有十亿级别的 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 倍。