Skip to content

OpenObserve 压测实录:27.3 亿行下的真实表现 ​

8951 字 · 约 24 分钟

上一篇读 OpenObserve 的源码,留了一堆只能靠实测回答的问题。这一篇就是实测:O2 和 ClickHouse,比存储、比写入、比查询。

比之前先把规矩定下来:同一份数据,同一个压缩级别,功能对等,要建全文索引两边都建,要不建都不建,测量口径一致。这几条有一条对不上,谁压得更狠的对比就没有意义。测试里没对齐的地方,我也会写出来。

先拿 O2 自己的样例数据 ​

为了免得有人说我故意挑了对 O2 不利的数据,第一组直接用 O2 官方发布的样例数据集,k8s 容器日志,JSON 格式:

  • 下载地址:https://zinc-public-data.s3.us-west-2.amazonaws.com/zinc-enl/sample-k8s-logs/k8slog_json.json.zip(zinc-* 是 O2 前身 ZincObserve 的命名,确实是官方数据)
  • 解压后 8.40 MiB(k8slog_json.json)

这是一份典型的 k8s 日志,字段高度重复,结构规整,熵很低,正是最有利于压缩、最能撑起百倍压缩说法的那类数据。

方法数据索引合计说明
zstd -19 单文件53.4 KiB—53.4 KiB通用压缩极限(O2 的 level 写死、用不到这一档)
zstd -1 单文件79.3 KiB—79.3 KiB与 O2 默认 zstd level 对齐
O280 KB(0.08M)100 KB(0.1M)184 KB数据部分几乎等于 zstd -1
ClickHouse70.53 KB132.02 KB202.55 KB数据更小,但索引开销更重

复现很简单:

bash
zstd -19 k8slog_json.json    # → 53.4 KiB
zstd -1  k8slog_json.json    # → 79.3 KiB

O2 用默认配置导入,读 stream 统计里的 compressed_size 和 index_size。CK 用 zstd-1 压缩,给 log 字段建全文索引,读 system.parts 的 bytes_on_disk,这个数是含索引的。CK 这边一定要把二级索引算进去,只看 data_compressed_bytes(70.53 KB)会漏掉 132.02 KB 的索引,结论正好反过来,这个坑我这次真踩了。

O2 的数据落盘 80 KB,跟命令行敲一句 zstd -1 得到的 79.3 KiB 几乎一样。O2 那一套列存加 Parquet 加排序加编码,在这份数据上的压缩贡献约等于零,列式编码省下来的,正好被 Parquet 元数据和小文件的固定开销吃掉了。说白了,O2 的压缩就是一个 zstd -1,没有黑科技。

zstd -19 能压到 53 KiB,可 O2 的 zstd level 写死在 1 左右,源码里能确认,是为了摄入速度。你想调到 -19,没有入口,这 34% 的空间是 O2 替你放弃的。

一开全文检索,O2 的存储直接翻了 2.3 倍,压缩率从 108x 掉到 47x。Tantivy 的全文索引不压缩,索引(100 KB)比数据本身(80 KB)还大,社区 issue 里报告的「索引反超数据」也是这个原因,这是架构决定的,没法配置掉。

可这一局,总量上 O2 反而赢了。都建全文索引,O2 184 KB,CK 202.55 KB,O2 小约 9%。差别不在数据,CK 的数据 70.53 KB 比 O2 的 80 KB 还小 12%,在索引的占比:CK 的全文索引 132.02 KB 是它数据的 1.9 倍,O2 的索引 100 KB 是它数据的 1.25 倍。数据量这么小,谁的索引重谁就输。所以并不是 CK 在什么场景下都压得更小,小数据量又要全文检索的话,O2 总占用反而更少。

不过这份数据只有 8.40 MiB,是小文件的场景,正好是列存的劣势区,Parquet 元数据占比偏高,索引的固定开销占比也过高,能得出的结论有限。确定的只有一条,O2 没有压缩黑科技,数据侧落盘就等于一个 zstd -1。O2 略胜 9% 这个结果,推不到生产规模上,它太依赖数据量和索引的固定开销了。

顺带澄清一个常被误引的说法:140x 是拿最臃肿的 Elasticsearch 当分母算出来的,从来不是对 ClickHouse 的优势,O2 官方也从没发布过和 ClickHouse 的对比,维护者公开承认过没有。

27.3 亿行 GitHub Events ​

第一组数据软、量小,算是 O2 最好的情况。第二组上真实的量级:

  • 数据集:GitHub Events,2,734,244,238 行、54 列,ClickHouse 官方的公开示例数据集(GitHub Events Dataset),谁都能下载复现
  • O2 版本 v0.91.1,8 个写入线程,S3 后端
  • 同一批数据,同样的行数:CK 两次都是 119 个成功的 INSERT,总行数完全一致;O2 只算最终成功的那次主迁移,失败的尝试不算

