三处落库:ClickHouse 分析引擎 + S3 大对象 + Postgres 元数据
30 秒导读: 上一章(03)把队列消息加工成了一条结构化日志。 这一章讲这条日志最终怎么落地。答案是拆成三份写到三个地方:能过滤、能聚合的分析字段进 ClickHouse 的一张宽表;又大又不常查的请求/响应正文进 S3(自建部署是 Minio);必须事务、 必须强一致的少量元数据留在 Postgres。三路并发写,谁都不等谁。核心看点是那张 ClickHouse 主表
request_response_rmt的 schema 取舍——排序键、去重引擎、跳数索引、TTL——这些决定了 dashboard 为什么快。
1. 这一章讲什么:为什么一条日志要分三处
先给结论,再解释。一条 LLM 调用日志天生是"混合体":
| 数据成分 | 例子 | 特点 | 该用什么库 |
|---|---|---|---|
| 分析维度/指标 | org、provider、model、tokens、cost、延迟、properties | 小、要过滤、要聚合、写多读多 | 列式分析库(ClickHouse) |
| 请求/响应正文 | 几十 KB 到 几 MB 的 prompt 和 completion JSON | 大、只在"看单条详情"时才读 | 对象存储(S3 / Minio) |
| 强一致的账务元数据 | prompt 版本、组织 onboarding 状态 | 少、要事务、要 join | 关系库(Postgres / Supabase) |
为什么不能一处装下? 三种成分的读写模式互相打架:
- 把几 MB 的正文塞进 ClickHouse 主表,会拖慢每一次扫描聚合(哪怕这次查询根本不看正文)。
- 把 tokens、cost 这类要
GROUP BY provider SUM(cost)的字段放进 Postgres 行存,亿级行的聚合会跪。 - 把 prompt 版本这种要事务 join 的东西放进 ClickHouse,它没有真正的事务和外键。
所以 Helicone 的选择是按访问模式分家:让每种数据去它最擅长的引擎。代价是写入端要做一次"分拣",
这就是本章主角 LoggingHandler 的活。
本章聚焦存储引擎与 schema 决策,不重复 03 章讲的责任链编排; sessions / scores 的物化视图放在 05 章。
2. 顶层全景:一条日志的三向落库
LoggingHandler 是责任链的最后一环。它先把 HandlerContext 映射成三套目标结构,再三路并发写出去。
怎么读下面这张图:从上往下是 时间顺序,底部三个框是并发发生的(Promise.all),不是先后。
一条已加工好的日志 (HandlerContext)
│
▼
┌────────────────────────────┐
│ LoggingHandler.handle() │ ← 先做映射,攒进 batchPayload
│ · mapRequest / mapResponse│
│ · mapRequestResponseCH │
│ · mapS3Records │
│ · 决定 storageLocation │ ← 存哪、正文放哪,这里定
└────────────┬───────────────┘
│ handleResults() → Promise.all([...])
┌────────────┼────────────────────────────┐
▼ ▼ ▼
┌────────────┐ ┌──────────────────┐ ┌────────────────────┐
│ Postgres │ │ S3 / Minio │ │ ClickHouse │
│ LogStore │ │ uploadToS3() │ │ logToClickhouse() │
│ │ │ │ │ │
│ prompt 输入 │ │ 超大请求/响应正文 │ │ request_response_ │
│ org 状态 │ │ (>10MB 时) │ │ rmt 宽表 + 指标 │
└────────────┘ └──────────────────┘ └────────────────────┘
强一致元数据 冷的大对象 热的分析数据
三个落库出口的一句话职责:
| 出口 | 代码 | 落到哪 | 装什么 |
|---|---|---|---|
insertLogBatch | stores/LogStore.ts | Postgres | prompt 输入、组织 onboarding 标记 |
uploadToS3 | LoggingHandler.uploadToS3 | S3/Minio | 请求+响应正文(仅超阈值时) |
logToClickhouse | LoggingHandler.logToClickhouse | ClickHouse | request_response_rmt 主表 + 缓存指标 |
三路的发起在一处,file:line 直接看:
const [pgResult, s3Result, chResult] = await Promise.all([
this.logStore.insertLogBatch(this.batchPayload),
this.uploadToS3(),
this.logToClickhouse(),
]);
—— valhalla/jawn/src/lib/handlers/LoggingHandler.ts:292-296,handleResults()。三路任一出错就整体返回对应错误(pgError/s3Error/chError),交给上游决定重试还是进死信。