作者:来自 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写代码
两条口径说明
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写代码
实测结果(单分片):
| 时间窗 | 区间内文档数 | 命中 | 配置 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 字节。这个异常我还没定位清楚。