先说清楚什么叫功能对等。O2 的全文索引是 Tantivy 分词倒排,是 token 语义,所以跟它对等的 CK 配置是 text(tokenizer=splitByNonAlpha)。CK 的 ngrams(5) 支持子串匹配,能力比 O2 强,拿它比对 CK 不公平;ngrambf_v1 是概率型索引,有假阳性,能力比精确倒排弱。这三个不能混着比。

存储:功能对等下 CK 小 25% ​

单位统一用 GiB(1 GiB = 1024³ 字节),跟 CK 的 formatReadableSize 一致。

方案数据索引合计vs O2
O2(Tantivy 全文索引)174.64 GiB45.14 GiB219.78 GiB基准
CK text(splitByNonAlpha)(对等)117.32 GiB46.80 GiB164.12 GiB小 25%
CK ngrambf_v1(概率型)105.64 GiB12.23 GiB117.87 GiB小 46%
CK 无二级索引105.58 GiB0105.58 GiB小 52%(无全文检索)
CK text(ngrams(5))(子串,能力更强)106.05 GiB417.81 GiB523.86 GiB大 138%

上表是同一轮测的。三天后(2026-07-21)跑查询压测时又取了一次数,CK 163.97 GiB(117.20 + 46.77),O2 218.72 GiB(173.85 + 44.87),两边都小了一点,是 compaction 还在继续推进,差异在 0.5% 以内,不影响结论,文中统一用上表的数。

O2 的索引效率不差,差在数据列。功能对等下索引几乎一样大(45.14 对 46.80 GiB),数据列却是 CK 117.32 GiB 对 O2 174.64 GiB,CK 小 33%。这是 CK 的列存编码,LowCardinality、逐列 codec、ORDER BY 聚类,在真实高熵数据上实打实的收益,也是 O2 排序键写死、不能逐列调 codec 的代价。

精确子串匹配是最贵的能力,ngrams(5) 的索引 417.81 GiB,是数据的 3.9 倍。想省空间,要么退回概率型(12.23 GiB,接受假阳性),要么退回 token 语义(46.80 GiB,放弃子串)。这几档 CK 让你自己挑,O2 只有一个固定的点。

压缩率倍数别拿来比。O2 报的是 3.08 TiB 压到 174.64 GiB,约 18x;CK 报的是 792.69 GiB 压到 105.58 GiB,约 7.5x。看着 O2 赢了,可落盘是 CK 更小。分母的口径完全不同,O2 的 original_size 是打平以后重新序列化的 JSON 文本,每行都把所有字段名重复一遍;CK 的是列式二进制的未压缩值。只比落盘的绝对字节数。

写入:功能对等下 CK 快 18 倍 ​

写入目标耗时行吞吐字节吞吐相对 O2
O2(8 workers)14h38m41s51,863 行/s67.23 MiB/s1×
CK ngrams(5)(能力更强)3h19m21s228,597 行/s77.3 MiB/s4.41×
CK splitByNonAlpha(对等)48m57s930,965 行/s318.9 MiB/s17.95×

行吞吐差 18 倍,字节吞吐只差 4.7 倍,因为两边每行要传的字节根本不一样:O2 走 JSON bulk,每行 1,359 字节;CK 列式写入,每行约 355 字节。

O2 一共发了 3.38 TiB 的 JSON 请求体,比它内部记的 original_size 3.08 TiB 多出约一成,多出来的应该主要是 bulk API 每条记录前面那行 action metadata。

字节吞吐差得不多,替 O2 开脱不了,只是把瓶颈看清楚了:O2 处理字节的能力不弱,是协议本身低效,同样的信息量要多传 3.8 倍的字节。而且这由不得使用者,JSON bulk 就是 O2 主要的摄入方式,没有更省的协议可选;CK 这边可以走 Native 二进制、列式批量、直接读 Parquet。协议低效也是架构的一部分,不能摘出去算。

资源:CK 每核效率是 O2 的 10 倍,O2 内存省 3.6 倍 ​

写入任务进程 CPU 平均/P95进程 RSS 平均/P95峰值 RSS整机 CPU 平均/P95
O24.01 / 5.83 核2.90 / 3.75 GiB5.61 GiB14.39% / 20.61%
CK ngrams(5)11.23 / 33.29 核13.21 / 17.43 GiB93.95 GiB38.62% / 63.20%
CK splitByNonAlpha6.94 / 15.53 核10.46 / 14.64 GiB17.15 GiB45.42% / 59.00%

换算成每核吞吐,把资源投入的差别抹掉,这是最公平的效率指标:

任务每核吞吐相对 O2
O212,944 行/s/核1×
CK ngrams(5)20,356 行/s/核1.57×
CK splitByNonAlpha134,145 行/s/核10.36×

