预加载 fielddata

Elasticsearch 加载内存 fielddata 的默认行为是 延迟 加载当 Elasticsearch 第一次查询某个字段时,它将会完整加载这个字段所有 Segment 中的倒排索引到内存中,以便于以后的查询能够获取更好的性能。

对于小索引段来说,这个过程的需要的时间可以忽略。但如果我们有一些 5 GB 的索引段,并希望加载 10 GB 的 fielddata 到内存中,这个过程可能会要数十秒。已经习惯亚秒响应的用户很难会接受停顿数秒卡着没反应的网站。

有三种方式可以解决这个延时高峰:

  • 预加载 fielddata
  • 预加载全局序号
  • 缓存预热

所有的变化都基于同一概念:预加载 fielddata ,这样在用户进行搜索时就不会碰到延迟高峰。

预加载 fielddata(Eagerly Loading Fielddata)

第一个工具称为 预加载 (与默认的延迟加载相对)。随着新分段的创建(通过刷新、写入或合并等方式),启动字段预加载可以使那些对搜索不可见的分段里的 fielddata 提前 加载。

这就意味着首次命中分段的查询不需要促发 fielddata 的加载,因为 fielddata 已经被载入到内存。避免了用户遇到搜索卡顿的情形。

预加载是按字段启用的,所以我们可以控制具体哪个字段可以预先加载:

  1. PUT /music/_mapping/_song
  2. {
  3. "tags": {
  4. "type": "string",
  5. "fielddata": {
  6. "loading" : "eager" (1)
  7. }
  8. }
  9. }

<1> 设置 fielddata.loading: eager 可以告诉 Elasticsearch 预先将此字段的内容载入内存中。

Fielddata 的载入可以使用 update-mapping API 对已有字段设置 lazyeager 两种模式。

[WARNING]

预加载只是简单的将载入 fielddata 的代价转移到索引刷新的时候,而不是查询时,从而大大提高了搜索体验。

体积大的索引段会比体积小的索引段需要更长的刷新时间。通常,体积大的索引段是由那些已经对查询可见的小分段合并而成的,所以较慢的刷新时间也不是很重要。


全局序号(Global Ordinals)

有种可以用来降低字符串 fielddata 内存使用的技术叫做 序号

设想我们有十亿文档,每个文档都有自己的 status 状态字段,状态总共有三种: status_pendingstatus_publishedstatus_deleted 。如果我们为每个文档都保留其状态的完整字符串形式,那么每个文档就需要使用 14 到 16 字节,或总共 15 GB。

取而代之的是我们可以指定三个不同的字符串,对其排序、编号:0,1,2。

  1. Ordinal | Term
  2. -------------------
  3. 0 | status_deleted
  4. 1 | status_pending
  5. 2 | status_published

序号字符串在序号列表中只存储一次,每个文档只要使用数值编号的序号来替代它原始的值。

  1. Doc | Ordinal
  2. -------------------------
  3. 0 | 1 # pending
  4. 1 | 1 # pending
  5. 2 | 2 # published
  6. 3 | 0 # deleted

这样可以将内存使用从 15 GB 降到 1 GB 以下!

但这里有个问题,记得 fielddata 是按分 来缓存的。如果一个分段只包含两个状态( status_deletedstatus_published )。那么结果中的序号(0 和 1)就会与包含所有三个状态的分段不一样。

如果我们尝试对 status 字段运行 terms 聚合,我们需要对实际字符串的值进行聚合,也就是说我们需要识别所有分段中相同的值。一个简单粗暴的方式就是对每个分段执行聚合操作,返回每个分段的字符串值,再将它们归纳得出完整的结果。尽管这样做可行,但会很慢而且大量消耗 CPU。

取而代之的是使用一个被称为 全局序号 的结构。(((“global ordinals”))) 全局序号是一个构建在 fielddata 之上的数据结构,它只占用少量内存。唯一值是 跨所有分段 识别的,然后将它们存入一个序号列表中,正如我们描述过的那样。

现在, terms 聚合可以对全局序号进行聚合操作,将序号转换成真实字符串值的过程只会在聚合结束时发生一次。这会将聚合(和排序)的性能提高三到四倍。

构建全局序号(Building global ordinals)

当然,天下没有免费的晚餐。 (((“global ordinals”, “building”))) 全局序号分布在索引的所有段中,所以如果新增或删除一个分段时,需要对全局序号进行重建。重建需要读取每个分段的每个唯一项,基数越高(即存在更多的唯一项)这个过程会越长。

全局序号是构建在内存 fielddata 和 doc values 之上的。实际上,它们正是 doc values 性能表现不错的一个主要原因。

和 fielddata 加载一样,全局序号默认也是延迟构建的。首个需要访问索引内 fielddata 的请求会促发全局序号的构建。由于字段的基数不同,这会导致给用户带来显著延迟这一糟糕结果。一旦全局序号发生重建,仍会使用旧的全局序号,直到索引中的分段产生变化:在刷新、写入或合并之后。

