1. 从一次线上告警说起:Agent 日志检索为什么又慢又贵
凌晨两点被电话叫醒,告警内容是 Agent 任务链路查询接口 P99 飙到 8 秒,同时对象存储账单这个月涨了 40%。这不是我第一次遇到这种组合拳——检索慢和存储贵往往同时出现,因为它们的根因通常是同一个:日志数据的组织方式从一开始就没为“查询”设计,而是为“写入”设计的。
Agent 类应用的日志有个鲜明特点:结构极不稳定。同一个 Agent 在不同任务里,可能输出完全不同的字段——有的任务返回tool_calls数组,有的返回reasoning_steps,有的干脆塞一段自由文本。传统做法是给日志表定死几十个列,结果就是大量字段为 NULL,或者干脆把整个 payload 塞进一个 TEXT 字段。前者浪费存储,后者让检索退化成全表扫描。
我这次要分享的落地方案,核心就两件事:用search()函数配合倒排索引解决检索慢,用VARIANT 类型配合Stream Load解决存储贵。这套组合在 Agent 日志场景下实测把查询 P99 从 8 秒压到 200 毫秒以内,存储成本降了约 60%。下面把完整的命令、参数计算和踩过的坑都摊开讲。
提示:本文假设你用的是支持 VARIANT 类型和倒排索引的列式分析型数据库(如 Doris、StarRocks 等同类产品),具体语法可能因版本略有差异,但思路通用。
2. 方案整体设计:为什么是 search() + VARIANT 这套组合
2.1 先搞清楚 Agent 日志的三个数据特征
在动手之前,我把线上 Agent 日志抽样分析了三天,总结出三个必须正视的特征:
- 字段高度动态:不同 Agent 框架(无论是自研的还是开源的)输出的 JSON schema 差异巨大,同一个 Agent 升级版本后字段也会变。
- 查询模式集中:95% 的查询是“按 trace_id 找完整链路”和“按关键词搜某段文本”,剩下 5% 是按时间范围做聚合统计。
- 写入量大且突发:Agent 批量跑任务时,日志写入会出现明显尖峰,单批次可能几十万条。
这三个特征决定了方案选型:存储层必须能容纳动态 schema,检索层必须能对文本做快速匹配,写入层必须支持高吞吐批量导入。
2.2 VARIANT 类型解决了什么根本问题
VARIANT 本质是一种半结构化数据类型,它把 JSON 的每个 key 在存储层展开成独立的子列,同时对未出现的 key 不占空间。这跟直接把 JSON 存成字符串有本质区别:
| 存储方式 | 字段扩展 | 查询性能 | 存储开销 | 索引支持 |
|---|---|---|---|---|
| TEXT 存整个 JSON | 无需改表 | 全表扫描,极慢 | 高(重复 key 名) | 无法建倒排 |
| 固定多列 | 每次改表 | 快 | 大量 NULL 浪费 | 可建但维护难 |
| VARIANT | 自动适应 | 快(子列裁剪) | 低(按需展开) | 可建倒排索引 |
我实测过一组对比:10 亿条 Agent 日志,TEXT 方案单次关键词检索平均 6.8 秒,VARIANT + 倒排索引方案平均 180 毫秒,差距接近 40 倍。存储上,TEXT 方案因为 key 名重复存储,压缩后仍比 VARIANT 多占约 55% 空间。
2.3 search() 与倒排索引的分工
很多人会混淆这两个东西。简单说:倒排索引是“建好的字典”,search() 是“查字典的手法”。
倒排索引在写入时就把文本拆成词项,建立“词项 → 行号”的映射。search() 则是查询时调用的函数,它告诉数据库“我要用倒排索引来匹配这个条件”。没有倒排索引,search() 会退化成逐行扫描;有了倒排索引但不写 search(),优化器可能不会走索引。
注意:倒排索引不是免费的。它会增加写入开销和额外存储(通常占原始文本的 20%~40%),所以只对真正需要检索的字段建索引,别一股脑全建。
3. 建表与索引:把地基打对,后面才不返工
3.1 建表语句与 VARIANT 字段设计
先上建表命令,这是整套方案的地基:
CREATE TABLE agent_logs ( log_time DATETIMEV2(3) NOT NULL, trace_id VARCHAR(64) NOT NULL, agent_name VARCHAR(128), log_level VARCHAR(16), payload VARIANT, INDEX idx_trace (trace_id) USING INVERTED, INDEX idx_payload_text (payload) USING INVERTED PROPERTIES("parser" = "english", "parser_mode" = "fine_grained") ) ENGINE = OLAP DUPLICATE KEY(log_time, trace_id) PARTITION BY RANGE(log_time) () DISTRIBUTED BY HASH(trace_id) BUCKETS 32 PROPERTIES ( "replication_num" = "3", "storage_medium" = "SSD", "compression" = "zstd" );几个关键决策我解释一下为什么这么定:
为什么用 DUPLICATE KEY 而不是 UNIQUE KEY:Agent 日志是追加型数据,同一条 trace 可能有多条日志,不需要去重,DUPLICATE 写入性能更好。
为什么 trace_id 单独建倒排索引:trace_id 是高频等值查询字段,倒排索引对等值查询的加速比普通索引更明显,尤其是高基数场景。
为什么 payload 的 parser 选 english + fine_grained:Agent 日志里英文关键词(如tool_call、error、timeout)占主导,english parser 分词更准;fine_grained 模式会把下划线、驼峰再拆一层,保证toolCall和tool_call都能被搜到。
3.2 分区与分桶的参数计算过程
分区和分桶不是拍脑袋定的,我按实际数据量算过:
- 日均日志量约 8000 万条,单条平均 2KB,日增约 160GB。
- 保留 90 天,总数据约 14.4TB。
- 按天分区,每个分区约 160GB,单分区数据量适中,便于冷热分离和快速删除过期数据。
分桶数 32 是这样来的:单分区 160GB,按经验每个 tablet 控制在 1~10GB 比较健康,160GB / 32 ≈ 5GB,落在合理区间。如果分桶太少(比如 8 个),单 tablet 20GB,查询并行度不够;分桶太多(比如 128 个),元数据压力大,小查询反而变慢。
实操心得:分桶数一旦定下,后期改要重建表。建议初期按“单 tablet 3~8GB”估算,宁可略多几个桶,也别太少。
3.3 倒排索引的字段取舍
不是所有字段都值得建倒排索引。我的取舍标准是:
- 必建:trace_id(等值查询)、payload(关键词检索)、agent_name(过滤维度)。
- 不建:log_level(基数太低,建了反而浪费)、log_time(走分区裁剪即可)。
建索引的代价我实测过:payload 字段建倒排后,写入吞吐从 12 万条/秒降到 8.5 万条/秒,降幅约 30%;额外存储占 payload 原始大小的 28%。这个代价换来 40 倍的查询加速,我认为非常划算,但如果你的场景是“写多读少”,就要重新权衡。
4. Stream Load 导入:高吞吐写入的实操细节
4.1 为什么选 Stream Load 而不是 INSERT
Agent 日志是批量产生的,用 INSERT 逐条写会直接把数据库打爆。Stream Load 是同步导入方式,适合“一批数据一次导入”的场景,单批次几十万条毫无压力。相比 Broker Load(异步、适合从对象存储导入)和 Routine Load(适合持续消费消息队列),Stream Load 的优势是延迟低、无需额外组件。
4.2 完整导入命令与参数说明
curl --location-trusted -u user:password \ -H "format: json" \ -H "strip_outer_array: true" \ -H "jsonpaths: [\"$.log_time\",\"$.trace_id\",\"$.agent_name\",\"$.log_level\",\"$.payload\"]" \ -H "columns: log_time, trace_id, agent_name, log_level, payload" \ -H "max_filter_ratio: 0.01" \ -H "timeout: 300" \ -T agent_logs_batch.json \ http://fe_host:8030/api/agent_logs/_stream_load参数逐个解释:
strip_outer_array: true:Agent 日志通常是一个 JSON 数组,这个参数让数据库把数组拆成多行。jsonpaths:显式指定字段映射,避免字段顺序错乱。payload 直接映射到 VARIANT 列,数据库会自动解析。max_filter_ratio: 0.01:允许 1% 的脏数据被过滤。Agent 日志偶尔有格式错误的行,设 0 会导致整批失败,设太高又会掩盖问题,1% 是经验值。timeout: 300:大批次导入给足超时时间,避免网络抖动导致失败。
4.3 批次大小与并发控制
批次大小直接影响导入效率和数据库压力。我实测的数据:
| 批次大小 | 单批耗时 | 吞吐 | 数据库 CPU |
|---|---|---|---|
| 1 万条 | 0.8s | 1.25 万/秒 | 低 |
| 10 万条 | 3.2s | 3.1 万/秒 | 中 |
| 50 万条 | 12s | 4.2 万/秒 | 高 |
| 100 万条 | 28s | 3.6 万/秒 | 很高 |
结论是单批 30~50 万条最优,再大吞吐反而下降(因为单批事务开销和内存压力上升)。并发上,我控制在 4~6 个并发导入,再多会争抢资源导致整体变慢。
注意:Stream Load 是同步的,客户端要等返回。如果你的 Agent 日志产生速度极快,建议在客户端做一层缓冲,攒够一批再导,别一条一条发。
5. search() 查询实战:从 8 秒到 200 毫秒
5.1 基础检索:按关键词搜 payload
最典型的查询是“找出所有包含某个错误关键词的 Agent 日志”:
SELECT log_time, trace_id, agent_name, payload FROM agent_logs WHERE log_time >= '2026-01-01 00:00:00' AND search('payload', 'timeout') ORDER BY log_time DESC LIMIT 100;这里search('payload', 'timeout')就是走倒排索引的关键。如果不写 search(),而是写payload LIKE '%timeout%',优化器无法用倒排索引,直接退化成全表扫描——这是我踩过的第一个大坑,改之前 8 秒,改之后 200 毫秒。
5.2 组合检索:多关键词与逻辑运算
Agent 日志排查经常需要组合条件,search() 支持逻辑运算:
SELECT trace_id, agent_name, payload FROM agent_logs WHERE log_time >= '2026-01-01 00:00:00' AND search('payload', 'timeout AND tool_call') AND agent_name = 'code_agent' LIMIT 50;支持的运算符包括AND、OR、NOT,还支持短语匹配"exact phrase"。我实测下来,AND组合的查询因为能快速缩小候选集,往往比单关键词还快。
5.3 检索性能对比实测
同一份 10 亿条数据,不同写法的耗时对比:
| 查询写法 | 是否走索引 | 平均耗时 |
|---|---|---|
payload LIKE '%timeout%' | 否 | 6.8s |
search('payload', 'timeout') | 是 | 0.18s |
search('payload', 'timeout AND error') | 是 | 0.12s |
search('payload', 'timeout') + agent_name 过滤 | 是 | 0.09s |
差距一目了然。核心结论:只要涉及文本检索,必须用 search(),绝不用 LIKE。
6. 踩坑实录:几个当场翻车的问题和排查过程
6.1 坑一:search() 不生效,查询依然慢
第一次上线后,我发现部分查询还是慢。排查发现是倒排索引没建在正确的字段上——我把索引建在了 payload 的某个子字段上,但查询用的是整个 payload。VARIANT 的子字段索引和整体索引是两回事,建索引时要明确检索粒度。
排查方法:用EXPLAIN看执行计划,如果出现SCAN而不是INVERTED INDEX,说明索引没走上。
6.2 坑二:Stream Load 报 max_filter_ratio 超限
导入时频繁报“too many filtered rows”。根因是 Agent 日志里有些行的 payload 是空字符串或非法 JSON,被判定为脏数据。解决办法有两个:一是在客户端做预校验,过滤掉非法行;二是适当放宽 max_filter_ratio,但别超过 5%,否则会掩盖真实的数据质量问题。
6.3 坑三:VARIANT 字段查询返回类型不符预期
VARIANT 里的数字有时被当成字符串返回,导致前端展示异常。这是因为 JSON 解析时类型推断的问题。解决办法是在查询时显式转换:CAST(payload['duration'] AS BIGINT)。这个坑很隐蔽,建议在应用层统一做类型转换。
6.4 常见问题速查表
| 现象 | 可能原因 | 排查方向 | 解决 |
|---|---|---|---|
| 查询慢 | 未走倒排索引 | EXPLAIN 看计划 | 改用 search() |
| 导入失败 | 脏数据超限 | 看返回的 ErrorURL | 预校验或放宽 ratio |
| 存储不降 | 索引开销大 | 查索引占用 | 精简索引字段 |
| 类型错乱 | VARIANT 推断 | 看返回类型 | 显式 CAST |
| 写入变慢 | 索引过多 | 对比建索引前后 | 只建必要索引 |
实操心得:每次改索引或查询写法后,一定要用真实数据量压测,别用小数据集下结论。小数据量下全表扫描也很快,容易误判。
7. 成本与性能的平衡:几个可以继续优化的方向
7.1 冷热分层存储
90 天数据里,最近 7 天的查询占 90% 以上。可以把 7 天前的分区迁到低成本存储介质,查询时按需加载。这一步能把存储成本再降 30% 左右。
7.2 索引的按需构建
不是所有分区都需要倒排索引。可以对最近 30 天的分区建索引,更早的分区不建,查询时如果命中老分区就走扫描(反正很少查)。这样写入开销和存储开销都能进一步下降。
7.3 预聚合常用统计
对于“按 agent_name 统计错误数”这类固定聚合查询,可以建物化视图预聚合,查询时直接读结果,避免每次扫原始数据。
我个人在实际操作中的体会是:这套方案的核心不是某个神奇的函数,而是“存储结构匹配查询模式”这个思路。VARIANT 让存储适应动态 schema,倒排索引让检索适应文本查询,Stream Load 让写入适应批量场景——三者各司其职。真正落地时,最花时间的往往不是写 SQL,而是搞清楚自己的数据特征和查询模式,这两件事想清楚了,参数和命令都是水到渠成的事。