差距不是堆资源堆出来的。CK 只多用了 73% 的 CPU(6.94 对 4.01 核),快了 17.95 倍,每核效率差 10.4 倍。

O2 也没吃满硬件,8 个 worker 只跑到 4.01 核,整机 CPU 才 14.39%,瓶颈不在 CPU,后面的对照实验会把这一点坐实。

内存上 O2 明显赢,平均 2.90 GiB,CK 要 10 到 13 GiB。这是 O2 真实的优势,小规模、资源紧、边缘部署这些场景,它有自己的位置。这笔账是 CK 用 1.7 倍的 CPU 加 3.6 倍的内存,换来 18 倍的吞吐。

采样口径有差别:O2 大约 10 秒采一次,CK 的历史指标大约 1 秒一次,所以 CK 的 P95 和峰值更容易抓到瞬时尖峰,比如 ngrams 那个 93.95 GiB 只是一分钟左右的瞬时值,P95 的 17.43 GiB 更有代表性。另外 O2 迁移时,源端 CK 还占着约 0.21 核、4.60 GiB。评价单项写入效率,看进程 CPU,别看整机。

给 O2 一次机会:memtable 分桶 ​

既然 O2 没吃满 CPU,是不是没调好?这个得排除,不然 18 倍站不住。

先说一个已经失效的调优项。大量资料,包括官方性能文档到现在都还在推荐,让你开 ZO_FEATURE_PER_THREAD_LOCK=true。可这个参数 2025-09 就删掉了(commit 3a1aaa0),在 v0.91.1 上设它会被静默忽略,不报错也不生效。等价的能力换成了 ZO_MEM_TABLE_BUCKET_NUM > 1,memtable 分桶,减少写入时的锁竞争。照着官方文档调,你会得到一个什么都没发生的结果,还以为自己已经优化过了。

所以按当前版本真正的等价项做对照:bucket=1 做基线,bucket=5 做优化组(5 是这个功能最初官方示例里的值),数据完全相同,8 个写入线程,S3 前缀各自独立。

指标bucket=1bucket=5变化
数据量14,868,852 行14,868,852 行—
写入用时383 s380 s—
吞吐38,822 行/s39,129 行/s+0.79%
O2 平均 CPU3.70 核3.65 核几乎不变
O2 平均 RSS2.49 GiB3.48 GiB+39.4%
写入期 Parquet GET124304+145%
写入期 Parquet PUT1,1643,034+161%
S3 最终对象数2,8078,355+197.7%
S3 最终字节2.459 GB2.426 GB−1.35%
compaction 期 CPU——+29.7%,峰值 3.66 → 8.73 核

这是一次净负面的优化。多花 39% 的内存、161% 的 PUT 请求、198% 的对象数、30% 的 compaction CPU,换来 0.79% 的吞吐提升,在噪声范围里。官方文档自己也警告过,低吞吐场景会产生更多小 WAL 文件。

它排除了两个嫌疑。不是 memtable 锁竞争,bucket 从 1 加到 5,吞吐纹丝不动;也不是 CPU 不够,始终只吃 3.7 核,整机大量空闲。剩下的只有上游的串行环节:HTTP、JSON 解析、schema 推断、打平。这跟 O2 每行传 1,359 字节、CK 只传 355 字节的观测对得上,瓶颈在协议和解析层,是架构的选择,调配置救不了,所以前面那个 18 倍是硬的。

有一点跟我原先猜的不一样。我本来以为小文件一多,压缩率会被拉低,实测对象数涨了 197.7%,字节数反而少了 1.35%,压缩率几乎没变。看来在这个量级上,Parquet 的 row group 和字典效率对文件数不敏感,代价全都转到了 S3 请求数和 compaction 负担上,存储体积倒没受影响。

compaction 确实在对象存储上跑 ​

上一篇从源码推断 compaction 是在对象存储上做的,这次实测也看到了。

客户端停止写入以后,S3 请求没停。基线组写完后 10 分钟里还有 474 次 Parquet GET、275 次 PUT;bucket=5 组更多,1,417 次 GET、1,226 次 PUT。O2 的日志里明确记着 [COMPACTOR:WORKER] 在读旧文件、合并生成新的 Parquet。观察窗口里没看到 DELETE,默认的延迟删除时间是 2 小时,窗口才 10 分钟,正好对上源码里 file_list 软删除加延迟物理清理的两段式设计。

对在线写入的影响很小。直接比时间窗口会得出写入降了 21% 到 29%,但那是 source part 陆续写完、并发降下来造成的时间混杂;按同样的活跃 source-part 并发度校正以后,基线组 −0.15%,优化组 +1.54%,净影响在 2% 以内。

