上一篇我们分析了 SigNoz,它的思路可以用一句话概括:服务端很薄,原始数据往 ClickHouse 里一塞,聚合的活全交给 ClickHouse 在查询时做。写完那篇以后我一直有个念头,这条路的前提是你得养一个 ClickHouse 集群,对小团队来说这个前提本身就不轻。那有没有连 ClickHouse 都不想维护的路?

今天要介绍的 OpenObserve 就是另一条路。它是用 Rust 写的单个二进制,启动就能用,不依赖任何外部数据库,数据以 Parquet 文件的形式直接写到对象存储(S3、GCS、MinIO,或者本地磁盘),查询用 Apache DataFusion 在 Parquet 上现算。官方宣传的"比 Elasticsearch 存储成本低 140 倍",底气就来自 Parquet 加 S3 这个组合。它同样是 logs、traces、metrics 全都支持,还有 RUM、告警、看板、pipeline 这些,不过那些都不是本文的重点。

和上一篇一样,这篇不是安装教程,也不是使用教程,我们只关心一件事:数据是怎么流动的。一条日志进来以后怎么变成 Parquet 文件、文件怎么在对象存储上组织、查询的时候怎么找到这些文件、全文检索怎么做,以及 traces 和 metrics 在这套通用存储上是怎么安放的。读完以后希望大家脑子里能有一个 OpenObserve 的数据处理模型,并且能和 SigNoz 做个对照,看看"自己做存储引擎"和"把存储外包给 ClickHouse"各要付什么代价。

本文基于 2026 年 10 月的 main 分支源码(v1.0 之后的版本),这个项目迭代非常快,几个月前的目录结构和现在都对不上,所以文中的默认值和文件名以我看的这个版本为准,大家读的时候要有心理准备。前置知识方面,假设读者熟悉 logs、traces、metrics 各自解决什么问题,最好读过我之前写的 Parquet 系列,因为后面会反复出现 row group、列编码、文件元数据这些词。不了解也没关系,我会在用到的时候简单带一下。

总览

先看总图:

  应用 / OTel SDK / Fluent Bit / Vector / Prometheus remote write / Kinesis ...
                        │  HTTP :5080  或  gRPC :5081
                        ▼
 ┌────────────────────────────── OpenObserve ──────────────────────────────┐
 │                                                                         │
 │  Router ──► Ingester ── WAL + 内存表 ──► 本地 Parquet ──► 对象存储(S3)     │
 │    │           ▲                                             │          │
 │    │           │ 最新数据还在 Ingester 内存里,查询会顺路去问它     │          │
 │    │           │                                             ▼          │
 │    └──────► Querier ◄──────── DataFusion 读 Parquet ─────────┘          │
 │                                                                         │
 │  Compactor:合并小文件、删过期数据、建索引                                   │
 │  Scheduler:跑告警和报表                                                  │
 └─────────────────────────────────────────────────────────────────────────┘
            元数据(schema、file_list):单机 SQLite,集群 PostgreSQL
            集群协调(节点注册、分布式锁):NATS

几个角色:

  • Router:无状态,收到请求后把写入转给 Ingester,把查询转给 Querier,顺便托管前端页面
  • Ingester:有状态,接收写入,先写 WAL 和内存表,攒够了转成 Parquet 文件上传到对象存储,然后在 file_list 表里登记一笔
  • Querier:无状态,接收 SQL 或 PromQL,从 file_list 挑出要扫的文件,用 DataFusion 算结果
  • Compactor:把 Ingester 上传的小文件合并成大文件,删除过期数据,生成全文索引和 bloom filter
  • Scheduler:跑告警、报表这类定时任务

单机模式下这几个角色全部跑在一个进程里(ZO_NODE_ROLE 默认就是 all),元数据存在本地的 SQLite 文件里,数据存本地磁盘,连对象存储都不需要。集群模式下每个角色可以独立扩缩,但元数据库必须换成 PostgreSQL,节点之间用 NATS 做注册和分布式锁,数据必须放对象存储。老版本里支持的 etcd 和 MySQL 在这个版本里都已经去掉了。

在进入细节之前,先给大家一个总的印象,这个印象和 SigNoz 那篇正好相反:SigNoz 的"薄"是因为它把存储和计算都外包给了 ClickHouse,自己只写了三个 exporter 和一个查询层;OpenObserve 没有外部数据库,它自己就是那个数据库。不过它也不是从零造轮子,而是站在 Rust 数据生态的几个成熟项目上:Arrow 做内存格式(我之前写过一篇 Arrow 的简单介绍),Parquet 做文件格式,DataFusion 做 SQL 执行引擎,tantivy(Rust 版的 Lucene)做全文索引,object_store 这个库抹平 S3、GCS、Azure 和本地磁盘的差异。所以这篇文章的重心会从 SigNoz 那篇的"表怎么设计",转到"文件怎么组织"上来。

还有一个贯穿全文的概念要先说一下:stream。OpenObserve 里的 stream 相当于一张表,或者说 Elasticsearch 里的一个 index。日志的 stream、traces 的 stream、每个 metric 一个 stream,都是 stream。每个 stream 有自己的 schema(字段会随着数据自动增长),有自己的 settings(分区键、全文检索字段、保留天数等等)。后面说"写进某个 stream",大家就理解为"写进某张表"。

下面我们先拿日志把这条通用的存储链路从头到尾走一遍,因为三种数据用的是同一套存储引擎,日志又是最简单的。走完以后再看 traces 和 metrics 是怎么把自己塞进这套模型的,那时候就只需要讲它们的特殊之处了。

一条日志的旅程

入口

OpenObserve 的写入入口非常多,这是它和 SigNoz 的第一个明显区别。SigNoz 只收 OTLP,其他格式都要先经过 OTel Collector 转一道;OpenObserve 为了方便大家从老系统迁过来,直接模仿了一堆别人的接口。HTTP 路由大致是这些:

POST /api/{org}/{stream}/_json          # 最简单的,body 是一个 JSON 数组
POST /api/{org}/{stream}/_multi         # 一行一个 JSON(NDJSON)
POST /api/{org}/_bulk                   # 兼容 Elasticsearch 的 bulk API,Filebeat、Logstash 可以直接指过来
POST /api/{org}/_hec                    # 兼容 Splunk HEC
POST /api/{org}/loki/api/v1/push        # 兼容 Loki 的 push API,Promtail 可以直接指过来
POST /api/{org}/v1/logs                 # OTLP/HTTP logs
POST /api/aws/{org}/{stream}/_kinesis_firehose
POST /api/gcp/{org}/{stream}/_sub
                                        # 另外 5081 端口上还有 OTLP/gRPC

路径里的 org 是组织,一个多租户隔离的概念,单机自己玩的话就一个 default,先不用关注。stream 就是前面说的表名。像 _bulk 这种路径里没有 stream 的,表名从 body 里拿:_bulk 用的是 ES 那个 _index 字段,Loki 用的是 label 里的 o2_stream_name,OTLP 用的是请求头里的 stream-name,都没有的话就叫 default。

