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 对齐 |
| O2 | 80 KB(0.08M) | 100 KB(0.1M) | 184 KB | 数据部分几乎等于 zstd -1 |
| ClickHouse | 70.53 KB | 132.02 KB | 202.55 KB | 数据更小,但索引开销更重 |
复现很简单:
zstd -19 k8slog_json.json # → 53.4 KiB
zstd -1 k8slog_json.json # → 79.3 KiBO2 用默认配置导入,读 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 GiB | 45.14 GiB | 219.78 GiB | 基准 |
CK text(splitByNonAlpha)(对等) | 117.32 GiB | 46.80 GiB | 164.12 GiB | 小 25% |
CK ngrambf_v1(概率型) | 105.64 GiB | 12.23 GiB | 117.87 GiB | 小 46% |
| CK 无二级索引 | 105.58 GiB | 0 | 105.58 GiB | 小 52%(无全文检索) |
CK text(ngrams(5))(子串,能力更强) | 106.05 GiB | 417.81 GiB | 523.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) | 14h38m41s | 51,863 行/s | 67.23 MiB/s | 1× |
CK ngrams(5)(能力更强) | 3h19m21s | 228,597 行/s | 77.3 MiB/s | 4.41× |
CK splitByNonAlpha(对等) | 48m57s | 930,965 行/s | 318.9 MiB/s | 17.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 |
|---|---|---|---|---|
| O2 | 4.01 / 5.83 核 | 2.90 / 3.75 GiB | 5.61 GiB | 14.39% / 20.61% |
CK ngrams(5) | 11.23 / 33.29 核 | 13.21 / 17.43 GiB | 93.95 GiB | 38.62% / 63.20% |
CK splitByNonAlpha | 6.94 / 15.53 核 | 10.46 / 14.64 GiB | 17.15 GiB | 45.42% / 59.00% |
换算成每核吞吐,把资源投入的差别抹掉,这是最公平的效率指标:
| 任务 | 每核吞吐 | 相对 O2 |
|---|---|---|
| O2 | 12,944 行/s/核 | 1× |
CK ngrams(5) | 20,356 行/s/核 | 1.57× |
CK splitByNonAlpha | 134,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=1 | bucket=5 | 变化 |
|---|---|---|---|
| 数据量 | 14,868,852 行 | 14,868,852 行 | — |
| 写入用时 | 383 s | 380 s | — |
| 吞吐 | 38,822 行/s | 39,129 行/s | +0.79% |
| O2 平均 CPU | 3.70 核 | 3.65 核 | 几乎不变 |
| O2 平均 RSS | 2.49 GiB | 3.48 GiB | +39.4% |
| 写入期 Parquet GET | 124 | 304 | +145% |
| 写入期 Parquet PUT | 1,164 | 3,034 | +161% |
| S3 最终对象数 | 2,807 | 8,355 | +197.7% |
| S3 最终字节 | 2.459 GB | 2.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 实例。到宿主机上查了容器挂载链路:
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 里 | 结果 |
|---|---|---|---|
| C | event_type(12 个值) | 是 | 错,差 -91,780 |
| D | actor_login(高基数) | 否 | 完全一致 |
| E | repo_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 小时 | 4 | 12 | 100.00% |
| 6 小时 | 24 | 12 | 100.00% |
| 1 天 | 96 | 12 | 100.00% |
| 7 天 | 757 | 7 | 82.87% |
| 31 天 | 4,125 | 3 | 29.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 倍
| 指标(首次触达,仅关闭结果缓存) | ClickHouse | O2 本地 | O2 S3 |
|---|---|---|---|
| 场景胜出数(24 个) | 14 | 3 | 7 |
| 24 场景累计墙钟 | 11.33 s | 82.24 s | 29.49 s |
| 每场景中位耗时比 | 1.00× | 4.28× | 1.25× |
| 几何平均耗时比 | 1.00× | 3.65× | 1.59× |
| 指标(同范围重复,文件缓存转热) | ClickHouse | O2 本地 | O2 S3 |
|---|---|---|---|
| 场景胜出数(23 个) | 11 | 5 | 7 |
| 累计墙钟 | 4.98 s | 9.87 s | 7.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.63 | 32.40 / 5.27 / 15.50 | 63.62 / 5.53 / 17.56 |
| A03 月度数值聚合 | 7.43 / 2.18 / 1.06 | 22.28 / 3.74 / 2.10 | 32.37 / 3.75 / 2.19 |
| G01 月度低基数多字段分组 | 7.70 / 3.85 / 1.10 | 22.16 / 5.27 / 2.02 | 31.40 / 5.56 / 2.17 |
| T01 正文子串 | 2.79 / 9.65 / 9.09 | 3.62 / 18.95 / 18.09 | 5.30 / 18.89 / 17.93 |
| D04 offset=100,000 深分页 | 2.61 / 1.74 / 1.67 | 3.51 / 3.14 / 3.29 | 3.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 GET | PUT | 响应体合计 |
|---|---|---|---|
| cache-bypass 首轮 | 12,911 | 2 | 约 1.269 GiB |
| cache-bypass 重复轮 | 0 | 0 | 0 |
| 原生缓存 5 次重复 | 0 | 2 | 近 0 |
| 1/4/8 并发测试 | 0 | 2 | 近 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 本地 | 1 | 8.75 核 | 28.57 核 | 3.46 GiB |
| O2 本地 | 4 | 18.23 核 | 39.61 核 | 5.71 GiB |
| O2 本地 | 8 | 23.21 核 | 43.05 核 | 6.07 GiB |
| O2 S3 | 8 | 19.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。跨端对比时,索引配置必须一一对应,不然你都不知道自己在比什么。