预构建全局序号(Eager global ordinals)

单个字符串字段(((“eager loading”, “of global ordinals”)))(((“global ordinals”, “eager”))) 可以通过配置预先构建全局序号:

  1. PUT /music/_mapping/_song
  2. {
  3. "song_title": {
  4. "type": "string",
  5. "fielddata": {
  6. "loading" : "eager_global_ordinals" (1)
  7. }
  8. }
  9. }

<1> 设置 eager_global_ordinals 也暗示着 fielddata 是预加载的。

正如 fielddata 的预加载一样,预构建全局序号发生在新分段对于搜索可见之前。

[NOTE]

序号的构建只被应用于字符串。数值信息(integers(整数)、geopoints(地理经纬度)、dates(日期)等等)不需要使用序号映射,因为这些值自己本质上就是序号映射。

因此,我们只能为字符串字段预构建其全局序号。

也可以对 Doc values 进行全局序号预构建:

  1. PUT /music/_mapping/_song
  2. {
  3. "song_title": {
  4. "type": "string",
  5. "doc_values": true,
  6. "fielddata": {
  7. "loading" : "eager_global_ordinals" (1)
  8. }
  9. }
  10. }

<1> 这种情况下,fielddata 没有载入到内存中,而是 doc values 被载入到文件系统缓存中。

与 fielddata 预加载不一样,预建全局序号会对数据的 实时性 产生影响,构建一个高基数的全局序号会使一个刷新延时数秒。 选择在于是每次刷新时付出代价,还是在刷新后的第一次查询时。如果经常索引而查询较少,那么在查询时付出代价要比每次刷新时要好。如果写大于读,那么在选择在查询时重建全局序号将会是一个更好的选择。

[TIP]

针对实际场景优化全局序号的重建频次。如果我们有高基数字段需要花数秒钟重建,增加 refresh_interval 的刷新的时间从而可以使我们的全局序号保留更长的有效期,这也会节省 CPU 资源,因为我们重建的频次下降了。


索引预热器(Index Warmers)

最后我们谈谈 索引预热器 。预热器早于 (((“index warmers”))) fielddata 预加载和全局序号预加载之前出现,它们仍然有其存在的理由。一个索引预热器允许我们指定一个查询和聚合须要在新分片对于搜索可见之前执行。这个想法是通过预先填充或 预热缓存 让用户永远无法遇到延迟的波峰。

原来,预热器最重要的用法是确保 fielddata 被预先加载,因为这通常是最耗时的一步。现在可以通过前面讨论的那些技术来更好的控制它,但是预热器还是可以用来预建过滤器缓存,当然我们也还是能选择用它来预加载 fielddata。

让我们注册一个预热器然后解释发生了什么:

  1. PUT /music/_warmer/warmer_1 (1)
  2. {
  3. "query" : {
  4. "bool" : {
  5. "filter" : {
  6. "bool": {
  7. "should": [ (2)
  8. { "term": { "tag": "rock" }},
  9. { "term": { "tag": "hiphop" }},
  10. { "term": { "tag": "electronics" }}
  11. ]
  12. }
  13. }
  14. }
  15. },
  16. "aggs" : {
  17. "price" : {
  18. "histogram" : {
  19. "field" : "price", (3)
  20. "interval" : 10
  21. }
  22. }
  23. }
  24. }

<1> 预热器被关联到索引( music )上,使用接入口 _warmer 以及 ID ( warmer_1 )。

<2> 为三种最受欢迎的曲风预建过滤器缓存。

<3> 字段 price 的 fielddata 和全局序号会被预加载。

预热器是根据具体索引注册的,每个预热器都有唯一的 ID ,因为每个索引可能有多个预热器。

然后我们可以指定查询,任何查询。它可以包括查询、过滤器、聚合、排序值、脚本,任何有效的查询表达式都毫不夸张。这里的目的是想注册那些可以代表用户产生流量压力的查询,从而将合适的内容载入缓存。

当新建一个分段时,Elasticsearch 将会执行注册在预热器中的查询。执行这些查询会强制加载缓存,只有在所有预热器执行完,这个分段才会对搜索可见。

[WARNING]

与预加载类似,预热器只是将冷缓存的代价转移到刷新的时候。当注册预热器时,做出明智的决定十分重要。 为了确保每个缓存都被读入,我们 可以 加入上千的预热器,但这也会使新分段对于搜索可见的时间急剧上升。

实际中,我们会选择少量代表大多数用户的查询,然后注册它们。

有些管理的细节(比如获得已有预热器和删除预热器)没有在本小节提到,剩下的详细内容可以参考 {ref}/indices-warmers.html[预热器文档(warmers documentation)] 。