这些入口各自把 body 解析成 JSON 对象的列表以后,最终都汇聚到同一个函数 ingest(),后面的处理就都一样了。

预处理:flatten 和 _timestamp

日志进来的时候是任意的 JSON,可能有嵌套对象、数组。Parquet 虽然支持嵌套结构(Parquet 系列第四篇讲过 repetition level 和 definition level),但 OpenObserve 没有用,它选择先把 JSON 拍平成一层,这样每个 key 直接对应 Parquet 的一列,后面的 schema 管理和查询都简单很多。拍平的规则其实很简单,看个例子:

// 原始日志
{
  "Level": "ERROR",
  "kubernetes": {
    "pod": { "name": "order-7f9c", "ip": "10.0.3.12" },
    "labels": { "app.kubernetes.io/name": "order" }
  },
  "tags": ["a", "b"],
  "extra": null
}

// flatten 以后
{
  "level": "ERROR",                                     // key 统一转小写
  "kubernetes_pod_name": "order-7f9c",                  // 嵌套用下划线拼接
  "kubernetes_pod_ip": "10.0.3.12",
  "kubernetes_labels_app_kubernetes_io_name": "order",  // 点、斜杠这类非 [a-z0-9_] 的字符全部换成下划线
  "tags": "[\"a\",\"b\"]"                               // 数组不展开,整个序列化成 JSON 字符串
                                                        // null 值直接丢掉,这一列对这条记录来说就是空
}

三条规则:key 规范化成小写字母、数字、下划线;嵌套对象用下划线拼接父子 key;数组不展开,原样序列化成字符串。默认最多展开 3 层(ZO_INGEST_FLATTEN_LEVEL),再深的部分整体变成一个 JSON 字符串。这里有个小坑,kubernetes.pod_name 和 kubernetes_pod.name 拍平后是同一个 key,后写的会覆盖先写的,代码里没有做冲突检查。

拍平以后要确定这条记录的时间。OpenObserve 只认一个字段名 _timestamp,没有 @timestamp、time 这类别名候选,你的日志里时间字段叫别的名字,要么在客户端改,要么用它的 pipeline 功能在写入时改。值的处理还是比较贴心的:

  • 数字:按量级猜单位,比 1971 年对应的纳秒值还大就当纳秒,否则依次判断微秒、毫秒、秒,最后统一转成微秒
  • 字符串:先试着当数字解析,不行就按 "2024-01-01 12:00:00"、RFC3339、RFC2822 这几种格式解析
  • 没有这个字段:用服务端收到的时间

所以 OpenObserve 内部的时间精度是微秒,_timestamp 列的类型是 Int64。另外还有一个时间窗口检查,默认只接受 5 小时以前到 24 小时以后之间的数据(ZO_INGEST_ALLOWED_UPTO 和 ZO_INGEST_ALLOWED_IN_FUTURE),太老的日志会被拒掉。为什么要拒太老的数据,看完后面的文件组织方式大家就明白了,先往下走。

schema 怎么长出来

flatten 完每条记录就是一层扁平的 key-value,接下来要把一批记录变成 Arrow 的 RecordBatch,这就需要 schema。OpenObserve 的 schema 是动态的,不用预先建表,第一批数据进来是什么样 schema 就是什么样,后面来了新字段就往上加。类型推断很朴素:JSON 字符串是 Utf8,布尔是 Boolean,整数是 Int64(超出范围是 UInt64),小数是 Float64,null 不产生字段。

新字段直接合并进 schema。麻烦的是类型冲突,比如 status 这一列原来是 Int64,现在来了一条 "200" 字符串。处理分两种情况:

  • 如果是"放宽"方向的变化(Int64 变 Utf8、Int64 变 Float64、Boolean 变任何类型),就把 schema 里这一列的类型改成更宽的那个,老文件不动,查询时由 DataFusion 把不同文件的 schema 统一起来
  • 如果是"收窄"方向(Utf8 变 Int64),schema 不动,把这个字段打上 zo_cast 标记,之后进来的记录在写入前把值 cast 成已有的类型,cast 不动的那条记录丢弃

schema 本身存在元数据库的 meta 表里,key 形如 /schema/{org}/logs/{stream},value 是 Arrow Schema 的 JSON,而且是带版本的:每次发生放宽型的类型变化,老 schema 记一个 end_dt,新 schema 记一个 start_dt,这样可以知道某个时间段的文件是用哪一版 schema 写的。stream 的 settings 也存在 schema 的 metadata 里,没有单独的表。

字段数量有上限,一个 stream 默认最多 1000 列(ZO_COLS_PER_RECORD_LIMIT),超过了整批拒绝。这是防高基数的第一道闸门。日志系统里字段爆炸是常见事故,比如有人把 user id 当成 key 而不是 value 打进 JSON。如果确实有很多字段又不想全部建列,可以给 stream 配 defined_schema_fields(文档里叫 user defined schema),只有列出来的字段成为独立列,其余字段整体序列化成一个 JSON 字符串塞进 _all 列,要用的时候再从字符串里解析。

好了,到这里一条日志已经变成了"有 schema 的扁平记录",下面进入真正的存储引擎。

写进 WAL 和内存表

OpenObserve 的 ingester 内部是一个很经典的 LSM 树前半段:先写 WAL 保证不丢,再写内存表供查询,内存表满了冻结成 immutable,再由后台线程刷成文件。如果读过我之前写的 RocksDB 那篇,这里会非常眼熟。

先看写入落到哪里。每个 HTTP 请求进来会拿到一个 thread_id(其实就是请求计数器取模,round robin),然后用 gxhash("{thread_id}_{stream_name}") 对内存表桶数取模,决定进哪个 Writer。Writer 的 key 是 (桶编号, org, stream_type),注意这里没有 stream,也就是说同一个 org 下所有日志 stream 共享同一组 Writer,共享 WAL 文件和内存表,stream 只是内存表内部的一级 key。Writer 长这样:

pub struct Writer {
    idx: usize,                              // 桶编号
    key: WriterKey,                          // (org_id, stream_type)
    wal: Arc<RwLock<WalWriter>>,             // 当前正在写的 WAL 文件
    memtable: Arc<RwLock<MemTable>>,         // 当前的内存表
    next_seq: AtomicU64,
    created_at: AtomicI64,                   // 用来判断到没到时间该 rotate 了
    write_queue: ...,                        // 异步写队列,默认没开,不用关注
}

一批记录进来,先在调用线程上用 schema 把 JSON 转成 Arrow 的 RecordBatch,然后做三件事:

  1. 把 RecordBatch 按 Arrow IPC 格式序列化,snappy 压缩,前面加 crc32 和长度,追加写到 WAL 文件
  2. 把 RecordBatch 放进内存表
  3. 看要不要 fsync。这里要特别说一下,ZO_WAL_FSYNC_DISABLED 默认是 true,也就是默认不 fsync,只是 flush 到操作系统的 page cache。进程崩了没事,操作系统宕机就会丢最后一点数据。官方的解释是数据在 ingester 上只停留很短时间,这个窗口他们认为可以接受