所以 compaction 的代价不在拖慢写入,在后台资源和 S3 请求数。bucket=5 的场景下,compaction 期间 CPU 平均多了 29.7%,峰值到 8.73 核,S3 请求量翻倍。这正是 140x 存储成本那笔账里从来不出现的部分。

S3 那块盘已经被打满了 ​

这一轮的 S3 后端是 192.168.101.95:59000 上的一个 RustFS 实例。到宿主机上查了容器挂载链路:

text
RustFS /data
  → /data01/data/container/rustfs
  → /data01
  → /dev/sda(ext4,PERC H730P RAID 虚拟盘)

RustFS 的 /logs 和 Docker 数据目录也在 /data01 上,数据、日志和容器运行时共用同一块 /dev/sda。O2 单流持续写入的时候,这块盘 util 97.5% 到 98.3%,读 IOPS 约 300 到 370,读 5 到 6 MiB/s,await 12 到 14 ms,整机 CPU 却还有约 92% 空闲。这不是顺序带宽用完了,是典型的小块随机 I/O 延迟瓶颈。

同期抓包看到 O2 往 S3 发的 PUT 请求体约 4.15 MiB/s,能解释写压力;但 O2 的 GET 数量解释不了全部的读 IOPS,多出来的读可能来自 RustFS 后台的对象扫描、元数据操作,或者同盘的其他容器,所以 /dev/sda 的读负载不能全算到 O2 头上。

这说明眼下一张表的负载,已经差不多把这个 S3 后端的 I/O 余量用光了。要是 10 张、100 张同样负载的表一起写、一起触发 compaction,GET、PUT、DELETE 和小对象的元数据操作会叠在一起,总吞吐不会跟着表数线性涨,排队、超时、重试反倒可能互相放大,最后就是 S3 严重变慢或者间歇性不可用。按单表约 4.15 MiB/s 的 PUT 粗算,10 张、100 张分别要 41.5 和 415 MiB/s 的纯写入请求体,这还没算 compaction 的读写放大,这套单盘的架构没有安全余量。

这是单个 RustFS、单目录、单个 RAID 虚拟盘、日志和 Docker 同盘这套测试环境的上限,不能直接当成 O2 软件本身的集群上限。反过来,这一轮 O2 的吞吐也已经被这块盘拖着,不能当成理想对象存储下 O2 的写入上限。真要评估 10 到 100 个高吞吐流,得换多盘 SSD/NVMe 或者分布式对象存储,单独测并发 compaction 下 S3 的 P95/P99 延迟、队列深度、错误率和重试次数。

写入这一段的账是这样:功能对等下 CK 落盘小 25%(164.12 对 219.78 GiB),O2 的索引效率不差,差距全在数据列;写入 CK 快 17.95 倍,每核效率差 10.4 倍;内存是 O2 赢,2.90 对 10.46 GiB,省 3.6 倍。O2 的写入瓶颈调配置救不了,bucket 调优净负面,CK 这边从 105 到 524 GiB 的索引光谱可以自己挑。隐藏成本是 compaction 在对象存储上持续产生 GET 和 PUT,停写 10 分钟内还有数百次请求,DELETE 又延迟 2 小时。还有一条边界,当前单表就把 RustFS 的数据盘压到了约 98% util,10 个、100 个同等负载的流,这套单盘 S3 架构承不住,但这是测试环境的上限,不是 O2 集群软件的上限。

查询:27 个场景,三个端点 ​

查询测了三个端点:CK 的 github_events_text_zstd_split(27.3 亿行,全列 ZSTD(1),加 splitByNonAlpha 全文索引);O2 本地盘的 github_events_text(27.3 亿行);O2 S3 的 github_events_text_s3(18.8 亿行,数据截止到 2019-10 左右)。时间范围只取三端都有数据的 2017、2018 年,左闭右开,file_time 和 _timestamp 对齐。CK 关掉 query result cache,O2 每次查询前删掉这个流的结果缓存。27 个场景乘 3 个端点,81 次绕过缓存的查询全部成功。

其实之前跑过一轮,作废了。那一轮用的是 ILIKE,而 ILIKE 在任何索引上都不走索引,EXPLAIN 里连 Skip 段都没有,测出来的是 CK 全表扫 27.3 亿行的时间,不是引擎的能力。同一张表换成 hasToken,granule 能从 346,381 剪到 14 个,剪掉 99.996%,差了四个数量级。跨引擎比查询,第一件事是确认两边真的走了索引。

O2 返回了错的聚合结果,还说自己是对的 ​

三个端点跑同一条查询,结果对不上:

同一份数据、同一时间范围(2018-05-01 ~ 2018-05-08):

  总行数                                    三端一致  7,405,699      ✅
  直接过滤 IssueCommentEvent
    AND repo_name='ant-design/ant-design'   三端一致  4,063          ✅
  ──────────────────────────────────────────────────────────────────
  GROUP BY event_type, repo_name
    ORDER BY count DESC LIMIT 100
        CK / O2 本地:4,063        O2 S3:4,053   ← 少 10 条
        且 API 同时报告 is_partial = false        ← 声称结果完整

先排除:不是 SQL 写法不同,同样的条件直接过滤三端一致;不是缺行,总数一致;也不是分词语义不同,这条查询里没有全文匹配。

当时排除法里有一步做错了。我还查了按 event_type 一级分组,三端每组都一致,于是判断低基数分组没问题,只有高基数才出错。后来复测把这条推翻了,同一时间范围里,5082 的 event_type 分组一样是错的,PushEvent 分组值 4,193,787,直接过滤是 4,285,567。这个假阴性把方向带偏了,让我以为问题只在高基数组合这种边缘场景,低估了它的严重性。真实情况是低基数分组一样中招,而低基数分组恰恰是生产上最常用的。

顺着往下追,又错了两次,都是动手测才推翻的。

第一次我以为是 S3 的问题,O2 的 S3 后端高基数聚合有缺陷,因为本地端对、S3 端错。为了分辨,在同一个 5082 实例、同一个 S3 后端上,只换分组字段:

测试分组字段是否在 index_fields 里结果
Cevent_type(12 个值)是错,差 -91,780
Dactor_login(高基数)否完全一致
Erepo_name(高基数)是错,差 -60

非索引字段全对,索引字段全错,S3 洗清了嫌疑,真正的变量是 index_fields 的配置。而我那两个端点恰好在这一点上不一样:5082 配了 index_fields=[event_type, repo_name],5080 是空的,这两个字段正好就是出问题那条查询的分组字段。

第二次我以为是 compaction 滞后。既然索引只在 compaction 阶段生成,那还没 compaction 的数据没有索引,在分组里蒸发了,听着顺理成章。可这里有个前提没验证:写入下午 13:30 就停了,四个半小时以后还会有没 compaction 的数据吗?查 file_list 元数据:

2018-05 时间段     757 个文件    index_size=0 的:0
2018-01 时间段   4,125 个文件    index_size=0 的:0
全流           205,835 个文件    index_size=0 的:0     索引覆盖率 100%

一个缺索引的文件都没有,这个推论当场作废。

两次都是机制听着合理,我就当成了结论,一次靠对照实验推翻,一次靠查元数据推翻。源码能告诉你代码是怎么写的,线上实际跑成什么样,还得自己测。

跨文件合并时丢数据 ​

排除完,剩下的变量是查询跨了多少个文件:

查询跨度文件数返回分组数行数覆盖
1 小时412100.00%
6 小时2412100.00%
1 天9612100.00%
7 天757782.87%
31 天4,125329.33%

event_type 实际只有 12 个不同的值,一条 31 天的 GROUP BY event_type 只回来 3 个分组,丢了 70.67% 的行,is_partial 还是 false。这已经不是高基数 Top-N 少算几行的事了,是最基础的聚合查询返回了严重残缺的结果,还说自己是完整的。

调大 LIMIT 只能缓解,找不到安全的值(同样 31 天):

LIMIT  100 →  3 组  29.33%          LIMIT 1000 →  6 组  68.84%
LIMIT  500 →  5 组  46.43%          LIMIT 2000 →  9 组  93.13%   ← 仍然错
LIMIT  999 →  6 组  68.80%

需要的 LIMIT 跟着文件数涨,757 个文件时 LIMIT 1000 就够了,4,125 个文件时 2000 还不够。最开始那条 G02 查询也是这样,LIMIT 100 差 11,LIMIT 1000 差 2,LIMIT 5000 才对。可你没法事先知道该设多大,要知道,就得先知道正确答案。

相关源码是 src/config/src/tantivy/query/topn_collector.rs 里的 TopNSegmentCollector::harvest,分组数超过 ZO_INVERTED_INDEX_TOPN_MAX_GROUP_NUM(默认 1000)时,调 select_top_k_dense() 只留本文件局部的 Top-K,注释里自己写着「the merged top-n becomes approximate」。但实测丢得比这个阈值能解释的更多,event_type 才 12 组,远低于 1000,照样丢,完整的机制得官方去定位。

我已经报给了官方:openobserve/openobserve#13363,复现步骤、文件数和丢失率的对照、LIMIT 扫描,还有跟存储后端无关、跟 compaction 无关这两项排除,都附在里面了,后续以官方回复为准。

还有一个旁证。后来(2026-07-22)我用 GreptimeDB 跑了同一份 4.5 亿行数据、同一条高基数 Top-N 查询,结果和 ClickHouse 完全一致。GreptimeDB 和 O2 架构很像,都是 Rust 加 Parquet 加 Puffin 加 Tantivy,它算对了,进一步排除了数据本身有问题或者分词差异,毛病出在 O2 的 TantivyOptimizeExec 近似路径上。

