ES 存日志很贵?我用 ES 9.5 把日志从 4.5G 压到 412M 压缩比11.2倍,日志硬扫每秒128万行!

作者:来自 Elastic 朱杰

这是一次用真实生产形态日志数据做的实测:1641 万条日志,4631 MB 原始文本大小,ES 落盘后 412.5 MB,压缩比 11.2 倍,只剩原始体积的 8.91%。而同一份数据用 zip 压缩是 411.6 MB。区别是:zip 那份要先解压才能看一眼,而 Elasticsearch 那份可以按时间范围过滤、可以聚合、可以全量扫描------它是在线的、活的数据。

"ES 做日志成本高" 这个印象,来自它的出身。Elasticsearch 是搜索引擎,一条文档进来会同时落到四套磁盘结构里:倒排索引(让全文检索快)、_source(原始 JSON 一整块存下来)、doc values(列式存储,给排序和聚合用)、BKD树(数值和日期的范围查询)。同一份字段值被写了好几遍。对搜索场景这是合理的代价,对日志场景------你几乎从不需要原始 JSON、也几乎从不做相关性排序------其中很大一部分是纯浪费。

index.mode: logsdb 和 9.5 新增的 index.mode: logsdb_columnar 就是在拆掉这些冗余。这篇文章用 5 组对照测试算清 ES 9.5 是如何实现这么极致的压缩的。

一、ES 的日志优化之旅

过去三年,ES 在日志存储上的改动不是一次大重构,而是一层一层叠上来的。按时间粗略排一下:

  • 列式存储(doc values) 是一切的基础。_source 是一行一个 JSON blob,doc values 是把每个字段拉成一整列。同一列里的值形态相似,压缩算法才有发挥空间。
  • 索引排序。如果日志按到达顺序落盘,几十台主机的时间戳交错在一起,列里的相邻值毫无规律,delta 编码无从下手。先按 host.name 再按 @timestamp 排序之后,同一台主机的日志连成一片,时间戳单调递增,主机名整段重复------delta、GCD、RLE 这些编码才能真正生效。
  • 合成 source(synthetic source)。写入时干脆不存 _source 那个 JSON blob;查询需要返回原文时,从各字段的 doc values 里重新拼出来。
  • ZSTD。8.16 起 best_compression 从 DEFLATE 换成 ZSTD,存储更小的同时写入吞吐还更高。
  • 专用 doc values codec。8.13/8.14 引入了一套针对时序/日志数据形态的编码,其中包括对排序后连续相同值的 run-length 编码,logsdb 自 8.15 起继承。

logsdb 在 8.17 GA,9.0 起对 logs-- 数据流默认启用。9.5 又带来了 logsdb_columnar------一个更彻底的形态:字段只存一次、只以 doc values 形式存在。

二、数据集与测试方法

测试数据集

采用 2022 年 CCF 国际 AIOps 挑战赛 数据集中的一个日志目录,2022-03-20-cloudbed2。这是一套基于微服务电商 Demo(hipstershop)的可观测性数据,日志形态非常接近真实生产的应用日志。

原始 CSV 4,856,187,767 字节 = 4631 MB
zip 压缩后 431,613,887 字节 = 411.6 MB(压掉 91.1%)
日志条数 16,410,236
平均单条 295.9 字节

原始数据样例:

sql 复制代码
`

1.  log_id,timestamp,cmdb_id,log_name,value

3.  KN43pn8BmS57GQLkQUdP,1647761110,cartservice-1,log_cartservice-service_application,etCartAsync called with userId=3af80013-c2c1-4ae6-86d0-1d9d308e6f5b

5.  M943pn8BmS57GQLkQUdP,1647761111,cartservice-1,log_cartservice-service_application,     Executed endpoint 'gRPC - /hipstershop.CartService/GetCart'

7.  Ot43pn8BmS57GQLkQUdP,1647761111,cartservice-1,log_cartservice-service_application,mptyCartAsync called with userId=e25fe877-95f8-4a44-ae29-ae6c3eca824a

`AI写代码

字段映射

