尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

从PostgreSQL到ClickHouse:LLM Trace数据存储架构优化实战

从PostgreSQL到ClickHouse:LLM Trace数据存储架构优化实战 1. 从一次深夜告警说起当LLM Trace数据开始“暴走”凌晨两点手机屏幕突然亮起一连串的告警通知像潮水一样涌来。我负责维护的一个LLM应用监控系统其底层的PostgreSQL数据库CPU使用率飙到了98%连接池几乎被占满几个核心的查询接口响应时间从平时的几十毫秒直接拉长到了十几秒。问题的根源很快被定位到Langfuse收集的LLM Trace数据量在当晚的一次大规模A/B测试中呈现了指数级的增长。每一个用户会话产生的不仅仅是一条简单的日志而是一棵包含数十个甚至上百个“Span”跨度的调用树——从用户输入、模型调用、工具执行到最终输出每一步都被详尽地记录了下来。当每秒有数千个这样的会话并发时产生的数据洪流瞬间冲垮了原本为结构化业务数据设计的PostgreSQL堤坝。这并非个例。随着大模型应用从Demo走向规模化生产可观测性Observability成为了刚需。开发者需要清晰地看到用户的每一次提问背后经过了哪些模型、调用了哪些工具、消耗了多少Token、耗时多久、花费多少成本。Langfuse这类工具的出现正是为了满足这一需求它通过标准的OpenTelemetry协议或SDK自动收集并结构化这些Trace数据。然而这份“清晰”的背后是对数据存储层的巨大考验。传统的OLTP联机事务处理数据库如PostgreSQL擅长高并发、强一致的事务处理但在面对海量、高吞吐的时序/事件数据写入以及需要实时聚合分析的查询场景时往往会显得力不从心。我的这次经历促使我深入对比了PostgreSQL与专为分析而生的ClickHouse在承载Langfuse这类LLM Trace数据时的表现差异。这不是一个简单的“孰优孰劣”的问题而是一个关于“如何为特定场景选择正确工具”的工程实践。本文将拆解LLM Trace的数据特点剖析PostgreSQL在此场景下的瓶颈并详细阐述如何利用ClickHouse的特性构建一个高性能、可扩展的存储方案最后分享我们在Langfuse生产环境中的迁移实践与踩坑实录。2. LLM Trace数据解剖为什么它成了数据库的“压力测试器”要理解存储选型的挑战首先必须看清我们面对的是什么数据。LLM Trace并非普通的应用日志它是一套具有鲜明特征的数据范式。2.1 数据结构深度嵌套的调用树一个典型的LLM Trace其核心是“Trace - Span”模型。一个Trace代表一次完整的请求链路如一次用户对话一个Span代表链路中的一个操作单元如调用GPT-4、查询知识库。它们之间是树形结构。在数据库中这通常需要至少两张表来存储traces表存储Trace级别的元信息如trace_id,user_id,project_id,start_time,end_time,total_tokens,total_cost等。spans表存储每个Span的详细信息通过trace_id与父表关联。字段可能包括span_id,parent_span_id,name(如llm,tool),model,input,output,metadata(JSON类型存储温度、top_p等参数),start_time,end_time,completion_tokens,prompt_tokens,cost等。关键在于一次简单的对话可能产生几十个Span而spans表的行数会是traces表的数十倍。更复杂的是input、output和metadata字段通常是JSON或Text类型存储着可能很长的文本和灵活的结构化数据。2.2 数据特征写多、读杂、量巨大极高的写入吞吐量生产环境中的LLM应用尤其是面向C端的产品每秒产生成千上万个Span是常态。这要求存储引擎具备极高的写入吞吐能力。复杂的分析型查询对Trace数据的查询很少是简单的SELECT * FROM spans WHERE id ?。更多的是分析型查询例如“过去一小时每个模型model的平均响应延迟和Token消耗是多少”按时间窗口和维度聚合“找出所有耗时超过10秒的Tool调用Span。”过滤与扫描“对比A/B测试两个版本下用户会话的平均交互轮次和总成本。”多维度关联与对比“统计今天成本最高的前10个用户。”排序与Top K数据冷热分明最近的数据如24小时内被查询的概率最高。随着时间推移历史数据主要用于归档和偶尔的长期趋势分析。数据只追加极少更新Trace数据一旦生成几乎不会被修改。偶尔的更新可能是给Span打上某些标签tag但这不是主要操作模式。当我们将这些特征映射到PostgreSQL上时瓶颈点就清晰可见了。3. PostgreSQL的“阿喀琉斯之踵”OLTP设计哲学与分析负载的冲突PostgreSQL是一位“全能战士”但在LLM Trace这种特定的分析型负载下其架构设计上的某些特点成为了性能瓶颈。3.1 写入瓶颈WAL、索引与表膨胀PostgreSQL为了保证ACID特性任何数据写入都必须先写入预写日志WAL这带来了额外的I/O开销。虽然对于高并发事务这是必要的保障但对于海量、无需立即强一致性的Trace数据写入这部分开销显得有些沉重。更棘手的是索引。为了加速查询我们通常会在spans表的trace_id、start_time、name等字段上创建索引。每次插入一行数据都需要更新对应的B-Tree索引。在海量写入场景下维护多个索引的成本极高会显著拖慢写入速度。如果你使用GIN索引来加速JSONB字段的查询其维护代价则更高。此外频繁的更新和删除虽然Trace数据更新少但可能有删除过期数据的需求会导致PostgreSQL的表膨胀——即表中存在大量“死元组”需要靠VACUUM来清理这又会带来额外的维护成本和性能波动。3.2 查询瓶颈全表扫描、聚合与Join当执行那些分析型查询时问题更加突出。聚合查询慢GROUP BY model, date_trunc(hour, start_time)这类操作PostgreSQL需要扫描大量数据行并在内存中进行哈希聚合。当数据量达到千万甚至亿级别时即使有索引聚合操作也可能非常缓慢并消耗大量内存。JSON查询效率低对metadata或input字段中的某个属性进行过滤如WHERE metadata-temperature 0.8即使有GIN索引其性能也远不如原生的结构化字段。深分页痛苦LIMIT 100 OFFSET 1000000这种深分页查询在PostgreSQL中需要先顺序扫描并跳过前100万行成本极高。Join开销大分析查询常常需要关联traces和spans表以获取完整视图在大数据量下的Hash Join或Nested Loop Join都可能成为性能杀手。3.3 资源利用效率PostgreSQL的存储模型以行为单位对于spans表中大量重复的字段如project_id,model无法做到高效的压缩。同时其执行查询时更倾向于使用单线程执行难以充分利用多核CPU的优势进行并行分析。实操心得PG的优化尝试与天花板在决定迁移前我们尝试了所有常规的PG优化手段按时间分区表、使用BRIN索引替代部分BTREE索引、调整work_mem等参数、甚至使用TimescaleDB基于PG的时序数据库扩展。这些措施确实带来了提升将系统的崩溃边缘向后推了推。例如按天分区使得清理旧数据变得容易BRIN索引对时序范围查询有帮助。但当我们面对持续增长的数据量和日益复杂的即席查询时性能的天花板依然清晰可见。维护成本如定期执行分区、清理、索引重建也在不断增加。我们意识到需要用更适合分析场景的武器来应对这场战争。4. ClickHouse的降维打击为分析而生的存储引擎ClickHouse从设计之初就是一个OLAP联机分析处理数据库它的每一个特性几乎都精准地命中了LLM Trace存储的需求痛点。4.1 列式存储分析查询的“加速器”这是ClickHouse与PostgreSQL最根本的区别。PostgreSQL按行存储读取一条记录需要加载该行所有列的数据。而ClickHouse按列存储将同一列的数据连续存放在一起。对于聚合查询当计算avg(duration)时只需要读取duration这一列的数据I/O效率极高。极高的压缩比同一列的数据类型相同重复值多如model字段只有有限的几种模型名称ClickHouse可以采用LZ4、ZSTD等算法获得极高的压缩比通常5-10倍显著降低存储成本和I/O压力。向量化执行CPU可以一次性处理一整列数据的多个值SIMD指令极大地提高了计算效率。4.2 预聚合引擎用空间换时间的魔法针对Trace数据中常见的、固定的聚合查询如每分钟的请求量、每秒的Token消耗ClickHouse提供了强大的物化视图和AggregatingMergeTree表引擎。 你可以创建一张物化视图实时地对流入的数据进行预聚合。例如预计算每分钟、每个项目的总请求数和总Token数。当查询这些聚合指标时直接查询这张很小的预聚合表即可速度极快避免了每次都对海量原始数据做全量扫描。这本质上是将计算成本从查询时转移到了写入时非常适合监控和仪表盘场景。4.3 稀疏索引与数据分区快速定位ClickHouse的主键索引是稀疏的。它不像PostgreSQL的B-Tree那样为每一行都建立索引项而是将数据划分为多个“数据块”Granule只为每个数据块记录一个索引标记。这使得索引非常小可以常驻内存。在进行范围查询时如WHERE start_time BETWEEN ... AND ...ClickHouse可以快速跳过不相关的数据块大大减少需要扫描的数据量。 结合分区键通常按日期数据在物理上被进一步组织使得按时间范围过滤数据的效率更高也便于管理数据生命周期TTL。4.4 多核并行与分布式ClickHouse的查询执行引擎天生为并行处理设计能充分利用所有CPU核心。更重要的是它原生支持分布式集群。当单机容量或性能不足时可以轻松地将数据分片Sharding到多个节点上查询也会被并行化执行实现线性扩展。5. 实战迁移将Langfuse数据从PG同步到ClickHouse理论很美好但落地过程充满细节。我们并没有完全抛弃PostgreSQL而是采用了混合架构PostgreSQL作为Langfuse的主元数据存储和实时查询接口处理最近几分钟的数据ClickHouse作为历史Trace数据的主分析存储。数据通过CDC变更数据捕获工具近乎实时地同步。5.1 表结构设计在ClickHouse中重塑在ClickHouse中建表需要充分考虑其特性。以下是一个简化的spans表设计示例CREATE TABLE langfuse.spans ( span_id String, trace_id String, parent_span_id String, name LowCardinality(String), -- 低基数优化 model LowCardinality(String), input String, output String, metadata String, -- 存储为JSON字符串或用JSONEachRow格式 start_time DateTime64(3, UTC), end_time DateTime64(3, UTC), duration_ms UInt32 MATERIALIZED (end_time - start_time) * 1000, -- 物化列 prompt_tokens UInt32, completion_tokens UInt32, total_tokens UInt32 MATERIALIZED prompt_tokens completion_tokens, cost Float64, project_id String, user_id String ) ENGINE MergeTree() -- 或 ReplicatedMergeTree 用于集群 PARTITION BY toYYYYMM(start_time) -- 按月分区 ORDER BY (project_id, start_time, trace_id) -- 排序键极大影响查询性能 SETTINGS index_granularity 8192; -- 索引粒度设计要点解析LowCardinality类型对于model、name这种枚举值较少的字段使用此类型可以大幅提升存储和查询效率。物化列duration_ms和total_tokens这类可以从其他列计算得出的字段定义为物化列。它不占用物理存储只在读取时计算或者可以在写入时计算并存储取决于定义方式。这里我们选择存储计算结果以加速查询。排序键ORDER BY子句是ClickHouse性能设计的灵魂。它决定了数据在磁盘上的物理排序顺序。我们的选择(project_id, start_time, trace_id)是基于最常见的查询模式按项目筛选并按时间范围查询。这样相同project_id和相近start_time的数据会排列在一起查询时能最小化数据扫描范围。分区键按start_time的月份分区便于管理数据生命周期使用TTL自动删除旧数据也利于分区裁剪。5.2 数据同步管道Debezium Kafka ClickHouse Sink我们使用了一套经典的CDC流水线Debezium作为PostgreSQL的CDC连接器捕获spans和traces表的INSERT操作几乎只有插入并将变更事件发送到Kafka。Apache Kafka作为可靠的消息队列缓冲变更事件解耦数据生产与消费。ClickHouse Sink Connector使用clickhouse-sink-connector或自行编写消费程序从Kafka消费数据并批量写入ClickHouse。这里的关键是批量写入ClickHouse擅长高吞吐的批量插入而非单条插入。5.3 查询路由与双写过渡在应用层面我们对查询进行了路由实时查询查询最近15分钟的数据直接走PostgreSQL保证低延迟。历史分析查询查询15分钟以前的数据以及所有的聚合分析查询统一路由到ClickHouse。 在迁移初期我们采用了双写策略应用同时写入PG和CH进行验证确保数据一致性。稳定后切为以CDC同步为主。6. 性能对比实测数字不会说谎迁移完成后我们进行了一次全面的性能对比测试。测试环境PG和CH均部署在同等配置的云主机上16核64GB内存SSD磁盘。测试数据约1亿条Span记录。测试场景PostgreSQL (带索引)ClickHouse性能提升倍数数据写入 (1000万条)~ 1200 秒~ 90 秒13x查询1: 按时间范围扫描SELECT count(*) FROM spans WHERE start_time 2024-01-01 AND project_idproj_abc~ 4.5 秒~ 0.8 秒5.6x查询2: 多维度聚合SELECT model, avg(duration_ms), sum(total_tokens) FROM spans WHERE start_time now() - INTERVAL 1 day GROUP BY model~ 22 秒~ 0.3 秒73x查询3: JSON字段过滤SELECT * FROM spans WHERE metadata-temperature 0.7 LIMIT 100~ 8 秒 (使用GIN索引)~ 1.2 秒 (将temperature提取为单独列)6.7x查询4: 深分页SELECT * FROM spans ORDER BY start_time DESC LIMIT 10 OFFSET 1000000~ 12 秒~ 0.05 秒240x存储空间占用约 420 GB约 47 GB (压缩后)压缩率 8.9x结果一目了然。ClickHouse在分析型查询上的优势是碾压性的尤其是在聚合和深分页场景。存储空间的节省也直接降低了云成本。7. 踩坑与避坑指南ClickHouse不是银弹虽然ClickHouse表现卓越但将其用于生产环境尤其是替代一个成熟的关系型数据库绝非简单的“一键切换”。以下是我们实践中遇到的主要挑战和解决方案。7.1 最终一致性与数据延迟CDC同步管道意味着数据从写入PostgreSQL到出现在ClickHouse中会有秒级甚至分钟级的延迟。这对于需要绝对实时性的监控告警可能是个问题。应对策略区分“实时”和“近实时”场景。关键的业务告警可以仍然基于PostgreSQL的实时数据。对于大多数分析和运营仪表盘分钟级延迟是可接受的。同时需要严密监控同步管道的延迟指标。7.2 查询模式的适应性ClickHouse对查询模式非常敏感。一个在PG上能跑即使慢的随意写的查询在CH上可能因为不符合排序键顺序而变成全表扫描甚至拖垮整个集群。避坑要点避免SELECT *始终只查询需要的列。谓词下推在WHERE和ORDER BY子句中尽量使用主键排序键的前缀字段。例如表按(A, B, C)排序查询条件用A和B就比单用C高效得多。谨慎使用JOINClickHouse的JOIN性能相对较弱尤其是大表关联。通常建议通过IN子查询或预聚合宽表来避免JOIN。使用物化视图预计算对于频繁的复杂聚合一定要设计物化视图。7.3 运维复杂度的提升ClickHouse集群的运维比单实例PostgreSQL复杂得多。需要关注ZooKeeper的协调如果使用ReplicatedMergeTree、分片与副本的数据均衡、后台合并Merge任务的状态、以及查询队列的管理。实操建议从小规模开始先使用单副本、无分片的MergeTree表引擎。在生产环境至少使用两个副本保证高可用。充分利用ClickHouse内置的系统表如system.query_log,system.parts进行监控。7.4 缺失的事务与点更新ClickHouse不支持事务也不擅长单行点更新和删除。虽然LLM Trace数据以追加为主但偶尔需要修正或删除错误数据的需求是存在的。解决方案对于少量数据的修正可以通过ALTER TABLE ... UPDATE/DELETE语句实现但这是异步操作会重写整个数据部分成本高。更优雅的做法是将“删除”标记为一种状态过滤或者在应用层逻辑中处理。另一种思路是将需要支持更新的“元数据”部分仍然放在PostgreSQL中。迁移到ClickHouse后我们的Langfuse监控系统再也没有因为数据量增长而出现性能危机。它稳定地承载着每天数十亿Span的写入和频繁的即席分析查询。成本方面由于极高的压缩比存储开销反而下降了超过60%。更重要的是数据分析师和产品经理现在可以自由地探索数据快速得到他们想要的洞察而不再需要担心一个查询会拖垮整个数据库。这次架构演进给我的核心体会是在云原生和数据密集型应用时代拥抱“多模数据库”或“异构数据架构”是一种必然。没有一种数据库能胜任所有场景。PostgreSQL依然是存储业务核心元数据、处理事务的基石而ClickHouse则是处理海量时序和分析数据的利器。正确的做法不是让它们互相替代而是让它们在统一的架构下各司其职通过可靠的数据管道连接起来共同构建一个既稳健又高性能的系统。对于所有正在或计划将LLM应用投入生产的团队来说提前规划好可观测性数据的存储架构和设计好应用本身同样重要。
返回列表