最新版本也没修:

topn_collector.rs   v0.91.1  vs  main (v0.93.0, HEAD 99c571b)
                    diff 输出为空(字节级完全相同)
ZO_INVERTED_INDEX_TOPN_MAX_GROUP_NUM 默认值 1000      两版一致

这段代码的演进都早于 v0.91.1:PR #12348 引入,PR #12528 因为性能问题临时禁用,PR #12574 用现在这套 flat ordinal collector 重写并重新启用,之后再没动过。

is_partial 在结构上就覆盖不到它。把赋值链追了一遍,cluster/flight.rs::search → cluster/http.rs::search → Response::set_partial,partial_err 只有两个来源,搜索超时或取消,以及返回行数超过默认展示上限。走近似路径时唯一的痕迹是一条 log::debug!,默认日志级别下根本看不见。所以这其实是两个得分开修的问题:Top-N 悄悄降级成近似,和 is_partial 反映不出这件事。

第二个也不是孤例。O2 官方 issue #6311(2025-03-19 提的,到现在还 open)报告 VRL 函数报错时 is_partial 同样不置位,2026-05-06 的验证评论写着「Root cause — STILL UNFIXED」,还指出 src/service/search/cluster/http.rs:124 设了 function_error,却没设 is_partial=true。is_partial=false 代表结果完整这个承诺,现在至少在两条独立的路径上靠不住。

能绕,但绕完没法确认绕过去了:

解法代价
调高 ZO_INVERTED_INDEX_TOPN_MAX_GROUP_NUM官方注释自述「trade speed for accuracy」
把 GROUP BY 字段移出 index_fields绕开优化路径,但这些字段的检索一并变慢
ZO_ENABLE_INVERTED_INDEX=false等于自废武功,O2 最大的优势就是全文检索

三个办法都要求你事先知道数据的基数分布,而出错的时候又没有任何提示。LIMIT 扫描已经说明了,没有哪个值能保证对,只有错得多一点和错得少一点。所以这几招只能降低触发概率,没法让你确认自己已经避开了。拿聚合结果做告警阈值、容量决策或者对外报表的,「调了参数应该没问题了」和「我能验证结果是对的」,差得很远。

按日志级别、服务名、状态码、事件类型分组,是可观测性里最基本的查询,而 index_fields 恰恰就是官方建议给这类低基数枚举字段配的。两个推荐做法叠在一起,出来的是悄悄错掉的结果,范围越大错得越多。「查最近 30 天各服务的错误分布」这种再普通不过的看板,你会拿到一张少了 70%、缺了 9 个类别的图,没有任何警告。它每次还稳定地返回同一个错误答案,数字不跳,你连起疑的机会都没有。

另外有 2 个全文 token 场景结果对不上:body 含 error,CK 78,852,O2 100,068;event_type 加 token,26,269 对 32,505。原因是 CK 的 hasAnyTokens 和 O2 的 match_all 在分词和大小写边界上不一样,这是语义差异,不算错误,耗时可以比,但不参与排名。

所以最终参与排名的是 24 个场景。

单查询延迟:首次差 4 倍,热了以后 1.4 倍 ​

指标(首次触达,仅关闭结果缓存)ClickHouseO2 本地O2 S3
场景胜出数(24 个)1437
24 场景累计墙钟11.33 s82.24 s29.49 s
每场景中位耗时比1.00×4.28×1.25×
几何平均耗时比1.00×3.65×1.59×
指标(同范围重复,文件缓存转热)ClickHouseO2 本地O2 S3
场景胜出数(23 个)1157
累计墙钟4.98 s9.87 s7.22 s
每场景中位耗时比1.00×1.39×1.13×
几何平均耗时比1.00×1.25×1.10×

O2 第一次查的代价很高,热了以后差距明显收窄,4.28 倍降到 1.39 倍。这跟上一篇源码里看到的一致,O2 的查询性能很依赖 footer_cache 预热和本地文件缓存,冷路径要下载、解析 Parquet footer,还要预热 Tantivy 的 term。

「O2 S3 比 O2 本地快」是个坑,别照抄。表面上 S3 端(1.25 倍)比本地端(4.28 倍)好得多,原因是 5082 这个流显式配了精确索引,5080 本地流没配。这是索引配置不同,不是 S3 比本地盘快。索引配置没对齐,是这一轮测试最大的遗留缺口。

还有一点出乎意料:O2 热了以后快,主要不是靠 result cache。24 个场景里只有 6 个 result_cache_ratio=100,1 个 75%,其余大多是 0,热态的快是 cached_ratio 文件缓存给的。CK 这边是 27 次 QueryCache miss 加 135 次 hit,正好对上 27×(1+5) 的设计。热态 p50 的胜出数,CK 17 个,O2 本地 2 个,O2 S3 5 个。

