用于减少字符串字段 fielddata 内存使用的技术之一称为序数(ordinals)。想象一下,我们有十亿个文档,每个文档都有一个状态字段。 只有三种状态:status_pending、status_published、status_deleted。 如果我们要在内存中保存每个文档的完整字符串状态,则每个文档将使用 14 到 16 个字节,即大约 15 GB。
相反,我们可以识别三个唯一的字符串,对它们进行排序并编号:0、1、2。
Ordinal | Term
-------------------
0 | status_deleted
1 | status_pending
2 | status_published
原始字符串在序数列表中仅存储一次,每个文档仅使用编号序数来指向它包含的值。
Doc | Ordinal
-------------------------
0 | 1 # pending
1 | 1 # pending
2 | 2 # published
3 | 0 # deleted
这将内存使用量从 15 GB 减少到不到 1 GB!
但有一个问题。 请记住,fielddata 缓存是按段进行的。 如果一个段仅包含两个状态(status_deleted 和 status_published),则生成的序号(0 和 1)将与包含所有三个状态的段的序号不同。
如果我们尝试在状态字段上运行 terms 聚合,则需要聚合实际的字符串值,这意味着我们需要在所有段中识别相同的值。 一种简单的方法是在每个段上运行聚合,从每个段返回字符串值,然后将它们更进一步减少为总体结果。 虽然这可行,但速度很慢并且占用大量 CPU 资源。
相反,我们使用一种称为全局序数的结构。 全局序数是构建在 fielddata 之上的小型内存数据结构。 唯一值在所有段中被识别,并存储在序数列表中,就像我们已经描述的那样。
现在,我们的 terms 聚合只能聚合全局序数,并且从序数到实际字符串值的转换仅在聚合结束时发生一次。 这将聚合(和排序)的性能提高了三到四倍。
构建全局序数(ordinals)
当然,生活中没有什么是免费的。 全局序数跨越索引中的所有段,因此如果添加新段或删除旧段,则需要重建全局序数。 重建需要阅读每个部分中的每个独特术语。 基数越高 —— 存在的独特术语越多 —— 这个过程花费的时间越长。
全局序数构建在内存中的字段数据和文档值之上。 事实上,它们是文档值表现良好的主要原因之一。
与字段数据加载一样,默认情况下全局序号是延迟构建的。 需要 fielddata 命中索引的第一个请求将触发全局序数的构建。 根据字段的基数,这可能会导致用户出现显着的延迟峰值。 一旦全局序数被重建,它们将被重复使用,直到索引中的段发生变化:在刷新、刷新或合并之后。
急切(eager)全局序数
各个字符串字段可以配置为预先渴望构建全局序数:
PUT /music/_mapping/_song
{
"song_title": {
"type": "string",
"fielddata": {
"loading" : "eager_global_ordinals"
}
}
}
在上面, 设置 eager_global_ordinals 也意味着急切地加载字段数据。
就像 fielddata 的急切预加载一样,急切全局序数是在新段对搜索可见之前构建的。
注意:序数仅用于字符串。 数值数据(整数、地理点、日期等)不需要序数映射,因为值本身充当内在序数映射。
因此,你只能为字符串字段启用急切全局序数。
Doc values 也可以急切地构建它们的全局序数:
PUT /music/_mapping/_song
{
"song_title": {
"type": "string",
"doc_values": true,
"fielddata": {
"loading" : "eager_global_ordinals"
}
}
}
在这种情况下,fielddata 不会加载到内存中,但 doc 值会加载到文件系统缓存中。
与 fielddata 预加载不同,全局序数的急切构建可能会对数据的实时性产生影响。 对于非常高的基数字段,构建全局序数可能会将刷新延迟几秒钟。 你可以选择在每次刷新时付出代价,还是在刷新后的第一个查询时付出代价。 如果你经常索引而很少查询,那么最好在查询时付出代价,而不是在每次刷新时付出代价。
提示:让你的全局序号收回成本。 如果你有非常高的基数字段,需要几秒钟才能重建,请增加 refresh_interval,以便全局序数保持更长时间的有效状态。 这也将减少 CPU 使用率,因为你将需要更少地重建全局序数。
测试
全局序数是有效的,除非引入新的段 —— 因为这需要集群重建映射。
下面可以看到全局序数映射的示例,假设文档有一个 make 字段,表示车辆的制造商。 假设我们有很多文档,并且所有文档都有五个左右的品牌,那么它看起来类似于:
Ordinal | Field
----------------
0 | Audi
1 | BMW
2 | Honda
3 | Lexus
4 | Toyota
Doc ID | Make
----------------
N | 0
N + 1 | 0
N + 2 | 1
N + 3 | 2
N + 4 | 2
N + 5 | 3
N + 6 | 4
通过在内存中定义上述映射,Elasticsearch 现在聚合全局序数结构而不是字符串值是一种更简单、更高效的操作。 一旦操作完成,从序数到字符串的转换只需要在最终的归约阶段发生一次。
为了查看急切加载全局序数的影响,我启动了一个索引并生成了约 300K 文档,其中包含文档中的单个字段。 下面是在有或没有预先加载全局序数的情况下在字段上进行术语聚合的结果,基数为 10(make 的 10 个唯一值):
没有急切加载:
$ curl -X GET \
-H "Content-Type: application/json" \
-d @agg.json \
http://localhost:9200/cars/_search
{"took":152}
使用急切加载:
$ curl -X GET \
-H "Content-Type: application/json" \
-d @agg.json \
http://localhost:9200/cars/_search
{"took":105}
通过对以下映射进行简单更改,术语聚合时间大约减少了 31%:
{
"properties": {
"make": {
"type": "keyword",
"eager_global_ordinals": true
}
}
}
结论
最终,Elasticsearch 无法提前预测或确定性地知道哪些字段将针对它们运行术语聚合。 因此,默认行为是延迟加载 keyword 和 text 字段的全局序数。
通过在映射中明确说明应用程序或客户端中通常聚合的字段,可以实现显着的性能提升。
注意:然而,这会将构建全局序数的成本从搜索时间转移到刷新时间。 对于许多用例来说,这是在搜索时为改进付出的代价是可以接受的。
这是一个简单的示例,因此请分享你的发现以及在 Elasticsearch 集群和配置中进行此更改对性能的影响!