虚拟表查询优化
虚拟表(Virtual Table)是 TDengine 为工业物联网场景设计的一种逻辑表结构。虚拟表本身不存储数据,而是通过列引用将多张物理源表的列按时间戳对齐组合在一起,为用户提供统一的查询视图。
虚拟表简化了跨表查询的 SQL 编写,但其底层实现面临显著的性能挑战:朴素的执行策略需要将所有源表的数据进行全量归并,代价高昂。为此,TDengine 在查询计划生成阶段引入了多项优化策略,在保持语义正确的前提下,尽可能减少不必要的数据归并和扫描。
本文介绍其中两项核心优化:聚合下推和窗口查询两阶段拆分。
聚合下推
本节及后续章节中,执行计划示意图中的算子名称(如 Agg、ColsMerge、AggA 等)为概念化标识,用于说明优化思路,并非 TDengine EXPLAIN 输出中的实际算子名称。
问题背景
虚拟表的查询可以抽象为如下模型:
用户查询 → 虚拟表 → [源表 A, 源表 B, 源表 C, ...]
查询虚拟表时,引擎需要从各个源表读取数据,按时间戳排序对齐,将多路数据归并到统一结果集上,再执行用户指定的聚合计算。
以如下聚合查询为例:
SELECT count(a), sum(b) FROM vtable;
其中列 a 来自源表 A,列 b 来自源表 B。假设源表 A 和 B 各有 100 万行数据,朴素执行路径下,引擎需要先将 A 和 B 的全量数据按时间戳归并为一张临时结果集,再在该结果集上执行聚合运算。
这一过程存在双重性能损失:
- 归并开销:即使 A 和 B 的时间戳完全对齐,负责归并的算子仍需逐行完成 100 万行的时间戳比对和列拼接,这一过程本身即为显著的计算开销。
- SMA 信息丢失:TDengine 的存储引擎在每个数据块上维护了 SMA(预聚合统计信息),记录该块内各列的 count、sum、min、max 等摘要值。当直接对源表执行聚合查询时,引擎可利用 SMA 信息跳过逐行扫描,直接读取预计算结果。然而,归并产生的动态结果集不携带原始数据块的 SMA 信息,聚合函数只能退化为逐行计算。
核心矛盾在于:虚拟表对用户呈现为"一张表",但对引擎而言是"多张表"。若执行计划未能识别这一结构特征,则会为维护"单表"的抽象而付出不必要的归并代价,并丧失存储引擎提供的优化能力。
依赖分析
回到上述查询,count(a) 仅需列 a 的数据,而 a 仅存在于源表 A;sum(b) 仅需列 b 的数据,而 b 仅存在于源表 B。两个聚合函数之间不存在数据依赖,各自只需对应源表的数据即可独立完成计算。
由此可得出优化的关键判断:若一个聚合函数的所有输入参数仅依赖某一张源表,则该聚合函数可直接在该源表上完成计算,无需等待数据归并。 这一判断可在查询计划生成阶段通过列依赖分析完成。
优化策略
基于上述分析,TDengine 实现了聚合下推(Pushdown Aggregation)优化,将聚合函数按源表依赖拆分,使各源表独立执行聚合,最后按列拼合结果。
优化前的执行计划:
Agg[count(a), sum(b)]
└── VirtualTableScan(归并 A、B 全量数据)
├── ScanA(100 万行)
└── ScanB(100 万行)
引擎先从 A 和 B 各扫描 100 万行,归并算子逐行比对时间戳并拼接列,产生约 100 万行的结果集(实际行数取决于时间戳对齐程度),再在其上执行 count 和 sum。归并后的结果集不携带 SMA 信息,聚合只能逐行计算。
优化后的执行计划:
ColsMerge(按列拼合)
├── AggA[count(a)] → ScanA
└── AggB[sum(b)] → ScanB
AggA 直接在源表 A 上执行 count(a),由于是对原始数据块的聚合,引擎可直接利用 SMA 信息读取预计算的 count 值。AggB 同理利用 SMA 计算 sum(b)。最后由 ColsMerge 节点将两个结果按列拼合后返回。
该优化带来两方面收益:
- 消除归并开销:100 万行的逐行时间戳比对和列拼接被完全跳过。
- 恢复 SMA 加速能力:聚合直接作用于原始数据块,引擎可利用块级预聚合信息,将聚合计算从逐行扫描优化为块级读取,性能提升可达数量级。
多聚合函数场景
当多个聚合函数依赖同一张源表时,将被划分至同一组:
SELECT count(a), avg(a), sum(b), max(b) FROM vtable;
对应的优化后执行计划为:
ColsMerge
├── AggA[count(a), avg(a)] → ScanA
└── AggB[sum(b), max(b)] → ScanB
每组仅需扫描一张源表的数据,既避免了归并开销,又能充分利用 SMA 信息。
当某个聚合函数的输入参数依赖多张源表的列时,该函数无法下推,需保留在归并路径上执行。但在时序数据的典型场景下,绝大多数聚合函数仅依赖单一源表的列,因此该优化具有较高的覆盖率。
窗口查询两阶段拆分
问题背景
聚合下推在普通聚合场景下效果显著,但面对窗口查询时存在局限性。窗口查询引入了窗口边界的概念,使源表之间产生了真实的数据依赖,无法简单地各自独立计算。
在 TDengine 中,窗口查询允许用户按时间间隔、状态变化等方式将数据划分为多个窗口,在每个窗口内独立执行聚合。例如:
SELECT _wstart, _wend, avg(b) FROM vtable STATE_WINDOW(a);
该查询的语义为:按列 a 的值变化划分窗口,在每个窗口内计算列 b 的平均值。
此处的挑战在于:列 a(状态列)位于源表 A,列 b(聚合列)位于源表 B。窗口边界由 A 决定,而聚合数据在 B 上。两张源表之间存在真实的数据依赖,avg(b) 的计算范围取决于 a 的状态变化,B 必须知道 A 的窗口边界才能开始计算。
在时序数据场景中,状态列通常较为稀疏——设备状态仅在变化时写入,数据量往往很小;而测量列则非常密集——传感器以较高频率持续采集,数据量可达数百万行。以一个典型场景为例:假设状态列 a 有 50 行,聚合列 b 有 500 万行。朴素方案的执行计划如下:
WindowAgg[avg(b), STATE_WINDOW(a)]
└── VirtualTableScan(归并 A、B 全量数据)
├── ScanA(50 行,稀疏的状态变化)
└── ScanB(500 万行,密集的测量数据)
即使状态列仅有 50 行,朴素方案仍需将其与聚合列的 500 万行进行全量归并,代价极高。
依赖结构分析
对窗口查询的依赖关系进行分析,可以发现两个关键结构特征:
- 依赖是单向的:窗口边界仅取决于状态列
a(源表 A),聚合计算仅取决于数据列b(源表 B)加上窗口边界。B 依赖 A 的窗口边界,但 A 不依赖 B 的任何数据。依赖方向为 A → B,而非 A ↔ B。 - 数据量高度不对称:决定窗口边界的状态列通常非常稀疏(数十行),而需要聚合的测量列非常稠密(数百万行)。确定边界的代价极低,而全量归并的代价几乎完全由稠密的聚合列决定。
上述两个特征指向明确的优化方向:先以极小的代价在稀疏的状态列上确定窗口边界,再让稠密的聚合列仅读取窗口覆盖范围内的数据进行计算,从而避免全量归并和全量扫描。
优化策略
基于单向依赖的特征,TDengine 将窗口查询拆分为两个执行阶段。
第一阶段:确定窗口边界
在状态列所在的源表 A 上执行窗口划分,不执行聚合计算,仅输出每个窗口的起止时间:
WindowSplit(STATE_WINDOW(a)) → ScanA
输出:[(_wstart₁, _wend₁), (_wstart₂, _wend₂), ...]
由于状态列本身稀疏,该阶段的扫描成本极低。产出的窗口列表规模也很小,通常仅有数个至数百个窗口。
第二阶段:各源表独立聚合
将第一阶段产出的窗口边界下发给需要执行聚合的源表。各源表根据窗口的时间范围,独立执行范围扫描和聚合计算:
ExtWindowAgg[avg(b)] → ScanB(仅扫描窗口范围内的数据)
源表 B 无需了解窗口的划分逻辑,仅需接收一组 (_wstart, _wend) 区间,在每个区间内计算 avg(b) 即可。聚合列不再需要全量扫描,而是仅读取各窗口时间范围内的数据,窗口之间的间隙数据被直接跳过。
完整执行计划
两个阶段通过调度节点(DynQueryCtrl)协调,完整的执行计划如下:
DynQueryCtrl(调度节点)
│
├── 第一阶段:WindowSplit(a) → ScanA
│ 输出:窗口边界列表
│
└── 第二阶段(窗口边界确定后启动):
ColsMerge
├── ExtWindowAgg[avg(b)] → ScanB
└── ExtWindowAgg[...] → ScanC(如有更多源表)
调度节点负责等待第一阶段完成,获取窗口列表后触发第二阶段各源表的执行器。
性能对比
以上述典型场景为例:状态列 a 有 50 行,聚合列 b 有 500 万行。
优化前(全量归并):
- ScanA 读取 50 行,ScanB 读取 500 万行
- 归并后在约 500 万行的数据上执行窗口划分与聚合
- 总处理量:全量归并 500 万行,附加归并本身的排序开销
优化后(两阶段拆分):
- 第一阶段:ScanA 读取 50 行,划分出 50 个窗口
- 第二阶段:ScanB 根据 50 个窗口边界执行范围扫描,仅读取窗口覆盖范围内的数据
- 总处理量:50 行扫描 + 远少于 500 万行的范围聚合,且完全省去归并开销
多源表聚合场景
当查询涉及多张源表时,窗口边界仅需确定一次,各源表可并行执行窗口聚合:
SELECT _wstart, avg(b), max(c) FROM vtable STATE_WINDOW(a);
其中列 a 在源表 A,列 b 在源表 B,列 c 在源表 C。优化后的执行计划为:
DynQueryCtrl
├── 第一阶段:WindowSplit(a) → ScanA → 输出窗口边界
└── 第二阶段:
ColsMerge
├── ExtWindowAgg[avg(b)] → ScanB
└── ExtWindowAgg[max(c)] → ScanC
源表越多,这种"一次确定边界、多路并行聚合"的收益越显著。
适用范围
两阶段拆分的策略可推广至其他类型的窗口查询:
- 时间窗口(INTERVAL):窗口边界为固定时间间隔,所有源表天然共享同一组边界,可跳过第一阶段直接进入并行聚合。
- 会话窗口(SESSION):会话边界由某列的活动间隔决定,与状态窗口类似,同样适用两阶段拆分。
不同窗口类型的差异集中在第一阶段(边界确定方式),第二阶段(按边界独立聚合)的逻辑具有通用性。
优化原理总结
上述两项优化遵循相同的设计原则:
| 聚合下推 | 窗口两阶段拆分 | |
|---|---|---|
| 核心问题 | 聚合函数间无数据依赖,无需归并 | 窗口边界与聚合数据间为单向依赖,可分阶段处理 |
| 优化手段 | 按源表拆分聚合函数,各自独立执行 | 先确定窗口边界,再按边界各自聚合 |
| 共同原则 | 分析依赖结构,识别独立性,推迟数据汇合 | 分析依赖结构,识别独立性,推迟数据汇合 |
两项优化的通用原则可归纳为:在查询计划生成阶段进行依赖分析,识别可独立处理的计算单元,尽可能推迟数据的汇合点,使数据在汇合之前完成尽可能多的计算。 数据越晚汇合,需要处理的数据量越小,也越能利用底层存储的优化特性(如 SMA 预聚合信息)。
这一思路与关系型数据库中的谓词下推、列裁剪、分区裁剪等优化本质相同。虚拟表的多源表结构为这一原则提供了典型的实践场景:源表之间的边界天然清晰,依赖关系易于分析,下推收益显著——尤其是在 TDengine 这类对存储层(包括 SMA)进行了深度优化的时序数据库系统中。