WAL 文件在 {ZO_DATA_WAL_DIR}/logs/{桶编号}/{org}/logs/{时间戳}.wal,创建的时候就用 set_len 预分配了最大长度。文件头是一个 "OPENOBSERVEV3" 的魔数,后面就是一条条 entry。它不是 RocksDB 那种一条记录一个 entry,而是一个 RecordBatch 一个 entry,这也是为什么要先转成 Arrow 再写 WAL。

内存表的结构是一棵四层的树,每一层对应后面文件路径的一段:

MemTable
 └── streams: HashMap<"org/stream", Stream>             // 第一层:哪个 stream
      └── partitions: BTreeMap<schema_key, Partition>   // 第二层:用的哪版 schema(schema 的 hash)
           └── files: BTreeMap<partition_key, PartitionFile>
                │                                        // 第三层:"2026/10/10/08/k8s_namespace=prod"
                │                                        //   也就是小时目录加分区目录
                └── data: Vec<RecordBatchEntry>          // 第四层:一个个 RecordBatch,各自记着 min_ts 和 max_ts

第二层按 schema_key 分,是因为同一个 stream 的 schema 会演进,一批数据用哪版 schema 转成的 RecordBatch 就归到哪个 Partition 下,同一个 Parquet 文件里所有 row group 的 schema 必须一致。第三层的 partition_key 是按记录的 _timestamp 算出来的小时,加上 stream settings 里配置的分区键的值,比如你配了按 kubernetes_namespace 分区,就是 2026/10/10/08/kubernetes_namespace=prod。这个 key 后面会直接变成对象存储上的目录。到这里大家应该能猜到前面为什么要拒绝太老的数据了:一条 5 小时前的日志会落到 5 小时前那个小时目录下,而那个小时的文件可能已经被 compactor 合并过了,再往里塞小文件会很别扭。

什么时候冻结?三个条件任一满足就 rotate:WAL 文件超过 512 MB(ZO_MAX_FILE_SIZE_ON_DISK),或者内存表超过 512 MB(ZO_MAX_FILE_SIZE_IN_MEMORY),或者这个 Writer 创建已经超过 600 秒(ZO_MAX_FILE_RETENTION_TIME)。rotate 就是新建一个 WAL 文件和一个空内存表换上去,老的内存表变成 immutable 挂到全局列表里等着落盘。所有 Writer 的内存表总预算默认是物理内存的一半(单机模式是四分之一),超了直接返回 503,这是内存保护。

从 immutable 到 Parquet

后台有一个任务每 2 秒(ZO_MEM_PERSIST_INTERVAL)扫一遍 immutable 列表,把它们刷成 Parquet 文件。前面说了内存表是四层树,刷盘就是沿着树走:每个 stream、每版 schema、每个小时加分区目录,下面的 RecordBatch 合并起来写成一个 Parquet 文件(超过 100 * 1024 行会切成多个文件)。写 Parquet 的参数只有几行:

WriterProperties::builder()
    .set_write_batch_size(8192)
    .set_max_row_group_row_count(Some(128 * 1024))           // 一个 row group 最多 128K 行
    .set_compression(zstd)                                   // ZO_PARQUET_COMPRESSION 默认 zstd
    .set_column_dictionary_enabled("_timestamp", false)      // 时间列不用字典编码
    .set_column_encoding("_timestamp", Encoding::DELTA_BINARY_PACKED)  // 用 delta 编码,时间戳单调递增压得很好
// 另外在文件的 KV metadata 里写了 min_ts / max_ts / records / original_size 四个值

读过 Parquet 系列第三篇的读者看这几行应该很舒服,_timestamp 这一列是有序的整数,delta 编码加 bit packing 是它的最佳编码,其他列就交给 Parquet 默认的字典加 RLE 和 zstd。Parquet 自带的 bloom filter 默认没开(ZO_BLOOM_FILTER_PARQUET_ENABLED 是 false),OpenObserve 的 bloom filter 是另一套东西,后面索引那一节说。

文件写到 WAL 目录下的 files 子目录里,路径就是前面那棵树:

{ZO_DATA_WAL_DIR}/files/{org}/logs/{stream}/{桶编号}/2026/10/10/08/{schema_key}/[k=v/]{min_ts}.{max_ts}.{snowflake}_{memtable_id}.parquet

落盘的过程有一个很细致的五步原子操作,值得说一下:先把 Parquet 写成 .par 后缀的临时文件,再写一个 .lock 文件记录这批 .par 的列表,然后删掉对应的 .wal 文件,再把 .par 改名成 .parquet,最后删 .lock。进程在任何一步崩掉,重启时都能根据 .wal、.lock、.par 三种文件的存在情况判断进行到了哪一步,要么回放 WAL 重做,要么清掉孤儿文件。这是比较标准的做法。

上传到对象存储

到这一步数据已经是本地磁盘上的 Parquet 文件了,接下来还有一个任务每 2 秒(ZO_FILE_PUSH_INTERVAL)扫描这些文件,把它们上传到对象存储。这里并不是一个文件一个文件地传,而是先按"去掉桶编号之后的目录前缀"分组,也就是同一个 stream、同一个小时、同一版 schema、同一个分区目录下的所有小文件归为一组,然后:

  • 如果这一组的总大小还没到 512 MB,并且最老的文件还没放满 600 秒,先不传,继续攒
  • 攒够了就用 DataFusion 把这一组小文件合并成一个(SQL 就是 SELECT * FROM tbl ORDER BY _timestamp DESC,所以文件内部是按时间倒序的),上传
  • 如果 stream 开了全文检索(默认是开的),顺便为这个文件生成 tantivy 索引一起上传
  • 在元数据库的 file_list 表里登记这个文件
  • 删掉本地的小文件

对象存储上的 key 是这样的:

files/{org}/logs/{stream}/2026/10/10/08/[kubernetes_namespace=prod/]{snowflake}{4位随机hex}.parquet

和本地路径比,桶编号和 schema_key 两段没有了,文件名里也不再带 min_ts 和 max_ts,因为这些信息这时候已经进了 file_list 表。大家对比一下 SigNoz:SigNoz 靠 ClickHouse 的 ORDER BY (ts_bucket_start, resource_fingerprint, ...) 把同一个服务同一个半小时的数据排在一起,OpenObserve 这边同样的事情是用目录做的,一个 stream 一个目录,下面按小时分目录,再按你配的分区键分子目录。本质上都是在让"选一个 stream,选一个时间范围,再按某个字段过滤"这种最常见的查询少读数据。

每一组最多攒多久,由前面那两个数决定:600 秒内存表冻结,再加上最多 600 秒等待上传,一条日志从进来到出现在对象存储上最坏要二十分钟。所以查询不能只看对象存储,这个问题后面查询那一节会解决。