CSV 列 ES 字段 处理 低基数字段不同值数量
log_id _id 20 个字符,与 ES 自动生成的 ID 形态一致,不重复写入,交给 ES 自动生成,自动生成 _id 比业务指定 ID 具有更好的性能
timestamp @timestamp 秒级 epoch,×1000 转毫秒
cmdb_id host.name keyword 31 个不同值
log_name logname keyword 22 个不同值
value message match_only_text

在 ES 说明文档中,logsdb 如果存在 host.name 则会按照 @timestamp+ host.name 联合排序,一般一个host产生的日志有比较大的相似性,聚集在一起存储可以获得比较高的压缩率

两个 keyword 字段都是极低基数 ------ 31 个服务实例、22 类日志源。message 则是每条几乎都不一样的长文本,是全部存储的主体。这个反差是理解后面所有数字的钥匙。

五组配置

配置 index.mode _source message 倒排 对照目的
1 logsdb stored(无 license) 关闭 与配置 2 对照:synthetic source 值多少钱
2 logsdb synthetic 关闭 基准最优配置
3 logsdb synthetic 开启 与配置 2 对照:倒排索引的成本
4 logsdb_columnar synthetic columnar 开启 与配置 3 对照:两种 index.mode 的差异
5 logsdb_columnar synthetic columnar 关闭 与配置 2 对照:两种 index.mode 的差异

全部为 1 primary / 0 replica,data stream 写入,日志场景统一禁用 sequence number(index.disable_sequence_numbers: true,9.4+)。

配置2 的索引模板(其余配置只改 index.mode 与 message 的 index 参数):

bash 复制代码
`

1.  PUT _index_template/aiops-log-stream-template
2.  {
3.    "index_patterns": ["logs-aiops-*"],
4.    "data_stream": {},
5.    "priority": 200,
6.    "template": {
7.      "settings": {
8.        "number_of_shards": 1,
9.        "number_of_replicas": 0,
10.        "index.mode": "logsdb",
11.        "index.disable_sequence_numbers": true
12.      },
13.      "mappings": {
14.        "properties": {
15.          "@timestamp": { "type": "date" },
16.          "host.name":  { "type": "keyword" },
17.          "logname":    { "type": "keyword" },
18.          "message":    { "type": "match_only_text", "index": "false" }
19.        }
20.      }
21.    }
22.  }

`AI写代码![](https://csdnimg.cn/release/blogv2/dist/pc/img/runCode/icon-arrowwhite.png)

两条口径说明

1)横向对比统一使用 force merge 到 1 段之后的落盘大小。各配置的自然段数从 8 段到 36 段不等,段数会带来 0.4%~3.3% 的体积差异。不统一口径的话,比出来的是段数噪声而不是配置差异。

配置 自然段数 / 落盘 segments=1 落盘 段数带来的膨胀
1 36 段 / 522.8 MB 506.2 MB +3.3%
2 8 段 / 414.2 MB 412.5 MB +0.4%
3 18 段 / 747.7 MB 732.1 MB +2.1%
4 10 段 / 760.4 MB 744.6 MB +2.1%
5 10 段 / 437.2 MB 423.4 MB +3.3%

顺带一个可以直接用的结论:数据合并得越充分,当然压缩越好。 但是从上面的实测数据看影响也没有那么大。

2)ES _disk_usage API 可以用来分析一个索引每个字段存储的明细, 本测试的明细都是 segments=1 下通过这个API分析获得的。

三、总览结果

按落盘从小到大排:

配置 说明 segments=1 占原始 压缩比 vs zip 字节/条
2 logsdb / synthetic / 无倒排 412.5 MB 8.91% 11.23× 1.002× 26.4
5 columnar / synthetic / 无倒排 423.4 MB 9.14% 10.94× 1.029× 27.1
1 logsdb / stored / 无倒排 506.2 MB 10.93% 9.15× 1.230× 32.3
3 logsdb / synthetic / 有倒排 732.1 MB 15.81% 6.33× 1.779× 46.8
4 columnar / synthetic / 有倒排 744.6 MB 16.08% 6.22× 1.809× 47.6

最优解 412.5 MB,与 zip 归档的 411.6 MB 几乎完全相等。

这张表还给出了另一件更有实操价值的东西:三个控制开关的量级差了一个数量级以上。

