【Elastic Engineering】Elasticsearch:Runtime fields 入门, Elastic 的 schema on read 实现 - 7.11 发布

作者:刘晓国


从历史上看,Elasticsearch 依靠 schema on write 的模式来快速搜索数据。现在,我们向 Elasticsearch 添加了 schema on read 模式,以便用户可以灵活地在摄取后更改文档的 schema,还可以生成仅作为搜索查询一部分存在的字段。schema on read 和 schema on write 一起为用户提供了选择,可以根据他们的需求来平衡性能和灵活性。

我们的 schema 【Elastic Engineering】Elasticsearch:Runtime fields 入门, Elastic 的 schema on read 实现 - 7.11 发布on read 解决方案是 runtime fields,它们仅在查询时进行评估。它们在索引映射或查询中定义,一旦定义,它们立即可用于搜索请求,聚合,过滤和排序。由于未对 runtime fields 进行索引,因此添加运行时字段不会增加索引的大小。实际上,它们可以降低存储成本并提高摄取速度。

但是,需要权衡取舍。对运行时字段的查询可能会很昂贵,因此你通常搜索或筛选所依据的数据仍应映射到索引字段。即使你的索引大小较小,runtime fields 也会降低搜索速度。我们建议结合使用 runtime fields 和索引字段,以在用例的摄取速度,索引大小,灵活性和搜索性能之间找到合适的平衡。


添加 runtime fields 很容易


定义 runtime fields 的最简单方法是在查询中。 例如,如果我们具有以下索引:

1. PUT my_index
2. {
3.   "mappings": {
4.     "properties": {
5.       "address": {
6.         "type": "ip"
7.       },
8.       "port": {
9.         "type": "long"
10.       }
11.     }
12.   }
13. }

并将一些文档加载到其中:

1.  POST my_index/_bulk
2.  {"index":{"_id":"1"}}
3.  {"address":"1.2.3.4","port":"80"}
4.  {"index":{"_id":"2"}}
5.  {"address":"1.2.3.4","port":"8080"}
6.  {"index":{"_id":"3"}}
7.  {"address":"2.4.8.16","port":"80"}

我们可以使用静态字符串创建两个字段的串联,如下所示:

1. GET my_index/_search
2.  {
3. "runtime_mappings": {
4. "socket": {
5. "type": "keyword",
6. "script": {
7. "source": "emit(doc['address'].value + ':' + doc['port'].value)"
8.        }
9.      }
10.    },
11. "fields": [
12. "socket"
13.    ],
14. "query": {
15. "match": {
16. "socket": "1.2.3.4:8080"
17.      }
18.    }
19.  }

产生以下响应:

1. {
2. "took" : 17,
3. "timed_out" : false,
4. "_shards" : {
5. "total" : 1,
6. "successful" : 1,
7. "skipped" : 0,
8. "failed" : 0
9. },
10. "hits" : {
11. "total" : {
12. "value" : 1,
13. "relation" : "eq"
14. },
15. "max_score" : 1.0,
16. "hits" : [
17. {
18. "_index" : "my_index",
19. "_type" : "_doc",
20. "_id" : "2",
21. "_score" : 1.0,
22. "_source" : {
23. "address" : "1.2.3.4",
24. "port" : "8080"
25. },
26. "fields" : {
27. "socket" : [
28. "1.2.3.4:8080"
29. ]
30. }
31. }
32. ]
33. }
34. }

我们在 runtime_mappings 部分中定义了字段 socket。 我们使用了一个简短的 painless script,该脚本定义了每个文档将如何计算 socket 的值(使用 + 表示 address 字段的值与静态字符串 “:” 和 port 字段的值的串联)。 然后,我们在查询中使用了字段 socket。 字段 socket 是一个临时运行时字段,仅对于该查询存在,并且在运行查询时进行计算。 在定义要与 runtime fields 一起使用的 painless script 时,必须包括 emit 以返回计算出的值。

如果我们发现 socket 是一个我们想在多个查询中使用的字段,而不必为每个查询定义它,则可以通过调用简单地将其添加到映射中:

1. PUT my_index/_mapping
2.  {
3. "runtime": {
4. "socket": {
5. "type": "keyword",
6. "script": {
7. "source": "emit(doc['address'].value + ':' + doc['port'].value)"
8.        }
9.      } 
10.    } 
11.  }

然后查询不必包含 socket 字段的定义,例如:

1. GET my_index/_search
2.  {
3. "fields": [
4. "socket"
5.   ],
6. "query": {
7. "match": {
8. "socket": "1.2.3.4:8080"
9.      }
10.    }
11.  }

仅在要显示 socket 字段的值时才需要语句 "fields": ["socket"]。 现在,字段查询可用于任何查询,但它不存在于索引中,并且不会增加索引的大小。 仅在查询需要 socket 以及需要它的文档时才计算 socket。

像任何其它字段一样使用

因为 runtime fields 通过与索引字段相同的 API 公开,所以查询可以引用某些索引(其中该字段是 runtime fields),以及其他索引(其中该字段是索引字段)。 你可以灵活选择要索引的字段以及要保留为 runtime fields 的字段。 字段生成和字段消费之间的这种分离促进了更易于创建和维护的更有组织的代码。

你可以在索引映射搜索请求中定义 runtime fields。 这种固有的功能为你如何结合使用 runtime fields 和索引字段提供了灵活性。


在查询时覆盖字段值


通常,当为时已晚时,你会在生产数据中发现错误。 尽管为将来要摄入的文档修复摄入说明很容易,但是要修复已经被摄入和建立索引的数据则要困难得多。 使用运行时字段,可以通过在查询时覆盖值来修复索引数据中的错误。Runtime fields 可以覆盖具有相同名称的索引字段,以便你可以更正索引数据中的错误。

这是一个简单的示例,可以使其更加具体。 假设我们有一个带有消息字段和地址字段的索引:

1. PUT my_raw_index
2. {
3.   "mappings": {
4.     "properties": {
5.       "raw_message": {
6.         "type": "keyword"
7.       },
8.       "address": {
9.         "type": "ip"
10.       }
11.     }
12.   }
13. }

然后将文档加载到其中:

1. POST my_raw_index/_doc/1
2. {
3. "raw_message": "199.72.81.55 - - [01/Jul/1995:00:00:01 -0400] GET /history/apollo/ HTTP/1.0 200 6245",
4. "address": "1.2.3.4"
5. }

哎呀,文档在 address 字段中包含错误的 IP 地址(1.2.3.4)。 raw_message 中存在正确的IP地址,但是不知何故在发送给文档中解析出错误的地址,以将其提取到 Elasticsearch 中并建立索引。 对于单个文档来说,这不是问题,但是如果一个月后我们发现 10% 的文档包含错误的 address 怎么办? 为新文档修复它并不重要,但是重新索引已经摄入的文档通常在操作上很复杂。 对于 runtime fields,可以通过使用 runtime fields 遮盖索引字段来立即修复它。 这是在查询中的处理方式:

1. GET my_raw_index/_search
2. {
3. "runtime_mappings": {
4. "address": {
5. "type": "ip",
6. "script": """Matcher m = /\d+\.\d+\.\d+\.\d+/.matcher(doc["raw_message"].value);if (m.find()) emit(m.group());"""
7.     }
8.   },
9. "fields": [
10. "address"
11.   ]
12. }

上面的命令返回的结果是:

1. {
2. "took" : 4,
3. "timed_out" : false,
4. "_shards" : {
5. "total" : 1,
6. "successful" : 1,
7. "skipped" : 0,
8. "failed" : 0
9. },
10. "hits" : {
11. "total" : {
12. "value" : 1,
13. "relation" : "eq"
14. },
15. "max_score" : 1.0,
16. "hits" : [
17. {
18. "_index" : "my_raw_index",
19. "_type" : "_doc",
20. "_id" : "1",
21. "_score" : 1.0,
22. "_source" : {
23. "raw_message" : "199.72.81.55 - - [01/Jul/1995:00:00:01 -0400] GET /history/apollo/ HTTP/1.0 200 6245",
24. "address" : "1.2.3.4"
25. },
26. "fields" : {
27. "address" : [
28. "199.72.81.55"
29. ]
30. }
31. }
32. ]
33. }
34. }

你还可以在映射中进行更改,以使其可用于所有查询。 请注意,现在默认情况下通过 painless script 启用了正则表达式的使用。


平衡性能和灵活性


使用索引字段,你可以在摄入期间进行所有准备工作,并维护复杂的数据结构以提供最佳性能。但是查询 runtime fields 比查询索引字段要慢。那么,如果你开始使用 runtime fields 后查询速度慢怎么办?