file_list:文件的目录

对象存储本身的 list 操作又慢又贵,OpenObserve 不靠它来找文件,而是在元数据库里维护了一张 file_list 表,每个 Parquet 文件一行:

CREATE TABLE file_list (
    id          BIGINT,          -- 自增主键,查询时节点之间传的是这个 id,不传长长的 key
    account     VARCHAR(128),    -- 哪个对象存储账号(支持配多个 bucket)
    org         VARCHAR(100),
    stream      VARCHAR(256),    -- "{org}/logs/{stream}"
    date        VARCHAR(16),     -- "2026/10/10/08",小时目录
    file        VARCHAR(1024),   -- 小时目录以下的部分,含分区目录和文件名
    deleted     BOOLEAN,
    flattened   BOOLEAN,         -- UDS 的 _all 列是否已经被后台展开过,不用关注
    min_ts      BIGINT,          -- 文件里最小和最大的 _timestamp
    max_ts      BIGINT,
    records     BIGINT,          -- 行数
    original_size   BIGINT,      -- 原始 JSON 大小
    compressed_size BIGINT,      -- parquet 文件大小
    index_size  BIGINT,          -- 对应的 tantivy 索引文件大小,0 表示没有索引
    mindex_size BIGINT,          -- metrics 索引大小,后面 metrics 一节会提一下
    bloom_ver   BIGINT,          -- bloom filter 版本,0 表示没有
    updated_at  BIGINT
) PARTITION BY RANGE (date);     -- PostgreSQL 按天分区,预建未来 7 天
-- 唯一索引 (stream, date, file),另有 (stream, max_ts, min_ts) 和 (stream, date) 两个索引

查询的时候就是一条很直白的 SQL:

SELECT id, records, original_size FROM file_list
WHERE stream = ? AND max_ts >= ? AND max_ts <= ? AND min_ts < ? AND date >= ? AND date < ?

这张表是整个系统的关键路径,集群里所有 ingester 上传完文件都要往里 INSERT,所有查询都要先来这里问一遍,所以集群模式要求必须用 PostgreSQL。原始大小和压缩后大小都记在这里,页面上 stream 列表显示的压缩比就是这两列算出来的。

Compactor

ingester 上传的文件也就几百 MB 的量级,而且一个小时内会有很多个(多个 ingester、多个桶、schema 变了、每 600 秒刷一次),文件一多查询就要开很多个连接去读,每个文件的 footer 也要单独读一遍。Compactor 的工作就是把这些小文件合并成大文件,合并的粒度是"一个 stream 的一个小时"。

它每 10 秒检查一次,某个小时结束后再过 30 分钟(3 倍的 ZO_MAX_FILE_RETENTION_TIME,等 ingester 把该小时的数据都传完),就为这个小时生成一个合并任务写进 file_list_jobs 表,集群里任何一个 compactor 都可以用 SELECT ... FOR UPDATE SKIP LOCKED 认领。执行的时候把这个小时目录下(同一个分区目录下)的文件按目标大小 2048 MB(ZO_COMPACT_MAX_FILE_SIZE)切批,每批下载下来用 DataFusion 重写成一个文件,上传,然后在一个事务里往 file_list 插新文件、删老文件,老文件的 key 记到 file_list_deleted 表里,120 分钟后才真正从对象存储删掉,给正在进行的查询留出时间。

数据过期也是 compactor 负责。每个 stream 可以配 data_retention 天数,不配就用全局的 ZO_COMPACT_DATA_RETENTION_DAYS(默认 3650 天,等于不删)。过期按天删,就是把那一天的 file_list 行标记 deleted,同样走延迟删除。注意和 SigNoz 比,SigNoz 的 TTL 是 ClickHouse 自己在后台做的,OpenObserve 这里是自己写的任务。

好了,一条日志从进来到变成对象存储上一个合并过的大 Parquet 文件,整条写入链路就走完了。大家可以回头看一眼总图,ingester 和 compactor 那两条线现在应该都清楚了。下面我们看查询。

查询

一条 SQL 的旅程

OpenObserve 的查询语言是 SQL(PostgreSQL 方言,用 sqlparser 解析),页面上点点选选最后也是拼成 SQL。一条 SELECT * FROM "default" WHERE level = 'ERROR' AND match_all('timeout') 发到 /api/{org}/_search 以后,流程是这样的:

leader querier(收到请求的那个节点)
  1. 解析 SQL,拿到 stream 名、WHERE 里的等值条件、有没有 match_all
  2. 用 stream 的最新 schema 注册一张"空表"(只有 schema 没有数据,leader 上不扫文件)
  3. 去 file_list 查时间范围内的文件 id 列表
  4. 按文件 id 做一致性哈希,把文件分给各个 querier
     (同一个文件总是落到同一个 querier,为了本地缓存命中)
  5. DataFusion 生成物理执行计划,在合适的位置切一刀,
     下半截序列化后用 Arrow Flight 发给每个 querier
  6. 收各节点流回来的 RecordBatch,做最终聚合或归并排序,转成 JSON 返回

follower querier(每个被分到文件的节点)
  1. 反序列化计划,把占位的空表换成真实的表
  2. 用文件 id 查出文件名和元信息(本地有一个 sqlite 缓存,没有再问 PostgreSQL)
  3. 按路径里的分区目录过滤文件
  4. bloom filter 裁一遍文件,tantivy 索引裁一遍文件和行
  5. 小文件(<= 1 MB)先下载进本地缓存再查,大文件直接对对象存储做 range 读,
     同时丢进后台下载队列,下次就有缓存了
  6. DataFusion 扫 Parquet,做局部聚合、局部排序,结果流回 leader

ingester(如果本节点同时也是 ingester)
  分到的文件列表是空的,但计划照样发过来,
  它把内存表、immutable、还没上传的本地 parquet 注册成表,一起 union 进来

几个点值得说。

第一是 leader 和 follower 的分工。leader 上注册的是一张只有 schema 没有数据的空表,DataFusion 照常生成计划,然后一个自定义的优化规则在计划树里找到 RepartitionExec、SortPreservingMergeExec 这类"汇聚点",在它们下面插一个 RemoteScanExec 节点,把下半截子树用 datafusion-proto 序列化。所以 partial aggregate 和本地的 sort 加 limit 是在 follower 上完成的,final aggregate 和多路归并在 leader 上完成,这和 Spark、Presto 这类分布式 SQL 引擎的思路一致,只是实现得很轻。顺便说一句,单机模式也走这条路,自己给自己发 Flight 请求,没有本地快捷路径。

第二是文件列表的来源。前面说过 file_list 表记了每个文件的 min_ts 和 max_ts,leader 查的就是前面那条 SQL,只拿 id,不拿文件名,分发时节点间传的也只是 id 列表。follower 拿到 id 以后用本地的 sqlite 缓存把 id 换回文件名和元信息,缺的再去 PostgreSQL 补。还有一个小优化:如果 SQL 是 count(*),而且某个文件完全落在时间范围内,那这个文件的行数直接用 file_list 里的 records 字段,不用打开文件。

