防止 Elasticsearch 中出现重复事件型数据的高效方法

很多不同用例中都会用到 Elastic Stack。最常见的用例之一是储存并分析不同类型的事件型或时序型数据,例如安全事件、日志和指标。这些事件的组成数据通常与特定时间戳相关,这些时间戳表示事件的发生时间或收集时间,多数情况下并没有自然键来对事件进行唯一标识。

针对某些用例而言,甚至对用例内的某些数据类型而言,很重要的一点要求即是 Elasticsearch 中的数据不能有重复值:重复文档可能会导致无法正确分析,也可能会导致搜索错误。我们去年便开始关注这一问题,详见博文使用 Logstach 处理去重问题初探,在本篇博文中,我们将会更加深入地进行讲解,并解决一些常见问题。

将数据索引到 Elasticsearch 中

将数据索引到 Elasticsearch 中时,您需要收到响应才能确定数据已经成功完成索引。如果由于错误(例如连接错误或者节点崩溃)而导致您无法收到响应,则您将无法确定是否已对任何数据完成索引。客户遇到这种情况时,确保索引成功的标准做法是重试,而这可能会导致对同一份文档索引多次。

如在有关去重的那篇博文中所述,为了避免这个问题,您或许可以在客户端中为每份文档定义一个唯一的 ID,而不是让 Elasticsearch 在索引时自动分配 ID。当将重复文档写入同一索引中时,Elasticsearch 将会进行更新,而不是重新写入,通过这种方式便可防止出现重复值。

UUID 与基于哈希函数的文档 ID 之间的对比

在决定使用何种类型的标识符时,有两种主要的标识符可供选择。

通用唯一标识符 (UUID) 是基于 128 位数字的标识符,可由分布式系统生成,基本可以确保唯一性。这种类型的标识符通常与相关联事件的内容无关。

如要使用 UUID 来避免出现重复值,至关重要的一点是:在对事件进行任何传输活动之前,便需要生成 UUID 并分配给事件,通过这种做法,便可确保仅对事件传送一次。通常情况下,这便意味着必须在源头处分配 UUID。如果发生事件时所在的系统无法生成 UUID,则可能需要使用另外一种类型的标识符。

另外一种主要标识符则是使用哈希函数基于事件内容生成的数字型哈希值。对于每条具体内容,哈希函数一直都会生成相同的值,但是并不能确保所生成值的唯一性。如果两个不同事件产生的哈希值相同,这种情况称为哈希值冲突;哈希值冲突的几率取决于索引中的事件数量、所用哈希函数的类型,以及所生成值的长度。在很多种情况下,长度至少为 128 位的哈希值(例如 MD5 或 SHA1)通常可以在长度和低冲突几率之间达到一个很好的平衡。如要在更高程度上确保唯一性,可以使用更长的哈希值,例如 SHA256。

由于基于哈希函数的标识符取决于事件内容,所以可以在后期处理阶段再为事件分配哈希值,因为无论在什么系统中,都会计算得出相同的值。这种方式让用户有可能在将数据索引到 Elasticsearch 中之前的任意时刻分配此类 ID,所以设计采集管道时可以更加灵活。

Logstash 支持计算 UUID,同时还通过 fingerprint filter plugin(指纹筛选插件)提供一系列热门且十分常用的哈希函数。

如何选择高效的文档 ID

如果允许 Elasticsearch 在索引时为文档分配标识符,由于 Elasticsearch 知道所生成的标识符不可能存在于索引中,所以能够执行优化。此种做法能够改善索引性能。对于外部生成并随文档传输到 Elasticsearch 中的标识符,Elasticsearch 必须将其视为潜在更新,并在既有的索引区段中查看文档标识符是否已经存在,由于这需要完成额外工作,所以会慢一些。

所生成的外部文档标识符并非都具有相同的索引性能。通常而言,相较于完全随机生成的标识符,随时间按升序生成的标识符的索引性能会更好。之所以会这样,是因为:Elasticsearch 能够基于既有索引区段的最小和最大标识符值快速确定某个标识符是否存在于此索引区段中,而无需搜索整个索引区段。这一点在此篇博文中有详细讲解,虽然这篇文章的时间有点久了,但是仍然能够起到帮助作用。

基于哈希函数的标识符以及很多类型的 UUID 通常都是随机性质的。当处理事件流并且其中的每个事件都有确定的时间戳时,我们可以使用时间戳作为标识符的前缀,以便对标识符进行排序,进而改善索引性能。

