摘要
合取查询(Conjunctive Query, CQ)是OLAP数据库中的常见操作。本文探讨了传统的联结查询处理方法,首先我会从传统二元联结(Binary Join)处理出发,讨论它们在扩展到多关系查询和复杂查询模式时所面临的局限性, 并分析不同处理方法在并行性与内存使用之间的权衡。接下来,我们会讨论联结查询的上界(size bound),解释了为什么只包含连接的查询计划(Join Plan)在最坏情况下可能会表现不佳,并进一步探讨了最坏情况最优联结(Worst Case Optimal Join, WCOJ)方法及其变体。最后,我将介绍列自由连接规划(Free Join ),这是一种用于CQ处理的统一理论框架。
关键词:联结处理、WCOJ、多路联结
1. 合取查询(Conjunctive Queries,CQs)
合取查询(CQs) 是数据库查询中一类基础操作,其查询结果由多个条件的合取(逻辑 AND)定义。为简化描述,我们将使用霍恩子句(Horn clause)语法表示合取查询。霍恩子句的形式为 :
表示当所有体子句(Body Clauses) 至
均满足时,可推导出头部
(Head)。 其中每一个字句对应语一张数据库表,每一个逻辑变量
则对应表上的列。当体字句中的逻辑变量
重复出现在多个字句中则代表这些子句所对应的表会在对应列上进行连接操作(Join)。基于霍恩子句的合取查询可以被对应到关系代数中的选择-投影-连接查询(select-project-join queries),例如以下查询:
foobar(a, b) :- foo(a, b), bar(b, c)
其语义等价于关系代数中的 。更多背景和形式化定义可参考wiki.
2. 二元连接(Binary Join)
高效的连接算法(join algorithms)是快速处理合取查询的核心。当 CQ 仅涉及两个关系时,可直接应用传统二元连接算法(binary join algorithms)(如哈希连接(hash join)和排序合并连接(sort-merge join))。以第 1 节中的 foobar 查询为例,其哈希连接的伪代码如下:
for (a, b) in foo {
for (b, c) in bar[b] { // bar indexed on the column named "b"
foobar.insert((a, c));
}
}
此处,我们首先在 bar 关系的列 b 上建立一个哈希映射,将每个 b 值映射到所有拥有该值的元组。接着,遍历 foo 关系中的所有元组;针对每个元组,根据其 b 值在 bar 的哈希映射中查找匹配元组,对于每个匹配元组,将 a 与 c 投影出来构造新的元组,并将其插入 foobar 结果中。
Note: 在文献中,被遍历的关系(如 foo)通常称为“外部关系”,而通过索引访问的关系(如 bar)称为“内部关系”。一种常见的性能优化是根据统计信息(如基数)交换这两个关系的角色,这需要在查询规划阶段调整联结顺序,但本文讨论的技术均假定内外关系的顺序已确定。
然而,实际应用中的 CQ 通常包含多个输入关系构成多路连接。例如,考虑下面的三路连接CQ:
foobar(a, c, d) :- foo(a, b), bar(b, c), baz(a, d).
为了继续复用二元联结处理算法来处理该查询,我们需要将其分解为两个二元联结:首先联结 foo 与 bar,然后将中间结果与 baz 进行联结。其伪代码如下:
let tmp = vec![];
for (a, b) in foo {
for (b, c) in bar[a] {
tmp.insert((a, c));
}
}
for (a, c) in tmp {
for (a, d) in baz[a] {
foobar.insert((a, c, d));
}
}
纯连接查询计划(Join Plan)
可以看出,除了先联结 foo 和 bar 之外,还存在其它处理三路联结的联结顺序。例如,可以将查询分解为两个中间联结——分别计算 和
——然后再联结这些中间结果。这种分解称为二元联结规划(Binary Join Planning),它将一个 k 路联结分解为一系列二元联结。
纯连接查询规划的一种常见的等价表示方式是:将每个关系视为图中的一个节点,共享的逻辑变量视为边,不同的联结方式将形成不同类型的图。第一种临时顺序计划形成一个左深树(称为左深线性计划)(left-deep tree, left-deep linear plan),而另一种则形成一个分支更多的丛状计划(bushy plan)。
联结计划的效率在很大程度上依赖于每个二元联结所产生的中间结果的大小。在三路联结中,第一步联结若产生较小的中间结果,不仅能减少内存开销(所需缓冲数组较小),而且还可降低后续内连循环中联结处理的数据量。因此,健壮的查询优化器必须对中间结果的大小进行估计,并选择总体成本最小的计划。
虽然不同拓扑构造的查询计划在算法上会有较大差异,但是从实际算法实现的角度来看,一旦联结计划确定,即使是丛状联结计划,其标准执行通常也会简化为将每个分支作为左深线性联结来处理。故而,优化左深计划通常足以获得良好性能,因此大多数研究主要关注与左深树的处理算法。
3. 二元连接中的内存与并行性权衡
3.1. 流水线处理模式
在上面的伪代码示例中,我们将联结 foo 与 bar 的中间结果存储在临时向量 tmp 中。然而,在实际查询中——例如当 foo 表示一个大规模社交图中的边时——中间结果可能非常庞大,从而带来显著的内存压力。
为避免物化(materialize)如此庞大的中间结果,一种经典的方法是采用流水线处理。流水线处理允许将单一二元联结的输出元组立即馈送至下一个联结操作,而无需将中间结果全部存储下来。下面的伪代码展示了这一方法:
for (a, b) in foo { // 使用 OpenMP 并行循环
for (b, c) in bar[a] {
for (a, d) in gez[a] {
foobar.insert((a, c, d));
}
}
}
在一些文献中,这种联结处理方式也被称为火山模型(Volcano)或迭代器模型,源自Goetz Graefe, William J. McKenna的相关早期论文。
除了高效的内存利用之外,流水线模型还可以在多核 CPU 上轻松实现并行加速。为增强流水线处理性能,可以将最外层循环中的工作负载通过均匀分区划分给不同线程,或者拆分多层循环步骤给不同线程,使得每个线程处理 foo 中一部分元组或者多路连接中的一部分连接,从而提升整体性能和吞吐量。
3.2 数据偏斜(Data Skew)与向量化(Vectorization)
虽然流水线模型具备并行性,但在数据偏斜(skew)情况下,它可能无法充分扩展。例如,在一个大型社交网络中,某些影响力较大的节点可能拥有远高于其他节点的粉丝数。当这样的社交图作为多关系 CQ 的外部关系时,处理这些高连接节点的线程负载会显著高于处理低连接节点的线程,从而导致线程之间负载不均。这种多路联结中的负载不均使得在大规模并行硬件上扩展流水线联结操作变得困难,同时也阻碍了流水线模型在 SIMD 架构(如 GPU 和支持 AVX 的 CPU)上的应用。
与之相对,前文讨论的中间结果物化方法虽然会引入额外内存开销,但其关键优势在于扫描 foo 和中间结果 tmp 的循环是解耦的。这样,即便 foo 存在数据偏斜,也不会导致扫描 tmp 时单个线程过载,从而使得整体工作负载在各线程间分布得更为均衡。正因如此,一些基于 GPU 或超级计算机的并行 CQ 查询引擎采用了中间结果物化的策略。
向量化处理(Vectorization)是一种介于流水线处理和第二节开头展示的完全物化之间的折中方案。它不对所有中间元组进行完全物化,而是一次处理有限数量的元组,将它们分成批次。同时,系统维护一个指针以追踪尚未处理的元组。当当前批次处理完毕后,从上一次中断处继续处理。此方法既能有效控制内存开销,又能利用批处理实现高效缓存利用。伪代码如下:
let cur_foo = foo.begin();
let cur_bar = bar.begin();
let BATCH_SIZE = ...;
while cur_foo != foo.end() && cur_bar != bar.end() {
let tmp[BATCH_SIZE];
let tmp_cnt = 0;
pfor (a, b) in cur_foo.next() {
if let None = *cur_bar {
cur_bar = bar[a];
}
for (b, c) in cur_bar.iter() { // 注意可能出现的不均衡问题
if tmp_cnt < BATCH_SIZE {
let pos = atomicAdd(tmp_cnt);
tmp[pos] = (a, c);
} else {
cur_foo = ...;
cur_bar = ...;
}
}
}
pfor (a, c) in tmp {
for (a, d) in gez[a] { // 注意可能出现的不均衡问题
foobar.insert((a, c, d));
}
}
}
固定大小的临时缓冲区使得向量化处理能够有效控制内存开销。在此模型中,foo ⋈ₐ bar 和 tmp ⋈ₐ gez 的联结操作是解耦的。这是经典的高性能系统设计中提高吞吐量的技巧。这每个步骤所需的均摊时间更小并且不会在时间上互相影响,因此负载均衡更加容易实现。预先分配的固定大小缓冲区也使得无锁(lock-free)访问成为可能,从而避免了动态缓冲向量在并行插入时所需的锁操作。
然而,对于要求每个线程执行相同(或几乎相同)工作量的架构(如 SIMD/SIMT),上述方法可能仍难以实现理想的负载平衡。例如,在基于 GPU 的数据库中,当数据严重偏斜时,parallel for 内部循环的负载可能存在较大差异。近期研究 [10] 建议将批次大小固定为与线程数相近,并允许每个线程在所有线程至少找到一个匹配元组后立即返回,从而在超大吞吐硬件,如数据中心 GPU 上更好地解决负载不均问题。
总体来说,向量化处理比前述模型更为复杂,尤其在批次划分、批次大小选择、数据重新排列以及针对特定硬件内存层次结构的优化等方面仍存在许多开放性研究问题。
4. 查询大小估计:CQ 的理论界限
合取查询的伪代码表明,一个 CQ 查询的运行时间是多项式级别的,因为它本质上涉及一系列候选关系的嵌套循环。因此,总体运行时间通常受输入关系大小的限制。考虑下面的 foobar 查询,假设 foo 的大小为 ,bar 的大小为
,baz 的大小为
:
foobar(a, b, c, d) :- foo(a, b), bar(b, c), baz(c, d).
考虑如下最坏情况,假设 foo 和 bar 在联结属性 b 上仅存在一个不同的值,则 foo 中的每个元组将与 bar 中的每个元组匹配,导致 foo 与 bar 的联结可能产生多达 个元组。当这一中间结果随后与 baz 联结时,结果的元组数量上界将变为
。此时,bar 上构建的哈希表未能过滤任何元组实现索引加速,因此运行时间完全依赖于输入表的大小。
对标准的最左深线性二元联结规划分析表明,“一个 CQ 的结果元组数量界等于所有输入关系元组数量大小的乘积”。然而,如果我们考虑非纯粹的连接查询规划策略可以获得一般情况下更紧的边界。考虑下面的三角联结查询 (triangle query):
foobar(a, b, c) :- foo(a, b), bar(b, c), baz(a, c).
在此查询中,关系 baz 未引入新的逻辑变量,因为 a 和 c 已经在前面的谓词中出现。因此,输出的大小并非所有输入关系大小的简单乘积,而是由三者中任意两个关系联结的最大值决定。该三角查询的一个更紧的理论结果元组数量上界为:
这个界明显比简单的立方上界小多项式级别。
对于查询计划一种直观的分析方法是构造查询图,其中节点代表逻辑变量(即列名),边代表关系。在这种模型中,最坏情况联结规划可以看作是一个边覆盖问题,其目标是找出一个最小的顶点集合(变量集合),使其覆盖每一条边。例如,对于本文前面展示的两个 foobar 查询图,第一个可能需要使用变量集合 来表示最坏情况,而第二个只需集合
,这表明在三角查询最坏情况输出大小仅受较少变量的支配而非全部变量。
对于高于二元的关系,可以将其建模为超边,整体讨论类似。本文为简化描述,所有边都指超边。
上述分析表明,在最坏情况下,一个 CQ 的有效大小界主要由候选关系中各列的值数量决定,而不仅仅是数据库中的行数。如果基数估计显示非重复的列值远少于总行数,则我们可以采用选择-联结规划(select-join planning)来获得更优的最坏情况性能,而不是单纯依赖纯联结规划,这会导致次优的上界。
AGM 界
013年,Atserias、Grohe 与 Marx 证明了对于选择-联结规划,存在一个更紧的最坏情况上界(AGM 边界),该上界与输入关系大小的几何平均相关。为了推导这一界限,我们将图模型从简单的边覆盖细化为分数边覆盖, 来更加精细的刻画每一顶点即每一张数据库的表对于其中中间连接结果的贡献程度。在该模型中,每个关系(或顶点)被赋予一个分数权重,表示参与联结结果中元组的比例,其约束条件为,对于查询中的每个逻辑变量(边),与该变量相连的关系权重之和至少为1。形式化描述为:
给定查询 的分数边覆盖是一个向量
,为每个表示关系的顶点
赋予权重
,使得对查询中每个逻辑变量
有:
从信息论的角度上来说,假设CQ查询结果 均匀分布,可以将其看作一个随机变量
,其熵为
对于均匀分布,上式简化为
根据 Shearer 不等式(Shearer’s inequality),我们可以得出:
对两边取指数,得到 AGM 边界:
我们还可以证明AGM 边界是紧的,即存在数据库实例使得等式成立。形式上,对于任意 ,总存在一个数据库
使得
且联结查询结果满足:
这一界的紧性使我们可以利用线性规划来求解最优解。利用约束满足问题语言,可以形式化为:
求解得到的最优解计作。根据这个结果我们可以简写AGM界为以下格式:
我们可以通过应用AGM边界,分析所有CQ查询的理论最坏情况最优输出规模。例如,在之前展示的三角联结中,假设每个输入关系的大小为 ;那么按传统上界输出可能是
,而 AGM 边界表明最坏情况输出大小可被界定为
(即
),详细证明可参见 [5]。
5. 最坏情况最优联结(Worst Case Optimal Join, WCOJ)
受到 AGM 边界的启发,Hung Q. Ngo、Christopher Ré 与 Atri Rudra 提出了一种通用框架 [6,7],用于设计能保证最坏情况,输出结果大小最优的联结算法。其基本思想可通过如下伪代码描述:
在每个递归层级中,算法选择一个联结变量——通常依据该变量在各关系中出现的频率等启发式因素。接着,它对所有参与该联结的关系进行投影,并计算这些值的交集以确定该逻辑变量的所有可能取值。对于每个交集中的值,算法将相应绑定该变量,并递归地对部分绑定查询应用相同过程。递归过程直至所有变量均被绑定,从而得到完整的联结结果。这种“投影-交集-联结”的模式正好契合原 AGM 论文的建议。
以三角联结查询为例,我们可以将通用 WCOJ 算法的递归过程展开为以下伪代码:
for a in foo.a ∩ baz.a:
foo_tmp = foo[a]; baz_tmp = baz[a];
for b in foo_tmp.b ∩ bar.b:
bar_tmp = bar[b]
for c in baz_tmp.c ∩ bar_tmp.c:
foobar(a, b, c)
5.1 延迟物化(Late Materialization)
通用 WCOJ 算法的一个显著特点是,在每个递归层级(或嵌套循环)中需要为存储中间结果(即部分绑定的元组)分配临时缓冲区。这相比传统左深二元联结中通过流水线或全局缓冲区物化中间结果的方法,会引入更多的内存开销。如果采用相同的数据结构同时用于存储与联结处理(如左深计划中常见的做法),这种内存压力可能严重影响性能。一个常见的解决方案是使用前缀 Trie 作为关系数据结构。
例如,在以 Trie 存储关系 A 时,操作 A[1]
可以通过查找以值 1 为根的子树指针来实现,而无需为中间结果开辟额外缓冲区。
Trie 存储的一大缺点是,它使得随机访问与整行遍历变得较为困难,这在许多传统面向行的数据库系统(Row-oriented database)中是必不可少的。然而,在 WCOJ 中,这一问题影响较小,因为 WCOJ 算法仅对各个单列进行操作,如集合交集、投影和过滤,而非完整元组级操作。正如我们在第4节讨论的那样,获得更紧的输出大小界要求采用面向列处理(column-oriented)而非传统的面向行执行方式。
数据库社区中关于面向列与面向行系统的讨论由来已久(参见相关综述[4]),选择哪种存储模型取决于硬件架构、数据特性与查询模式等多方面因素。本文不对此展开深入讨论。
5.2 蛙跳字典连接 (Leapfrog Triejoin, LFTJ)
在通用 WCOJ 算法中,除用于绑定部分变量的临时缓冲区外,还需为每个递归层级的集合交集分配临时存储空间。为降低内存开销,可以借鉴传统最左深二元联结中的流水线或迭代器模型,将“先求完全交集再处理”转变为“找到一个交集,处理该交集,然后继续”的方法,从而避免物化大规模交集集。
这种方法的一种实现就是蛙跳字典连接(Leapfrog Triejoin,LFTJ)[9],该算法由 Todd L. Veldhuizen 提出,并在商用数据库系统 LogicBlox 中得到应用。LFTJ 专门针对所有列值为整数、且各关系均以排序 Trie 方式建立索引的场景设计。在这种 Trie 中,每一层代表一个联结变量,每个节点的子 Trie 保持有序。下面的伪代码描述了采用迭代器模型的 LFTJ:
我们以下图为一个具体示例,说明了该算法的操作过程。初始时,算法为每个输入关系的联结列初始化迭代器,在本例中,关系 A、B 与 C 的迭代器分别指向 0、0 和 2。算法随后计算这些初始值的最大值,作为下一可能联结值的下界,即候选值 2(此值由关系 C 提供)。接下来,算法使用线性探测函数(leapfrog-seek)在另一关系(例如 A)中搜索候选值 2。在关系 A 中搜索时发现 2 不存在,迭代器便前移至大于 2 的最小值,即 3。以 3 作为新的候选下界后,算法继续在关系 B 中重复该过程。迭代推进直到在所有关系中均找到一个候选联结值(例如 8)。当找到这样一个值时,意味着该值存在于所有联结列的交集中,算法便进入内部循环,继续完成通用联结操作。
尽管 LFTJ 是一种用于流水线处理最坏情况最优联结的有力算法,但它对关系存储要求采用排序 Trie。排序 Trie 虽支持高效的顺序迭代,但其强制的排序约束可能导致查找操作不能实现常数因子的随机访问性能。对于需要真正常数时间索引访问的系统而言,基于 Hash-Trie 的算法更具吸引力。
列自由连接(Free Join)
回顾之前讨论的三种加速最坏情况最优联结处理 CQ 的关键技术:
- 采用面向列联结(如通用 WCOJ);
- 延迟中间结果物化(通过 Trie 存储实现);
- 每次迭代产生一个联结结果(采用流水线处理,类似 LFTJ)。
一个问题随之而来:是否存在一种统一框架能统一实现上述所有方法?近期,Remy Wang 与 Max Willsey 提出了 Free Join 框架[8]来回答这一问题。
Free Join 将查询分解为多个 子原子(subatoms),即关系中列的子集。形式上,给定关系模式 一个子原子的形式为
其中
.
一个 Free Join 计划是由多个组构成的序列,每个组是子原子的列表。形式上可表示为:
其中,每个 、
、
等均为从相应关系模式中选取的子原子.
执行 Free Join 计划时,只需将每组括号视作一个循环层级。在每个层级中,遍历该组中的第一个子原子,并利用扫描到的值对剩余子原子进行绑定或查找。例如,将经过 WCOJ 优化的三角联结查询可表示为如下伪代码:
for a in foo.a ∩ baz.a:
# 对当前 a 值绑定相关子原子
for b in foo[a].b ∩ bar[b]:
# 对当前 b 值绑定相关子原子
for c in baz[a].c ∩ bar[b].c:
foobar(a, b, c)
在此伪代码中,每个循环层级对应 Free Join 计划中的一组子原子。该层级中,第一个子原子驱动迭代,其扫描值用于限制后续子原子的匹配,从而逐步生成联结结果。Free Join 框架统一了面向列联结、延迟物化以及逐条结果流水线处理的优势。
原始 Free Join 论文的一个附加贡献是,论文中提出了一种利用新数据结构—— Lazy Generalized Hash Trie (LGHT)——实现该联结计划的算法。与用于 LFTJ 的排序 Trie 类似,LGHT 使得基于 Hash 的最坏情况最优联结也能够实现全流水线处理,既具备常数时间索引访问的优势,又能高效支持多路联结处理
结语
在本文中,我们探讨了联结查询处理的多种算法,但大多数方法(尤其是最坏情况最优联结技术)主要面向单核系统设计。然而,如何将这些方法扩展到并行处理环境仍是一项复杂且未完全解决的研究课题。最近关于将最坏情况最优联结移植到并行硬件上的工作(如[10]) 有了一些发展,尽管这些方法尚未成熟,难以在实际数据库系统中大规模应用。我将在未来的博客文章中讨论这些进展,并分享我对并行化联结查询处理的思考。
参考文献
[1]. Andy Palvo 关于多路联结算法的讲义
[2]. Paris Koutris 关于联结大小界的讲义
[3]. Joe Hellerstein 的 CS286 讲义
[4]. Abadi, D., Boncz, P., Harizopoulos, S., Idreos, S., & Madden, S. (2013). The design and implementation of modern column-oriented database systems. Foundations and Trends® in Databases, 5(3), 197-280.
[5] Atserias, A., Grohe, M., & Marx, D. (2013). Size bounds and query plans for relational joins. SIAM Journal on Computing, 42(4), 1737-1767.
[6] Ngo, H. Q., Porat, E., Ré, C., & Rudra, A. (2018). Worst-case optimal join algorithms. Journal of the ACM (JACM), 65(3), 1-40.
[7] Ngo, H. Q., Ré, C., & Rudra, A. (2014). Skew strikes back: new developments in the theory of join algorithms. ACM SIGMOD Record, 42*(4), 5-16.
[8] Wang, Y. R., Willsey, M., & Suciu, D. (2023). Free join: Unifying worst-case optimal and traditional joins. Proceedings of the ACM on Management of Data, 1(2), 1-23.
[9] Veldhuizen, T. L. (2014, March). Leapfrog triejoin: A simple, worst-case optimal join algorithm. In Proc. International Conference on Database Theory.
[10] Lai, Z., Sun, X., Luo, Q., & Xie, X. (2022). Accelerating multi-way joins on the GPU. The VLDB Journal, 1-25.