第三是 DataFusion 怎么读 Parquet。OpenObserve 用的是 DataFusion 自带的 ListingTable,但它没有让 ListingTable 去 list 对象存储的目录,而是把文件列表注册到进程内的一个 map 里,用自定义的 memory:/// 这个 scheme 让 ListingTable 从 map 里取;读文件内容的时候先查本地磁盘缓存,没有再去对象存储按字节范围读。文件的 schema 可能和 stream 的最新 schema 不一样(老文件少几列、某列类型从 Int64 变成了 Utf8),这个由 DataFusion 的 schema adapter 在读的时候补 null 和 cast,OpenObserve 自己没有再写一套。另外,完全落在时间范围内的文件(min_ts >= start 且 max_ts < end)不加 _timestamp 过滤条件,只有边界上的文件才加,这是利用 file_list 元信息省掉一次列扫描。

最新数据在 ingester 上

前面算过,一条日志从进来到出现在对象存储上最坏要二十分钟,只查对象存储的话最近二十分钟就是空的。OpenObserve 的解法很直接:leader 挑节点的时候把 ingester 也算进去,分给它们的文件列表是空的,但计划照样发。ingester 收到计划以后,把自己的内存表、已冻结还没刷盘的 immutable、已经刷盘还没上传的本地 Parquet 文件全部注册成表(内存里的 RecordBatch 要对齐到最新 schema,缺列补 null),和对象存储那边的表 union 起来扫。已经刷盘但内存里还有的数据靠 memtable_id 去重,正在被查询的本地文件会加一个引用计数锁,防止上传任务中途把它删了。

这也解释了为什么 ingester 是有状态的,以及为什么 OpenObserve 没有像 SigNoz 那样建议在前面加一层 Kafka:数据在 ingester 本地就是可查的,不需要先落到别的地方再消费。

缓存

对象存储的延迟是几十毫秒级的,一次查询要读几百个文件,每个文件 footer 一次、几个 row group 几次,不缓存根本没法用。OpenObserve 有三层缓存:

  • 文件缓存:Parquet 文件和索引文件下载后放本地磁盘,默认开,容量默认是磁盘剩余空间的一半(上限 500 GB),淘汰策略默认是 time_lru,也就是优先淘汰数据时间最老的小时,很符合日志"越新越热"的访问模式。内存缓存默认关闭,开了以后下载直接进内存,淘汰时降级写回磁盘。前面说的"同一个文件总落到同一个 querier"就是为了这层缓存
  • 结果缓存:对有时间列的查询(明细列表按时间排序、带 histogram 的聚合),把最终结果 JSON 按时间段存到磁盘。再查一个重叠的时间范围时,只补查缓存没覆盖的两头,然后合并。最近 5 分钟(ZO_CACHE_DELAY_SECS)的数据不进缓存,因为还可能有数据在路上
  • 聚合缓存(streaming aggregation):对没有时间列的简单聚合,比如 SELECT level, count(*) FROM t GROUP BY level,缓存的不是最终结果,而是 DataFusion partial aggregate 的中间状态(Arrow IPC 文件),按时间窗口切片,下次查询把能复用的窗口的中间状态直接接进 final aggregate。这个比较巧,和结果缓存正好互补

页面上的柱状图是怎么长出来的

用过 OpenObserve 的读者会注意到,日志页的柱状图是一截一截出来的,而且最新的时间段最先出来。这是因为页面和服务端之间是 HTTP/2 的 SSE 流式接口(/_search_stream),服务端拿到查询后先做一次时间分区:根据 file_list 里这个时间范围的总原始大小,除以所有 querier 的核数乘一个基准速度(默认每核每秒 1024 MB),估算出要跑多少秒,再按每 5 秒一个分区把时间范围切开(最多 100 个分区),最前面还单独切一个 60 秒的小分区让首屏尽快返回。然后按时间倒序逐个分区跑 SQL,每个分区的结果立刻推给前端。

列表和柱状图是两条 SQL、两个请求:先发列表查询,列表返回后再发柱状图查询。柱状图的 SQL 是服务端从原查询的 WHERE 重新生成的:

SELECT histogram(_timestamp) AS zo_sql_key, count(*) AS zo_sql_num
FROM "default" WHERE {原查询的 WHERE}
GROUP BY zo_sql_key ORDER BY zo_sql_key DESC

histogram() 这个函数在逻辑优化阶段会被改写成 DataFusion 的 date_bin,间隔不指定的话根据时间跨度自动选,查 1 小时左右是 30 秒一个桶,查一两天是 1 小时一个桶,60 天以上是 1 天一个桶。如果 stream 里有 level、severity、status 这几个字段之一,还会多加一个 breakdown 维度做堆叠柱状图。

索引:tantivy 倒排索引和 bloom filter

SigNoz 那篇说过,ClickHouse 不是 Elasticsearch,日志关键字搜索靠 ngram bloom filter 跳数索引,命中的块还是要扫。Parquet 更没有倒排索引这种东西,OpenObserve 的做法是在 Parquet 文件旁边再放一个 tantivy 索引文件。tantivy 是 Rust 版的 Lucene,Quickwit 用的也是它,OpenObserve 用的是自己 fork 的版本。

索引什么时候建、长什么样

索引有两个生成时机:ingester 把一组本地小文件合并上传之前,以及 compactor 把小文件合并成大文件之后。注意 ingester 写本地 Parquet 的时候不建索引,所以还在 ingester 上的最新数据是没有倒排索引的,查它们的时候 match_all 退化成扫描。

索引文件以 Puffin 格式(Iceberg 定义的一种"blob 容器"格式)打包,后缀 .ttv,和 Parquet 文件放在同一个对象存储里,路径只是把 logs 换成了 index:

files/{org}/logs/{stream}/2026/10/10/08/xxx.parquet
files/{org}/index/{stream}_logs/2026/10/10/08/xxx.ttv       # 和上面一对一

file_list 里的 index_size 大于 0 就表示这个文件有索引。索引里一个 Parquet 行对应一个 doc,doc id 就是行号,所以查索引得到的 doc id 列表可以直接换算成"第几个 row group 的第几行",交给 DataFusion 的 ParquetAccessPlan 只读命中的行。

索引的 schema 分两种字段:

  • 全文字段:stream settings 里的 full_text_search_keys,加上默认的 log、message、msg、content、data、body、json、error 这几个。所有全文字段的值合并写进一个叫 _all 的 text 字段,用 OpenObserve 自己的分词器切词(按非字母数字切、转小写、拆驼峰,中日韩字符一个字一个 token,token 长度 2 到 64,超长的直接丢弃),索引里只记 doc id,不记词频和位置,所以不支持短语查询
  • 二级索引字段:stream settings 里的 index_fields,加上默认的 trace_id、service_name、operation_name。每个字段单独建一个 raw 字段,整个值作为一个 term,不分词、不转小写,用于等值、IN 和 str_match

