工程

开始使用运行时字段 — 了解 Elastic 对读时模式的实现

作者

过去,Elasticsearch 一直依赖于写时模式方法来实现快速的数据搜索。现在,我们为 Elasticsearch 添加了读时模式功能,这不仅让用户能够在采集数据后灵活地更改文档的模式,还可以生成只作为搜索查询的一部分存在的字段。用户可以选择将读时模式和写时模式结合使用,根据需要在性能和灵活性之间取得平衡。

运行时字段是我们针对读时模式的解决方案,这些字段只在查询时才进行计算。它们在索引映射或查询中定义,一旦定义,就可以立即用于搜索请求、聚合、筛选和排序。由于运行时字段未编入索引,因此添加运行时字段不会增加索引大小。事实上,它们可以降低存储成本并提升采集速度。

但是,有利也有弊。对运行时字段的查询有时会很昂贵,因此通常搜索或筛选的数据仍应映射到索引字段。即使您的索引大小较小,运行时字段也会降低搜索速度。我们建议将运行时字段与索引字段结合使用,以便在您的用例的采集速度、索引大小、灵活性和搜索性能之间找到适当的平衡。

添加运行时字段轻而易举

要定义运行时字段,最简单的方法是在查询中进行说明。例如,假设我们有如下所示的索引:

 PUT my_index
 {
   "mappings": {
     "properties": {
       "address": {
         "type": "ip"},
       "port": {
         "type": "long"
       }
     }
   } 
 }

并将一些文档载入其中:

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

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

 GET my_index/_search
 {
   "runtime_mappings": {
     "socket": {
       "type": "keyword",
       "script": {
         "source": "emit(doc['address'].value + ':' + doc['port'].value)"
       }
     }
   },
   "fields": [
     "socket"
   ],
   "query": {
     "match": {
       "socket":"1.2.3.4:8080"
     }
   }
 }

得到以下响应:

…
     "hits" : [
       {
         "_index" : "my_index",
         "_type" : "_doc",
         "_id" :"2",
         "_score" :1.0,
         "_source" : {
           "address" :"1.2.3.4",
           "port" :"8080"
         },
         "fields" : {
           "socket" : [
             "1.2.3.4:8080"
           ]
         }
       }
     ]

我们在 runtime_mappings 部分定义了字段套接字。我们使用了一个简短的 Painless 脚本,这个脚本定义了如何计算每个文档的套接字值(使用 + 来表示地址字段的值与静态字符串“:”和端口字段的值的串联)。然后我们在查询中使用了字段套接字。这个字段套接字是一个临时的运行时字段,仅针对此查询存在,并在查询运行时才进行计算。在定义与运行时字段一起使用的 Painless 脚本时,您必须包含 emit 以返回计算的值。

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

 PUT my_index/_mapping
 {
   "runtime": {
     "socket": {
       "type": "keyword",
       "script": {
         "source": "emit(doc['address'].value + ':' + doc['port'].value)"
       }
     } 
   } 
 }

然后这个查询就不必包含字段的定义了,例如:

 GET my_index/_search
 {
   "fields": [
     "socket"
  ],
   "query": {
     "match": {
       "socket":"1.2.3.4:8080"
     }
   }
 }

语句 "fields": ["socket"] 仅在要显示套接字字段的值时才是必需的。虽然字段套接字现在可用于任何查询,但它不存在于索引中,因此不会增加索引的大小。只有当查询需要套接字并且针对需要它的文档时,才会计算套接字。

使用方法与其他字段无异

由于运行时字段通过与索引字段相同的 API 公开,因此查询可以引用一些字段为运行时字段的索引,以及其他一些字段为索引字段的索引。您可以灵活地选择要索引哪些字段以及保留哪些字段作为运行时字段。字段生成和字段使用之间的这种分离,有利于保持代码更为整洁有序,进而更易于进行创建和维护。

您可以在索引映射搜索请求中定义运行时字段。这种固有的功能为如何将运行时字段与索引字段结合使用提供了灵活性。 

在查询时覆盖字段值

很多时候,您意识到生产数据中的错误时已经为时已晚。  虽然很容易为您将来要采集的文档修复采集指令,但修复已经采集和索引的数据要困难得多。使用运行时字段,您可以通过在查询时覆盖值来修复索引数据中的错误。运行时字段可以覆盖具有相同名称的索引字段,以便更正索引数据中的错误。  

