当追踪记录突破每日 4000 万条:Opik 写入、存储与查询的调优路径
【免费下载链接】comet-llmDebug, evaluate, and monitor your LLM applications, RAG systems, and agentic workflows with comprehensive tracing, automated evaluations, and production-ready dashboards.项目地址: https://gitcode.com/GitHub_Trending/co/comet-llm
Opik 是面向 LLM 应用的开源可观测性与评估平台,核心能力是追踪、评估与生产监控。当追踪记录量涨到每日数千万条,性能瓶颈会沿数据链路逐层显形:写入先扛不住,存储和索引跟不上,查询变慢,告警随之变多。下面按写入、存储索引、查询分析、监控生命周期四层,逐层说清瓶颈在哪、对应怎么解。
📥 写入层:批量接口先扛住峰值
写入层最大的风险不是吞吐不够,而是把在线评分、线程更新这些重活压在写路径上,导致单条写入的延迟被放大。Opik 的解法是把写路径做"薄",把重活挪到事件之后异步执行。
批量接口POST /traces/batch一次收 1 到 1000 条 trace,先按id+last_updated_at去重,再用一条批量 SQL 落 ClickHouse,最后才发TracesCreated事件。整条链路用 Project Reactor 做非阻塞处理,落库走 ClickHouse 的async_insert(配合wait_for_async_insert和async_insert_deduplicate),把"逐条插入"变成"攒批异步刷盘"。
重活全部挂到事件总线上:线程状态更新、项目元数据、BI 上报各走各的监听器;在线评分由OnlineScoringSampler按规则里的sampling_rate(0–1)采样后丢进 Redis Stream,评分在流里慢慢做,不占写路径。限流是写入层的背压阀:workspace维度默认 5000 事件/60 秒、user维度 10000 事件/60 秒,超限直接挡在门外,避免某个项目突发流量把共享后端打挂。
写路径上还有一个容易忽略的点:projects.last_updated_trace_at这种"每条 trace 都要碰"的行,Opik 用 Redis 缓冲 + 定时(默认 30 秒)批量刷回 MySQL,而不是每条同步写,从而避开projects行的锁竞争。
🗄️ 存储与索引:分区、排序键和跳过索引决定上限
存储层的瓶颈在于 trace 是宽行、写入不可逆地累积,一旦分区和排序键没对齐热查询,任何全表扫描都会拖垮整库。Opik 的traces_local_v2布局就是围绕"放得下、查得快"设计的。
分区与排序键
表用ReplicatedReplacingMergeTree,按toMonday(id_at)周分区,id_at由每行的 UUIDv7id派生——同一行无论 upsert 多少次都落在同一周分区,时间范围查询可以直接跳分区。排序键定为(workspace_id, project_id, id),恰好对齐最高频的两类查询:按 workspace + project 过滤、按 id 点查。id留在键里不是白占内存,而是让点查能做 granule 级裁剪;键再短一点省下的内存索引很小,换来的却是实打实的点查开销。
跳过索引与 granule 调优
热字段上挂了 minmax 索引(id、id_at、created_at、last_updated_at)、bloom filter 索引(id、thread_id)和 set 索引(source、environment)。id同时挂 minmax 和 bloom 两种:minmax 服务id >= x AND id < y这类范围判断,bloom 服务id = x/id IN (...)这类精确匹配,二者互不替代。granule 调成index_granularity = 8192行、index_granularity_bytes ≈ 40 MiB,目的是让宽行能把 granule 填满到接近 8192 行,跳过索引的裁剪才真正生效。
编码与物化列
时间列用Delta + ZSTD(1),长度计数列用T64 + ZSTD(1),input/output/metadata这类大文本用ZSTD(3),其余走ZSTD(1)基线。end_time、ttft、duration改为非空,缺失值用纪元 / NaN 哨兵顶替,直接省掉热读路径上的 null 掩码开销。duration、input_length、truncated_output、output_keys等都做成MATERIALIZED,读的时候不再重算。表刻意不建SAMPLE BY——采样需要把哈希列塞进排序键,会破坏 id/时间排序这一主访问路径,而 Opik 的分析是按 workspace/project 精确查的,采样换不回这点成本。
🔍 查询分析:点查裁剪与只读隔离
查询层的瓶颈是"一条没边界的大查询能拖垮整个库",以及分析查询与在线点查抢资源。Opik 的思路是让点查尽量走裁剪,把重的分析查询隔离到受限账号。
点查命中(workspace_id, project_id, id)排序键后按 granule 裁剪,配合use_skip_indexes_if_final=1和do_not_merge_across_partitions_select_final=1,避免无谓地跨分区分片合并。Distributed 拓扑下再开optimize_skip_unused_shards=1,让查询只路由到真正有数据的分片。
分析侧单独建了一个只读的 free-form SQL 账号(Agent Insights 用),带受限 profile 和行级策略,max_execution_time上限 180 秒、socket 超时设 200 秒,让服务端先掐断长查询而不是让客户端等满超时。读接口同样限流:getTraces/searchTraces默认每 workspace 30 次/60 秒,单条查询体上限 100 MB。这套组合保证一个跑偏的聚合不会把点查的 p99 一起拖上去。
📡 监控告警与生命周期:TTL、冷热分层和健康检查
长期运行最怕的不是某次慢,而是数据无界增长 + 删除残留 + 配置悄悄跑偏,三者叠加后性能是缓慢失血的。这一层的任务是把"增长"和"漂移"都变成可观测、可自动收敛的事。
TTL 与冷热分层
冷数据按is_deleted走 ReplacingMergeTree 的删除元列,用墓碑行 upsert 后在后台合并时自然消掉,不占用在线查询路径。配合 TTL 和分层存储策略(冷数据下沉到cold_s3对象存储盘),热数据留在本地、冷数据出账,存储曲线才能长期可控。
健康检查与基准测试
健康检查里专门有clickhouse-traces-topology(校验 Distributed 包装与traces_local拓扑一致,不一致就摘出轮转)和clickhouse-cold-storage-disk(冷盘不可达就降级)。写入侧还有 UUIDv7 校验:可开关、可先只审计计数(opik.ingestion.uuid_v7.rejected)不拦截,让脏 id 先暴露出来。回归基线靠tests_load的负载套件:10 万 trace×1 span、25 万 span、以及约 1 GB 的重载荷场景,每周定时跑一遍,性能退化在进生产前就能被抓住。
🧭 落地顺序:先做什么,怎么验证效果
调优按投入产出从高到低排,先验证再扩量:
- 先把写路径做薄:确认 SDK 侧 flush 合并成批(单批 ≤1000)、后端
async_insert已开,在线评分走 Redis Stream 采样、不占写路径。 - 核对存储 DDL:用
system.tables确认线上是分区 + 排序键(workspace_id, project_id, id)+ 跳过索引齐备的版本,granule 是否按 8192 行 / ~40 MiB 配置。 - 抓慢查询:对最热的 workspace + project 点查和列表查做查询剖析,确认走主键 granule 裁剪和跳过索引,而不是全分区扫描。
- 接监控与生命周期:开 TTL + 冷热分层,挂拓扑 / 冷盘健康检查,跑一轮
tests_load基线留档。 - 设分级告警:写入排队、查询 p99、存储增长各设阈值,按严重度触发不同动作。
优化不是一次性交付:写入量每上一个量级,就要沿这条链路重新测一遍基线、调一次参数。
参考:批量写入流程 · traces 表结构迁移手册 · traces_local_v2 建表 DDL · 后端配置
【免费下载链接】comet-llmDebug, evaluate, and monitor your LLM applications, RAG systems, and agentic workflows with comprehensive tracing, automated evaluations, and production-ready dashboards.项目地址: https://gitcode.com/GitHub_Trending/co/comet-llm
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考