作者:来自 Elastic Luca Wintergerst
默认情况下,Elasticsearch 优化的是检索性能,而不是存储。LogsDB 改变了这一点。下面是实现索引大小减少 77% 的分层架构。
Elasticsearch 最初是作为搜索引擎构建的。这种“以检索为中心”的设计在日志存储上带来了代价:每一条事件都会扩展到多个磁盘结构中,而这些结构都更偏向于查询性能而不是压缩效率。LogsDB 改变了这一点。在我们的夜间基准测试中,Enterprise 模式使用相同的数据生成 37.5 GB 的索引,而不使用 LogsDB 时则需要 161.9 GB —— 仅通过一个设置就实现了 77% 的减少。
写入开销
Lucene(底层库)会为每一个被索引的文档维护多种结构:
- 倒排索引(inverted index)将词项映射到文档。这使得文本搜索非常快速。
- _source 存储原始 JSON 数据块,在你获取文档时返回。
- doc values 以列式方式存储字段值,用于排序和聚合。
- points / BKD trees 为数值和日期字段建立索引,用于范围查询。
更多有关 Elasticsearch 的存储,请参阅文章 “Elasticsearch:inverted index,doc_values 及 source”。
倒排索引是有价值的:它让你可以在毫秒级别内通过关键词搜索数十亿条日志。这种能力没有更低成本的实现方式。_source 的存在是为了让你拿回完全原始的写入内容:搜索结果和 GET 请求都会直接返回这个 JSON 数据块。但问题在于,即使这些字段值已经通过 doc values 和其他结构存在,_source 仍然会完整存储整个事件。
以一个日志事件为例,字段包括 host.name 、 @timestamp 、 http.response.status_code 和 duration_ms 。整个事件会以 JSON 形式序列化存入 _source 。同样的字段值也会被写入 doc values 列、倒排索引结构,以及 BKD tree 用于范围查询。相同的数据,被写入多个结构,并各自占用磁盘空间。
对于一个需要跨维度快速检索的搜索引擎来说,这种开销是合理的权衡。但对于日志场景来说,你很少需要原始 JSON,也几乎不会进行相关性排序搜索,其中相当一部分存储实际上是浪费。
一次写入,四种磁盘结构:_source(原始 JSON 数据块)、倒排索引、doc values 列,以及用于数值范围查询的 BKD / points trees。相同的字段值最终会出现在多个位置。
为什么列式存储对压缩很重要
doc values 是 LogsDB 所做一切的关键。与 _source 不同(它以整体 blob 的形式存储完整文档),doc values 将每个字段按列方式存储在 Lucene segment 中的所有文档之间。
想象一个包含一百万条日志事件的 segment。_source 的表示方式是一百万个 JSON blob,每个事件一个 blob,并将所有字段混在一起存储。而 doc values 的表示方式则是一组列:一列是一百万个时间戳,一列是一百万个主机名,一列是一百万个状态码,依此类推。
行式的 _source 将每个文档的所有字段放在一个 blob 中 —— doc0 到 doc5 每个都把 host.name 、 @timestamp 、 status 、 duration_ms 等字段混杂在一起存储。列式的 doc values 则重新组织这些数据,使所有 host.name 的值放在一列中,所有时间戳放在另一列中,所有状态码放在另一列中。然后压缩编码可以在每个连续的列上独立运行。
这种列式布局正是实现按列压缩的基础。当 http.response.status_code 的所有值都连续存放在同一列中时,Lucene 可以应用能够利用序列模式的编码方式。
Delta encoding 存储的是相邻值之间的差,而不是完整数值。GCD encoding 会找出一个共同因子,将所有值按比例缩小。Run-length encoding 会压缩重复值序列。Lucene 会在每个 segment 级别选择合适的编码方式,并在 segment 合并时重新评估。
来自同一 host 的四个已排序 @timestamps,在四个阶段中的压缩过程:
RAW:四个 32-bit 整数,共 128 bits。
DELTA:不存完整值,而存差值 —— 保留基准值,增量为 +100、+200、+300,共 59 bits。
GCD:提取共同因子 100,剩下 1、2、3,仅 39 bits。
BIT-PACK:将这三个小整数打包为连续的 bit 存储,节省 9 bits。
但关键点在这里:这些编码方式只有在相邻文档的值具有相关性时才有效。以 @timestamp 列为例。
如果日志来自几十个主机并且是随机交错写入的,那么列中的时间戳会不断跳变。相邻值之间的差可能是 +3 秒,然后是 -47 秒,再然后是 +120 秒。在这种情况下,Delta encoding 几乎无法发挥作用。
现在假设在写入 segment 之前,按照 host.name 和 @timestamp 进行排序。这样同一 host-A 的日志会连续写在一起,然后是 host-B,以此类推。在每个 host 的连续区间内,时间戳是单调递增的,因此 delta 是可预测的。
同一个 host 的四个时间戳可能是 1706745600,+100s,+200s,+300s。Delta encoding 会将其压缩为一个基准值加上三个小整数。
GCD encoding 进一步发现 100、200、300 都可以被 100 整除,于是只需要存 1、2、3。Bit-packing 最终将这三个值压缩到极少的 bit 数中。同样的模式也适用于 host.name 、 service.name 或 http.response.status_code 等字段:在排序后的连续区间中,大量重复值会在 run-length encoding 下几乎被压缩为零。
五个主机 — api-01 , api-02 , db-01 , web-01 , web-02 — 在到达顺序中随机分散( left )。按 host.name 排序将它们分组为五个连续的八个块( center )。Run-length encoding 将每个块压缩为单个( value , count )对——5 个对而不是 40 ,其余槽位被释放( right )。
Elasticsearch 默认从不进行排序。文档按照到达顺序写入,并通过 DEFLATE 进行压缩。我们因此浪费了大量潜力。
我们是如何走到这一步的:2012–2026
LogsDB 中的许多单项技术最初并不是为日志设计的,它们是在十二年间为解决不同问题逐步构建出来的,而 LogsDB 正是将这些技术叠加后的结果。
基础阶段(2012–2017)。Lucene 4.0 在 2012 年引入了 doc values。到 2016 年的 Elasticsearch 5.0,它已经默认用于所有 keyword 和 numeric 字段。Lucene 7.0 引入了 sparse doc values,使得只在部分文档中出现的字段不会在整个 segment 中浪费空间。这解决了一个严重的 force-merge 膨胀问题(在稀疏字段上最高可达 10 倍),并建立了后续所有存储模型的基础。
Dense encoding 为每个文档预留一个 8-byte 的槽位,无论是否存在值。Sparse encoding 只存储实际有值的文档,每个条目 12 bytes(value + doc ID)。对于 error_code ,在 16 个文档中只有 2 个有值(12% 填充率),sparse 要小 81%:24 B vs 128 B。对于 request_path ,填充率 88% 时,sparse 反而更大:168 B vs 128 B。Lucene 按字段选择策略;在低于约 67% 填充率时,sparse 会胜出。
增量优化(2020–2021)。两项较小的改动针对可观测性工作负载进行了优化。基于字典的 stored fields 压缩对重复字符串元数据进行去重,带来了约 10% 的收益。
match_only_text 字段类型移除了倒排索引中的词频和位置。词频是 BM25 用来进行相关性评分的依据 —— 衡量一个词在文档中相对于整个语料出现的频率。但对于日志搜索,这个信号没有意义:你并不关心 “timeout” 在一条日志里出现了两次还是七次,你只需要能找到它即可。位置数据也是类似的:它用于 Elasticsearch 支持精确短语匹配,但其存储成本较高,而日志场景下短语查询足够少,因此这个取舍是合理的。当你在 match_only_text 字段上执行短语查询时,它仍然可以工作 —— 只是会退化到较慢的路径,通过重新评分候选结果,而不是直接利用已存储的位置数据。
text 会为每个 term 存储其出现频率以及它出现的所有位置。match_only_text 只保留 doc IDs——足以找到文档,其它信息全部省略。timeout 这个词在这条消息中出现了两次(位置 1 和 4),这正是会被丢弃的数据类型。
移除词频和位置可以将文本字段的倒排索引大约减少 40%。但在 2021 年,这一改动对整体索引的影响只有约 10%,看起来像是一个相对于 40% 字段级优化而言回报较低的改动。原因在于当时存储的分布方式:_source 对每个文档都完整保存为原始 JSON blob,doc values 未压缩也未排序,并且没有使用 ZSTD。message 字段的倒排索引只是整个存储体系中很小的一部分,而整体存储仍然被大量低效结构占据。随着接下来五年的优化逐步改善其他结构,同样 40% 的字段级节省开始在一个更小、更高效的整体存储中占据显著比例。
这两个改动本身都不是决定性的,但它们确立了一个关键认知:针对日志的存储优化是值得深入推进的。
TSDB 转折点(2023 年 4 月)。故事真正从这里开始。我们在 Elasticsearch 8.7 中为时间序列指标发布了 synthetic _source 和 index sorting。
synthetic source 改变了写入与读取的契约。在写入时,我们不再存储原始 JSON blob。在读取时,当查询需要返回原始文档,我们会从 doc values 和 stored fields 中逐字段读取值,并重新组装成 JSON。结果在功能上等价于原始 _source(除了字段顺序等少量差异),但我们并没有存储这个 blob。
index sorting 在写入磁盘之前,按维度字段和时间戳对文档进行排序。两者结合,使 metrics 存储最多减少 70%。
这个结果说明了一件重要的事:同样的架构也可以适用于日志。
没有 LogsDB 时,Elasticsearch 会为每条日志事件写入两次:一次作为磁盘上的原始 _source blob,一次写入 doc values 列。LogsDB 则完全跳过这个 blob。在读取时,一个 GET /_doc/1 请求会从 doc values 中收集字段值,并在运行时重新组装成完整文档。
TSDB codec(2024)。在 8.13 和 8.14 中,我们构建了一个自定义 doc values codec,包含针对排序连续值优化的 run-length encoding、PFOR-delta encoding,以及用于多值维度的 cyclic ordinal encoding。结果非常显著:在一次基准测试中,kubernetes.pod.name 的 doc values 从 110 MB 降到了 7.25 MB。我们还将覆盖范围扩展到所有数值和 keyword 类型,包括 ip、scaled_float 和 unsigned_long。
LogsDB 技术预览(2024 年 8 月)。在 8.15 中,我们将所有能力整合到 index.mode: logsdb 中:host-first 排序、synthetic _source、ZSTD 压缩,以及 TSDB 数值 codec。其中一个关键决策影响比预期更大:排序方式。按 host.name 再按 @timestamp 排序可以带来最高约 40% 的存储减少,而按 timestamp 排序则只有 ≤10%。host-first 的排序方式让具有相同字段值的文档更紧密地聚集,这正是数值 codec 所依赖的结构。
ZSTD 与正式发布(2024 年 11–12 月)。在 8.16 中,我们将 best_compression 从 DEFLATE 永久切换为 ZSTD(level 3,block 最大 2,048 个文档或 240 kB,通过 JDK 21+ 的 Panama FFI 使用 native bindings)。ZSTD 在提升压缩率的同时,还带来了约 12% 更小的 stored fields 和约 14% 更高的索引吞吐,这种情况几乎不常见。LogsDB 在 8.17 达到 GA。
在正式发布阶段,我们宣称最高可减少 65% 的存储。
路由与恢复(2025 年 4 月)。在 8.18 中,route_on_sort_fields 开始基于排序字段值而不是 _id 进行分片路由。在此之前,Elasticsearch 使用 _id 哈希来决定 shard,这会导致同一 host 的日志分散到所有 shard 中。启用 sort field 路由后,相同 host.name 的日志会进入同一个 shard,将相似文档的局部性从 segment 级扩展到 shard 级,在 1–4% 的写入开销下再带来约 20% 的存储优化。该机制要求使用自动生成的 _id。
数据流 .ds-logs-nginx-default-00001 包含六个主机,分布在三个分片上。STANDARD(按 _id 哈希):所有 host 的颜色随机分散。ROUTED(route_on_sort_fields):相同 host 的日志会落在同一个 shard,但在 shard 内仍保持到达顺序。ROUTED + SORTED(host-first sort):每个 shard 内包含单一 host 的连续块——这是让数值 codec 和 RLE 发挥全部潜力的组合方式。
我们还将 peer recovery 切换为 synthetic source 重建,从而消除了重复的 recovery_source blob。在 9.0 中,logs--_ 索引默认使用 LogsDB。
2024 年 12 月的 nightly synthetic source 基准测试。索引写入大小下降 39% —— 从约 279 GB 降至 171 GB —— 这一变化发生在 peer recovery 从复制原始 _recovery_source blob 切换为基于 doc values 重建文档的当天。
合并与恢复重构:9.1(2025 年 7 月)。我们完全移除了 recovery source。peer recovery 采用批量 synthetic reconstruction,将写入 I/O 减少约 50%,并在 8.17 基线之上将中位索引吞吐提升约 19%。我们将最多四个独立的 doc values merge passes 合并为单次 pass,使后台 merge CPU 最多降低 40%。同时,我们用 Lucene doc value skippers 替换了 _seq_no 的 BKD tree,使 _seq_no 存储减半。
pattern_text 与 Failure Store:9.2–9.3(2025 年 10 月–2026 年 2 月)。在 9.2 中,我们发布了 pattern_text 的技术预览:一种将日志消息拆分为静态模板和动态变量部分的新字段类型。例如一条日志 Session opened for user alice from 10.0.1.42 via TLS 会被拆分为模板 Session opened for user {} from {} via TLS(作为 template ID 只存一次)以及变量 alice、10.0.1.42(按文档存储)。对于模板高度重复的日志,这可以将 message 字段存储减少高达 50%。配套的 template_id 子字段支持按模板排序,并且 LogsDB 配置 index.logsdb.default_sort_on_message_template 可以自动启用该行为。pattern_text 在 9.3 达到 GA。
TEXT 将每条日志消息作为每个文档的完整字符串存储 —— 八份几乎相同的 blob。PATTERN_TEXT 将其拆解:共享模板 Session opened for user {} from {} via TLS 只存储一次并分配 ID T0,而仅将变量列(user、ip)按文档存储——alice/10.0.1.42、bob/10.0.1.87、carol/10.0.2.11,依此类推。
pattern_text 确实带来了索引 CPU 成本:在写入时,将每条消息拆解为模板和变量,比直接存储原始字符串要多做一些工作。这个权衡是否合理取决于你的数据集和优先级。
如果日志消息具有高度重复的模式(结构化应用日志、Kubernetes 事件、访问日志),存储收益会很大,而 CPU 开销是可控的。如果消息是自由文本或重复率较低,那么压缩收益会变小,但 CPU 成本基本保持不变。
对于需要保存数月或数年的数据,累计的存储节省通常是值得的。但对于高基数、快速变化且存储不是瓶颈的数据,这个方案可能并不划算。
9.3 还引入了对二进制 doc values 的压缩,使 wildcard 字段类型显著更节省存储。在内部,wildcard 字段会在 binary doc values 列中存储 trigram 的倒排索引;该列现在使用 Zstandard 压缩,而不是原始存储。在一次基准测试中,一个 URL 字段从 2.92 GB 降到了 1.12 GB,压缩超过 60%。如果你大量使用 wildcard 字段,这种优化是自动生效的,无需修改 mapping。
同样在 9.3 中,@timestamp 和 host.name 的 skip list 作为 LogsDB 的可选能力引入。skip list 允许 Elasticsearch 在 doc values 列中跳跃访问,而无需逐条扫描,从而加速大 segment 上的时间范围查询。其他 index mode 默认不启用 skip list,而在 LogsDB 中可以针对最常做 range 查询的字段选择性开启。
另外在 9.3 中,Failure Store 默认对 logs-- 数据流启用。失败文档(mapping 冲突、ingest pipeline 错误)现在会进入专用的 ::failures 索引,而不是被直接拒绝,这使得 LogsDB 对 synthetic source 的严格要求在迁移过程中更不容易导致静默数据丢失。
性能,而不仅仅是存储
LogsDB 最初是一个存储优化方案,早期版本确实伴随着吞吐量成本 —— 排序、synthetic source 重建以及 ZSTD 都会增加写入开销。但经过两年的迭代,这些成本已经被逐步弥补。现在的索引吞吐量已经与未启用 LogsDB 时基本持平。你可以在不牺牲原有 ingest 速率的情况下获得存储压缩收益。
吞吐量(teal)从技术预览阶段的约 25k 提升到约 35k docs/s。磁盘存储(blue)在同一基准数据集上从约 65 GB 降至约 36 GB。两条曲线都在朝正确方向变化,这些改进来自同一套分层发布:8.16 的 ZSTD、8.18 的路由优化,以及 9.1 的 merge 与 recovery 重构。实时数据可见于 elasticsearch-benchmarks.elastic.co。
这两种趋势会相互叠加产生效果。更少的存储意味着需要合并的 segment 更少,从而释放 CPU 给索引写入使用。synthetic source 的重建成本低于存储并复制原始 blob 的成本。每一次让索引变小的版本更新,都会减少后台 I/O,而这些 I/O 的减少又反过来提升吞吐量。
实际结果是:如果你两年前在使用标准 Elasticsearch 做日志摄取,那么当时的吞吐量,大致就是现在 LogsDB 在相同条件下能提供的水平 —— 同时索引体积还减少了 50–75%。
如何启用
从 9.0 开始,logs-- 数据流会自动默认使用 LogsDB。如果你的数据流匹配这个模式,你已经在使用它了。
如果你想进行动手实践,可以参考《通过 LogsDB 将 Elasticsearch 日志存储成本降低 76%》,其中展示了如何创建两个索引、执行 reindex,并通过 _stats API 测量差异——同时包含了 8.x 集群的版本相关启用方式。
对于其他索引模式,可以在 index template 中设置:
`
1. PUT _index_template/logs-template
2. {
3. "index_patterns": ["logs-*"],
4. "template": {
5. "settings": {
6. "index.mode": "logsdb"
7. }
8. }
9. }
`AI写代码
synthetic _source 会在启用 index.mode: logsdb 时自动开启。
对于路由优化(8.18+),需要再添加一个配置:
`
1. PUT _index_template/logs-template
2. {
3. "index_patterns": ["logs-*"],
4. "template": {
5. "settings": {
6. "index.mode": "logsdb",
7. "index.logsdb.route_on_sort_fields": true
8. }
9. }
10. }
`AI写代码
这会将分片路由从基于 _id 改为基于排序字段值,从而额外带来约 20% 的存储减少,但会带来约 1–4% 的写入吞吐损耗。它要求除了 @timestamp 和自动生成的 _id 之外,至少再提供两个排序字段。
将已有索引切换到 LogsDB 需要执行 reindex,回滚也是同样如此。这不是一种可以原地转换的能力,因此建议先在新的数据流上进行尝试。
随着 segment 的合并,存储还会进一步优化 —— 新写入的数据压缩效果已经很好,但合并后的 segment 压缩效果会更好。
接下来会发生什么
Elasticsearch 仍然保留了一些来自搜索引擎时代的结构性开销。_id 和 _seq_no 就是其中两个例子:它们都会消耗相当可观的磁盘空间(在小文档场景下,甚至可能占到索引的一半以上),但对于日志分析工作负载来说,它们并不是必需的。
我们已经在 TSDB 上迈出了第一步:PR #144026 通过从 doc values 按需重建字段,移除了 TSDB 索引中的 stored _id 字节,这与 synthetic _source 的思路一致。我们正在 LogsDB 上探索同样的方向。
9.4 及以后。这个架构仍然有优化空间,我们正在持续推进。
完整参考请见 logs data stream 文档。