
这是一次用真实生产形态日志数据做的实测: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 在日志存储上的改动不是一次大重构,而是一层一层叠上来的。按时间粗略排一下:
_source 是一行一个 JSON blob,doc values 是把每个字段拉成一整列。同一列里的值形态相似,压缩算法才有发挥空间。host.name 再按 @timestamp 排序之后,同一台主机的日志连成一片,时间戳单调递增,主机名整段重复——delta、GCD、RLE 这些编码才能真正生效。_source 那个 JSON blob;查询需要返回原文时,从各字段的 doc values 里重新拼出来。best_compression 从 DEFLATE 换成 ZSTD,存储更小的同时写入吞吐还更高。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 字节 |
原始数据样例:
log_id,timestamp,cmdb_id,log_name,value
KN43pn8BmS57GQLkQUdP,1647761110,cartservice-1,log_cartservice-service_application,etCartAsync called with userId=3af80013-c2c1-4ae6-86d0-1d9d308e6f5b
M943pn8BmS57GQLkQUdP,1647761111,cartservice-1,log_cartservice-service_application, Executed endpoint 'gRPC - /hipstershop.CartService/GetCart'
Ot43pn8BmS57GQLkQUdP,1647761111,cartservice-1,log_cartservice-service_application,mptyCartAsync called with userId=e25fe877-95f8-4a44-ae29-ae6c3eca824aCSV 列 | ES 字段 | 处理 | 低基数字段不同值数量 |
|---|---|---|---|
|
| 20 个字符,与 ES 自动生成的 ID 形态一致,不重复写入,交给 ES 自动生成,自动生成_id 比业务指定id具有更好的性能 | |
|
| 秒级 epoch,×1000 转毫秒 | |
|
|
| 31个不同值 |
|
|
| 22个不同值 |
|
|
|
在ES说明文档中,logsdb 如果存在 host.name 则会按照 @timestamp+ host.name 联合排序,一般一个host产生的日志有比较大的相似性,聚集在一起存储可以获得比较高的压缩率
两个 keyword 字段都是极低基数——31 个服务实例、22 类日志源。message 则是每条几乎都不一样的长文本,是全部存储的主体。这个反差是理解后面所有数字的钥匙。
配置 | index.mode |
|
| 对照目的 |
|---|---|---|---|---|
1 |
| stored(无 license) | 关闭 | 与配置2 对照:synthetic source 值多少钱 |
2 |
| synthetic | 关闭 | 基准最优配置 |
3 |
| synthetic | 开启 | 与配置2 对照:倒排索引的成本 |
4 |
| synthetic columnar | 开启 | 与配置3 对照:两种 index.mode 的差异 |
5 |
| 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 参数):
PUT _index_template/aiops-log-stream-template
{
"index_patterns": ["logs-aiops-*"],
"data_stream": {},
"priority": 200,
"template": {
"settings": {
"number_of_shards": 1,
"number_of_replicas": 0,
"index.mode": "logsdb",
"index.disable_sequence_numbers": true
},
"mappings": {
"properties": {
"@timestamp": { "type": "date" },
"host.name": { "type": "keyword" },
"logname": { "type": "keyword" },
"message": { "type": "match_only_text", "index": "false" }
}
}
}
}配置 | 自然段数 / 落盘 | 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% |
| ) |
顺带一个可以直接用的结论:数据合并得越充分,当然压缩越好。 但是从上面的实测数据看影响也没有那么大。
_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 几乎完全相等。
这张表还给出了另一件更有实操价值的东西:三个控制开关的量级差了一个数量级以上。
控制开关 | 影响 | 占索引比例 |
|---|---|---|
| ±320 MB | ±77% |
| ±94 MB | ±19% |
| ±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 给出的字段级明细给出了更刺眼的对比:
大小 | |
|---|---|
| 320.9 MB |
| 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 全文查询,全都依赖它。关掉之后这些能力直接消失。
所以这不是一个"免费优化",而是一次明确的能力交换:
那么关掉之后,还能查吗?
关闭倒排后,字符串检索只能靠查询时候的 runtime field 全量扫描。听起来很暴力,实测数据比预期好得多。
测试查询:在指定时间窗内,扫描 message 找出包含某个卡号的日志。
GET logs-aiops-22/_search
{
"runtime_mappings": {
"message_rt": {
"type": "keyword",
"script": { "source": "emit(doc['message'].value)" }
}
},
"query": {
"bool": { "filter": [
{ "range": { "@timestamp": { "gte": "2022-03-20T08:18:27Z", "lte": "2022-03-20T08:28:27Z" }}},
{ "wildcard": { "message_rt": { "value": "*4432-8015-6152-0454*" }}}
]}
},
"fields": ["@timestamp", "message_rt"],
"_source": false
}实测结果(单分片):
时间窗 | 区间内文档数 | 命中 | 配置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% 那部分可以节省巨大的存储成本。
两种 index mode 差多少?
对照 | logsdb | logsdb_columnar | 差异 |
|---|---|---|---|
| 412.5 MB | 423.4 MB | +10.9 MB(+2.6%) |
| 732.1 MB | 744.6 MB | +12.5 MB(+1.7%) |
只差 2%,而且在这份数据上是 columnar 略大。 它和很多人的预期相反。差异来自两种模式对字段的编码方式不同,最明显的几处:
字段 | logsdb-配置2 | logsdb_columnar-配置5 |
|---|---|---|
| 0.99 MB | 9.02 MB |
| 8.55 MB | 13.63 MB |
| 124.98 MB | 122.96 MB |
| 274.41 MB | 272.90 MB |
| 0.75 MB | 不存在 |
| 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 的日志硬扫的速度是 logsdb 的 3.8 倍。这个差距不是来自"列存天生扫得快"这种笼统理由,而是来自一个具体的、我在测试中才发现的实现差异。
先看两个配置的查询写法:
doc['message'].value——直接读列式 doc valuesparams._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 上,这是一项商业功能。针对这一功能也做了对照测试。
配置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):
字段 | 大小 | 占比 |
|---|---|---|
| 274.1 MB | 66.1% |
| 124.9 MB | 30.1% |
| 8.5 MB | 2.1% |
| 4.8 MB | 1.2% |
| 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 实测 | 消除 | |
|---|---|---|
落盘 | 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% 的开关,比其它所有选择加起来都重要。核心链路日志保留,海量次要日志关掉。logsdb 还是 logsdb_columnar******?** 存储上只差 2%,别在这里纠结。如果你的负载是大量冷日志的暴力扫描, logsdb_columnar 3.8 倍于 logsdb 的扫描速度值得试。_disk_usage API)POST .ds-logs-aiops-22-000001/_disk_usage?run_expensive_tasks=true
配置2(logsdb / 无倒排,1段,414.8 MB)
字段 | 总计 | 倒排 | stored | doc values |
|---|---|---|---|---|
| 274.1 MB | — | — | 274.1 MB |
| 124.9 MB | 63.5 MB | 61.3 MB | — |
| 8.5 MB | 2.7 MB | — | 5.7 MB |
| 4.8 MB | — | — | 4.8 MB |
| 1018.6 KB | 1018.3 KB | — | 278 B |
| 770.8 KB | 770.8 KB | — | — |
| 250.4 KB | — | — | 250.4 KB |
配置5(logsdb_columnar / 无倒排,1段,423.4 MB)
字段 | 总计 | 倒排 | stored | doc values |
|---|---|---|---|---|
| 272.6 MB | — | 0 | 272.6 MB |
| 122.9 MB | 61.0 MB | 0 | 61.9 MB |
| 13.3 MB | — | 0 | 13.3 MB |
| 8.7 MB | — | 0 | 8.7 MB |
| 4.8 MB | — | 0 | 4.8 MB |
| 751.2 KB | — | 0 | 751.2 KB |
配置3(logsdb / 有倒排,1段,732.1 MB)
字段 | 总计 | 倒排 | stored | doc values |
|---|---|---|---|---|
| 320.8 MB | 320.8 MB | — | — |
| 272.6 MB | — | — | 272.6 MB |
| 121.5 MB | 61.5 MB | 59.9 MB | — |
| 8.5 MB | 2.7 MB | — | 5.8 MB |
| 4.8 MB | — | — | 4.8 MB |
| 1018.6 KB | 1018.3 KB | — | 278 B |
配置4(logsdb_columnar / 有倒排,1段,744.6 MB)
字段 | 总计 | 倒排 | stored | doc values |
|---|---|---|---|---|
| 593.3 MB | 320.7 MB | 0 | 272.6 MB |
| 122.1 MB | 60.2 MB | 0 | 61.8 MB |
| 13.3 MB | — | 0 | 13.3 MB |
| 8.7 MB | — | 0 | 8.7 MB |
| 4.8 MB | — | 0 | 4.8 MB |
配置1(logsdb / stored source / 无倒排,1段,522.8 MB)
字段 | 总计 | 倒排 | stored | doc values |
|---|---|---|---|---|
| 396.6 MB | — | 396.6 MB | — |
| 85.4 MB | 56.3 MB | 29.0 MB | — |
| 26.9 MB | — | — | 26.9 MB ⚠️ |
| 9.5 MB | — | — | 9.5 MB |
| 1.9 MB | 1.2 MB | — | 726.5 KB |
| 1.1 MB | 1.1 MB | — | 11.1 KB |
| 704 KB | 704 KB | — | — |
本文系转载,前往查看
如有侵权,请联系 cloudcommunity@tencent.com 删除。