match_all 是怎么执行的

SQL 里的 match_all('timeout') 本身是一个不能执行的占位 UDF。follower 在收到计划以后,有一个优化规则把 FilterExec 里能走索引的条件抽出来变成 IndexCondition,并且默认把这个条件从计划里删掉(ZO_FEATURE_QUERY_REMOVE_FILTER_WITH_INDEX),意思是"索引说命中了就是命中了,Parquet 层不再复核"。match_all 的参数会经过同一个分词器,多个词之间是 AND 关系,不看顺序;err* 是前缀查询,*err 是正则,err 是在词典上做子串匹配。所以 match_all 的语义是 token 级别的、大小写不敏感的匹配,不是 LIKE '%timeout%' 那种子串匹配,这一点和 ES 的 match 查询是一样的,和 SigNoz 的关键字搜索(本质是 LIKE)不一样,习惯了 SigNoz 的读者要注意。

还有一个回退机制:如果一个文件里命中的行超过 35%(ZO_INVERTED_INDEX_SKIP_THRESHOLD),走索引不如直接扫,这个文件就放弃索引、把过滤条件加回去全扫;放弃的文件数达到 CPU 核数时整个查询放弃索引。另外对于 count(*)、histogram、top N、distinct 这几种简单聚合,如果条件全部能走索引,结果直接从 tantivy 的 fast field 算出来,Parquet 文件根本不碰。

str_match(field, 'xxx') 默认是纯扫描的子串匹配,但如果 field 在 index_fields 里,就会被改写成在 raw 词典上做子串匹配,也走索引。re_match 永远是扫描。

bloom filter

OpenObserve 的 bloom filter 和你想的可能不一样,它默认不用 Parquet 内置的列级 bloom filter,而是由 compactor 在合并完一个小时以后,打开每个文件的 .ttv 索引,遍历指定字段的词典,把所有 term 塞进一个 split block bloom filter,每 256 个文件一组写成一个独立的 .bf 文件:

files/{org}/bloom/{stream}_logs/2026/10/10/08/{bloom_ver}.bf

查询时对 WHERE field = 'xxx' 这样的等值条件(也支持 IN 和同字段的 OR),先用 .bf 把肯定不含这个值的文件整个排除掉,然后才轮到 tantivy。粒度只到文件级,不到 row group 级。默认的 bloom 字段是 trace_id 和 session_id,正好对应"按 trace_id 找 span"和"按 session 找 RUM 事件"这两个点查场景。想加别的字段可以在 stream settings 的 bloom_filter_fields 里配,但该字段必须同时在 index_fields 里,因为 bloom 是从索引词典里来的。

到此,通用的存储和查询部分就讲完了。下面看 traces 和 metrics,它们都是往这套东西里写 stream,所以只讲各自的数据模型,也就是"一个 span 怎么变成一行"、"一个 metric 怎么变成一行"。

Traces

一个 span 怎么变成一行

OTLP 的 span 长什么样我在 SigNoz 那篇里详细讲过(trace_id、span_id、parent_span_id、name、kind、起止时间、status、attributes、events、links,外面还包着 resource 和 scope 两层),这里不重复了。OpenObserve 收到以后,把它转成这样一个 struct,再 flatten 成一行:

pub struct Span {
    pub trace_id: String,         // 32 位小写 hex
    pub span_id: String,          // 16 位 hex
    pub flags: u8,                // 写死的 1,源码注释是 TODO
    pub span_status: String,      // "OK" / "ERROR" / "UNSET"
    pub span_kind: String,        // 注意是 OTLP 枚举的数字字符串 "1".."5","2" 是 SERVER
    pub operation_name: String,   // 就是 span.name
    pub start_time: u64,          // 纳秒
    pub end_time: u64,            // 纳秒
    pub duration: u64,            // 微秒!(end - start) / 1000
    #[serde(flatten)]
    pub reference: HashMap<String, String>,       // 父子关系,见下
    pub service_name: String,     // 从 resource 的 service.name 来
    #[serde(flatten)]
    pub attributes: HashMap<String, json::Value>, // span attributes,平铺到这一层
    #[serde(flatten)]
    pub service: HashMap<String, json::Value>,    // resource attributes,平铺到这一层
    pub events: String,           // JSON 字符串
    pub links: String,            // JSON 字符串
}

三个 #[serde(flatten)] 是关键:父子关系、span attributes、resource attributes 全部被平铺到和固定字段同一层,所以 traces stream 的 schema 是"固定的十来个列,加上你所有 attribute 各自一列"。这里和 SigNoz 的差别很大,SigNoz 是把 attributes 按类型塞进三个 Map 列,常用的再提升为物化列;OpenObserve 是每个 attribute 直接就是一列,靠的是 Parquet 列存"不读的列不花钱"这个特性,以及前面说的 schema 自动演进。代价是前面提过的 1000 列上限对 traces 同样生效。

几个命名规则:

  • 父子关系没有 parent_span_id 这一列,而是三列 reference_parent_span_id、reference_parent_trace_id、reference_ref_type(值固定是 ChildOf),root span 这三列为空。这是 Jaeger 的叫法
  • resource attributes 统一加 service_ 前缀,比如 k8s.pod.name 变成 service_k8s_pod_name,deployment.environment 变成 service_deployment_environment
  • span attributes 不加前缀,http.method 变成 http_method,只有撞上保留字段名的时候才加 attr_ 前缀
  • 时间单位是混着的:start_time 和 end_time 是纳秒,duration 是微秒,_timestamp(取 start_time)也是微秒,写 SQL 的时候要小心

还有一点要特别提醒:OTLP 里的 int、double、bool 类型的属性,到了 traces 的行里全部变成字符串。http.response.status_code 是 500 这个整数,存下来是 "500"。这是转换代码里直接 to_string 导致的,所以 WHERE http_response_status_code > 499 这样的条件要先 cast。SigNoz 那边按类型分成了 attributes_string、attributes_number、attributes_bool 三个 Map,是保留了类型的。

events 和 links 和 SigNoz 一样,不做结构化,整体序列化成 JSON 字符串存在一列里。一个带异常的 span,events 列大概是这样:

[{"name":"exception","_timestamp":1728540000120000000,
  "exception.type":"java.net.SocketTimeoutException",
  "exception.message":"Read timed out",
  "exception.stacktrace":"java.net.SocketTimeoutException: Read timed out\n\tat ..."}]

注意 event 里 attribute 的 key 没有被规范化,还是带点的。SigNoz 会把 exception 事件单独抽出来写一张 error 表供 Exceptions 页面用,OpenObserve 没有这一步,异常就留在 events 字符串里,要找异常得靠 span_status = 'ERROR' 过滤再看 events。

