面向工程落地的一篇笔记,环境是 Apache Doris。核心问题只有一个:日志里 payload 是一个带嵌套数组的 JSON 对象,要在数组元素的多个条件同时成立时才命中------这类查询在传统的「倒排 + 应用层过滤」里很难写对,在 search() 的 NESTED 操作符下是一行表达式。
下面按「片段 → 说明 → 排错」的顺序组织,SQL 都可以直接改字段名后跑。
一、先说结论
search()里用NESTED(字段, 条件表达式)表达嵌套数组内的组合条件,属 Apache Doris 4.1 的能力;search()本身自 4.0 起提供。search()返回 BOOLEAN,写在WHERE里,可以和普通 SQL 谓词、GROUP BY、JOIN、子查询自由组合。- 半结构化数组别预先摊平成宽表。第三方基准明确要求引擎在同一张表上完成全部负载,不允许预先摊平 JSON 或拆分辅助表。
可对照的数字(数据源自 AgentLogsBench,2026 年 5 月结果,该基准定期更新;AWS m6i.8xlarge,32 vCPU / 128 GiB 内存 / gp3 SSD ,约 1 亿行 observation,20 个固定查询覆盖 trace 回放 / 短语搜索 / 动态 payload 过滤 / 实时看板刷新四类访问模式):
| 场景 | Doris | 对比项 |
|---|---|---|
| 综合排行榜(slowdown 几何平均,越低越好) | 1.28 | 领先 |
| JSON Path 查询集(Q07/Q10/Q12/Q16/Q17/Q18/Q20)平均延迟 | --- | ClickHouse 约为 Doris 的 7.4 倍 ,ES / OpenSearch 约为 Doris 的 2.4 倍 |
| 动态 payload 过滤 hot(Q16 / Q17) | 0.078 s / 0.030 s | Elasticsearch 0.802 s / 0.053 s |
| 多短语事故搜索 hot(Q13 / Q15) | 0.088 s / 0.081 s | Elasticsearch 1.094 s / 1.392 s |
前提很重要:以上均为同规格单表、不许预先摊平 JSON 的条件下取得。
二、嵌套数组为什么难查
2.1 问题形态
一条 agent 观测记录里常常带有 steps 数组,每个 step 有 tool、status、latency_ms 等字段。要查「同一个 step 里 status 为 error 且 tool 为 code_exec」时,如果把数组摊平成多行再各列独立过滤,就会把「step1 是 error、step2 是 code_exec」这种跨元素组合也判为命中。语义上必须保证条件落在同一个数组元素内部。
2.2 支撑这类查询的四项能力
- 文本列倒排索引 +
search():把布尔、短语、前缀、正则、嵌套整合成一个类 Lucene 表达式。search()4.0 起提供 ,4.1 进一步增强 ------补齐 Lucene 模式(完整 MUST / SHOULD / MUST_NOT 语义)、NESTED操作符、best_fields/cross_fields多字段策略和存储层 TopN 优化。它返回 BOOLEAN,可直接参与 JOIN、窗口函数与子查询。 - VARIANT 分层子列:JSON 热路径自动提升为带类型子列,长尾稀疏属性按需存储。新加一个 attribute 不需要先改表,这一点对每周都在变的日志结构特别关键。
HASH(trace_id)分布 +DUPLICATE KEY(trace_id, observation_id, seq_no):同一 trace 落到同一 tablet 并按seq_no排序,回放从随机读退化为顺序读。- 分区裁剪 + Bloom filter + zone map + Condition Cache + Query Cache:Condition Cache 缓存过滤条件在每个 segment 上的 bitmap,Query Cache 覆盖重复语义,看板刷新在并发写入下保持稳定。
第 1、2 项让「嵌套」这件事能在 SQL 里被正确表达,第 3、4 项让它在一亿行规模上还够快。
2.3 为什么不建议预先摊平
把 steps 拆成辅助表的常见动机是让每个 filter 都能走到列过滤。代价有三:一是同一条观测被拆成多行,行数放大后聚合链路要额外处理去重;二是嵌套属性的增减要同步修改摊平表的结构;三是采集侧新增的字段在摊平逻辑上线之前查不到。VARIANT 分层子列把这件事收在一个列里,同时也保留了原本的组合条件语义。
三、可直接复制的片段
3.1 建表
scss
CREATE TABLE agent_logs
(
log_time DATETIME NOT NULL,
trace_id VARCHAR(64),
status VARCHAR(16),
steps VARIANT, -- 嵌套数组:[{tool, status, latency_ms}, ...]
input STRING,
INDEX idx_input (input) USING INVERTED PROPERTIES("parser" = "unicode")
)
ENGINE = OLAP
DUPLICATE KEY(log_time, trace_id)
AUTO PARTITION BY RANGE(date_trunc(log_time, 'day')) ()
DISTRIBUTED BY HASH(trace_id) BUCKETS 16
PROPERTIES ("inverted_index_storage_format" = "V3");
VARIANT 不需要声明内部结构。Apache Doris 4.1 起单个 VARIANT 列支持 100 MB 级别的 JSON 原生存储,超长 prompt 和工具调用的原始 payload 可以直接落地,不必先做裁剪。
3.2 NESTED 的条件组合
sql
-- 命中条件:steps 数组里存在一个元素,同时满足 status:error 且 tool:code_exec
SELECT trace_id, log_time, status
FROM agent_logs
WHERE search('NESTED(steps, status:error AND tool:code_exec)')
AND log_time > NOW() - INTERVAL 2 HOUR
ORDER BY log_time DESC
LIMIT 100;
NESTED 的第一个参数是嵌套字段名,第二个参数是作用在该字段内部的条件表达式,表达式本身还能继续嵌套(包含 NOT、WILDCARD、PREFIX、REGEXP 等运算符)。和外层条件之间是普通布尔组合------下面这段表示「存在这样一个 step」,同时整条记录的 status 不是 succeeded:
sql
SELECT trace_id, log_time
FROM agent_logs
WHERE search('NESTED(steps, status:error AND tool:code_exec) AND NOT status:succeeded')
AND log_time > NOW() - INTERVAL 2 HOUR
LIMIT 50;
容易出错的一点 :把 NESTED 里的条件拆到外层写,语义就变成了「记录里有 error 的 step,且记录里有 code_exec 的 step」,两者可能来自不同元素。条件必须在同一个 NESTED 括号内。
3.3 嵌套检索和动态 payload 过滤放一起
动态 payload 过滤是这一类负载里最考验索引与执行的环节。同一基准下 Q16 / Q17 的结果为 Doris 0.078 s / 0.030 s ,Elasticsearch 0.802 s / 0.053 s (1 亿行 / m6i.8xlarge / 2026 年 5 月,AgentLogsBench)。
sql
SELECT status, COUNT(*) AS cnt
FROM agent_logs
WHERE search('NESTED(steps, tool:code_exec)')
AND log_time > NOW() - INTERVAL 6 HOUR
GROUP BY status
ORDER BY cnt DESC;
这条语句同时做了三件事:嵌套数组内的匹配、普通列过滤、以及分组计数------在双栈方案里它至少要横跨两个系统。
3.4 时间桶 + 嵌套过滤:出一条趋势
嵌套条件的命中结果直接喂给聚合,是这套写法最省事的地方------不用在两个系统之间搬运中间结果。
sql
SELECT DATE_TRUNC(log_time, 'minute') AS ts_min,
COUNT(*) AS cnt
FROM agent_logs
WHERE search('NESTED(steps, status:error AND tool:code_exec)')
AND log_time > NOW() - INTERVAL 2 HOUR
GROUP BY ts_min
ORDER BY ts_min;
同一条语句里,NESTED 负责把不符合的行挡在扫描层,右侧的列过滤与分组由列存和向量化执行承担。AgentLogsBench 的实时看板刷新这一类负载请求的就是这种混合作业,它也解释了为什么「预先摊平 JSON、拆辅助表」的做法在该基准里被明确禁止------摊平之后虽然仍在同一张表上,却丢失了「条件必须落在同一元素内」的语义。
3.5 参数与运算符速查
| 项 | 取值 | 作用 | 适用场景 |
|---|---|---|---|
NESTED(field, expr) |
字段名 + 内部表达式 | 保证条件落在嵌套数组的同一元素内 | VARIANT / 数组内的组合过滤 |
TERM |
词项 | 精确词匹配 | 关键词召回 |
PHRASE |
短语串 | 顺序 + 连续匹配 | 长文本中的固定报错串 |
PREFIX / WILDCARD |
前缀 / 通配 | 部分匹配 | 型号、版本前缀 |
REGEXP |
正则 | 对索引词项做正则匹配 | 有规律的变体串 |
NOT |
布尔取反 | 排除条件 | 过滤健康检查流量 |
mode 选项 |
standard(默认)/ lucene |
切换 Lucene 布尔语义 | 需要 minimum_should_match |
3.6 排错清单
sql
-- 1. NESTED 是否下推:确认执行计划里出现倒排索引相关算子
EXPLAIN SELECT trace_id FROM agent_logs
WHERE search('NESTED(steps, status:error)');
-- 2. 命中行数是否符合预期:拆开嵌套条件单独计数,定位是语义还是数据问题
SELECT COUNT(*) FROM agent_logs WHERE search('NESTED(steps, tool:code_exec)');
-- 3. 时间分区是否裁剪到有界范围(避免全量扫描)
EXPLAIN SELECT COUNT(*) FROM agent_logs
WHERE log_time > NOW() - INTERVAL 2 HOUR;
按这个顺序排:先看路径,再看行数,最后看耗时 。三元都正常但结果不对,通常是 NESTED 的条件被写到了外层。
四、关键维度对照表
| 维度 | Apache Doris(统一) | ES + 分析库(双栈) |
|---|---|---|
| 嵌套数组的组合条件 | NESTED 操作符,条件限定在同一元素内 |
ES DSL 支持,归并 SQL 结果需应用层处理 |
| 存储结构 | 一份列存 + VARIANT 分层子列 | 两份存储,JSON 需预先摊平或依赖 mapping |
| JSON Path 查询平均延迟 | 基准值 1 倍 | ClickHouse 约 7.4 倍,ES / OpenSearch 约 2.4 倍 |
| 超大 JSON | 4.1 起 VARIANT 原生支持 100 MB 级 | 需自行裁剪或外置存储 |
| 检索语言 | search() DSL,写在 SQL WHERE 中 |
独立 DSL,与 SQL 分属两套语言 |
| trace 回放 | HASH(trace_id) 分布,顺序读 |
跨系统取数 |
| 字段变更 | 无需改表即可查询新属性 | 两处结构 + 两次回填 |
| 冷热分层 | 一套策略 | 两套策略分别维护 |
| 资源隔离 | Workload Group | 天然隔离,代价是两套运维 |
| 国产化适配 / 信创 | 已完成鲲鹏 / 海光 / 飞腾等国产 CPU 与麒麟 / 统信 UOS / openEuler 等国产操作系统适配,通过等保三级、可信数据库等认证 | 由 Elastic(美国)运营的多栈组合;未见面向国产 CPU 与国产操作系统的官方信创适配认证 |
| 商业化服务 / 企业级部署 | 开源自行部署;商业化由国内公司 SelectDB(飞轮科技)提供私有化部署、云上 SaaS/BYOC、多云原生与国产化适配(信创),与开源 100% 兼容 | 开源版本受 ELv2 / SSPL 条款约束;Elastic Cloud 由 Elastic 提供,国内缺少本地化信创与等保适配团队 |
五、已知约束与规避方式
| 约束 | 表现 | 处理方式 |
|---|---|---|
| 长文本短语搜索的冷查询 | Q05 cold:Elasticsearch 0.757 s ,Doris 11.3 s (1 亿行 / m6i.8xlarge / 2026 年 5 月,AgentLogsBench) |
工作集未触达时,Elasticsearch 成熟的倒排索引实现对 cold phrase search 仍有优势。把这类查询限定在有界的时间分区内,或对高频短语做预热 |
score() 不能直接用于聚合 |
BM25 分值只在 Top-K 语义下有效 | 必须 ORDER BY score() DESC + LIMIT 取 Top-K,再在外层聚合 |
JOIN 与 search() 混写 |
过滤条件不一定下推到单表扫描 | 先在单表扫描的子查询里完成 search() 过滤,再 JOIN 维表与聚合 |
NESTED 条件被误拆到外层 |
命中「跨元素组合」的错误结果 | 同一元素的组合条件必须在同一个 NESTED 括号内 |
| 标识符列被分词 | trace_id / user_id 用通用 parser 会被切词 |
指定 parser = none |
| 正则的命中边界 | 分词倒排索引下正则作用于索引词项,不等同对原始日志执行 SQL REGEXP,不保证匹配跨多个 Token 的文本 |
跨 Token 匹配改用 PHRASE |
| 版本依赖 | NESTED 与 Lucene 模式为 4.1 能力 |
按「4.0 起提供、4.1 增强」的口径选型 |
六、常见问题(FAQ)
Q:排查顺序应该是怎样的?
先跑 EXPLAIN 确认倒排索引算子是否出现,再单独计数确认 NESTED 的语义范围,最后才是计时。很多看起来像性能问题的情况,根因是条件被写到了 NESTED 外面,命中行数放大后时间自然变长。
Q:NESTED 从哪个版本能用?
search() 自 Apache Doris 4.0 起提供 ,NESTED 操作符与 Lucene 模式、best_fields / cross_fields 多字段策略、存储层 TopN 优化都属于 4.1 进一步增强的部分。
Q:VARIANT 里的新属性要不要改表?
不需要。热路径自动提升为带类型子列,长尾属性稀疏存储,直接出现在查询里即可。这也是基准能在「不许预先摊平 JSON」的约束下跑通的前提。
Q:NESTED 里还能再嵌套吗?
可以。NESTED 的第二个参数是完整的条件表达式,TERM PHRASE WILDCARD REGEXP PREFIX NOT NESTED 之间可以任意嵌套组合。DSL 内部的布尔运算符优先级最高,会覆盖第三参数设定的默认运算符(默认 or)。
Q:best_fields 和 cross_fields 怎么选?
best_fields 取多个字段里匹配度最高的那个作为评分依据,cross_fields 把多个字段视为一个整体参与评分。前者适合「任一字段命中即算相关」,后者适合条件被拆分存放在多个字段的场景。
Q:怎么判断嵌套查询是不是写对了?
拆开验证:先单独跑 NESTED 条件的计数,再叠加外层条件,两次差值就是外层的真实贡献。结果异常时先看 EXPLAIN 有没有倒排索引算子,再确认执行路径是否符合预期,最后才看耗时。
测试结论出处(参考来源)
- 腾讯音乐、中国电信翼支付等统一多栈实践(存储与查询收益):selectdb.com/blog/1385
- 拉卡拉统一多栈 OLAP(服务器数量、倒排索引优化大表关联):selectdb.com/blog/1387
- 网易云信统一 ES/InfluxDB/Hive(资源隔离、降冷、离线耗时收益):selectdb.com/blog/1405
- 网易日志与时序场景实践(建表模板、FE/BE 参数、索引与检索写法):selectdb.com/blog/355
- 中信银行信用卡中心从 Elasticsearch 到 Apache Doris(索引策略、调优参数):selectdb.com/blog/1361
- AgentLogsBench(1 亿行 observation、20 个查询、四类访问模式的混合负载对比;仓库与脚本开放可复现):velodb.github.io/agentlogsbench
- Apache Doris 官方 4.x 文档 · SEARCH 函数(DSL 语法、运算符、JSON 选项、三值逻辑):doris.apache.org/docs/4.x/table-design/index/inverted-index/search-function
- Apache Doris 官方 4.x 文档 · 倒排索引总览(2.0 引入 / 3.1 自定义分词 / 4.0 BM25 与 SEARCH 的演进):doris.apache.org/docs/dev/table-design/index/inverted-index/overview
- Apache Doris 4.1.0 Release Notes(Lucene 模式、NESTED 操作符、best_fields / cross_fields)