作者:来自 Elastic Tanguy LerouxFrancisco Fernández Castaño 及 Anton Persson

了解 synthetic _id 如何利用 Bloom filters 在保持完整 API 兼容性的同时,将时序存储降低 34%。

测试 Elastic 开箱即用的前沿能力。深入体验 Elasticsearch Labs 仓库中的示例 notebooks,开启免费的云试用,或者立即在你的本地机器上尝试 Elastic。


synthetic _id 可将时序索引存储减少最多 34%,并消除摄取阶段 6% 的 CPU 开销。Elasticsearch 不再为 _id 构建倒排索引,而是通过 _tsid 和 @timestamp 动态计算文档标识符,并使用 Bloom filter 来实现去重。这个优化已在 Elasticsearch 9.4 中发布,并已经在 Elastic Cloud Serverless 上线。

这篇文章将深入介绍其实现方式。关于 synthetic _id 如何融入更广泛的 metrics 性能体系,可以参考我们如何将 Elasticsearch 重构为领先的列式 metrics 数据存储,从而为 OpenTelemetry metrics 实现最高 6.6 倍的存储效率提升以及 50% 的索引吞吐量提升。

我们将首先解释为什么 _id 字段在时序工作负载中代价高昂。随后,我们将介绍 synthetic _id 的工作原理,以及它如何使用 Bloom filter 来优化文档去重,而不是维护传统倒排索引。最后,我们会分享基准测试以及 serverless 生产部署中的性能结果。

_id 在时序索引中的隐藏成本

时序索引是一种专门优化 metrics、logs、traces 以及其他带时间戳数据的特殊索引模式。它们存储的是数据点序列(例如 CPU 使用率、股票价格或传感器读数),用于跟踪特定实体随时间的变化。在 Elasticsearch 中,每个数据点都会被索引为一个带有唯一标识符 _id 的文档。这个标识符用于查找、更新或删除特定文档。当一个文档被索引到 Elasticsearch 时,系统会检查是否已经存在具有相同 _id 的文档。根据操作类型(op_type),已有文档会被替换(index),或者拒绝新文档(create);后者是 metrics 摄取中最常见的路径。

为了高效执行这个查找,Elasticsearch 会为 _id 字段构建倒排索引。这个倒排索引会将每个 _id 值映射到它在索引中的位置,从而实现快速文档查找。在 8.11 版本之前,_id 值还会被单独存储,以便在搜索结果和其他 API 中返回。从 8.11 开始,我们对 Elasticsearch 进行了优化,仅临时存储该值用于文档复制目的,随后快速 merge 掉,并在需要时动态重建。

对于许多使用场景来说,构建并存储倒排索引是可以接受的开销。但对于 metrics 或 traces 这样的时序数据来说,这个成本会迅速累积。我们的实验表明,与不为 _id 建立索引相比,为字段 _id 构建倒排索引会增加 6% 的 CPU 开销。在某些极端情况下,我们的基准测试显示它甚至可能使索引吞吐量下降 25%。

这种开销对于时序工作负载尤其痛苦,因为数据点通常非常小(往往只是一个时间戳和几个数值字段),并且能够非常高效地压缩。然而,_id 字段无法获得同样的压缩效果。因此,_id 的倒排索引可能会占据总存储中不成比例的大部分。在我们针对 OpenTelemetry(OTel)metrics 的基准测试中,仅 _id 倒排索引就消耗了每个数据点总计 25 bytes 中的大约 5 bytes。

我们曾考虑过多种方式来消除这一开销:

  • 停止为 _id 建立索引并停止检查重复项:这是最简单的方案,但如果没有去重,重复数据点会破坏聚合结果。例如,一个 gauge average 会因为重复值而产生偏差。
  • 在索引时接受重复数据,在查询时去重:这种方式能够保证正确性,但会给每次查询增加额外开销,从而降低 dashboard 响应速度。
  • 在 segment merge 期间进行去重:重复数据最终会被移除,但在尚未 merge 的 segment 上执行查询时,结果仍然会包含重复数据。
  • synthetic _id:通过已经能够唯一标识每个数据点的字段动态计算文档标识符,并使用轻量级 Bloom filter 进行去重,而不是完整倒排索引。

我们最终选择了 synthetic _id,因为它能够在摄取阶段保证正确性,同时消除传统方案带来的存储与 CPU 开销。并且我们决定首先将其应用于时序索引,因为它们非常适合这种优化。

