Elasticsearch 如何通过 synthetic _id 和 Bloom filters 将时序存储降低 34%

作者:来自 Elastic Tanguy Leroux, Francisco Fernández CastañoAnton 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.namepod.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

相关推荐
LONGZETECH2 小时前
架构师实战拆解|无人机智慧实训SaaS中台:断电续考、AI组卷、多端同步核心设计
大数据·人工智能·架构·系统架构·无人机
jkyy20142 小时前
大模型重构饮食健康服务链路:多维技术赋能膳食管理智能化升级
大数据·人工智能·信息可视化·重构·健康医疗
一只鹿鹿鹿2 小时前
信息化项目管理规范(参考Word文件)
java·大数据·运维·开发语言·数据库
这个DBA有点耶2 小时前
多模融合数据库深度解析:关系、文档、向量、图如何统一?
数据库·自然语言处理·aigc·dba·改行学it
TAOCARTS0012 小时前
反向海淘旺季运营技巧,借助独立站快速拉升店铺单量
大数据·人工智能
数据仓库_晨曦2 小时前
【无标题】
大数据·sql·spark
anew___2 小时前
《数据库原理》精要解读(三)—— SQL:与数据库对话的艺术
数据库·sql·oracle
KaiwuDB2 小时前
KWDB 3.2.0 版本发布,数据管理查询增强,安装部署体验全面升级
数据库