Elasticsearch 如何通过 synthetic _id 和 Bloom filters 将时序存储降低 34%
作者来自 Elastic Tanguy Leroux, Francisco 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 的倒排索引可能会占据总存储中不成比例的大部分。在我们针对 OpenTelemetryOTelmetrics 的基准测试中仅 _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 _idElasticsearch 会动态地将 _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 _idElasticsearch 会将 _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 rateElasticsearch 如何保持其低水平基于 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 被反复 mergefalse 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 GiB24%。同样time-series downsampling 基准测试也表现出类似的下降从 3 GiB 降低到 2.3 GiB23%。另一个更偏 metrics 的基准测试则取得了更明显的改进从 3.0 GiB 降低到 2.0 GiB34%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 通过组合 _tsidtime 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 兼容性吗不会。所有文档 APIBulk、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