在时序索引中,_id 并不是随意生成的。每个文档都拥有一个 time series identifier(_tsid)以及一个时间戳(@timestamp)。_tsid 是根据文档中的 dimensions 字段(例如 host.name、pod.name 或 sensor_id)生成的,而 @timestamp 则表示文档对应的时间点。这两个字段组合在一起即可唯一标识一个文档:对于同一个时间序列,在同一时间点只能存在一个数据点。这意味着我们可以直接根据 _tsid 和 @timestamp 字段值推导出 _id,而无需单独存储它。

synthetic _id 在 Elasticsearch 中是如何工作的?

通过 synthetic _id,Elasticsearch 会动态地将 _tsid 和 @timestamp 字段组合作为文档标识符进行计算。这个计算出来的值会在所有原本使用 _id 的地方被使用:包括 API 响应、文档查找以及去重。然而,它既不会被存储在倒排索引中,也不会被持久化到磁盘以供后续检索。

真正的挑战在于去重。当一个新文档到达时,Elasticsearch 必须验证是否已经存在具有相同 _id 的文档。如果没有 _id 的倒排索引,我们该如何高效地执行这个检查?

synthetic _id 如何在不构建倒排索引的情况下模拟倒排索引

我们的 Elastic Lucene 专家提出了一个巧妙的想法:由于 _tsid 和 @timestamp 已经以 doc values 的形式存储,我们可以暴露一个自定义的 Lucene postings format,在实际上不构建倒排索引的情况下模拟倒排索引。

这意味着,当 Elasticsearch 需要通过 _id 查找文档时,它仍然会像往常一样使用相同的代码路径:查询底层 Lucene 索引来查找 _id term。但不同的是,它不会命中真实的倒排索引,而是由我们的自定义 postings format 拦截这次调用,提取 synthetic _id 中编码的 _tsid 和 @timestamp,并利用它们的 doc values 来定位文档。由于时序索引会按照这些字段排序,因此属于同一个时间序列的文档会连续存储。这使得 Elasticsearch 能够跳过大量不匹配的文档子集(有时甚至是整个 segments),从而快速找到目标文档。

虽然这个过程已经足够高效,但它仍然可能涉及多次随机访问读取:查找 _tsid 值、扫描匹配文档,以及读取时间戳。对于时序索引中最常见的情况 —— 我们通常预期文档并不存在 —— 我们希望能够在完全不访问 doc values 的情况下快速失败。

用于快速成员测试的 Bloom filters

我们使用 Bloom filter 来解决这个问题。Bloom filter 是一种概率型数据结构,它可以快速回答 “这个元素是否可能存在于集合中?” 这个问题。它存在极小概率的 false positives(假阳性),但绝不会出现 false negatives(假阴性)。换句话说,Bloom filter 偶尔可能会在实际答案为 no 时返回 yes,但绝不会在实际答案为 yes 时返回 no。

当一个文档被索引时,它的 synthetic _id 会被加入 Bloom filter。当一个新文档到达时,我们首先检查 Bloom filter。如果 Bloom filter 返回 no,我们就可以完全确定不存在具有该 _id 的文档,因此能够立即继续索引。如果 Bloom filter 返回 maybe yes,我们则会退回到代价更高的验证流程,使用 _tsid 和 @timestamp 的 doc values 进行检查。

synthetic _id 的索引工作流:逐步解析

让我们一步一步来看,当一个文档被索引到启用了 synthetic _id 的时序索引时,会发生什么:

  • 计算 synthetic _id:Elasticsearch 会将 _tsid || @timestamp 组合作为 _id 进行计算。
  • 检查 live version map:和当前实现一样,我们首先会检查一个内存中的 map,其中保存了最近索引过的文档。如果文档已经存在于这个 map 中,我们就可以立即处理重复问题。
  • 根据 timestamp 过滤 segments:时序索引会按照 _tsid 和 @timestamp 排序。我们可以跳过所有时间范围与当前传入文档 timestamp 不重叠的 segments。
  • 检查 Bloom filter:对于每个候选 segment,我们会通过 Bloom filter 测试该 _id 是否可能存在。
  • 必要时执行验证:如果 Bloom filter 返回正结果,我们就会使用 _tsid 和 @timestamp 的 doc values 来查找文档。由于文档已经按这些字段排序,因此这个查找过程是高效的。
  • 索引文档:如果没有发现已有版本,则执行文档索引。_id 会被加入 segment 的 Bloom filter,但不会构建倒排索引,同时字段值也永远不会被存储。

