OpenObserve 写入链路上的 3 个关键设计:单二进制如何做到零丢数据
【免费下载链接】openobserveOpen source observability platform for logs, metrics, traces, frontend monitoring, pipelines and LLM observability. A sophisticated, simple and highly performant alternative to Datadog, Splunk, and Elasticsearch with 140x lower storage costs and single binary deployment.项目地址: https://gitcode.com/GitHub_Trending/op/openobserve
OpenObserve(O2)是一个开源可观测性平台,主打比 Elasticsearch 低 140 倍存储成本、单二进制部署。这篇文章不聊功能清单,直接钻进它的写入链路:ingester 怎么保证崩溃不丢日志、compaction 怎么收拾小文件、search 怎么在海量 Parquet 里不扫全盘。看完你会明白,"便宜"和"不丢"这两件事是怎么同时成立的。
| 路径 | 一句话用途 |
|---|---|
| src/ingester/ | 写入核心:memtable 内存写、WAL 崩溃恢复、内存/磁盘熔断 |
| src/compaction/ | 后台整理:小时级合并、增量 compaction、Bloom 过滤、保留策略 |
| src/search_service/ | 查询侧:分区裁剪、分布式搜索调度 |
| README.md | 架构与部署总览,整仓的入口 |
建议顺序:先看 ingester(写入是一切的底),再看 compaction(它是写入的善后),最后看 search(前两个的下游)。
写入中途断电会发生什么:WAL 的 5 个步骤
高吞吐写入场景里,进程被 OOM 杀掉、节点被驱逐都是常态,所以写入链路必须回答一个问题:写到一半崩了,数据怎么办?
O2 的做法是:数据先进内存memtable,flush 时落 Parquet,但落盘被拆成 5 步,中间用 lock 文件当"进度标记":写.par→ 建 lock 文件 → 删 wal →.par改名.parquet→ 删 lock。重启时check_uncompleted_lock_files扫一遍 lock 文件,就能判断崩溃发生在第几步、从哪一步续。
这里有个"为什么"值得停一下:为什么不直接写.parquet?因为一个写到一半的.parquet和一个写完整的文件长得一样,查询侧没法区分,读它要么报错要么读到脏数据。而.par+ lock 的组合把"进行中"变成了显式状态,每步都可幂等续跑——本质上是个落盘状态机。代码注释里甚至列出了 4 种崩溃位置各自该怎么恢复。
配套的还有两道熔断器:check_memory_circuit_breaker和check_disk_circuit_breaker在内存占用、磁盘空间逼近阈值前就主动拒绝写入,把"硬 OOM / 磁盘写满"降级成"可控的写失败"。
💡 打开 src/ingester/src/wal.rs,文件头部的注释就是这份恢复设计文档,对着
check_uncompleted_lock_files看每个分支。
小文件为什么还要被合并一遍:compaction
理解了写入路径,合并就是顺理成章的善后:flush 出来的是一堆小 Parquet,高吞吐时当前小时能堆几千个,查询全变慢。
| 模式 | 触发条件 | 行为 |
|---|---|---|
| 定时 compaction | 每小时 | 只合并已完整结束的小时 |
| 增量 compaction | 当前小时文件数超过ZO_COMPACT_PENDING_FILES_TRIGGER | 立即排队合并,只"封卷"满尺寸的文件组,余下留给下轮 |
增量模式里有个精妙的分布式取舍:文件计数器PENDING_FILES是每个 ingester 节点本地内存里的,不全局精确。但没关系——合并任务靠唯一索引ON CONFLICT DO NOTHING去重,真正"合哪些文件"由 merge worker 读全局file_list 决定。本地计数只是廉价信号,决策点是单一的。这就是很多分布式系统共用的思路:不追求全局一致,只追求决策唯一。
💡 src/compaction/src/incremental.rs 的模块注释把"为什么需要它、为什么默认关着"写得比多数设计文档还清楚,值得逐句读。
搜索是怎么不扫全盘的
数据经 compaction 后仍是海量文件,查询侧的第一原则是先缩小范围再碰数据:
- 按时间范围 + 分区键先圈定候选文件(
generate_partitions); - 用Bloom 过滤整文件排除——构建侧在 src/compaction/src/bloom/,按 (文件, 字段) 生成并转置成小时级
.bf文件;剪枝侧在 src/search_service/src/bloom_pruner.rs。
为什么需要 Bloom 而不是直接看 Parquet 元数据?行组 min/max 对数值列够用,但字符串字段几乎剪不掉。Bloom 让"这个文件一定不含该值"这个判断不用打开文件就能做。
⚠️ 翻车现场:4 个高频坑
坑 1:崩溃恢复日志看不懂
- 现象:进程被 kill 后重启,日志刷出一堆
.par/.lock相关输出。 - 根因:落盘 5 步在第 2~4 步之间中断,留下了带或不带 lock 的孤儿
.par。 - 正确姿势:启动日志里搜
Scanning lock files与found uncompleted wal file确认恢复在跑;跑完后数据目录还残留.par的话,再找Clean orphan par files日志确认孤儿清理已完成。
坑 2:熔断器静默拒写
- 现象:写入突然开始报错,但机器没 OOM、磁盘也没满。
- 根因:内存或磁盘熔断器在阈值内提前拦截,这是设计行为不是故障。
- 正确姿势:检查配置字段
common.memory_circuit_breaker_ratio和common.disk_circuit_breaker_threshold,为 0 或 false 表示对应熔断关闭——grep这两个字段名即可确认当前生效值。
坑 3:增量 compaction 默认是关的
- 现象:高吞吐时当前小时几千个小文件,最近一小时查询特别慢。
- 根因:
ZO_COMPACT_PENDING_FILES_TRIGGER默认 0 = 禁用,只有定时的小时级合并在跑。 - 正确姿势:
grep ZO_COMPACT_PENDING_FILES_TRIGGER确认环境值;非 0 才会按阈值触发增量合并,且任务完成后会被删除、下轮可再触发。
坑 4:本地编译全绿 ≠ enterprise 代码没问题
- 现象:
cargo clippy本地通过,CI 的 enterprise 特性构建却挂了。 - 根因:默认 features 下
#[cfg(feature = "enterprise")]的代码根本不参与编译,绿色证明不了它。 - 正确姿势:在 diff 里
grep 'cfg(feature = "enterprise")',有命中就在 PR 里说明本地无法验证(仓库 CLAUDE.md 把这条写成了硬性规则)。
你的下一步
git clone https://gitcode.com/GitHub_Trending/op/openobserve,按 README.md Quick Start 里的 docker 命令 2 分钟起一个单二进制实例;- 打开 src/ingester/src/wal.rs,对照头部注释的 4 种崩溃情形,在
check_uncompleted_lock_files里逐条找到处理分支; - 打开 src/compaction/src/incremental.rs,确认
ZO_COMPACT_PENDING_FILES_TRIGGER的默认值是 0,再去看它非 0 时走的分支。
这个仓库的价值不在功能列表,而在写入链路上能读到的工程取舍:状态机让崩溃恢复幂等、熔断给资源兜底、用"本地信号 + 单一决策点"替代分布式一致。
【免费下载链接】openobserveOpen source observability platform for logs, metrics, traces, frontend monitoring, pipelines and LLM observability. A sophisticated, simple and highly performant alternative to Datadog, Splunk, and Elasticsearch with 140x lower storage costs and single binary deployment.项目地址: https://gitcode.com/GitHub_Trending/op/openobserve
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考