控制开关 影响 占索引比例
message 建不建倒排索引 ±320 MB ±77%
synthetic source 开不开 ±94 MB ±19%
logsdb 还是 logsdb_columnar ±11 MB ±2.6%

如果你只想记住一件事:先去看你的 message 字段需不需要倒排索引

下面按这个顺序逐个拆开。

四、最节省空间的抉择:message 到底要不要建倒排索引

两组独立对照,结果高度一致:

对照组 无倒排 有倒排 增量
logsdb(配置 2 → 3) 412.5 MB 732.1 MB +319.6 MB(+77.5%)
logsdb_columnar(配置 5 → 4) 423.4 MB 744.6 MB +321.2 MB(+75.9%)

_disk_usage 给出的字段级明细给出了更刺眼的对比:

项目 大小
message 的倒排索引 320.9 MB
message 的列式正文(doc values) 272.7 MB

倒排索引比它索引的正文本身还大 18%

这里值得多说一句机制。message 用的已经是 match_only_text 而不是 text ------ 这个类型专为日志设计,砍掉了词频(BM25 打分用的,日志场景没意义:你不关心 "timeout" 在这行里出现了两次还是七次)、位置信息(短语精确匹配用的,日志上很少用到),只保留"哪些文档里有这个词"。官方口径是这一刀能把文本字段的倒排索引砍掉约 40%。

砍掉 40% 之后,剩下的仍然有 320.9 MB

原因是倒排索引的成本主要由 term 字典的基数决定,而不是由存了多少额外信息决定。这份日志的 message 里内嵌了 JSON,token 里全是 UUID、信用卡号、金额、纳秒时间戳------每一个都是只出现一两次的高基数字符串。字典里塞满了这种"只用一次就扔"的词。对机器生成的日志来说,倒排索引的性价比天生就不高。

但这 320 MB 买到的东西是实打实的: match、match_phrase、Kibana 里的自由文本搜索、KQL 全文查询,全都依赖它。关掉之后这些能力直接消失。

所以这不是一个 "免费优化",而是一次明确的能力交换:

  • 重要日志(核心交易链路、审计日志、告警相关):付这 77% 的存储,换亚秒级的全文检索能力。值。
  • 大量的次要日志、冷日志(Debug 级别、访问日志、查询频次很低但必须留存的合规日志):这 77% 可能是整个日志平台最大的一笔浪费。

那么关掉之后,还能查吗?

五、关掉倒排之后,硬扫日志到底能不能用?

关闭倒排后,字符串检索只能靠查询时候的 runtime field 全量扫描。听起来很暴力,实测数据比预期好得多。

测试查询:在指定时间窗内,扫描 message 找出包含某个卡号的日志。