并发:CK 能吃满 8 并发,O2 聚合到 4 并发就封顶 ​

单位是 QPS,顺序是 CK / O2 本地 / O2 S3:

场景1 并发4 并发8 并发
P01 repo 精确点查9.08 / 3.24 / 10.6332.40 / 5.27 / 15.5063.62 / 5.53 / 17.56
A03 月度数值聚合7.43 / 2.18 / 1.0622.28 / 3.74 / 2.1032.37 / 3.75 / 2.19
G01 月度低基数多字段分组7.70 / 3.85 / 1.1022.16 / 5.27 / 2.0231.40 / 5.56 / 2.17
T01 正文子串2.79 / 9.65 / 9.093.62 / 18.95 / 18.095.30 / 18.89 / 17.93
D04 offset=100,000 深分页2.61 / 1.74 / 1.673.51 / 3.14 / 3.293.96 / 2.95 / 2.79

CK 的点查从 9.08 涨到 63.62 QPS,7.0 倍;数值聚合从 7.43 到 32.37,4.4 倍,并发扩展基本是线性的。O2 的聚合到 4 并发就平了,2.18、3.74、3.75,第 8 个并发基本只是把单个请求的延迟拉长。CPU 也说明了这一点,O2 从 4 并发到 8 并发,平均 CPU 只从 18.23 核涨到 23.21 核。深分页三家都很差,O2 在 8 并发下的吞吐甚至比 4 并发还低,生产上应该用 search-after 或者游标,别再加大 offset,这对三个引擎都一样。

O2 确实赢的地方:正文子串 ​

这是这一轮 O2 唯一大幅领先、原因也说得清的场景:

T01 正文子串,8 并发:
  O2 本地  18.89 QPS
  O2 S3    17.93 QPS
  CK        5.30 QPS      ← O2 快 3.6 倍

O2 单并发就有 9.65 QPS(CK 2.79),4 并发就到顶。这是 Tantivy 倒排加列式直读的主场,也是 O2 相对 CK 最有说服力的能力差别。CK 这边的成本也得看一眼,query_log 显示 T01 每个请求的峰值内存约 1.2 到 1.25 GiB,在 CK 上也不是什么便宜操作。

后面几轮根本没读 S3 ​

阶段S3 GETPUT响应体合计
cache-bypass 首轮12,9112约 1.269 GiB
cache-bypass 重复轮000
原生缓存 5 次重复02近 0
1/4/8 并发测试02近 0

除了第一轮,所有 O2 S3 的数字测的都是远端流加本地文件缓存,不是对象存储冷读,上面那些热态和并发的 S3 数据,一次 GET 都没发出去过。要评估对象存储冷读,得换一个隔离的新前缀,或者用能控制文件缓存的实例重测。

这也从实测上印证了上一篇源码里的判断:O2 在对象存储上的查询性能,拼的就是缓存命中率。缓存命中的时候,它能和本地盘引擎打个五五开;一旦落到冷路径上,新的时间范围、缓存被挤出去、节点重启、一致性哈希重新分布,首次 4.28 倍的代价就又回来了。

另外 .95 那台机器的 /dev/sda 在各个阶段都还是 97% 到 100% 的 util,可热态和并发阶段抓包是 0 次 GET、只有 2 次 PUT,这段磁盘饱和不能算到前台查询头上,更可能是 RustFS、O2 的后台工作,或者同盘的其他负载,要用进程级块 I/O 统计或者 eBPF 才能最终定位。

查询时的资源 ​

引擎并发O2 CPU 平均O2 CPU 峰值O2 RSS 峰值
O2 本地18.75 核28.57 核3.46 GiB
O2 本地418.23 核39.61 核5.71 GiB
O2 本地823.21 核43.05 核6.07 GiB
O2 S3819.82 核33.40 核5.18 GiB

对照着看挺有意思:O2 写入时只用得动 4 核左右,查询时却能瞬间冲到 43 核。DataFusion 的查询并行是正常工作的,写入时 CPU 上不去,确实是上游 HTTP、JSON 解析那条链路的问题,不是 O2 整体不会用多核。O2 内存的峰值来自深分页 D04(6.07 GiB)。