在最常见的场景中,新数据通常带有较新的 timestamp,因此第 3 步会排除大多数 segments,而第 4 步能够快速确认文档是新的。只有在 Bloom filter 出现 false positive 时,第 5 步中代价较高的验证流程才会发生,而这种情况预计是非常少见的。

Bloom filter 的 false positive rate:Elasticsearch 如何保持其低水平

基于 Bloom filter 的去重方案面临的一个挑战,是如何在不牺牲我们所追求的存储效率的前提下控制 false positive rate。为了有效地为 Bloom filters 设定大小,我们会考虑每个 segment 中的数据点数量,并同时追求较低的 false positive rate 以及低于 50% 的 bit set saturation。

设定 saturation 目标有一个特殊原因:在 segments merge 时,我们会对 bit sets 执行 OR 操作,而不是从头重新构建 Bloom filters。这样可以让 merge 过程更快,但也意味着随着 segments 被反复 merge,false positive rate 会逐渐趋近于 100%。在 merge 之前将 saturation 保持在 50% 以下,可以预留一定空间,从而延缓这种收敛过程。

低 false positive rate 的目标也是有依据的,因为访问模式本身具有明显偏向:由于我们会基于数据点 timestamp 来裁剪搜索空间,因此最近的 segments 会比旧 segments 更频繁地被检查。而那些经过大量 merge、Bloom filters 已经退化的旧 segments,则很少会被访问。

synthetic _id 性能基准测试:索引与存储

我们进行了大量基准测试来验证这一实现。

索引吞吐量

这项工作的核心目标之一,是达到或超过现有的索引吞吐量。理论上,新方案需要执行的工作更少:为 _id 构建倒排索引需要对每个值进行哈希计算,在内存中构建并维护复杂的数据结构,并最终将其 flush 到磁盘。在 segment merge 期间,这些结构还必须被重新构建,从而在高吞吐场景下增加 CPU 与 I/O 开销。

构建 Bloom filter 并非没有成本(我们仍然需要对每个值进行哈希计算),但其内存占用更小,也不存在需要维护或 flush 的复杂数据结构。Bloom filter 的 merge 成本同样非常低:在可能的情况下,我们只需对 bit sets 执行 OR 操作,而无需从头重新构建。

synthetic _id 的主要成本来自于使用 doc values 验证潜在重复项。然而,这部分成本会被两个因素显著缓解:首先,Bloom filter 的 false positives 非常少,因此大多数文档都会完全跳过这个步骤。其次,时序索引会按照 _tsid 和 @timestamp 排序,这意味着 doc value 查找能够高效地跳过大量不匹配的文档块。

而在实际测试中,我们观察到的结果也正是如此。即使考虑到 Bloom filter 返回正结果时,需要额外执行 tsid 和 timestamp 匹配验证所带来的 seek 开销,整体吞吐量依然与之前相当,甚至更好。不再构建和 merge 倒排索引所节省下来的成本,超过了偶发 false positive 检查所带来的额外开销。我们的 nightly benchmarks 也验证了这一点:

存储节省

在我们针对 OTel metrics 的基准测试中,synthetic _id 每个数据点大约减少了 5 bytes 的存储开销。对于一个平均每个数据点大小为 25 bytes 的数据集来说,仅这一项优化就带来了约 20% 的存储降低。

这些结果很快也在我们的 nightly benchmarks 中得到了验证。下图展示了自 2026 年 3 月 19 日启用 synthetic _id 功能后,存储占用随时间下降的情况:

我们的标准时序数据库(TSDB)基准测试显示,从 2.5 GiB 降低到 1.9 GiB(24%)。同样,time-series downsampling 基准测试也表现出类似的下降,从 3 GiB 降低到 2.3 GiB(23%)。

另一个更偏 metrics 的基准测试则取得了更明显的改进,从 3.0 GiB 降低到 2.0 GiB(34%):

API 兼容性

一个重要的设计目标是保持与现有 Elasticsearch API 的兼容性。通过 synthetic _id,所有文档 API 仍然可以按预期工作:Bulk、Get、Update、Delete、Reindex,以及基于 Update/Delete by Query 的操作。这种兼容层也限制了变更的影响范围,确保任何问题都被限制在内部实现之中。

当 API 请求中没有提供 _id 时,Elasticsearch 会根据 _tsid 和 @timestamp 字段计算它。为了检查文档是否已经存在,它首先查询 Bloom filter,如果需要再回退到 doc values。_id 在搜索结果或 API 响应中返回时,也是通过 doc values 按需动态生成的。