bash 复制代码
`

1.  GET logs-aiops-22/_search
2.  {
3.    "runtime_mappings": {
4.      "message_rt": {
5.        "type": "keyword",
6.        "script": { "source": "emit(doc['message'].value)" }
7.      }
8.    },
9.    "query": {
10.      "bool": { "filter": [
11.        { "range": { "@timestamp": { "gte": "2022-03-20T08:18:27Z", "lte": "2022-03-20T08:28:27Z" }}},
12.        { "wildcard": { "message_rt": { "value": "*4432-8015-6152-0454*" }}}
13.      ]}
14.    },
15.    "fields": ["@timestamp", "message_rt"],
16.    "_source": false
17.  }

`AI写代码![](https://csdnimg.cn/release/blogv2/dist/pc/img/runCode/icon-arrowwhite.png)

实测结果(单分片):

时间窗 区间内文档数 命中 配置 2 - logsdb 配置 5 - logsdb_columnar 加速
10 分钟 180,563 137 554 ms 151 ms 3.67×
1 小时 1,104,256 825 3,323 ms 871 ms 3.82×
1 天 6,972,239 5,195 20,762 ms 5,430 ms 3.82×

注意:多分片可以有更多的并发搜索,可以调用更多的核心进行扫描

换算成吞吐:

配置 文档吞吐 逻辑数据吞吐
配置 2 - logsdb 约 33 万条/秒 约 95 MB/s
配置 5 - logsdb_columnar 约 128 万条/秒 约 360 MB/s

:测试CPU AMD 5700G 3.8-4.6G 内存DDR4 32GB,单分片一个段扫描所以只用到一个核心

这个能力落到什么场景?

它依赖时间窗过滤把扫描范围先缩小。 上面每一次查询都带 @timestamp 的 range 过滤,这不是可选项,而是这套方案成立的基础。日志天然按时间排序、天然按时间分索引,所以这个前提在日志场景里几乎总是满足的。

在这个前提下,它足够覆盖排障中最高频的那个动作:"我知道大概是昨天下午三点前后,要找这个订单号/卡号/traceID 出现在哪些日志里。" 哪怕 10 亿级数据、20 分片,几十秒返回。

它不能替代什么:Kibana 里那种边打字边出结果的交互式检索(要亚秒级)、需要相关性排序的场景。

所以完整的决策是分层的:核心链路日志付倒排索引的钱,换交互式检索;海量的次要日志、冷日志关掉倒排,用时间窗 + 实时扫描兜底。前者可能只占日志总量的 10%,后者占 90% ------ 而 90% 那部分可以节省巨大的存储成本。

六、logsdb vs logsdb_columnar:本数据集差异只有 2%,但不代表所有

两种 index mode 差多少?

对照 logsdb logsdb_columnar 差异
message 无倒排(配置 2 vs 5) 412.5 MB 423.4 MB +10.9 MB(+2.6%)
message 有倒排(配置 3 vs 4) 732.1 MB 744.6 MB +12.5 MB(+1.7%)

只差 2%,而且在这份数据上是 columnar 略大。 它和很多人的预期相反。差异来自两种模式对字段的编码方式不同,最明显的几处:

字段 logsdb - 配置 2 logsdb_columnar - 配置 5
host.name 0.99 MB 9.02 MB
logname 8.55 MB 13.63 MB
_id 124.98 MB 122.96 MB
message 274.41 MB 272.90 MB
_field_names 0.75 MB 不存在
stored_fields(全局) 61.3 MB 0 字节

看得很清楚:columnar 在 message 和 _id 上确有小幅优势,但在两个低基数 keyword 字段上多花了 13 MB,把优势吃掉了还倒欠。 logsdb 对这类 "低基数 + 与索引排序强相关" 的列有一套非常高效的编码(host.name 的 doc values 只占几百字节),logsdb_columnar 当前的编码格式没有这项优化。但是如果 keyword 扩展到更多的列,并且数据形态不利于 logsdb 的压缩算法,那么 logsdb_columnar 的压缩会超过 logsdb。有待后续的测试验证。

这里也顺便看到了 logsdb_columnar "真列存"的硬证据:整个索引的 stored fields 是 0 字节,_field_names 这个元数据字段直接消失,连 _id 都从 stored fields 迁移到了 doc values。它不只是营销口径上的列存。

需要说明的是,logsdb_columnar 在 9.5 是技术预览版 。它的设计目标写在官方 release notes 里也很清楚:为将来更快的分析型查询提供基础结构。至少在这份窄 schema 的日志数据上,选它的理由不是 "更省空间"。 而是在上一节那个取出 message 硬扫时速度是 logsdb 的 3.8 倍!

七、logsdb_columnar 真正的收益:扫描快 3.8 倍,以及一个被忽略的实现差异

第五节的表里,logsdb_columnar 的日志硬扫的速度是 logsdb 的 3.8 倍。这个差距不是来自"列存天生扫得快"这种笼统理由,而是来自一个具体的、我在测试中才发现的实现差异。

先看两个配置的查询写法:

  • 配置5-logsdb_columnar:doc'message'.value------直接读列式 doc values
  • 配置2-logsdb:params._source.message------触发 合成 source 重建

为什么 logsdb 不也用 doc'message'因为它会报错,跑不起来

这就是那个实现差异。两种模式下 合成 source 的落地方式不一样:

logsdb 沿用经典文档模型 。 match_only_text 字段本身没有 doc values,合成 source 靠一个 mapper 生成的隐藏字段 message._original 来重建原文 ------ 从 _disk_usage 里能看到它实实在在占了 274.1 MB 的 doc values。但这个字段是实现细节,不进入用户可见的 field lookup,所以 doc'message._original' 解析不到;而 message 本体作为 text 字段又没有 fielddata,doc'message' 同样不可用。

两条路都堵死,只剩 params._source。而走这条路,每一条文档都要完整地跑一遍:从 doc values 取值 → 组装成 JSON → 脚本侧再解析出来。 3.8 倍的差距就在这个往返里。

logsdb_columnar 的前提是 "每个字段只存一次,且必须有 doc values"。 所以 message 的列存副本直接挂在字段本名上,doc'message' 天然可用,完全不需要 source 重建。

小结一下 logsdb_columnar 在这份数据上的取舍:用 +2.6% 的存储,换 3.8 倍的暴力扫描吞吐。 如果你的场景是"大量冷日志 + 偶尔全量捞一次",这笔交换是划算的。

八、商业版功能 合成 source 能提升多少?

前面所有的最优数字都建立在 合成 source 上,这是一项商业功能。针对这一功能也做了对照测试。

配置1:index.mode: logsdb + index.mapping.source.mode: stored(老老实实存 _source)+ message 关倒排。

配置 落盘 占原始 压缩比
配置 2 - 有合成 source 412.5 MB 8.91% 11.23×
配置 1 - 无合成 source 506.2 MB 10.93% 9.15×

差 93.7 MB,也就是 18.5%。

九、未来展望:还能再省多少?_id 占了 30%!

配置2 的字段构成(414.8 MB):

字段 大小 占比
message._original(正文列存) 274.1 MB 66.1%
_id 124.9 MB 30.1%
logname 8.5 MB 2.1%
@timestamp 4.8 MB 1.2%
host.name 1.0 MB 0.2%

其它元数据 < 1 MB ---

_id 一个字段吃掉了 30%。 logsdb_columnar 那边是 29.0%,同一个量级。

换算成单条:约 8 字节/文档。注意本次已经采用了 ES 自动生成的 ID ------ 这是日志场景的正确做法,用户指定 _id 会同时废掉 合成 source 优化和分片路由优化。即便如此,20 个字符的 flake ID 压缩后仍然要 8 字节,因为它天然不可压缩,而且必须建倒排索引来支持按 ID 取文档和去重。

这已经不是用户侧能优化的东西了,解法只能来自引擎。而这件事官方已经在做:TSDB 上已经落地了从 doc values 实时重建 _id 的方案(ES PR #144026),彻底消除了 _id 的存储字节;官方也明确表示正在为 LogsDB 探索同一方向。Elastic 自己的表述是,_id 和 _seq_no 对小文档而言可能占到索引一半以上 ------ 本次实测的 30% 和这个判断是吻合的。

如果这条路走通,按当前数据推演(注意:这是推演,不是实测):

指标 配置 2 实测 消除 _id 后推演
落盘 414.8 MB 约 290 MB
占原始日志 8.91% 6.26%
压缩比 11.2× 16.0×
vs zip 归档 1.00× 0.70×

也就是比 zip 归档还小 30%,而且可以直接查。

十、结语

回到开头那个成见。

"ES 做日志太贵" ------ 这句话只在几年可以理解,因为那个时候 ES 还是一个通用搜索引擎,没有为日志专门优化。一条日志进来写四套结构,其中大部分对日志场景是冗余的。

现在把同一份日志灌进 ES 9.5,落盘和 zip 归档相当,而且能按时间过滤、能聚合、能在几十秒内扫完十亿条。

虽然 ES 现在已经优化的不错了,仍然有继续改进的地方:_id 白白占掉 30%,logsdb_columnar 在低基数 keyword 字段上比 logsdb 多花 13 MB。

给一份可以直接抄的决策顺序:

  • 先看 message 需不需要倒排索引。 这是 ±77% 的开关,比其它所有选择加起来都重要。核心链路日志保留,海量次要日志关掉。
  • 有 license 默认打开合成 source 再省 18.5%。
  • logsdb 还是 logsdb_columnar? 存储上只差 2%,别在这里纠结。如果你的负载是大量冷日志的暴力扫描, logsdb_columnar 3.8 倍于 logsdb 的扫描速度值得试。
  • force merge。 合并越充分压缩越好,但是效果并不会特别显著,本次实测段数差异带来 0.4%~3.3% 的体积波动。
  • 附录

各配置测试索引的完整字段存储明细(ES _disk_usage API)

ini 复制代码
`POST .ds-logs-aiops-22-000001/_disk_usage?run_expensive_tasks=true`AI写代码

配置 2(logsdb / 无倒排,1 段,414.8 MB)

字段 总计 倒排 stored doc values
message._original 274.1 MB --- --- 274.1 MB
_id 124.9 MB 63.5 MB 61.3 MB ---
logname 8.5 MB 2.7 MB --- 5.7 MB
@timestamp 4.8 MB --- --- 4.8 MB
host.name 1018.6 KB 1018.3 KB --- 278 B
_field_names 770.8 KB 770.8 KB --- ---
message._original.counts 250.4 KB --- --- 250.4 KB

配置5(logsdb_columnar / 无倒排,1段,423.4 MB)

字段 总计 倒排 stored doc values
message 272.6 MB --- 0 272.6 MB
_id 122.9 MB 61.0 MB 0 61.9 MB
logname 13.3 MB --- 0 13.3 MB
host.name 8.7 MB --- 0 8.7 MB
@timestamp 4.8 MB --- 0 4.8 MB
*.counts × 3 751.2 KB --- 0 751.2 KB

配置3(logsdb / 有倒排,1段,732.1 MB)

字段 总计 倒排 stored doc values
message 320.8 MB 320.8 MB --- ---
message._original 272.6 MB --- --- 272.6 MB
_id 121.5 MB 61.5 MB 59.9 MB ---
logname 8.5 MB 2.7 MB --- 5.8 MB
@timestamp 4.8 MB --- --- 4.8 MB
host.name 1018.6 KB 1018.3 KB --- 278 B

配置4(logsdb_columnar / 有倒排,1段,744.6 MB)

字段 总计 倒排 stored doc values
message 593.3 MB 320.7 MB 0 272.6 MB
_id 122.1 MB 60.2 MB 0 61.8 MB
logname 13.3 MB --- 0 13.3 MB
host.name 8.7 MB --- 0 8.7 MB
@timestamp 4.8 MB --- 0 4.8 MB

配置1(logsdb / stored source / 无倒排,1段,522.8 MB)

字段 总计 倒排 stored doc values
_source 396.6 MB --- 396.6 MB ---
_id 85.4 MB 56.3 MB 29.0 MB ---
_seq_no 26.9 MB --- --- 26.9 MB ⚠️
@timestamp 9.5 MB --- --- 9.5 MB
logname 1.9 MB 1.2 MB --- 726.5 KB
host.name 1.1 MB 1.1 MB --- 11.1 KB
_field_names 704 KB 704 KB --- ---

⚠️ 配置1 设置了 index.disable_sequence_numbers: true,但 _seq_no 仍占 26.9 MB,而配置2~5 该字段均为 0 字节。这个异常我还没定位清楚。

相关推荐
Elasticsearch4 小时前
Elasticsearch 作为统一平台:引入第二套数据系统究竟要付出什么代价
elasticsearch
Elastic 中国社区官方博客4 小时前
Elasticsearch:列式索引模式 - Columnar index mode
大数据·数据库·elasticsearch·搜索引擎·全文检索
Elasticsearch4 小时前
Elasticsearch 的批量查询阶段如何在大规模场景下提升搜索性能
elasticsearch
Ramboooooooo6 小时前
SkyWalking-10.4.0 Docker + Nacos + Elasticsearch 生产级部署手册
elasticsearch·docker·skywalking
互联网中的一颗神经元16 小时前
04 — 安全撤销:改错了怎么退回去
大数据·安全·elasticsearch
Elasticsearch21 小时前
Elasticsearch:列式索引模式 - Columnar index mode
elasticsearch
Elasticsearch1 天前
从 CrashLoopBackOff 到根本原因,只需几秒:使用 Elastic Observability 自动化 20 分钟的 Kubernetes 调查流
elasticsearch
互联网中的一颗神经元2 天前
09 — .git 地图:打开那个隐藏文件夹
大数据·git·elasticsearch
独行侠影a2 天前
Elasticsearch海量数据查询优化:深度分页与聚合查询提速方案
大数据·elasticsearch·搜索引擎