我们建议在检索运行时字段时使用异步搜索。如果查询在给定的时间阈值内完成,则返回完整的结果集,就像在同步搜索中一样。但是,即使查询在那个时候没有完成,你仍然会得到部分结果集,Elasticsearch 将继续轮询直到返回完整的结果集。在管理索引生命周期时,此机制特别有用,因为更新的结果通常会首先返回,并且通常对用户也更重要。

为了提供最佳性能,我们依靠索引字段来完成查询的繁重工作,以便仅针对文档的子集计算 runtime fields 的值。


将字段从 runtime 更改为索引字段


Runtime fields 允许用户在实时环境中处理数据时灵活地更改其映射和解析。因为运行时字段不会消耗资源,并且因为可以更改定义它的脚本,所以用户可以尝试直到达到最佳映射。如果发现 runtime fields 对于长期而言是有用的,则可以通过简单地将模板中的该字段定义为索引字段并确保摄取的文档包括它来在索引时预先计算其值。该字段将从下一次索引转换开始被索引,并提供更好的性能。使用该字段的查询根本不需要更改。

此方案对于动态映射特别有用。一方面,允许新文档生成新字段非常有帮助,因为这样可以立即使用其中的数据(条目的结构经常更改,例如,由于生成日志的软件的更改)。另一方面,动态映射会带来加重索引甚至创建映射爆炸的风险,因为你永远不知道某些文档是否会因2000个新字段而使你感到惊讶。Runtime fields 可以为这种情况提供解决方案。可以将新字段自动创建为 runtime fields,以免增加索引负担(因为它们不存在于索引中),并且不计入 index.mapping.total_fields.limit 中。这些自动创建的运行时字段虽然性能较低,但可以查询,因此用户可以使用它们,并在需要时决定在下一次转换时将它们更改为索引字段。

我们建议最初使用 runtime fields 来试验你的数据结构。处理完数据后,你可能决定对 runtime fields 建立索引,以提高搜索性能。你可以创建一个新索引,然后将字段定义添加到索引映射中,将该字段添加到 _source 中,并确保新字段包含在摄取的文档中。如果你正在使用数据流,则可以更新索引模板,以便从该模板创建索引时,Elasticsearch 知道要对该字段进行索引。在将来的版本中,我们计划使将 runtime fields 更改为索引字段的过程变得简单,就像将字段从映射的 runtime 部分移到属性部分一样。

以下请求创建带有 timestamp 字段的简单索引映射。包括 "dynamic": "runtime" 指示 Elasticsearch 在此索引中动态创建其他字段作为 runtime fields。如果 runtime fields 包含 painless script,则将基于 painless script 计算该字段的值。如果在没有脚本的情况下创建了 runtime fields,如以下请求所示,则系统将在 _source 中查找与该运行时字段名称相同的字段,并将其值用作该 runtime fields 的值。

1. PUT my_index-1
2. {
3. "mappings": {
4. "dynamic": "runtime",
5. "properties": {
6. "timestamp": {
7. "type": "date",
8. "format": "yyyy-MM-dd"
9.       }
10.     }
11.   }
12. }

让我们为文档建立索引,以查看这些设置的优点:

1. POST my_index-1/_doc/1
2. {
3. "timestamp": "2021-01-01",
4. "message": "my message",
5. "voltage": "12"
6. }

现在我们有了索引的 timestamp 字段和两个 runtime fields(message 和 voltage),我们可以查看索引映射:

GET my_index-1/_mapping

运行时部分包括 message 和 voltage。 这些字段未建立索引,但是我们仍然可以像对其进行索引一样精确地查询它们。上面的命令显示:

1. {
2. "my_index-1" : {
3. "mappings" : {
4. "dynamic" : "runtime",
5. "runtime" : {
6. "message" : {
7. "type" : "keyword"
8. },
9. "voltage" : {
10. "type" : "keyword"
11. }
12. },
13. "properties" : {
14. "timestamp" : {
15. "type" : "date",
16. "format" : "yyyy-MM-dd"
17. }
18. }
19. }
20. }
21. }

我们将创建一个简单的搜索请求,以查询 message 字段:

1. GET my_index-1/_search
2. {
3. "query": {
4. "match": {
5. "message": "my message"
6.     }
7.   }
8. }

响应包括以下 hits:

1. {
2. "took" : 999,
3. "timed_out" : false,
4. "_shards" : {
5. "total" : 1,
6. "successful" : 1,
7. "skipped" : 0,
8. "failed" : 0
9. },
10. "hits" : {
11. "total" : {
12. "value" : 1,
13. "relation" : "eq"
14. },
15. "max_score" : 1.0,
16. "hits" : [
17. {
18. "_index" : "my_index-1",
19. "_type" : "_doc",
20. "_id" : "1",
21. "_score" : 1.0,
22. "_source" : {
23. "timestamp" : "2021-01-01",
24. "message" : "my message",
25. "voltage" : "12"
26. }
27. }
28. ]
29. }
30. }

查看此响应后,我们注意到一个问题:我们未指定 voltage 为数字! 由于 voltage 是 runtime fields,因此可以通过更新映射的 "runtime" 部分中的字段定义来轻松解决:

1. PUT my_index-1/_mapping
2. {
3. "runtime":{
4. "voltage":{
5. "type": "long"
6.     }
7.   }
8. }

先前的请求将 voltage 更改为 long 类型,这对于已建立索引的文档立即生效。 为了测试这种行为,我们对所有电压在11到13之间的文档构造一个简单的查询:

1. GET my_index-1/_search
2. {
3. "query": {
4. "range": {
5. "voltage": {
6. "gt": 11,
7. "lt": 13
8.       }
9.     }
10.   }
11. }

因为我们的 voltage 为12,所以查询返回 my_index-1 中的文档。 如果再次查看该映射,我们将看到,voltage 现在是 long 类型的运行时字段,即使对于在更新映射中的字段类型之前被摄取到 Elasticsearch 中的文档也是如此:

1. {
2. "took" : 2,
3. "timed_out" : false,
4. "_shards" : {
5. "total" : 1,
6. "successful" : 1,
7. "skipped" : 0,
8. "failed" : 0
9. },
10. "hits" : {
11. "total" : {
12. "value" : 1,
13. "relation" : "eq"
14. },
15. "max_score" : 1.0,
16. "hits" : [
17. {
18. "_index" : "my_index-1",
19. "_type" : "_doc",
20. "_id" : "1",
21. "_score" : 1.0,
22. "_source" : {
23. "timestamp" : "2021-01-01",
24. "message" : "my message",
25. "voltage" : "12"
26. }
27. }
28. ]
29. }
30. }

稍后,我们可能会确定 voltage 在聚合中很有用,我们希望将其索引到在数据流中创建的下一个索引。 我们创建一个新索引(my_index-2),该索引与数据流的索引模板匹配,并将 voltage 定义为整数,并在尝试 runtime fields 后知道我们想要哪种数据类型。

理想情况下,我们将更新索引模板本身,以使更改在下一次翻转时生效。 你可以在与 my_index* 模式匹配的任何索引中的 voltage 字段上运行查询,即使该字段在一个索引中是 runtime fields,而在另一个索引中又是索引字段。

1. PUT my_index-2
2. {
3. "mappings": {
4. "dynamic": "runtime",
5. "properties": {
6. "timestamp": {
7. "type": "date",
8. "format": "yyyy-MM-dd"
9.       },
10. "voltage":
11.       {
12. "type": "integer"
13.       }
14.     }
15.   }
16. }

因此,对于 runtime fields,我们引入了新的字段生命周期工作流。 在此工作流程中,可以将字段自动生成为 runtime fields,而不会影响资源消耗,也不会冒映射爆炸的风险,从而使用户可以立即开始使用数据。 在仍然是 runtime fields 的情况下,可以根据实际数据优化字段的映射,并且由于运行时字段的灵活性,这些更改对已经提取到 Elasticsearch 中的文档生效。 当很明显该字段很有用时,可以更改模板,以便在从该点开始创建的索引中(下一次翻转之后),将对该字段建立索引以获得最佳性能。


总结


在大多数情况下,尤其是如果你知道自己的数据以及要使用的数据,则索引字段由于其性能优势而成为必经之路。 另一方面,当需要在文档解析和模式结构方面具有灵活性时,runtime fields 现在可以提供答案。

Runtime fields 和索引字段是互补的功能 - 它们形成了共生关系。 runtime fields 提供了灵活性,但是如果没有索引的支持,它们将无法在大规模环境中良好地运行。 索引强大而坚固的结构提供了一个庇护环境,在该环境中,runtime fields 的灵活性可以夸耀其本来面目,其方式与藻类在珊瑚中的庇护方式没有太大不同。 每个人都将从这种共生中受益。


更多阅读:

上一篇:3.15比特币能否延续涨势?ETH多头有戏吗?短线如何获利?


下一篇:【Elastic Engineering】Elasticsearch:Elasticsearch 中的数据强制匹配