有一个需要特殊处理的场景是按 _id 前缀或模式进行搜索或过滤。这类查询需要扫描大量文档来找到匹配结果,虽然行为是正确的,但相比直接 _id 访问会带来性能开销。我们认为这种用例在时序索引中不会很常见。

Elasticsearch 9.4 与 Elastic Cloud Serverless 可用性

synthetic _id 功能将在 Elasticsearch 9.4.0 中发布,并且已经在 Elastic Cloud Serverless 上可用。

无需任何配置:该功能默认启用,新创建的时序索引(包括通过 data stream rollover 创建的索引)都会自动受益于这一优化。9.4 之前创建的现有时序索引仍然会继续为 _id 字段构建倒排索引。

我们预期 synthetic _id 在所有时序使用场景中都能表现良好。不过,在一些非常特殊的、以更新为主的场景中,如果遇到性能问题,可以通过在新索引中设置 index.mapping.synthetic_id = false 来关闭该功能。

总结:synthetic _id 的存储与性能收益

在本文中,我们介绍了 synthetic _id 如何消除时序索引中文档标识符带来的存储与计算开销。通过基于 _tsid 和 @timestamp 动态计算 _id,并使用 Bloom filter 进行去重,我们在保持完整 API 兼容性的同时,实现了与原方案相当或更优的索引性能,并将存储占用最高降低 34%。对于大规模时序工作负载用户来说,这直接转化为基础设施成本的降低。

路线图:synthetic _id 之后的演进

synthetic _id 是 Elasticsearch 持续降低存储开销更大方向的一部分。

sequence number trimming:每个文档都会携带用于复制与并发控制的 sequence number。对于追加写入的时序数据,在 segment merge 之后这些信息会变得冗余。Elasticsearch 9.4 已在 merge 过程中对其进行裁剪,从而进一步回收存储空间:我们将在后续博客中详细介绍这一优化。

synthetic _id 扩展到时序之外:我们正在探索将 synthetic _id 推广到普通索引的可能性,允许用户声明哪些字段可以唯一标识文档,并基于这些字段配置 index sorting,从而实现高效查找。

敬请期待!

常见问题解答

为什么 _id 字段在 Elasticsearch 时序索引中很昂贵?

_id 字段需要倒排索引来进行去重,这在索引过程中会增加 6% 的 CPU 开销,并且每个数据点大约占用 5 bytes 的存储。对于较小的时序文档(通常只有 25 bytes),这相当于总存储的 20%。

synthetic _id 在 Elasticsearch 中是如何工作的?

synthetic _id 通过组合 _tsid(time series identifier)和 @timestamp 来动态计算文档标识符。Elasticsearch 不再存储倒排索引,而是使用 Bloom filter 来检查重复,仅在出现 false positive 时才回退到 doc values 进行验证。

synthetic _id 可以节省多少存储空间?

基准测试显示可节省 20–34% 的存储,具体取决于数据类型。OTel metrics 工作负载减少了 34%(从 3.0 GiB 到 2.0 GiB),而通用 TSDB 工作负载减少了 24%(从 2.5 GiB 到 1.9 GiB)。

synthetic _id 会影响 Elasticsearch API 兼容性吗?

不会。所有文档 API(Bulk、Get、Update、Delete、Reindex、Update/Delete by Query)都可以正常工作。_id 是透明计算的,并会在 API 响应中返回。

Bloom filters 如何帮助 Elasticsearch 做去重?

Bloom filter 可以以 “是否可能存在某个元素” 为问题进行判断,并且不会产生 false negatives。当新文档到达时,Elasticsearch 首先检查 Bloom filter。如果结果为 no,则直接索引文档;如果结果为 maybe yes,则使用 doc values 进行验证。这避免了对“绝大多数是新文档”的场景进行昂贵查找。

如果遇到问题,我可以关闭 synthetic _id 吗?

可以。对于新索引,可以设置 index.mapping.synthetic_id = false。仅建议在某些以 update 为主并且观察到性能问题的场景中使用。

synthetic _id 什么时候可用?

synthetic _id 已在 Elastic Cloud Serverless 上提供,并将在 Elasticsearch 9.4.0 中发布。默认对所有新的时序索引启用。

这篇内容对你有多大帮助?

原文:https://www.elastic.co/search-labs/blog/elasticsearch-synthetic-id-time-series-storage

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