默认写进名为 default 的 traces stream,想分开可以在 OTLP 请求头里带 stream-name。

怎么找一个 trace 的所有 span

trace 详情页的 SQL 是 SELECT * FROM "default" WHERE trace_id = '...' ORDER BY start_time, span_id。问题和 SigNoz 那篇说的一样:按 trace_id 点查,但文件是按时间组织的,不给时间范围就得扫全部文件。SigNoz 的解法是先查 trace_summary 物化视图拿起止时间;OpenObserve 的解法是在写 traces 的同时,往一个叫 trace_time_index_{stream} 的 metadata 类型 stream 里写一行 {trace_id, min_ts, max_ts}(每个 trace 每小时聚合成一行),查详情时先去这个小 stream 里 GROUP BY trace_id 拿到 min 和 max,再带着时间范围去大 stream 查。两边思路一模一样,一个是物化视图,一个是自己维护的辅助 stream。

另外 trace_id 这一列默认有 bloom filter(就是前面说的 .bf 文件),可以直接跳过不含这个 trace_id 的文件。trace 列表页则是一条 GROUP BY trace_id 的聚合 SQL,算出每个 trace 的起止时间、span 数、错误数、涉及的服务数。

没有 spanmetrics,service map 是定时跑 SQL

SigNoz 的 collector 里有 signozspanmetrics 处理器,每个 span 流过去的时候顺手累加 RED 指标,Services 页面的 RPS 和 P99 就从那里来。OpenObserve 的 traces 写入路径上没有这一步,它不在 ingest 时派生任何指标。

服务拓扑图(service map)是一个定时任务,隔一段时间对 traces stream 跑一条自连接 SQL,把 CLIENT 类型的 span 和它的 SERVER 类型子 span 按 trace_id 加 parent_span_id 配对,GROUP BY 两边的 service_name 得到调用关系和次数,再把结果写成 traces_service_graph_request_total 这一组 metrics(指标名和 Grafana Tempo 的 service graph 完全一致)。写入时只做了一件轻量的事:从 CLIENT span 的 db.system、messaging.system、server.address 这些属性推断出"未埋点的依赖",比如 MySQL、Kafka,写进 infer_service_name 之类的几列,供那条 SQL 配对用。不过要注意,这个 service graph 功能是企业版的,开源版的 topology 接口直接返回 403。

采样方面 OpenObserve 服务端完全没有,head sampling 和 tail sampling 都是 OTel SDK 和 Collector 的事。SigNoz 那篇讲的 tail sampling 其实也是 collector 做的,只是 SigNoz 自带了一个 collector 发行版,OpenObserve 没有,你得自己部署一个 OTel Collector 放在前面。

Metrics

一个 metric 一个 stream

metrics 是三种数据里 OpenObserve 和 SigNoz 差异最大的。SigNoz 把所有指标存进 samples_v4 和 time_series_v4 两张大表,靠 fingerprint 把 sample 和 label 分开。OpenObserve 是每个 metric name 一个 stream,http_requests_total 是一个 stream,process_cpu_seconds_total 是另一个 stream,一个 Prometheus 节点上报几百个指标就是几百个 stream,对象存储上就是几百个目录。

每个 stream 里一行就是一个 sample:

__name__      Utf8      指标名,等于 stream 名
_timestamp    Int64     微秒
value         Float64   值
__hash__      UInt64    这条时间序列的 id,见下
<label 1>     Utf8      每个 label 一列,值统一是字符串
<label 2>     Utf8
...

也就是说 label 直接作为列存在 sample 旁边,没有 SigNoz 那种 series 表和 sample 表的分离。听起来很浪费,同一条序列的 label 值要在每一行重复一遍?其实不然,Parquet 的字典编码加 RLE 对这种"一列里只有几十个不同值且连续重复"的数据压缩效果极好,几千行重复的 "GET" 压完可能就是几个字节。这正是列式存储的优势,Parquet 系列第三篇讲的就是这个。

hash 是把 name 和所有 label 按名字排序后拼成 "k:v|k:v" 字符串,算一个 gxhash 64 位哈希,在写入时算好存进去。它的角色和 SigNoz 的 fingerprint 完全一样,是一条时间序列的身份,PromQL 查询时按它分组。注意 OTLP 的 start_time、flag 这种每个点都可能变的字段被排除在哈希之外,否则进程重启一次就会分裂出一条新序列。

metric 的类型(counter、gauge、histogram)、help、unit 这些元数据不存成列,而是存在这个 stream 的 schema metadata 里,key 叫 prom_metadata。

各种类型怎么落

OTLP 和 Prometheus remote write 两条入口最终存的格式是一样的,这里按 OTLP 的类型说:

  • gauge 和 sum:一个数据点一行。sum 会多两列 aggregation_temporality 和 is_monotonic,原样记录
  • histogram:转成 Prometheus 的经典风格,一个数据点拆成多行,而且落到多个 stream:{name}_count、{name}_sum,每个桶一行写进 {name}_bucket 并带 le 标签,桶计数是累积的,最后一桶 le 是 "inf"。另外 OpenObserve 自己加了 {name}_min 和 {name}_max 两个 stream
  • exponential histogram:先把 scale 降到一个目标精度(默认 target scale 为 1,相邻桶边界比是 2 的 0.5 次方,约 1.41 倍),再退化成经典 histogram 的 _bucket 行。所以它不像 Prometheus 那样原生存 native histogram,是近似存法
  • summary:{name}_count、{name}_sum,加上原名带 quantile 标签的行

这里有一个坑要提醒:delta temporality 的 sum 只是被记录成了一列,没有做 delta 到 cumulative 的转换,PromQL 的 rate() 是按 cumulative counter 的语义算的,对 delta 数据算出来是错的。用 OTLP 上报 metrics 的话,要在 collector 里加 deltatocumulative 处理器,或者 SDK 配成 cumulative。

和 SigNoz 对照一下存储量:SigNoz 一个 20 桶的 histogram 数据点是 24 行 sample,OpenObserve 这里也差不多是 24 行,只是分散在 _bucket、_count、_sum、_min、_max 五个 stream 里。两边在这件事上没有谁更省。

PromQL 不是翻译成 SQL

大家可能会想,既然底下是 DataFusion,PromQL 是不是解析一下翻译成 SQL 就行了?不是。OpenObserve 是自己实现了一个 PromQL 执行引擎,用第三方的 promql_parser 解析成 AST,然后对 AST 递归求值,rate、increase、histogram_quantile、聚合、二元运算全部在这个引擎里用 Rust 对内存中的样本数组算。DataFusion 只负责最底下一层:拿到一个 vector selector,比如 http_requests_total{job="api"},引擎把它变成一次对同名 stream 的扫描,label matcher 变成 DataFusion 的 filter,只取 _timestamp、hash、value 三列,按 hash 分组成一条条序列交给上层。label 本身是单独按 hash 再加载一次的,有一层进程内缓存,sum(rate(...)) 这种最终丢掉全部 label 的查询干脆跳过加载。