创建标识符时加上时间戳前缀还有一个优势,即降低哈希值冲突的几率,因为哈希值只要针对每个时间戳是唯一值便可以了。采用这种办法,即使在高采集量的情况下,也可以使用较短的哈希值。

我们可以在 Logstash 中创建这些类型的标识符,做法是:使用 fingerprint filter plugin(指纹筛选插件)生成 UUID 或哈希值,并使用 Ruby 筛选器来创建时间戳的十六进制编码字符串表达式。假设我们有一个可以应用哈希函数的消息字段,并且事件中的时间戳已经解析到 @timestamp 字段中,则我们可以创建标识符的组成部分,并像下面这样以元数据的形式进行存储:

fingerprint {
  source => "message"
  target => "[@metadata][fingerprint]"
  method => "MD5"
  key => "test"
}
ruby {
  code => "event.set('@metadata[tsprefix]', event.get('@timestamp').to_i.to_s(16))"
}

然后可以使用这两个字段在 Elasticsearch 输出插件中生成一个文档 ID:

elasticsearch {
  document_id => "%{[@metadata][tsprefix]}%{[@metadata][fingerprint]}"
}

最后生成的文档 ID 采用的是 16 进制编码,并且长度为 40 个字符,例如 4dad050215ca59aa1e3a26a222a9bbcaced23039。有关完整的配置示例,请参见这个 Gist

对索引性能的影响

使用不同类型标识符所造成的影响在很大程度上取决于您的数据、硬件和用例。尽管我们可以给出一些通用的指南,但很重要的一点是运行基准测试来准确确定标识符对您的用例的影响。

如要实现最优的索引处理量,最高效的方式肯定是使用 Elasticsearch 自动生成的标识符。由于无需进行更新检查,索引性能不会随着索引和分片规模的增加而受到大幅影响。因此,但凡有可能,我们都推荐使用这种方法。

如果使用外部 ID,进行更新检查时需要额外的磁盘访问权限。此种方式造成的影响取决于多个因素:操作系统对所需数据进行缓存的效率如何,存储的速度如何,以及存储处理随机读取请求的表现如何。随着索引和分片规模的增加,由于需要检查的区段越来越多,索引速度通常也会变慢。

使用滚动 API

对于传统的时序型索引,每个索引都会涵盖特定的固定时间段。这意味着,如果数据量会随着时间波动,索引和分片的规模会有很大差别。分片规模不均并非理想状况,可能会导致性能问题。

我们推出了 rollover index API(滚动索引 API),支持用户基于多个条件(而非仅限时间)灵活地管理时序型索引。通过此 API,您能够在索引达到特定规模、文档数量和/或时间后,便滚动至下一个新索引,这样便可以更好地预测所生成分片和索引的规模。

然而,这样做的话,会打破事件时间戳与其所属索引之间的联系。如果严格基于时间进行索引,则无论处理时间的早晚,某个事件都会归属到同一个索引中。正是基于这一原则,您才能够使用外部标识符防止出现重复值。然而使用滚动 API 时,虽然您能够降低出现重复值的几率,但是却无法完全防止出现重复值。有可能会出现下面这种情况:两个重复事件分别到达滚动前和滚动后的两个索引,尽管这两个事件的时间戳相同,Elasticsearch 却不会进行更新,而是会将它们存储在这两个不同的索引中。

因此,如果防止出现重复值是一项硬性要求,我们不推荐使用滚动 API。

适应难以预测的流量

即使不能使用滚动 API,如果流量会波动并且导致时序型索引的规模过大或过小,仍然有一些其他方法可以适应并调整分片规模。

如果分片规模由于流量骤升等原因而过大,您可以使用 split index API(分割索引 API) 来将索引分割成更多的分片。由于此 API 要求在创建索引时便应用一项设置,所以此 API 需要通过索引模板来添加。

另一方面,如果流量过低并导致分片规模过小,您可以使用 shrink index API(压缩索引 API)来减少索引中分片的数量。

结论

如您在本篇博文中所看到的,要想防止在 Elasticsearch 中出现重复值,您可以在将数据索引至 Elasticsearch 之前指定一个外部文档标识符。标识符的类型和结构会对索引性能产生重大影响。然而,对索引性能的影响因用例而异,所以我们推荐您进行基准测试,以确定适用于您和您的特定情况的最佳方法。