Tempo TraceQL 引擎架构解析:从查询解析到存储层执行的完整链路
【免费下载链接】tempoGrafana Tempo is a high volume, minimal dependency distributed tracing backend.项目地址: https://gitcode.com/GitHub_Trending/tempo1/tempo
Grafana Tempo 的 TraceQL 引擎是连接 Tempo API 处理器与存储层之间的核心桥梁:它负责把用户书写的 TraceQL 查询解析为存储层可执行的扁平化条件,从存储层拉取候选 spanset 后逐条重新验证,最终组装并返回搜索结果。本文基于 Tempo 仓库中的架构文档与pkg/traceql、tempodb/encoding/vparquet4等模块源码,完整还原 TraceQL 引擎的三个核心职责、两阶段执行模型、spanset 抽象以及存储层落地细节,帮助你理解一次 TraceQL 搜索请求从 API 到 Parquet 数据块的完整执行链路。
TraceQL 引擎在 Tempo 中的定位
在 Tempo 的整体架构中,TraceQL 引擎(TraceQL Engine)是连接Tempo API handler与存储层(storage layer)的中间组件。它在一次搜索请求中承担以下三项核心职责:
- 解析(Parse)传入的查询请求,将其提取为存储层可以直接处理的扁平化条件(flattened conditions);
- 从存储层拉取 spanset,并对每一个拉取到的 spanset 使用完整的查询表达式重新验证(revalidate),确认其确实匹配该查询;
- 将验证通过的结果组装为搜索响应(search response)返回给上层调用方。
从源码结构看,这一设计对应的核心类型与文件分别是:
pkg/traceql/engine.go:Engine类型与Compile、ExecuteSearch等入口函数,承载解析、编译与两阶段执行编排;pkg/traceql/storage.go:定义FetchSpansRequest、Condition、Spanset、SpansetFetcher等引擎与存储层之间的契约接口;pkg/traceql/ast_conditions.go:实现从 AST(抽象语法树)到扁平化条件的提取逻辑;tempodb/encoding/vparquet4/block_traceql.go:vParquet4 数据块上对FetchSpansRequest的落地实现。
为什么需要 TraceQL:默认搜索与精确查询的对比
Tempo 的默认搜索(default search)会扫描整条 trace 的全部内容,而 TraceQL 提供了一种构造精确查询的方法,让你能够快速缩小到所需的数据范围。由于查询条件限制了被扫描的数据量,TraceQL 查询通常能更快返回结果。
例如,默认搜索可能需要遍历块中所有 span 的元数据才能判断一条 trace 是否命中;而一条{ .http.status = 200 }这样的 TraceQL 查询,可以在存储层通过 Parquet 列式剪枝(column pruning)直接跳过大量不相关的行组(row groups),只针对包含该属性的列做谓词过滤,从而显著减少被检查的数据量(关于列级下推的具体实现,可参见 vParquet4 的 Fetch 实现)。
引擎执行链路全景
一次 TraceQL 搜索请求在引擎中的完整路径可以概括为:
查询字符串(如 { .http.status = 200 }) │ ▼ Parse(词法/语法分析,pkg/traceql/parse.go、expr.y) │ ▼ validate(AST 语义校验,pkg/traceql/ast_validate.go) │ ▼ extractConditions(AST → []Condition,pkg/traceql/ast_conditions.go) │ ▼ FetchSpansRequest(StartTime/EndTime + Conditions + SecondPass) │ ▼ 存储层 Fetch(如 vparquet4 块级 Fetch,列下推 + 行组跳过) │ ▼ 迭代器逐个产出 Spanset → 引擎 SecondPass 回调重新验证 │ ▼ MetadataCombiner 合并结果 → tempopb.SearchResponse第一步:解析与编译(Parse & Compile)
引擎的入口是traceql.Compile函数(见 pkg/traceql/engine.go),其流程如下:
func Compile(query string, opts ...CompileOption) (*RootExpr, Pipeline, SpansetFilterFunc, *FetchSpansRequest, error) { expr, err := Parse(query, opts...) // 词法与语法分析 if err != nil { ... } err = expr.validate() // 语义校验(如操作数类型、运算符合法性) ... p, ok := expr.SinglePipeline() // 单管道提取(不支持数学表达式时返回错误) ... req := FetchSpansRequest{AllConditions: true} requests := expr.extractConditions(req) // AST → 扁平化条件 ... return expr, p, p.evaluate, &req, nil }- Parse:通过 parse.go 与 goyacc 生成的 expr.y.go 将查询字符串解析为 AST(
RootExpr、Pipeline、SpansetFilter、BinaryOperation等节点); - validate:对 AST 做语义校验(ast_validate.go);
- extractConditions:遍历 AST,把每个条件表达式转换为存储层可执行的
Condition列表(详见下一节); - 返回的
Pipeline及其evaluate函数,将作为第二步"重新验证"阶段的过滤回调。
另外,CompileFetchSpanRequests 则面向支持多个子查询的场景(如指标查询中的数学表达式),返回按子查询划分的map[string]FetchSpansRequest。
第二步:AST 到扁平化条件的提取
"扁平化条件"是指把嵌套的 TraceQL 表达式拆解为一系列Condition(属性 + 运算符 + 操作数),存储层可以针对每个Condition直接构造列级谓词。核心逻辑位于 pkg/traceql/ast_conditions.go,其提取规则要点如下:
- 属性比较属性:当二元运算两侧都是属性(如
parent.service.name != .service.name)时,两边都会被提取为OpNone条件,即"只要读出这两列即可",真正的比较留给引擎执行; - 属性比较静态值:当一侧是静态值(如
.http.status >= 200)时,会提取为带运算符和操作数的完整条件{Attribute: .http.status, Op: OpGreaterEqual, Operands: [200]},这样存储层就能用该条件构建谓词; - 布尔运算:遇到
OpOr时,request.AllConditions会被置为 false(表示"满足任一条件即可"),反之&&会保持AllConditions = true,让存储层可以做更激进的优化(见 ast_conditions.go); - Select 操作:
SelectOperation.extractConditions会把 select 子句中引用的属性放入SecondPassConditions,由第二遍执行时读取。
Condition结构体(storage.go)还带有CallBack字段,可由上层 watcher(例如引擎字节跟踪)在列扫描过程中提前终止无用的列读取。
第三步:两阶段执行模型(First Pass + Second Pass)
FetchSpansRequest(pkg/traceql/storage.go)是引擎与存储层之间最重要的契约,它包含:
| 字段 | 作用 |
|---|---|
StartTimeUnixNanos/EndTimeUnixNanos | 查询的时间范围(由搜索请求的Start/End转换而来) |
Conditions | 第一遍扫描的扁平化条件 |
AllConditions | 提示存储层:是否可以优化为"只返回满足所有条件的 spanset"(对应{ a && b && c }场景) |
TraceSampler/SpanSampler | 采样器,可选,存储层可以不生效 |
SecondPass/SecondPassConditions | 第二遍执行的过滤回调与需要额外读取的条件 |
SecondPassSelectAll | 忽略第二遍条件、读取全部属性的开关 |
这种设计的关键动机是分两遍读取数据:
- 第一遍:存储层只按
Conditions读取解析 TraceQL 表达式真正需要的列,快速筛选出候选 spanset; - 第二遍:引擎通过
SecondPass回调(即编译得到的p.evaluate)对每个候选 spanset 执行完整的表达式求值,只有真正匹配的 spanset 才保留;同时通过SecondPassConditions(如SearchMetaConditions中的 trace 根服务名、trace 名、trace ID 等元数据列)补齐搜索结果所需的元信息。
这一机制在ExecuteSearch中体现得十分清晰(pkg/traceql/engine.go):引擎把求值函数注册为SecondPass,对存储层产出的每个非空 spanset 执行eval([]*Spanset{inSS}),结果为空则丢弃,否则还会把每个 spanset 截断到SpansPerSpanSet(默认 3,见DefaultSpansPerSpanSet)个 span 以减少后续元数据查询的开销,并记录attributeMatched(__matched)属性统计命中 span 数。
Spanset 抽象:引擎与存储层协作的最小单元
TraceQL 的语义是"以 trace 为单位做 spanset 选择与管道处理",因此引擎与存储层之间传递的核心数据结构是Spanset(pkg/traceql/storage.go):
type Spanset struct { Scalar Static Spans []Span TraceID []byte RootSpanName string RootServiceName string StartTimeUnixNanos uint64 DurationNanos uint64 ServiceStats map[string]ServiceStats Attributes []*SpansetAttribute ReleaseFn func(*Spanset) }- 一个 spanset 对应一条 trace 中被挑选出来的一组 span(携带 trace 级元数据,如根 span 名、根服务名、时长、各服务的 span 数/错误数);
Span接口(storage.go)抽象了 span 的取属性能力(AttributeFor、AllAttributes)、时间/时长,以及结构关系运算(SiblingOf、DescendantOf、ChildOf),后者正是 TraceQL 结构运算符(>、>>、~)得以在引擎层实现的根基;ReleaseFn用于内存回收,引擎消费完 spanset 后调用Release()归还内存。
存储层通过SpansetFetcher接口(Fetch/FetchSpans)向引擎提供数据,FetchSpansResponse除返回SpansetIterator外,还携带Stats回调用于汇报FetchSpansStats(扫描字节数、检查/跳过的行组与页数、按缓存角色区分的命中与未命中、后端读取等),最终汇总进tempopb.SearchMetrics,作为查询吞吐与 SLO 指标的依据(见 pkg/traceql/engine.go)。
存储层落地:vParquet 块上的 Fetch 实现
引擎产出的FetchSpansRequest最终由存储层执行。以 vParquet4 为例,backendBlock.Fetch(tempodb/encoding/vparquet4/block_traceql.go)的执行要点包括:
- 条件校验(checkConditions):检查每个
Condition的操作数数量是否与运算符匹配、所有操作数类型是否一致、操作数类型是否与操作兼容,例如OpNone/OpExists必须为 0 个操作数,比较类运算符必须恰好 1 个操作数;同时拦截当前编码不支持的固有属性(如 vParquet4 不支持childCount固有属性,会返回util.ErrUnsupported); - 条件合并(coalesceConditions):合并语义等价的条件,减少重复列扫描;
- 打开 Parquet 文件并选择行组:通过
openForSearch与rowGroupsFromFile拿到文件与行组,结合元数据中的专用列(DedicatedColumns)信息; - 构建迭代器(fetch):基于 parquetquery 构造列级迭代器,利用谓词在读取过程中跳过不匹配的行组与数据页;
- 返回带统计的响应:
Stats回调汇报实际读取字节数,供引擎填充搜索指标。
类似实现同样存在于 vParquet5(tempodb/encoding/vparquet5/),不同编码版本会在支持的特性上存在差异(例如固有属性的支持范围),引擎在遇到util.ErrUnsupported时会优雅降级(返回空结果而非报错,见 engine.go)。
引擎与前端搜索分片器的配合
在前端(query-frontend)侧,TraceQL 引擎与搜索分片器(Search Sharder)协同工作:modules/frontend/frontend.go中将newAsyncSearchSharder挂载到搜索请求的中间件链上,负责把一次大范围搜索按时间/数据分片(shard)拆分为多个并行的子请求;分片权重计算则通过traceql.CompileFetchSpanRequests解析查询后,按提取出的子查询条件评估各分片的数据量权重(见 modules/frontend/pipeline/async_weight_middleware.go)。此外,modules/frontend/tracefilter/filter.go 使用traceql.CompileSpansetFilter将 TraceQL 查询编译为 spanset 过滤器,用于 trace-by-id 场景下的细粒度过滤。
分阶段开发与当前限制
TraceQL 语言与引擎是分阶段(phases)实现的。根据架构文档的说明,引擎的初始迭代版本(initial iteration)聚焦于 spanset 选择(spanset selection)与管道(pipelines)两个能力:
- spanset selection:通过
{}中的条件表达式从 trace 中挑选 span 集合; - pipeline:通过
|将多个表达式串联,逐级传递与加工 spanset。
从当前源码可以确认,这两项能力已经在 pkg/traceql/ast.go(AST 定义)、pkg/traceql/ast_execute.go(管道求值)、pkg/traceql/spanset_filter_match.go(spanset 过滤匹配)中完整落地,后续迭代又逐步加入了聚合函数、指标查询(engine_metrics.go)、数学表达式(ast_metrics_math.go)等能力。
关于 TraceQL 的完整设计理念与后续扩展方向,可参考仓库内的两份设计提案:
- TraceQL Concepts(2022-04 设计提案):阐述语言的基本结构——基于 span 属性、时序与时长、结构关系以及聚合数据来选择 trace,并给出
{ .http.status = 200 }、{ duration > 2s }、{ } >> { }(后代)、{ } > { }(子)、{ } ~ { }(兄弟)、count() > 10、avg(duration) > 1s、by(.region) | count() > 5等语法示例; - TraceQL Extensions(2023-11 设计提案):记录 TraceQL 面向指标查询等方向的扩展设计。
需要说明的是,仓库中的 architecture.md 已被标记为 draft 并隐藏(页面注释标明"very out of date"),因此本文以当前源码实现为准进行论述,上文所述的分阶段开发信息属于该文档保留的历史性说明。
小结
TraceQL 引擎通过"解析提取扁平化条件 → 存储层列下推预筛选 → spanset 级重新验证 → 元数据合并"的流水线设计,把精确的查询语义下沉到 Parquet 列式存储中,既保证了查询结果的准确性,又借助列剪枝与行组跳过显著减少了被扫描的数据量。理解这条链路,是排查 TraceQL 查询性能、理解引擎指标(inspected bytes、spansets evaluated 等)以及为 Tempo 贡献新查询能力的前提。若需进一步学习查询语法,可参阅 构造 TraceQL 查询 与 调整 TraceQL 查询性能 等文档。
【免费下载链接】tempoGrafana Tempo is a high volume, minimal dependency distributed tracing backend.项目地址: https://gitcode.com/GitHub_Trending/tempo1/tempo
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考