rate 的实现是照着 Prometheus 来的:先处理 counter reset,再做两端的外推。histogram_quantile 把 _bucket 序列按去掉 le 之后的 label 分组,然后在桶内线性插值。这些和 Prometheus 的行为是对齐的,我之前写的 Prometheus 那篇讲的概念在这里都适用。集群模式下 PromQL 的分发也和 SQL 不一样,不是切计划,而是按时间切,leader 把 query_range 的时间范围分段发给各 querier,每个 querier 本地跑完整的 PromQL 引擎,leader 把矩阵拼起来。

另外有一个默认关闭的实验特性叫 metrics index(ZO_METRICS_INDEX_ENABLED):开启后 metrics 的 Parquet 文件内部改成按 (hash, _timestamp) 排序,并且在旁边生成一个 .midx 文件(就是 file_list 里 mindex_size 那一列对应的东西),里面按序列切块,每块记录 label 值、时间范围和 Gorilla 风格压缩的样本,PromQL 可以按 matcher 直接定位到块,不用下载整个 Parquet 文件。这明显是在往 Prometheus TSDB 的方向靠,说明"把 metrics 当普通表存"这条路在性能上是有瓶颈的,等它稳定了值得再看一眼。

总结

把上面的内容压缩成几句话:

  • OpenObserve 自己就是数据库:Arrow 做内存格式、Parquet 做文件格式、DataFusion 做执行引擎、tantivy 做倒排索引、object_store 对接对象存储,元数据放 SQLite 或 PostgreSQL,集群协调靠 NATS
  • 写入:任意 JSON 拍平成一层,schema 动态演进,每个字段一列,上限 1000 列。先写 WAL 和 Arrow 内存表,600 秒或 512 MB 冻结,刷成本地 Parquet,攒够再合并上传到 files/{org}/{type}/{stream}/年/月/日/时/ 目录下,并在 file_list 表登记 min_ts、max_ts 等元信息。compactor 以"stream 加小时"为单位把小文件合并到 2 GB,并负责过期删除
  • 查询:leader 从 file_list 挑文件 id,按 id 一致性哈希分给 querier,DataFusion 计划切成两半用 Arrow Flight 下发,follower 扫 Parquet 做局部聚合,ingester 把内存表和本地文件也 union 进来,所以最新数据可查。磁盘缓存 Parquet 文件,结果缓存和聚合缓存分别覆盖有时间列和无时间列的查询
  • 索引:Parquet 旁边一对一放 tantivy 索引文件,全文字段合并进 _all 分词索引供 match_all,index_fields 建 raw 索引供等值和 str_match,compactor 再从索引词典生成文件级的 .bf bloom filter。索引在上传时和合并时建,ingester 本地的最新数据没有索引
  • traces:一个 span 一行,固定字段加 attributes 全部平铺成列,属性值全是字符串,events 和 links 是 JSON 字符串。靠辅助 stream trace_time_index 解决按 trace_id 点查的时间范围问题。不派生指标,service map 是企业版的定时 SQL
  • metrics:一个 metric 一个 stream,label 直接是列,hash 是序列 id,histogram 展开成 _bucket、_sum、_count 等多个 stream。PromQL 是自研引擎,DataFusion 只负责按 hash 加载样本

数据链路说清楚了,最后和 SigNoz 那篇一样,我们来想想这套架构的压力点在哪里。

一是 schema 和列数。SigNoz 用 Map 列兜住任意属性,OpenObserve 是每个属性一列,1000 列上限摆在那里,日志里只要有人把动态 key 打进 JSON(比如以订单号做 key),这个 stream 很快就会撞墙,撞墙的表现是整批写入被拒。UDS 是解药,但要人去配。另外 schema 演进是单向的,字段类型一旦被放宽成 Utf8 就回不去了。

二是小文件和 file_list。对象存储上的文件数直接决定查询要发多少请求,OpenObserve 用"攒到 512 MB 或 600 秒再传"和 compactor 两级合并来控制,但 ingester 多、分区键基数高(比如按 pod 名分区)的时候文件数还是会很多,每个文件在 file_list 里一行,这张表的大小和查询速度就成了瓶颈。它要求集群用 PostgreSQL 并按天分区,以及提供了把历史 file_list 行 dump 成 Parquet 的开关,都是为这个问题准备的。

三是最新数据和 ingester 的状态。数据在 ingester 本地停留最多二十分钟才到对象存储,这期间可查但只有一份,而且默认不 fsync。ingester 进程挂了,重启后 WAL 能恢复,但机器没了这二十分钟就没了。官方的态度是接受这个窗口,对可靠性要求高的话要自己在前面加 Kafka,或者缩短 ZO_MAX_FILE_RETENTION_TIME,代价是更多小文件。

四是查询的冷启动。磁盘缓存没命中的时候,每个 Parquet 文件都要从对象存储读 footer 和 row group,大文件还是直接 range 读。第一次查一个长时间范围会明显慢,第二次才快。这是所有"计算存储分离"架构的共同特征,OpenObserve 用一致性哈希和 time_lru 尽量提高命中率,但本质上它的查询延迟下限比 SigNoz 的本地 ClickHouse 要高。另外开源版的集群模式下,查询排队用的是一把全集群的 NATS 锁,从源码看同一时刻只有一个 leader 级查询在执行(这是我读代码的推断,没有实际跑过验证),企业版才有细粒度的 work group。

五是 metrics。每个 metric 一个 stream,label 当列存,PromQL 每次 selector 都要扫 Parquet 把样本按 hash 聚起来,没有 Prometheus TSDB 那种按 series 组织的倒排。几千个指标就是几千个 stream 和几千个目录,高基数的 label 不会撞列数上限(因为基数在值里不在 key 里),但会让每个 stream 的文件变大、按 hash 分组变慢。那个实验性的 metrics index 就是在补这个课。我的感觉是 OpenObserve 的 metrics 能用,但如果 metrics 是你的主要负载,Prometheus 或 VictoriaMetrics 这类专门的 TSDB 还是更稳。

最后说点感受。SigNoz 和 OpenObserve 都站在 OpenTelemetry 的肩膀上,差别在存储:一个把存储交给 ClickHouse,一个用 Parquet 加 S3 自己做了一个。OpenObserve 这条路的好处很实在,单个二进制、数据放 S3 上几乎不要钱、冷数据不用管、扩容就是加无状态节点;代价是它自己要解决 LSM 写入、小文件合并、分布式查询、索引、缓存这一整套数据库才有的问题,每一块都比 ClickHouse 里对应的部分要年轻。读它的代码能明显感觉到这是一个还在快速长的项目,目录结构几个月一变,很多配置项带着实验性质。如果你的团队不想养 ClickHouse,日志量又大到 ES 吃不消,它很值得一试;如果你已经有 ClickHouse,SigNoz 那条路的确定性会更高。

(全文完)