查询这一段,正确性的问题最严重:GROUP BY 的字段只要配进了 index_fields,聚合结果就可能静默残缺,实测 12 个分组只回来 3 个、行数少 70.67%,is_partial 还是 false,跨的文件越多越严重,调高 LIMIT 找不到安全阈值,跟存储后端无关,到 v0.93.0 仍未修复。性能上,冷查询 CK 胜,24 个场景赢 14 个,O2 本地中位 4.28 倍、O2 S3 1.25 倍;热了以后收敛到 1.39 倍和 1.13 倍,而且 O2 的快主要来自文件缓存,不是 result cache。并发扩展 CK 明显更强,点查 7 倍线性扩展,O2 的聚合 4 并发就封顶。O2 的强项是正文子串扫描,8 并发下 18.89 对 5.30 QPS,快 3.6 倍。深分页三家都差,都得改用 search-after 或者游标。最大的缺口是 5080、5082 和 CK 的索引配置没有一一对应,S3 冷读也没真正测到,重复轮 0 次 GET。

测完了,选谁 ​

把两篇放在一起看,O2 赢的地方有两个:内存,写入时 2.90 GiB 对 CK 的十个 G 出头;还有正文子串扫描,8 并发下快 CK 3.6 倍。小数据量又要全文检索的时候,它的总占用也略小。

其余的,存储(功能对等小 25%)、写入(快 18 倍,每核效率 10 倍)、冷查询、并发扩展,都是 CK 占优。更要紧的是聚合结果的正确性,只要 GROUP BY 的字段配进了 index_fields,O2 就可能悄悄少返回分组,还标着结果完整,到 v0.93.0 也没修。这一条在我这儿分量比性能重得多,查得快,结果却不对,那就没法用。

所以日志存储和分析,我推 ClickHouse。我是做 CK 的,这里面有偏向,但上面这些数都是同一份数据、同样的口径测出来的,谁都可以拿去复现。

O2 也有该选它的时候:机器内存很紧,或者是边缘部署;主要的查询就是在正文里搜子串;团队没人愿意碰建表和调优,数据又是纯追加的日志,图的是对象存储上存得便宜、单二进制部署省事。真要上 O2,先等 #13363 有结论,在那之前别在 index_fields 字段上做 GROUP BY,或者至少拿一条已知答案的查询定期对一下。

附:怎么测的 ​

两份数据都是公开数据集,不含任何生产数据。第一组是 O2 官方的 k8s 日志样例(8.40 MiB),第二组是 ClickHouse 官方的 GitHub Events 示例数据集(27.3 亿行、54 列)。两边都没用自家的主场数据,各自拿的是对方的公开数据,这是我有意这么安排的。

压缩率的口径是原始未压缩大小除以落盘大小,含索引的地方会注明。

O2 用 compressed_size 加 index_size,也就是 UI 上的「实际存储」加「索引大小」。千万别用 storage_size,这个字段名非常误导人,源码里是 self.storage_size += meta.original_size,累加的是 original_size,也就是打平以后的记录重新 serde_json::to_vec() 得到的 JSON 文本字节数,UI 上显示为「采集数据 / Ingested Data」,跟落盘大小没关系。用错这个字段,会把 174.64 GiB 看成 3.08 TiB。

CK 用 SELECT sum(bytes_on_disk) FROM system.parts WHERE active,含二级索引。只看 system.columns 的 data_compressed_bytes 会漏掉全文索引的体积,这个坑我这次也踩了,一度得出完全相反的结论。

两边的统计口径都有坑,方向还相反:O2 容易把未压缩的 JSON 文本当成存储量,算多了;CK 容易漏算索引,算少了。所以我坚持只比落盘的绝对字节数,每个数字都注明从哪个字段取的。

查询的口径:CK 关掉 query result cache,O2 每次绕过缓存的查询前删掉这个流的结果缓存。没有执行 drop_caches,所以「首次触达」只描述观测到的缓存状态,不等于物理意义上的冷盘。三个引擎串行执行单查询,按场景轮转;并发测试每次只压一个引擎。明细和分页查询用稳定的复合排序,结果做规范化摘要逐字段校验,正确性问题就是这么发现的。跨引擎压测要是只比耗时不比结果,会把一个正确性 bug 当成 O2 聚合还挺快。

比查询之前先 EXPLAIN 确认索引真的命中了。ILIKE 在 CK 上从来不走全文索引,用它测出来的「O2 快 N 倍」,都是在跟全表扫描比。

公平比较的四条,同数据、同 zstd level、功能对等(索引对齐)、口径一致,详细做法见上一篇附录 C。这一轮查询测试第三条没完全做到:O2 的 5082(S3)配了精确索引,5080(本地)没配,所以「O2 S3 比 O2 本地快」不能当结论。索引配置不对齐的代价比想的大,它不光影响性能能不能比,还制造了一个 S3 专属缺陷的假象,直到追进源码才发现真正的变量是 index_fields。跨端对比时,索引配置必须一一对应,不然你都不知道自己在比什么。


相关:OpenObserve 的 140x 压缩:源码里的分母是什么 · 存算分离横评 · 日志检索三家实测

内容来自一线生产环境,客户信息均已脱敏