为了更具体地说明上述工作原理,请查看下面这个简单的例子。假设我们有一个带有消息字段和地址字段的索引:

 PUT my_raw_index 
{
  "mappings": {
    "properties": {
      "raw_message": {
        "type": "keyword"
      },
      "address": {
        "type": "ip"
      }
    }
  }
}

我们将一个文档载入其中:

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

哎呀,这个文档的地址字段中包含一个错误的 IP 地址。消息中有正确的 IP 地址,但不知何故,在打算采集到 Elasticsearch 中并编制索引的文档中解析出了错误的地址。如果是一个文档,并没什么大碍,但如果我们在一个月后才发现 10% 的文档包含错误地址怎么办?针对新文档修复这个问题并不是一件大事,但是为已经采集的文档重新编制索引,这在操作上常常会很复杂。有了运行时字段,您可以立即修复上述错误,方法就是通过运行时字段覆盖已编制索引的字段。以下是在查询中执行这项操作的方法:

GET my_raw_index/_search
{
  "runtime_mappings": {
    "address": {
      "type": "ip",
      "script":"Matcher m = /\\d+\\.\\d+\\.\\d+\\.\\d+/.matcher(doc[\"raw_message\"].value);if (m.find()) emit(m.group());"
    }
  },
  "fields": [ 
    "address"
  ]
}

您还可以在映射中进行更改,以便它可用于所有查询。请注意,现在通过 Painless 脚本默认启用正则表达式的使用。

平衡性能与灵活性

使用索引字段,您可以在采集过程中做好所有准备工作并维护复杂的数据结构,以提供最佳性能。但是查询运行时字段的速度要比查询索引字段慢。那么,如果您在开始使用运行时字段后查询速度很慢怎么办?

我们建议在检索运行时字段时使用异步搜索。只要查询在给定的时间阈值内完成,就会像在同步搜索中一样返回完整的结果集。不论怎样,即使查询在那段时间内没有完成,您仍会得到部分结果集,并且 Elasticsearch 将继续轮询,直到返回完整的结果集。这种机制在管理索引生命周期时特别有用,因为较新的结果通常会先返回,而且这些结果往往对用户也更重要。

为了提供最佳性能,我们依靠索引字段来完成查询的繁重工作,以便只为文档的一个子集计算运行时字段的值。

将字段从运行时更改为索引

使用运行时字段,用户能够在实时环境中处理数据的同时灵活地更改他们的映射和解析。因为运行时字段不消耗资源,而且定义它的脚本可以进行更改,所以用户可以不断试验,直到达到最佳映射。当发现某一运行时字段长期有用时,可以通过以下方式在索引时预先计算这个字段的值:只需将模板中的字段定义为索引字段,并确保采集的文档中包含这个值。这个字段将从下一次索引滚动更新开始编入索引,并提供更好的性能。使用这个字段的查询根本不需要进行更改。 

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

我们建议一开始先使用运行时字段来试验数据结构。处理完数据后,您可能会决定为某一运行时字段编制索引,以提高搜索性能。您可以创建一个新索引,然后将字段定义添加到索引映射中,将该字段添加到 _source 并确保采集的文档中包含新字段。如果您使用的是数据流,则可以更新索引模板,以便在从这个模板创建索引时,Elasticsearch 知道要为该字段编制索引。在将来的版本中,我们计划将运行时字段更改为索引字段的过程简化为将字段从映射的运行时部分移动到属性部分。 

下面的请求创建了一个带有时间戳字段的简单索引映射。添加 "dynamic": "runtime" 会指示 Elasticsearch 在这个索引中动态创建其他字段作为运行时字段。如果运行时字段包含 Painless 脚本,则该字段的值将根据 Painless 脚本进行计算。如果在没有脚本的情况下创建了运行时字段,如下面的请求所示,系统将在 _source 中查找与运行时字段同名的字段,并将它的值用作运行时字段的值。

PUT my_index-1
{
  "mappings": {
    "dynamic": "runtime",
    "properties": {
      "timestamp": {
        "type": "date",
        "format": "yyyy-MM-dd"
      }
    }
  }
}

下面我们为文档编制索引,看看这些设置的优点:

POST my_index-1/_doc/1
{
  "timestamp":"2021-01-01",
  "message": "my message",
  "voltage":"12"
}

至此,我们有了一个索引字段 (timestamp) 和两个运行时字段(message 和 voltage),我们可以查看索引映射:

GET my_index-1/_mapping

运行时部分包括 message 和 voltage。这两个字段没有编制索引,但我们仍然可以像索引字段一样查询它们。

{
  "my_index-1" : {
    "mappings" : {
      "dynamic" : "runtime",
      "runtime" : {
        "message" : {
          "type" : "keyword"
        },
        "voltage" : {
          "type" : "keyword"
        }
      },
      "properties" : {
        "timestamp" : {
          "type" : "date",
          "format" : "yyyy-MM-dd"
        }
      }
    }
  }
}

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

GET my_index-1/_search
{
  "query": {
    "match": {
      "message": "my message"
    }
  }
}

响应包括以下命中信息:

...
"hits" : [
      {
        "_index" : "my_index-1",
        "_type" : "_doc",
        "_id" :"1",
        "_score" :1.0,
        "_source" : { 
          "timestamp" :"2021-01-01",
          "message" : "my message",
          "voltage" :"12" 
        } 
      } 
    ]
…

查看一下这个响应,我们注意到一个问题:我们没有将 voltage 指定为一个数字!因为 voltage 是一个运行时字段,所以可以通过更新映射的运行时部分中的字段定义来轻松修复:

PUT my_index-1/_mapping
{
  "runtime":{
    "voltage":{
      "type": "long"
    }
  }
}

上一个请求将 voltage 更改为 long 类型,这将立即对已编制索引的文档生效。为了测试这一行为,我们为 voltage 介于 11 和 13 之间的所有文档构建了一个简单的查询:

GET my_index-1/_search
{
  "query": {
    "range": {
      "voltage": {
        "gt":11,
        "lt":13
      }
    }
  }
}

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

...
{
  "my_index-1" : {
    "mappings" : {
      "dynamic" : "runtime",
      "runtime" : {
        "message" : {
          "type" : "keyword"
        },
        "voltage" : {
          "type" : "long"
        }
      },
      "properties" : {
        "timestamp" : {
          "type" : "date",
          "format" : "yyyy-MM-dd"
        }
      }
    }
  }
}
…

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

理想情况下,我们会更新索引模板本身,以便所做更改在下一次滚动更新时生效。您可以在任何与 my_index* 模式匹配的索引中对 voltage 字段运行查询,即使这个字段在一个索引中是运行时字段,在另一个索引中是索引字段也可以进行查询。

PUT my_index-2
{
  "mappings": {
    "dynamic": "runtime",
    "properties": {
      "timestamp": {
        "type": "date",
        "format": "yyyy-MM-dd"
      },
      "voltage":
      {
        "type": "integer"
      }
    }
  }
}

因此,对于运行时字段,我们引入了一个新的字段生命周期工作流。在这个工作流中,字段可以自动生成为运行时字段,而不会影响资源消耗,也没有造成映射爆炸的风险,同时允许用户立即开始处理数据。字段的映射可以在实际数据上进行优化,而它仍然是运行时字段,并且由于运行时字段的灵活性,这些更改会对已经被采集到 Elasticsearch 中的文档生效。当明确这个字段有用时,就可以更改模板,以便在从那个时间点起(下一次滚动更新之后)创建的索引中,将这个字段编入索引,以获得最佳性能。

总结

在大多数情况下,特别是如果您了解自己的数据,知道想用数据做什么,那么索引字段就是最佳选择,因为它们的性能优势毋庸置疑。另一方面,当文档解析和模式结构需要进行灵活更改时,运行时字段就可以为您效劳。

运行时字段和索引字段两大特性优势互补 — 它们形成了一种共生关系。运行时字段提供了灵活性,但如果没有索引的支持,它们将永远无法在高扩展环境中很好地运行。索引强大而严格的结构提供了一个受保护的环境,在这个环境中,运行时字段可以充分发挥自身的灵活性优势,这与藻类在珊瑚中寻找庇护所的方式有着异曲同工之妙。各方都能从这种共生关系中受益。

即刻开始使用

如要开始使用运行时字段,既可在 Elasticsearch Service 上快速部署一个集群,也可安装最新版本的 Elastic Stack。Elasticsearch 已在运行了?只需将集群升级到 7.11 版,即可开始试用。有关运行时字段及其优势的概要介绍,请阅读运行时字段:Elastic 读时模式博文。此外,我们还录制了 4 段视频来帮您开始使用运行时字段。