CMU-SQL-或死亡研讨会笔记-全-

CMU SQL 或死亡研讨会笔记(全)

001:拉里·埃里森是对的(某种程度上)!面向现代时代的 TypeScript 存储过程

在本节课中,我们将探讨一个核心问题:如何更好地在数据库中执行业务逻辑。我们将回顾传统方法的局限性,并介绍一种现代解决方案:使用 TypeScript 编写存储过程。我们将看到,这种方法结合了事务的可靠性与现代开发流程的便捷性。

概述:数据库交互的困境

数据库是现代应用的基石,其核心构建模块是事务。事务确保一系列操作要么全部成功,要么全部失败,这是构建可靠应用的基础。

然而,在事务中表达业务逻辑却面临挑战。以经典的银行转账为例,我们需要从一个账户扣款并添加到另一个账户,前提是资金充足。

用纯 SQL 实现这个逻辑非常困难,因为 SQL 是声明式查询语言,缺乏 if 语句等控制结构。虽然可以通过复杂的 JOINCASE 语句实现,但这对于实际业务逻辑来说既不直观也不可维护。

这引出了一个关键观点:SQL 是查询语言,而非业务逻辑语言。应用程序由业务逻辑构成,而 SQL 并非为此设计。

现实世界中的两种方案

既然难以在 SQL 中直接编写业务逻辑,现实世界中开发者通常采用以下两种方案:

方案一:API 服务层
这是最常见的方式。前端通过 API 服务与后端通信,后端服务(如用 Python 编写)负责执行业务逻辑并与数据库交互。这种方式虽然常见,但存在一些问题:

  • 容易出错:SQL 是弱类型、基于文本的语言,小错误可能导致严重后果。
  • 测试困难:SQL 代码不易进行单元测试。
  • 状态泄露风险:容易在事务提交前就将未提交的状态(如发送邮件)泄露到外部。
  • 性能问题:应用服务器与数据库之间的网络延迟可能导致事务持有锁的时间过长。
  • 开发效率:通常需要专门的团队(后端/API团队)来编写和维护这些接口,导致前端开发者迭代速度慢。

方案二:将数据库直接暴露给客户端
随着现代应用(尤其是由前端开发者主导的全栈应用)的发展,出现了另一种模式:将数据库直接暴露给客户端代码(如浏览器中的 TypeScript)。这种模式由 Firebase 等平台推广,旨在消除前后端隔阂,让开发者能快速迭代。

然而,这种方法也带来了新问题:

  • 缺乏抽象与安全:数据模型直接暴露,需要复杂的行级安全策略。
  • 放弃事务:客户端环境不可靠,难以维持长时间的事务。
  • 一致性挑战:难以保证不同客户端视图的数据一致性。

存储过程:一个被忽视的好主意?

上述两种方案各有优劣。那么,有没有一种方法能将业务逻辑放在离数据最近的地方(数据库),同时又能用现代、易用的语言来编写呢?

这让我们想起了存储过程。数据库巨头 Oracle 的创始人拉里·埃里森及其团队很早就意识到了在数据库中执行业务逻辑的需求,因此发明了 PL/SQL(Procedural Language for SQL)。

存储过程允许你在数据库事务内运行包含控制流(如 if、循环)的代码。以上述银行转账为例,用 PL/SQL 编写就直观得多:

CREATE PROCEDURE transfer_funds(
    sender_id INT,
    recipient_id INT,
    amount DECIMAL
) LANGUAGE plpgsql
AS $$
BEGIN
    -- 检查发送方余额
    IF (SELECT balance FROM accounts WHERE id = sender_id) >= amount THEN
        -- 执行转账
        UPDATE accounts SET balance = balance - amount WHERE id = sender_id;
        UPDATE accounts SET balance = balance + amount WHERE id = recipient_id;
        -- 记录日志等...
    ELSE
        RAISE EXCEPTION 'Insufficient funds';
    END IF;
END;
$$;

存储过程的优点很明显:

  • 业务逻辑靠近数据
  • 减少网络往返,提升性能。
  • 在事务内运行,保证原子性。
  • 内置错误处理

为何存储过程不受欢迎?

既然存储过程有这么多优点,为什么在实际开发中并不流行呢?核心原因在于开发者体验

PL/SQL 或 PostgreSQL 的 PL/pgSQL 是一种专门为数据库设计的、相对小众的语言。对于应用开发者而言,这意味着:

  • 脱离主流开发流程:无法使用熟悉的 TypeScript/JavaScript 生态系统、工具链(测试框架、类型检查、包管理)。
  • 难以集成:与应用程序其他部分的代码库分离,部署和版本管理复杂。
  • 数据库管理员的顾虑:DBA 通常不信任在数据库核心中运行任意的一般性计算代码,担心影响数据库稳定性和性能。

因此,存储过程成了一个“好主意,但错误执行”的典型。它解决了技术问题,但没有解决软件开发中的工程效率和协作问题。

现代解决方案:TypeScript 作为存储过程

如果我们重新思考存储过程,保留其核心优势(事务内执行、靠近数据),但改变其实现方式呢?

设想:用 TypeScript 编写存储过程。
TypeScript/JavaScript 是现代 Web 开发中最流行的语言。如果能在数据库事务中安全地运行 TypeScript 函数,我们就能:

  1. 解决软件工程挑战:使用相同的语言、工具链、测试框架和库。
  2. 让应用开发者(而不仅仅是 DBA)能够直接编写安全的数据库逻辑。
  3. 保持事务的所有优势。

这就是 Convex 等现代系统所采用的思路。它允许你像编写普通 TypeScript 函数一样编写查询和变更(Mutation),这些函数会在后端的受控环境中(如 V8 引擎)自动以事务形式执行。

以下是一个简单的购物车应用示例,展示了如何用 TypeScript 函数实现“添加商品到购物车”这个业务逻辑:

// 这是一个 Convex 变更函数,会在事务中执行
export const addToCart = mutation({
  args: { itemId: v.id("items") },
  handler: async (ctx, args) => {
    // 1. 鉴权(调用现有库函数)
    const user = await getCurrentUser(ctx);
    if (!user) throw new Error("Not authenticated");

    // 2. 查询商品库存(在事务快照中读取)
    const item = await ctx.db.get(args.itemId);
    if (!item || item.remaining <= 0) {
      throw new Error("Item out of stock");
    }

    // 3. 执行业务逻辑:更新购物车和库存
    await ctx.db.insert("cart_items", {
      userId: user._id,
      itemId: args.itemId,
    });
    await ctx.db.patch(args.itemId, { remaining: item.remaining - 1 });

    // 可以轻松调用其他 TypeScript 库函数...
  },
});

这种模式带来了革命性的开发者体验:

  • 与前端代码共置:数据库函数与 React 组件等在同一个项目、使用相同的工具链。
  • 完整的类型安全:从数据库到前端的全链路类型提示。
  • 易于测试:可以像测试普通 TypeScript 函数一样编写单元测试。
  • 自动缓存与实时同步:系统知道每个查询的读取集和每个变更的写入集,可以自动、高效地实现查询结果缓存和无效化。当前端绑定一个查询时,数据变化会自动推送到 UI 更新。

实现细节:安全与隔离

在数据库中运行任意 TypeScript 代码,安全是关键。现代实现(如 Convex)通常采用以下架构:

  1. 执行与存储分离:TypeScript 代码不在数据库进程内运行,而是在一个独立的、可扩展的执行环境(如 V8 引擎集群)中运行。
  2. 无副作用与确定性:运行时环境会限制函数,禁止进行网络请求(fetch)、文件系统访问等副作用操作,并模拟 DateMath.random 等以保持确定性,这有利于缓存。
  3. 基于快照的乐观并发控制:系统使用多版本并发控制(MVCC)。每个事务在一个特定的数据库快照时间戳上执行,记录所有读取的数据范围。提交时,检查是否有冲突的写入发生。如果没有冲突,则成功提交;否则,自动重试事务。这提供了可序列化(Serializable)的隔离级别。
  4. 将数据库用作持久化日志:底层可以使用 MySQL、PostgreSQL 或 SQLite 作为可靠的、版本化的预写日志(WAL)和存储引擎,而事务协调、冲突检测等逻辑在应用层实现。

超越可序列化:调度与工作流

强一致性(如可序列化)并非所有场景都需要,有时我们还需要异步处理的能力。

通过在运行时中添加事务性调度器,我们可以创建复杂的工作流。例如,在提交一个订单后,可以安排一个函数在 1 小时后检查是否支付,或者立即异步执行一个发送确认邮件的任务。这些调度任务本身的状态是事务性保存的,确保了可靠性。

ctx.scheduler.runAfter(0, async () => {
  // 此函数会在主变更提交后立即异步执行
  await sendOrderConfirmationEmail(orderId);
});

总结与展望

本节课我们一起探讨了数据库业务逻辑编写的演进:

  1. 认识到 SQL 的局限性:它不适合编写复杂的业务逻辑。
  2. 分析了传统方案:API 服务层存在工程效率问题,直接暴露客户端则牺牲了安全与事务。
  3. 重新审视了存储过程:其核心思想(事务内执行、靠近数据)是优秀的,但传统实现(PL/SQL)因开发者体验差而失败。
  4. 提出了现代解决方案:使用 TypeScript 作为存储过程语言。这结合了事务的可靠性、数据库的性能优势以及现代 TypeScript 开发流的卓越体验。
  5. 看到了额外优势:这种架构天然支持自动查询缓存、实时数据同步,并且由于其清晰的语义和类型安全,也非常适合与大型语言模型(LLM)结合,辅助生成正确的数据库操作代码。

拉里·埃里森关于在数据库中运行业务逻辑的愿景是正确的。如今,通过 TypeScript 和新的系统架构,我们终于可以以一种开发者喜爱的方式实现这一愿景,构建出既正确又高效的应用。

核心公式/概念总结:

  • 理想模式业务逻辑 = TypeScript 函数 + 数据库事务
  • 关键架构执行(TypeScript/V8) ←→ 事务协调层 ←→ 存储(SQL 数据库)
  • 并发控制快照隔离(MVCC) + 乐观冲突检测 = 可序列化
  • 开发者体验类型安全 + 同构代码库 + 自动缓存/同步

002:迈向合理的查询语言

在本节课中,我们将探讨 SQL 语言的现状、其存在的问题,以及如何构建一个更合理、更标准化的查询语言未来。我们将首先分析 SQL 的缺陷,然后介绍一种改进语法的方法,最后深入探讨解决 SQL 语义不统一这一核心问题的构想。

概述:SQL 的功与过

关系模型是一个伟大的构想,具有许多优良特性。然而,作为其实现语言的 SQL,虽然取得了巨大成功,但在今天看来却存在诸多问题。它并非一个设计完美的语言。

上一节我们概述了课程内容,本节中我们来看看 SQL 语言具体包含哪些优点和缺点。

SQL 的优点

首先,SQL 有其非常优秀的部分:

  • 声明式查询语言:这是 SQL 极佳的特性,我们绝对需要它。
  • 多重集语义:即允许重复项的“包”(bag)语义,这虽然增加了复杂性,但为了实用性我们可以接受。
  • 三值逻辑:处理 NULL 值虽然痛苦,但这是必要的。

SQL 的缺点

然而,SQL 也存在许多不尽如人意的部分。

以下是 SQL 语法层面的一些问题:

  • 奇怪的语法:SQL 语法设计初衷是像可读的英语,但这仅适用于极简单的案例。复杂的语法结构使得添加新功能和进行优雅的抽象变得非常困难。
  • 过多的关键字:SQL-92 有 227 个关键字,而 SQL-23 已增长到 409 个。这导致用户几乎不能安全地使用任何英文单词作为列名或表名。
  • 容易出错:例如,在多表 JOIN 查询中,如果遗漏连接条件,SQL 不会报错,而是生成一个巨大的笛卡尔积,并可能返回看似合理实则错误的结果。
  • 冗余与冗长:在 GROUP BY 查询中,分组列必须在 SELECTGROUP BY 子句中重复书写。而像 GROUP BY ALL 这样的“解决方案”又非常危险,容易导致意外的分组。
  • 语法顺序与语义顺序不符:SQL 的书写顺序(SELECT -> FROM -> WHERE ...)与实际的逻辑执行顺序(FROM -> WHERE -> GROUP BY ...)完全不同,这非常不直观,容易导致误解。

改进方案:更优的语法设计

既然 SQL 的语法存在诸多问题,我们能否设计出更好的方案呢?本节中,我们将介绍一种旨在解决语法问题的查询语言设计思路。

我们追求一种简单而富有表达力的查询语言。它看起来可能类似于数据帧 API,但关键区别在于它是一种独立的查询语言,可以作为文本发送给数据库。

与 SQL 相比,这种设计具有以下异同:

  • 相同点:它同样是声明式查询语言,支持多重集语义和三值逻辑。
  • 不同点:它拥有完全规则、无关键字的语法,明确的语义顺序,并且支持更高层次的构造。

其核心思想是对关系应用函数进行求值。以下是一个查询示例,它连接两个表 RS

R.join(S, condition)

这种设计使得连接条件必须显式写出,避免了遗忘连接条件的问题。并且,操作符的应用没有固定顺序,可以按任何逻辑顺序书写。

此外,该语言还支持更强大的功能:

  • 关键字参数:例如,可以指定连接类型 R.join(S, condition, type="left")
  • 列表参数与命名:在聚合函数中可以直接为结果列命名,如 R.groupby(["col"]).agg(total=sum("value"))
  • 高阶函数:可以定义接收其他函数作为参数的函数,从而实现查询逻辑的模块化和复用。例如,可以定义一个通用的 left_semi_join 函数,并在查询中像内置操作一样使用它。这解决了 SQL 中只能通过无参数视图进行有限复用的问题。

这种类数据帧风格的语法相比传统 SQL 具有显著优势:

  1. 更易学习:无需记忆执行顺序,写法更符合直觉。
  2. 更易调试:可以轻松获取中间结果,也更容易提供清晰的错误信息。
  3. 更易实现:实现这样一个前端所需的工作量远小于实现一个完整的 SQL 解析器。

核心挑战:语义的“巴别塔”

上一节我们介绍了一种改进语法的方法,但正如 Thomas 所言,这并未触及 SQL 最根本的问题。本节中,我们将深入探讨 SQL 在语义层面面临的巨大挑战。

SQL 最主要的问题是缺乏真正的兼容性。它自称标准,实则不然:

  1. 无完整实现:没有一个数据库系统完整实现了 SQL 标准。
  2. 有意偏离:所有实现都有意偏离标准,且偏离程度各异。
  3. 非标准扩展:各系统引入了大量独有的非标准功能。
  4. 标准缺失:即使庞大的 SQL 标准,也未能明确规定许多重要行为(如隐式类型转换规则)。

这种状况导致了一个分裂的世界:SQL 标准与任何商业系统之间重叠有限,且各大系统之间也差异巨大。编写一个能在多个系统上运行的查询极其困难。

以下是一些体现这种混乱的例子:

  • 在子查询中使用 ORDER BY,SQL Server 会报错,而其他系统可能允许。
  • FULL OUTER JOIN 使用非等值连接条件,PostgreSQL 不支持。
  • 一个涉及公共表表达式和聚合的查询,在 BigQuery 中产生的结果与 SQL 标准定义不同。

即使近年来被视为“事实标准”的 PostgreSQL,其兼容性追随者(如 DuckDB, Umbra)也无法做到完全一致,在数据类型处理、特殊值支持等方面存在偏差。

这些差异并非无关紧要的边角案例,它们会导致结果不同,对依赖机器生成查询的现代应用构成严重障碍。这种碎片化严重阻碍了技术创新:

  • 联邦数据库:难以实现高效且正确的跨库查询。
  • 硬件加速:市场碎片化使得为哪种语义设计硬件变得不明朗。
  • 可组合数据系统:如 Calcite、DataFusion 等项目,因底层语义(尤其是类型系统)的细微差异而难以无缝集成。
  • 查询优化即服务:缺乏统一语义,无法在不同系统间自由迁移和优化查询计划。
  • 抑制竞争:客户被锁定在特定的“语义孤岛”中,难以切换供应商。

愿景:定义统一的语义中间表示

面对语义分裂的困境,我们该如何构建一个更合理的未来?本节将提出我们的构想。

我们需要一个类似 LLVM IR 的中间层,即一个定义明确的语义中间表示。它不仅要定义关系操作符的语义,还必须包括:

  • 标量表达式和内置函数
  • 数据类型及其行为
  • 隐式类型转换规则
  • 错误处理行为(如溢出)

理想情况下,这首先是一份形式化规范,并配有一个参考实现。然后,可以定义标准化的序列化格式(内存表示、JSON 表示等)。

那么,如何实现这个愿景呢?我们认为路径需要务实:

  1. 初期:构建一个桥梁,将 PostgreSQL 风格 SQL 的一个子集 转换到这个中间表示。鉴于 PostgreSQL 已成为实际上的通用方言,从此处着手最平滑。
  2. 中期:推动可组合数据系统等新兴项目采纳这个中间表示作为其通用基础。
  3. 长期:希望商业厂商也直接实现或支持这个中间表示,最终使人们可以摆脱 SQL 方言的困扰,直接使用更优雅的前端语言,而引擎只需关注统一的中间表示。

当然,这仍是进行中的工作,面临许多开放问题:

  • 语义应与 PostgreSQL 多接近?
  • 如何高效地支持非 PostgreSQL 兼容的系统?
  • 如何在定义明确错误行为的同时,不妨碍重要的查询优化?

总结

本节课我们一起探讨了 SQL 语言的现状与未来。我们认识到,关系模型是伟大的,但 SQL 作为一个语言却存在严重缺陷:语法糟糕、难以学习、难以实现,最关键的是,各系统间缺乏兼容性。

这种语义上的分裂并非小事,它严重阻碍了数据领域的创新,如联邦查询、硬件加速、可组合系统等伟大构想都因此举步维艰。我们认为,解决之道在于定义一个统一、形式化、明确的语义中间表示

这项工作旨在为数据世界建立一个“理智之岛”,虽然前路挑战重重,但它对于打破藩篱、激发真正的竞争与创新至关重要。最终,我们希望看到一个不再被糟糕语法和矛盾语义所束缚的、更美好的数据处理世界。

003:Apache Pinot 查询优化器

在本节课中,我们将要学习 Apache Pinot 分布式查询引擎的核心优化技术。我们将从 Pinot 的基础架构和查询引擎模型开始,深入探讨其针对连接(Join)和半连接(Semi-Join)的多种优化策略,并介绍一些更高级的优化技术,如动态过滤和公共子表达式消除。课程内容力求简单直白,适合初学者理解。

概述

Apache Pinot 是一个实时分布式 OLAP 数据库,专为超低延迟分析设计,能够在毫秒级延迟下处理每秒数万次查询。其核心是一个列式数据存储,并采用了多种智能索引和预聚合技术。本节课将重点介绍其查询引擎如何通过各种优化策略来达成高性能。

Pinot 简介

首先,我们来了解一下 Apache Pinot 是什么,以及它在数据库生态中的定位。

Pinot 被称为实时分布式 OLAP 数据库,这是一个相对较新但快速发展的类别。它的特点是支持在 PB 级数据上进行超低延迟(毫秒级)和高吞吐量(每秒数万次查询)的分析。

Pinot 的核心是其列式数据存储,它采用了多种智能索引和预聚合技术。另一个显著特点是其出色的可扩展性,你可以通过简单地添加服务器来水平扩展集群的吞吐量和容量,同时 Pinot 会采用智能技术确保查询的尾部延迟不会失控。

Pinot 的主要用例是面向用户的实时分析,这与传统企业内部业务分析不同。它将分析能力通过应用程序直接交付给终端用户,因此需要能够扩展到每秒数万次查询的规模,以支持应用程序用户数量的线性增长。

当然,Pinot 也可用于其他分析场景,包括内部仪表盘、异常检测、即席数据探索等。

为了让大家相信我们接下来要讨论的优化技术并非纸上谈兵,让我们看看一些实际应用数据。许多大型公司都在大规模使用 Pinot。例如,LinkedIn(Pinot 的诞生地)的集群每秒处理超过 65 万次查询,支持 100 多个产品功能。Uber 也将其用于多种用例,包括系统日志分析和餐厅经理应用。Stripe 同样使用 Pinot 处理大量查询。这些数字表明,Pinot 是一个在全球范围内大规模使用的生产级数据库。

数据存储与查询

上一节我们介绍了 Pinot 的基本定位,本节中我们来看看 Pinot 如何存储和查询数据。

和大多数数据库一样,Pinot 将数据组织成表。这些表被划分为不同的段(Segment),段是 Pinot 中的原子存储单元。这些段由列构成(记住 Pinot 是列式存储),而列本身又由各种索引构成。Pinot 支持多种单列索引,如倒排索引、范围索引、地理空间索引、JSON 索引、文本索引等,以适应不同的用例。

在计算方面,Pinot 集群包含控制器(Controller)、代理(Broker)和服务器(Server)三种节点。控制器是集群的“大脑”,负责管理元数据和集群协调,但不直接参与查询执行。服务器负责在本地磁盘存储段,并承担主要的查询处理工作。为了提高可用性和扩展吞吐量,段通常会在多个服务器上存储副本。代理负责接收来自客户端的 SQL 查询,计算最优查询计划,并将查询分派到相应的服务器。计算查询计划时,代理会确定需要查询哪些服务器,以及每个服务器需要处理哪些段。

单阶段查询引擎

了解了数据存储后,我们来看看 Pinot 最初的查询执行模型。

单阶段查询引擎是 Pinot 最初的查询引擎,它支持投影、转换、聚合、分组和用户定义函数等操作。但它不支持更高级的 SQL 结构,如连接、子查询和窗口函数。原因在于它采用了简单的“分散-收集”执行模型:代理接收查询,根据查询计划将请求分散到各个服务器;服务器对分配给它的段执行计算,然后将响应返回给代理;代理收集所有服务器的响应后,将最终结果返回给客户端。

在大多数数据库中,瓶颈通常是 I/O。因此,Pinot 的许多优化都集中在减少服务每个查询所需的 I/O 上。在单阶段查询引擎中,一种方法是在代理和服务器上使用剪枝(Pruning)技术。

例如,在代理端,可以利用分区信息、时间边界等元数据进行剪枝,以减少需要分发查询的服务器数量。在服务器端,则利用列值信息、列统计信息、布隆过滤器等段级元数据进行剪枝,以减少需要处理的段数量,从而降低 I/O 和计算量。

这里需要补充一点,Pinot 中的表可以按列进行物理分区,这一点在后面讨论优化时会很重要。

单阶段查询引擎中最有趣的优化是索引的使用,它们可以极大地减少服务查询所需的 I/O。这些单列索引中,最有趣的是 星树索引,它是 Apache Pinot 的“杀手锏”之一。这个索引实际上执行了预聚合,可以将聚合和分组查询的速度提升数个数量级。这是一个非常有趣的话题,CMU DB 2021 年的研讨会系列中有专门讲座介绍,如果你感兴趣,强烈建议去观看。

多阶段查询引擎

上一节我们介绍了单阶段查询引擎的局限性,本节中我们来看看 Pinot 如何通过新的引擎来支持更复杂的查询。

大约一年半前,我们在 Apache Pinot 1.0 中引入了多阶段查询引擎。这是对原有引擎的一次迭代。其核心思想是将查询划分为多个阶段,而不仅仅是“分散-收集”。查询被分解成这些阶段后,我们会为每个阶段分配并行度。通常,我们会根据要分析的数据创建虚拟分区。

我们从逻辑操作符转换到阶段,再从阶段转换到我们称之为“工作者”的实例。工作者是阶段的不同实例,它们将处理数据的分区。通过这种方式,我们能够以分布式方式执行连接等操作,而无需使用单台机器上的所有资源,这也是多阶段查询引擎命名的关键原因。

在这个模型中,代理仍然承担大量工作。当代理收到查询时,首先会进行优化,应用一些标准的 SQL 转换规则到关系代数上。然后,一个核心任务是决定如何将这些操作符拆分到不同阶段,如何为这些阶段分配工作者,以及信息如何在不同的工作者之间交换。

信息交换可以通过 RPC 通道在不同进程间进行,或者,如果工作者运行在同一进程内,则可以直接在 JVM 内共享内存,后者效率极高,是我们一直努力实现的方式。

需要强调的是,对我们而言,SQL 只是一个前端接口。我们不在 SQL 层面进行优化,而是始终将其转换为关系代数节点。我们重用了 Calcite 的许多组件来跳过一些工作,并应用其已有的规则。我们选择 SQL 作为查询语言,主要是因为它是 OLAP 世界的通用语,就像我们现在用英语交流一样。但这并不意味着我们被 SQL 束缚,事实上,Pinot 社区正在努力支持其他查询语言,特别是在时间序列领域,例如支持 PromQL。

连接算法与策略

现在,让我们进入本次课程的核心部分,开始讨论连接算法和连接策略。

连接算法和连接策略是两个不同的概念。连接算法指的是数据已经位于同一位置后,用于生成连接输出的实际算法,这是经典数据库文献中常见的内容,例如哈希连接算法、排序合并算法和嵌套循环连接算法。

在 Pinot 中,我们主要使用哈希连接算法。在这种算法中,有一个构建侧和一个探测侧。我们使用连接键作为查找键,在构建侧构建哈希表。探测侧在 Pinot 中实现为流式输入,行数据逐一进入,对每一行在构建侧的哈希表中进行查找,如果找到匹配项,则创建连接输出。根据 SQL 连接的类型(如外连接、左连接、右连接、全连接),可能还需要跟踪未匹配的行。哈希连接通常用于等值连接。对于非等值连接,我们有时不得不回退到嵌套循环算法,即遍历一侧的所有行,并对每一行遍历另一侧的所有行,其时间复杂度是二次方的,效率很低,通常应避免使用。

但更有趣的部分是连接策略,它指的是我们用来将所有数据移动到正确位置以执行连接的各种策略。对于像 PostgreSQL 这样的经典单节点数据库,这不是问题,因为所有数据都已经在单个节点上。但在像 Pinot 这样的分布式数据库中,表的数据天然分布在多个服务器上,我们需要确保所有需要连接的数据都在正确的位置。

这通常涉及服务器之间的网络数据洗牌(Shuffle),这是一种昂贵的 I/O 操作,通常是连接查询的瓶颈。我们的目标就是优化这种数据洗牌,尽可能减少网络 I/O 开销。接下来,我们将通过几种连接策略来探讨如何实现这一点。

连接策略详解

以下是 Pinot 中使用的几种主要连接策略。

查询时分区策略

这种策略可用于等值连接(连接条件如 A.某列 = B.某列)。我们可以在查询时使用一个分区函数(通常是哈希函数),该函数保证如果连接条件成立,那么由该函数计算出的分区值将是相同的。

具体操作是:为连接的两侧创建相同的虚拟分区,并确保相同编号的分区位于同一台服务器上。然后,对于每个段中的每一行,计算其虚拟分区,并将数据发送到该分区所在的服务器。这可能会涉及网络数据洗牌和内存数据传输。由于分区函数的特性,我们可以保证最终位于不同分区的行原本就不需要连接。这样,我们就能在多个服务器上并行执行本地连接,同时保持连接语义。最后,合并各服务器的结果即可得到最终输出。

此策略洗牌的行数受连接两侧总行数的限制。在最坏情况下,每个段中的每一行都需要通过网络洗牌到另一个服务器的虚拟分区。

广播连接策略

查询时分区策略并不总是适用,例如对于非等值连接条件(如 A.某列 > B.某列)。此时,我们不能对两侧都进行分区,因为某些行可能永远不会被连接,从而破坏连接语义。

取而代之的策略是:对连接的一侧进行分区(分区策略可以是随机的或哈希的),而将另一侧广播到所有分区所在的服务器。这确保了连接语义得以保留。这种策略通常在连接的一侧远小于另一侧时效果很好,我们可以广播较小的一侧,而对较大的一侧进行分区。

此策略洗牌的行数受分区侧的行数加上广播侧的行数乘以执行连接的服务器数量的限制。我们可以控制执行连接的服务器数量(即连接并行度),这需要在数据洗牌开销和计算并行度之间进行权衡。

广播加本地连接策略

这是前一种策略的变体,也可用于非等值连接等情况。不同之处在于,广播侧的数据被洗牌到所有已经包含另一侧(本地侧)数据的服务器上。这对于大小严重倾斜的表连接非常高效:我们只需将小表的数据洗牌到所有存放大表数据的服务器上,然后在每个服务器上执行本地连接算法。

这种策略的优势是有一侧完全不需要洗牌。缺点是连接的规模不能超过存放本地侧数据的服务器数量,但这通常不是问题。此策略洗牌的行数仅仅是广播侧的行数乘以实际执行连接的服务器数量。

共置连接策略

这是一种可以完全消除数据洗牌的策略,但需要满足一系列条件:仅适用于等值连接;连接的两张表必须在连接列上进行物理分区;分区函数、分区数量以及分区到服务器的映射必须完全相同;并且服务器必须持有连接中每个表的所有对应分区段。

当所有这些条件都满足时,我们可以执行共置连接。由于数据已经根据连接键共置在相同的服务器上,我们只需在内存中移动数据,无需任何网络开销,即可执行本地连接。这种策略的速度通常比其他策略快一两个数量级。虽然条件苛刻,但 Pinot 支持多种表配置来促成这种策略。许多其他分布式实时 OLAP 数据库只支持这种策略,而在 Pinot 中,它只是众多选项之一,当然也是最有效的,我们会尽可能采用。

查找连接策略

这是共置连接的一种变体,非常具有 Pinot 特色。我们有一种称为“维度表”的小表,它们被复制到所有服务器上,并作为查找表物化在内存中。当与这类表进行连接时,即使不满足严格的共置条件,我们也可以利用这些内存查找表来执行无数据洗牌的连接。

优化规则与应用

上一节我们介绍了各种连接策略,本节中我们来看看 Pinot 如何应用优化规则来选择这些策略。

Pinot 目前不使用基于成本的优化(至少尚未使用),而是急切地应用优化规则。我们执行许多规则转换,例如将过滤器下推到连接中、将聚合下推到连接中,以及在可能时将连接转换为半连接等。我们还需要为数据洗牌选择连接策略。

由于目前只基于简单的启发式方法急切地应用这些规则,某些规则只能通过用户提供的提示(Hint)有选择地应用。这意味着我们依赖用户了解其数据布局和查询模式,并通过试错来选择最佳策略。这样做的原因是,代理目前对表统计信息和段级数据布局的了解非常有限,让代理维护数百万个段的元数据并据此做出决策成本过高。

我们确实希望支持更智能的优化,社区也一直在讨论支持基于成本的优化或基于历史的优化(根据类似查询的历史统计信息进行优化)。但我们必须非常小心优化本身的开销。目前我们的优化过程非常简单,只是急切地应用规则并计算一个查询计划,这个计划可能不是全局最优的,但肯定比显而易见的方案更优。我们需要确保引入的延迟开销是值得的,因为 Pinot 通常用于低延迟环境,优化本身不应成为新的瓶颈。

高级优化技术

除了针对连接的优化,Pinot 还采用了一些更高级的优化技术来进一步提升查询性能。

流水线断路器与半连接优化

考虑这样一个查询:一个大表与另一个小表进行半连接(使用 INEXISTS 子查询)。在正常情况下,这需要扫描整个大表和小表,然后为每一行应用过滤,效率不高。

我们采用的优化算法是“流水线断路器”。首先,单独评估子查询(紫色部分)。一旦获得其值,如果结果集不大,我们可以用这些实际值替换子查询。这样,后续执行时,服务器就可以利用大表连接列上的索引(如倒排索引)来高效地过滤行,而无需加载所有行。这种技术带来的性能提升是巨大的。

这种优化也可以应用于某些内连接,只要该连接在语义上等价于半连接。但并非所有连接都能这样转换,它通常只适用于内连接,且连接的一侧不能用于投影,并且该侧的连接键必须是唯一的。在 OLAP 世界中,通常没有唯一键约束,因此数据库无法自动知晓这一点。这时,用户可以通过提示告知系统假设该侧是唯一的,或者在子查询中显式使用 DISTINCT

公共子表达式消除

随着许多查询由 ORM、BI 工具或 LLM 自动生成,查询变得非常冗长,经常重复相同的结构和表达式。我们不想多次计算相同的代码。

公共子表达式消除(CSE)就是我们用来解决这个问题的工具。它确保相同的表达式只计算一次。例如,如果一个派生关系在过滤器和连接中被使用了两次,我们可以通过 CSE 只计算该状态一次,然后通过交换(Exchange)将信息发送到两个不同的下游阶段。这也是我们用来改进普通连接(即使不满足半连接优化所有要求)的一种工具。

动态过滤

这是一个有趣的功能,它旨在通过使查询计划更复杂来使其更快。其思想是支持类似“流水线断路器”的特性,但应用于更广泛的场景,而不仅仅是半连接。

假设我们有一个内连接查询,由于需要投影小表的列,或者数据库不知道小表连接键是否唯一,我们无法直接应用半连接优化。动态过滤的思路是,在保持原有连接的同时,额外添加一个过滤器,该过滤器再次读取小表。然后,我们可以利用“流水线断路器”特性来编译这个过滤器,使其能够使用大表上的索引(如倒排索引)。这样,我们就能在连接之前有效地过滤大表。

这是一种“横向信息传递”的风格,即在一侧计算出一个过滤集合并用它来过滤另一侧的大表,而无需实际物化所有行并通过网络发送。这个功能在 Trino 或 Alibaba AnalyticDB 等数据库中已被证明非常有用,Pinot 社区也正在计划实现它。

总结与展望

本节课中我们一起学习了 Apache Pinot 查询引擎的核心优化技术。

我们了解到 Pinot 是一个分布式实时 OLAP 数据库。我们选择 SQL 作为查询语言,并且正在努力支持其他语言(如 PromQL)。但在底层,无论使用何种语言,最终都会被转换为我们内部的关系代数模型,从而能够应用相同的优化技术。

我们深入探讨了多种分布式连接策略,包括查询时分区、广播、共置连接等,目标是减少昂贵的数据洗牌。我们还介绍了一些高级优化,如针对半连接的“流水线断路器”优化、公共子表达式消除以及未来的动态过滤功能。

目前,Pinot 主要依赖启发式规则和用户提示进行优化,未来可能会引入基于成本或历史的更智能优化,但必须谨慎权衡优化开销与收益。

Pinot 是开源项目,拥有活跃的社区。如果你对贡献代码感兴趣,欢迎参与其中,许多大型功能都是先经过设计讨论再开始实现的。

希望本教程能帮助你理解 Apache Pinot 如何通过一系列巧妙的优化,在分布式环境下实现高速查询处理。

004:面向21世纪的SQL

在本节课中,我们将要学习Google提出的SQL管道语法(PipeSQL),这是一种旨在解决传统SQL语法问题、提升开发体验和促进语言未来发展的新语法结构。

概述:SQL的挑战与机遇

SQL作为一种已有50年历史的语言,虽然取得了巨大成功,但其语法结构也存在一些问题。这些问题使得SQL对于初学者难以学习,对于专家用户也显得笨拙和低效。与此同时,近年来许多新设计的查询语言和API(如数据框架)都采用了类似Unix管道的数据流语法,用户普遍认为这种结构更易于理解和使用。

我们的解决方案不是替换SQL,而是在SQL内部引入管道语法。它保留了SQL所有优秀的特性(如声明式语义、关系运算符、表级组合性),同时通过一种更直观、更灵活的语法结构来提升开发体验。

SQL语法的问题

上一节我们概述了SQL面临的挑战,本节中我们来看看具体是哪些语法特性导致了这些问题。

SQL查询的编写顺序非常固定和武断,必须按照 SELECTFROMWHERE 等子句的特定顺序书写。进行任何非平凡操作通常都需要使用子查询或其他变通方法。

这种结构导致了奇怪的“由内向外”的数据流。查询从中间的 FROM 子句(或嵌套子查询)开始,逻辑同时向上下两个方向展开。此外,还存在大量的冗余和重复,例如需要在 SELECTGROUP BY 等子句中反复列出相同的列名。SELECTGROUP BY 虽然语义紧密相关,但在查询文本中却相隔甚远,这增加了复杂性。

管道语法:一种现代的解决方案

既然我们看到了传统SQL语法的问题,本节将介绍管道语法如何作为一种解决方案。

管道语法借鉴了Unix管道、数据框架API等现代设计模式。其核心思想是:将SQL中的所有操作符转化为对应的管道操作符,并使用管道符号(||)将它们以任意顺序、任意次数地连接起来。这样,查询逻辑就从查询的顶部流向底部,非常易于理解。

重要的是,这仍然是声明式的。查询优化器仍然可以介入并重新排序操作符,以找到最优的执行路径。

我们使用双竖线符号(||)作为管道操作符。这是一个折衷方案,因为单竖线(|)在许多SQL方言(包括我们的)中已被用作位或运算符。一旦习惯,这并不算太糟,并且我们看到相同的符号也出现在许多其他语言中用于类似目的。

以下是管道语法支持的主要操作符列表:

  • 起始操作:查询可以从任何标准的 FROM 子句开始,包括在 FROM 中进行连接操作。
  • 标准子句:许多标准SQL子句都有对应的管道版本,语法完全相同。
  • 选择列表操作符SELECT 列表本身可以作为一个操作符。同时提供了一些快捷方式,用于添加、更新、更改列,而无需列出要保留的所有其他列。
  • 独立的聚合操作符:将聚合操作与投影分离,使其在数据流中成为一个独立的部分。这出于多种原因(包括可读性)被证明非常方便。
  • 其他操作符:还包括一系列其他操作符。

示例对比:传统SQL vs. 管道SQL

理解了管道语法的概念后,我们通过一个具体例子来直观感受两者的差异。

这个例子来自TPC-H基准测试中的一个查询,它需要进行两步聚合操作。

传统SQL写法

SELECT ... FROM (
    SELECT ... FROM lineitem
    JOIN part ON ... GROUP BY ...
) AS intermediate
GROUP BY ... ORDER BY ...

这种写法展示了标准SQL中奇怪的“由内向外”数据流模式。你必须从最中间的内部 FROM 子句开始,向上追踪逻辑,再向下追踪逻辑,并匹配许多内容。

管道SQL写法

FROM lineitem
|| JOIN part ON ...
|| AGGREGATE BY ...   -- 第一步聚合
|| AGGREGATE BY ...   -- 第二步聚合
|| ORDER BY ...

管道语法直接按照你想要执行的顺序来表达操作:扫描表、连接、聚合、再次聚合、排序。整个过程非常直接且易于理解。

有一张来自CIDR 2023年论文的图片清晰地说明了标准SQL的奇特之处:语法书写顺序与查询执行时的语义顺序严重脱节。相比之下,用管道语法书写时,这些线条被拉直了,语法与语义完全匹配,非常清晰简洁。

需要再次强调,两种语法都是声明式的,并不指定执行顺序。因此,无论用哪种方式编写查询,你都应该获得相同的性能和结果。

互操作性与实现

看到管道语法的优势后,你可能会担心它与现有生态的兼容性。本节将说明其出色的互操作性。

互操作性是一个重要优点。由于这是在SQL内部进行的改进,我们允许在任何正常查询可以工作的地方添加管道语法。你可以在同一个查询、跨视图、公共表表达式(WITH子句)中混合使用两种语法。甚至可以在一个标准语法的查询末尾添加管道操作符来进行额外的计算,并且可以使用所有相同的工具。

例如,你可以在一个查询中混合使用两种语法的操作符。

关于实现,我们已在Google SQL中完成了这项工作。Google SQL是我们用于SQL解析和SQL分析的共享组件,用于Google内部所有SQL相关工具以及我们的云产品(如BigQuery、F1等)。在查询前端分析器中实现,使我们能够为管道语法查询生成与标准语法查询相同的中间表示。因此,对于查询引擎来说,它们接收到的是相同的东西,只需启用一个标志即可免费获得管道语法支持,而无需实现任何新的执行逻辑。这使我们能够在多个工具中支持此功能。

我们在去年的VLDB会议上发表了相关论文,其中包含了关于语言设计、所做选择以及分析的更多细节。

实际使用与用户反馈

理论上的优势需要实践检验,本节我们将看看管道语法在实际应用中的表现。

我们观察了Google内部主要查询引擎F1中管道查询的使用情况。使用量增长相当迅速,并且还在加速。我们看到的现象是:用户一旦看到这种语法,就能很快学会并希望使用它。它具有良好的粘性,并像病毒一样传播开来,这表明用户对其非常满意。

这些数据主要代表用户手写的查询。目前我们不知道有大量生成管道语法查询的工具。但这也正是优势之一:在代码中生成查询时,以这种形式生成应该会更容易。

那么,管道语法适合谁用?我们的答案是:所有编写SQL的人,至少是所有编写非平凡SQL查询的人。

  • 对于已经精通SQL的专家用户,学习它非常容易,只需几分钟了解其工作原理和几个例子即可。它的好处在于,你可以使用相同的操作符和基本相同的语法,只是应用它们的结构更好、更灵活。这些用户会立即发现自己在编写和编辑SQL时效率更高。
  • 对于初学者用户,以及那些接触过SQL但不喜欢它的用户,管道语法修复了许多导致用户不愿使用SQL的困难和烦人之处。

我们收到的用户反馈 overwhelmingly 是积极的。一些评论称这可能是他们在SQL中见过的最有用的变化,兴奋程度令人惊讶。这不仅仅是看起来更漂亮的语法,能够在这种语法下工作实际上改变了你思考和使用SQL的方式,让人感到非常自由。用户甚至表示,以这种方式使用SQL更有趣。当你使用过管道语法再回到标准SQL时,你会真切地感受到标准SQL中的许多笨拙之处,感觉像是变通方法或不必要的困难。

管道语法如何提升开发体验

用户反馈证实了管道语法的价值,本节我们深入探讨它是如何具体提升编写、编辑、阅读和调试SQL的体验的。

编写和编辑查询

  • 增量构建:你可以从 FROM 子句开始,然后在构建查询时不断在末尾添加操作符。在任何时间点,查询都是可执行的,你可以运行它查看当前结果,然后只需在末尾添加另一个操作符。
  • 操作独立性:通常,你添加的操作符独立于查询中的大部分内容,无需进行那些保持 SELECTGROUP BY 和子查询同步的全局编辑。
  • 更好的工具支持:自动完成和建议通常效果更好,因为上下文来自你正在编写的上方位置,而标准SQL不具备这个特性。这对于未来的AI编程助手(如SQL Copilot)也是一个很好的探索领域,可以充分利用这一点做出更智能、更有帮助的功能。

阅读和调试查询

  • 前缀属性:管道查询有一个非常好的前缀属性:截止到任何一个管道操作符的查询前缀,本身也是一个有效的查询。你可以抓取这个查询前缀并运行它,查看中间结果,或者查看在应用某个操作符(如聚合)之前和之后的结果。
  • 即时分析:在任何中间查询中,你都可以插入另一个操作符(如聚合)来查看计数或值的分布,这在调试时非常有帮助。
  • IDE潜力:我认为有机会构建一个用于处理SQL的出色IDE,例如可以设想一个能够单步调试查询的调试器。在标准SQL中,你甚至无法考虑这样做,因为其语法不支持逐步执行。

此外,在现有查询引擎和SQL语言中实现此功能也有很大优势。因为它不是一个需要学习的新产品或新语言,用户无需做出重大决策即可开始使用。它只是一个现成的功能,非常容易尝试,无需承诺或设置。这允许单个用户先进行尝试,也许只是通过他们的即席查询,然后当他们看到价值时,可以将其推广到团队的其余部分。这实现了“病毒式”传播。相比之下,其他方法可能需要更大的决策或某种迁移,而在这里,它是渐进式的,你可以继续之前的工作,只在有帮助的地方使用新语法,并与之前的所有内容保持完全的互操作性。同时,你也避免了额外系统、代理或转换层可能带来的缺点,例如调试时无法确定实际运行内容的问题,或者通过额外代理层带来的成本或延迟问题。

可扩展性:为SQL注入新活力

管道语法不仅改善了现有体验,更重要的是它为SQL的未来扩展打开了大门。本节我们将探讨其在可扩展性方面的巨大潜力。

我将可扩展性分为三类:查询引擎进行的扩展、用户进行的扩展以及语言设计层面的扩展。

1. 表值函数(TVFs)
TVF本质上是通用的关系运算符。你可以在查询引擎中添加TVF,并在标准语法中调用它们,但语法非常糟糕且难以使用,因为它迫使你使用嵌套子查询的模式将输入作为参数传递给TVF。当你尝试链接多个TVF时尤其糟糕。

我们为调用TVF创建了管道操作符形式。输入表作为第一个参数传递给TVF,这允许以非常自然的方式调用它们,就像内置的管道操作符一样。

例如,BigQuery有许多用于机器学习操作的函数,其中一些是作为TVF实现的。标准SQL中调用两个ML模型(如嵌入模型和分类器模型)的示例,展示了TVF在标准SQL中典型的“由内向外”或“自底向上”的模式。而在管道语法中,调用这些操作符显然直接得多。

2. 用户扩展(以滑动窗口为例)
关于在SQL中添加流处理操作符(如滑动窗口、跳跃窗口、会话化)的讨论已有一段时间。将其作为TVF实现是一种很好的语义方法,但调用语法很笨拙。管道调用语法对此会很友好。

如果你想在SQL中计算滑动窗口(例如计算7天滑动窗口内的活跃用户),你可能会在多个查询中重复一段逻辑。更好的方法是将其制作成可重用的函数。

在管道语法中,你可以轻松地将这一系列操作符提取出来,封装成一个表值函数。然后,在调用时,只需一行代码即可完成之前查询中的逻辑。这实际上是一个用户构建的扩展,可以非常容易地插入到许多查询中,看起来基本上像是一等操作符。这种封装使得构建扩展库并用SQL编写它们成为可能。

在标准语法中,技术上你也可以用TVF做到这一点,但调用语法太差,用户通常不会这么做。而且,在标准语法中,你甚至不具备“可以剪切出查询中间的一部分作为自包含内容运行”的特性。

3. 语言设计扩展(以递归查询为例)
在标准SQL中扩展语言实际上非常困难。存在许多语法挑战(如放在哪里、保留关键字冲突)、语义挑战(新操作符如何与聚合、窗口函数、相关子查询等交互),这造成了二次复杂度。由于语法折衷,最终往往导致可用性差,并且通常需要使用更多子查询才能让新功能工作。

相比之下,在管道语法中添加新功能要容易得多。操作符之间是独立、正交的。解析通常很容易,因为管道符号是操作之间的自然分隔符,它们可以独立解析,通常不需要保留关键字,至少不会相互冲突或引起冲突。语义也简单且局限于每个单独的操作符,除了输入表和输出表之外,操作符之间没有全局交互或状态。因此,在添加功能时,复杂度更像是线性的,你只需添加功能,而无需考虑交互。对于用户来说,以这种方式添加的功能非常棒,因为你可以在任何地方使用新操作符,并且完全灵活。

以递归查询为例。在标准SQL中,递归查询的工作方式相当奇特,难以解释或理解。它使用 WITH RECURSIVE 语法,并非通常意义上的递归,对查询形状有复杂的支持要求,并且需要围绕 WITH RECURSIVE 子句构建查询。

我们的管道操作符递归查询实现方式不同。它从一个生成输入数据的基础查询开始,然后 RECURSIVE UNION 操作符接收输入查询并重复运行子查询,直到没有新行添加为止。它返回该联合的输出,然后查询的其余部分继续。这提供了更清晰的语法,明确区分了基础查询和递归查询,可以用简单的伪代码轻松解释其行为,并且是可组合的——你可以在查询的任何地方使用它。例如,如果你想在第三次连接中进行递归树遍历,你可以直接将其混合到查询中间,而无需围绕 WITH RECURSIVE 子句重构整个查询。

管道语法对未来SQL发展的意义

我们探讨了管道语法在可扩展性上的优势,本节将展望这对SQL生态系统的长远意义。

为什么语言可扩展性很重要?许多人可能见过描述一种重复模式的论文:为了解决新领域的问题,经常会创建新事物(通常是新语言、新的数据处理系统)。最终,我们总是发现希望将这些功能添加回SQL和关系模型中。但这个过程需要很长时间,我们总是先创建那些其他系统。

我认为,这种模式出现的一个重要原因是,标准SQL是一个非常糟糕的创新平台,由于我之前描述的二次复杂度问题,很难在SQL中进行实验或添加新东西。

管道语法可以改变这一点,因为添加新操作符变得容易,你可以独立于其他所有内容进行添加,而不会破坏现有查询。这有可能释放大量创新。我们可以审视其他系统、其他地方出现的好想法,将它们作为操作符添加到SQL中,并且可以相当容易地做到。对于未来的创新,我们不需要为其发明新语言并说服人们使用,我们可以直接在SQL中添加操作符和功能。

案例研究:运维分析
运维分析(生产监控、告警)领域通常不使用SQL。该领域有许多语言和工具,但与SQL相比,在查询和理解数据方面相当原始。在Google,我们的SRE或工程师对现有工具的状态并不满意。

选项一是尝试再次发明一种新语言,我强烈反对这样做。选项二是寻找行业或开源中喜欢并想使用的东西,但我们没有找到令人信服的选择。那么SQL呢?实际上,过去10年里,我每隔几年就会与这个领域的团队讨论这个问题。以前总觉得“我们可以用SQL做这个,但真的可行吗?真的有人想用吗?”现在,有了管道语法,情况发生了转变。现在它看起来是可行的,并且似乎有吸引力。我们需要做的就是添加一些更多的时间序列操作符(用于对齐、插值等),然后我们就可以将整个领域整合到SQL分析和生态系统中,从中获得很多好处。这是一个相当早期但活跃的工作领域。

我认为还有许多其他领域同样适用。每个人可能都有自己的清单,列出他们希望在SQL或其他系统中实现的功能。未来存在大量机会。

总结与展望

在本节课中,我们一起学习了Google提出的SQL管道语法(PipeSQL)。我们探讨了传统SQL语法存在的问题,介绍了管道语法如何通过类似Unix管道的直观数据流结构来解决这些问题。我们看到了它在提升开发体验、增强可读性、简化调试方面的优势,更重要的是,它极大地降低了为SQL添加新功能的门槛,为SQL未来的创新和发展铺平了道路。

SQL已经成功运行了50年,其基本面和生态系统非常强大。它最需要改进的不是核心语义,而是语法。语法是用户的负担,也是未来增长和创新的障碍。

为了迎接SQL的下一个50年,我们首先需要修复语法。管道语法是一种非常有帮助的方法。我们不需要替换SQL,不需要放弃所有优点或生态系统,我们只需要修复语法。仅语法改变本身对用户来说就是一个巨大的胜利,他们喜欢使用它,并且这对语言的未来演进也是一个巨大的改变。

我对未来可以做的事情以及SQL的未来充满乐观。

最好的感受方式是亲自尝试。自2024年2月起,任何人都可以在BigQuery中使用它。它也在Databricks和Spark中实现,他们的首个版本最近刚刚发布。我们的一些代码也已作为开源提供,供大家试用。

对于社区来说,有趣的是我们如何向前发展:如何在更多系统中支持SQL管道语法,或者最终使其成为标准。我希望如此。

005:用于分析、转换和建模数据的现代开源语言

在本节课中,我们将学习 Malloy,一种由劳埃德·塔布(Lloyd Tabb)设计的现代开源数据语言。它旨在解决传统 SQL 在数据建模和分析中遇到的可重用性和复杂性挑战。

背景与动机

上一节我们介绍了数据工作通常围绕矩形(表格)展开。本节中,我们来看看传统方法在处理数据关系时遇到的问题。

劳埃德·塔布拥有丰富的职业生涯,从早期的 Dbase for Windows 到共同创立 Looker。他观察到,在数据工作中,人们总是需要从一堆表和关系说明开始,每次都要重新构建逻辑关系,缺乏一个可重用的数据 API。

传统数据仓库的问题

在 SQL 中,我们首先将多个矩形(表)连接起来,然后在最后阶段进行过滤、分组、投影和窗口操作。

让我们从一个简单的用例开始:我们有两个表,orders(订单)和items(商品项)。我们想计算总运费(来自orders表)和总收入(来自items表)。

按订单日期分组计算这两项似乎很直接。但是,如果我们想要一个同时包含订单日期收入运费的表格,直接连接两个表后求和会导致错误。原因是连接操作会使数据“扇出”:一个订单行会因关联的多个商品项而重复。

公式:错误结果源于连接后对重复行进行简单求和。

为了解决这个问题,传统方法需要先按维度(如订单日期)分别聚合生成两个矩形,然后再将它们连接起来。这导致了大量几乎相同的重复代码或物化表。

Malloy 的解决方案:源(Source)与对称聚合

Malloy 承诺:连接关系不会影响聚合计算。其核心创新是“源”声明,它是一个可重用的对象,描述了数据关系网络和聚合计算。

在源中,聚合计算通过导航到关系网络中的特定位置来编写。例如,计算总收入时,我们导航到 items.price 然后求和。计算运费时,我们导航到顶层的 shipping_cost 然后求和。

代码示例(源声明):

source: orders_with_items is orders {
  join_one: items is items on order_id = items.order_id
  measure:
    total_revenue is items.price.sum()
    total_shipping is shipping_cost.sum()
}

这个源成为了数据的 API。我们可以从中选择任何维度或度量,并获得正确的结果。

代码示例(查询):

query: orders_with_items -> {
  group_by: order_date
  aggregate:
    total_revenue
    total_shipping
}

Malloy 通过“对称聚合”机制实现这一点:它识别矩形的唯一键,对值进行去重,然后再求和。这保证了在任何连接和维度组合下,聚合都能被正确计算到指定的矩形上。

嵌套数据与非矩形结构

数据并不总是矩形的,它通常以网络形式(如 JSON)出现。Malloy 的第二个创新是原生支持嵌套数据。

对于嵌套数据(如订单内包含商品项数组),Malloy 会自动理解如何连接和取消嵌套。用户使用源的方式与处理扁平化连接表时完全相同,无需关心底层数据是嵌套的还是已连接的。

反过来,Malloy 也允许在查询中创建嵌套结果。任何在主查询中可以进行的操作(如分组、过滤)都可以在嵌套查询中进行,甚至可以包含聚合,这使得构建用于后续管道处理的数据层次结构变得非常容易。

代码示例(嵌套查询):

query: orders_with_items -> {
  group_by: order_date
  nest: items_by_category is {
    group_by: category
    aggregate: item_count is count()
  }
}

其他特性与演示

Malloy 还包含其他特性:

  • 未分组聚合:使用 all() 函数可以跳出当前分组,用于计算占总数的百分比等。
  • 注解与渲染:可以为查询中的任何元素添加元数据标签,指导下游如何渲染(如将州代码标记为地图形状)。
  • 维度索引:识别数据网络中的低基数维度值(如用户名、产品类别),有助于 AI 或 LLM 理解数据并正确过滤。
  • 复合源:支持多源继承,用于访问超大规模数据集的立方体数据。

在演示中,我们看到了 Malloy 的 VS Code 开发环境,它支持预览数据、编写查询、查看生成的 SQL 以及使用可视化查询构建器进行探索。一旦建立源模型,就能快速构建和组合各种查询视图。

问答与总结

在问答环节中,讨论要点包括:

  • Malloy 支持多对多连接,通过声明连接类型和导航到正确的矩形进行聚合,可以正确处理重复计数问题。
  • 与 SQL 视图相比,Malloy 的“源”提供了维度自由,无需为不同粒度创建大量视图或表。
  • Malloy 查询最终被编译为单一 SQL 语句,可以利用数据库引擎的强大功能。它也可以被物化。
  • 目前 Malloy 支持约 8 种 SQL 方言,通过巧妙的 SQL 生成来适配不同数据库的特性。
  • 未来的重点是用户采纳、构建更完善的企业级工具(包括权限模型),并在 Meta 内部和开源社区推广。

本节课中我们一起学习了 Malloy 如何通过引入“源”的概念和对称聚合,解决了 SQL 在数据建模中可重用性差和容易出错的问题。它使数据关系成为一种可重用的抽象,让查询变得更简单、更安全,并能够自然地处理嵌套数据结构,为现代数据分析提供了新的范式。

006:流水线式关系查询语言

在本节课中,我们将要学习 PRQL(Pipeline Relational Query Language,流水线式关系查询语言)。这是一种旨在改进 SQL 语法和开发体验的新型查询语言。我们将探讨其设计理念、核心特性以及它如何解决 SQL 在可读性、可组合性和可测试性方面的痛点。

欢迎回到我们的“SQL 或死亡?”系列研讨会。本周 Andy 因病缺席,由我 Sam 代为主持。今天我们邀请到了 PRQL 的贡献者 Tobias Brandt。Tobias 是一位从理论物理转向金融数据领域的专家,拥有二十多年使用 SQL、Python 乃至 Excel 处理市场数据的经验。他对数据转换的简洁性和优雅性充满热情。Tobias 目前在南非,现在是当地晚上 10:30,非常感谢他能在这个时间加入我们。有请 Tobias。

谢谢 Sam 的介绍,也感谢 Andy 的邀请。我非常兴奋能向大家介绍我们正在开发的 PRQL。作为一个同样遵循“数据库导向生活方式”的人,我很认同开场音乐的氛围。那么,让我们开始吧。

为什么需要另一种查询语言?

这一切始于 2022 年 1 月,我们的核心维护者之一 Max 在 Hacker News 上发布了一篇题为“Better SQL”的帖子。这篇帖子迅速登上了 Hacker News 的首页,并引发了大量有趣的讨论。我当时看到了这篇帖子,并在几个月后参与其中,一直工作至今。

从数据科学家和数据工程师的视角来看,PRQL 主要针对复杂的 OLAP 类型查询,适用于交互式工作和探索性数据分析。因此,语言的人体工程学(易用性)、可读性(我相信代码被阅读的次数远多于被编写的次数)以及可组合性、可扩展性和可测试性都至关重要。

SQL:鲨鱼还是恐龙?

SQL 已有 50 年历史,并且经久不衰。我们想问的是:它是一只已达到进化顶峰、无需进一步演化的鲨鱼,还是一只臃肿不堪、濒临灭绝的恐龙?

首先,让我们看看 SQL 作为“鲨鱼”的一面。SQL 无处不在,从关系型数据库管理系统,到 Pandas 或 Polars 等 DataFrame API,再到数据湖仓引擎。在 PRQL,我们认为 SQL 的成功基于两个关键方面:关系模型声明式特性

首先,关系模型。数据本质上是表格形式的。一张来自 5000 年前记录早期贸易的泥板图片说明,数据以表格形式存在至少已有五千年历史。因此,关系模型在过去 50 年里一直存在,并且很可能在未来 50 年继续存在。

其次,SQL 是声明式的。这为查询优化器提供了大量优化查询的机会,例如谓词下推。这意味着计算被带到数据附近进行,尤其是在现代云数据仓库的背景下,这与需要将所有数据拉取到本地内存中处理的 DataFrame 方法形成对比。

SQL 作为“恐龙”的问题

现在,让我们看看 SQL 作为“恐龙”的问题。在本系列研讨会的早期,Victor Lh 和 Thomas Neumann 做了一个关于“Towards Sanity in Query Languages”的精彩演讲,我强烈推荐大家观看。我基本同意他们的观点,并从一个实践者的角度复述其中一些关键点。

其中一个问题是 SQL 基于关系演算。这导致查询具有全局作用域,各种子句(如 JOIN)将数据引入这个作用域,然后在 SELECT 子句中暴露。对于阅读查询的人类来说,这很不理想,因为当你在 SELECT 部分时,必须追溯每个列的来源,这使得分析和维护此类查询变得困难。

接下来,我想简要探讨一下关系代数与关系演算的区别。网上常有人说关系演算是声明式的,而关系代数是命令式的。但 Thomas 也指出,关系代数看起来是命令式的,实际上并非如此。从数学定义上讲,代数关乎函数的组合,因此它本质上是函数式的,而非过程式或命令式,因为没有不通过函数传递的外部状态。所以,关系代数仍然是声明式的。通过坚持关系代数而非关系演算,我们并没有放弃任何东西。Codd 定理证明两者是等价的,PRQL 具有关系完备性,因此没有丧失通用性。

SQL 的另一个问题是其语法零散且重载,尤其是在 SELECT 子句中。例如,SUM 在窗口函数和 GROUP BY 聚合中的行为不同,但使用相同的语法。这看起来非常过时。

更实质性的问题是 SQL 缺乏可组合性。有人可能会用嵌套 FROM 子句来反驳,但 SQL 无法像其他语言那样构建良好的抽象。一条推文说得很好:“SQL 查询是代码世界中的一次性塑料。”在实践中,人们经常复制粘贴查询并尝试修改,没有可重用的 SQL 代码库或函数,也缺乏可靠的测试。

此外,SQL 语法不规则,难以学习。Julia Evans 在 2023 年 Strangeloop 的演讲中描述了同事学习 SQL 的困难。对于初学者,必须记住子句的顺序,WHERE 和 HAVING 都进行过滤但使用不同关键字,窗口函数更加复杂。这给初学者带来了很大障碍。

另一个问题是 SQL 方言众多。如今,数据工程师和科学家经常需要从多个数据源提取数据,不同方言之间的差异成为了实际工作中的障碍。

最后是 反语义问题,Victor 在他的演讲中详细讨论过。不同数据库系统之间存在许多不一致的行为,这呼吁一种通用的中间表示。PRQL 可以作为一种人类可读的前端查询语言,序列化成这种表示,再发送给查询执行引擎。

替代性查询语言

现在让我们看看一些替代性查询语言。Thomas 介绍了 SanQL,如果你只看代码片段,它和 PRQL 非常相似。我将他的示例用 PRQL 重写,编译后得到的 SQL 也很直接。

但 PRQL 并非第一个。在此之前,已经有许多遵循流式 API管道流的替代方案。它们通常从记录集或表开始,逐步构建查询。但这些查询构建器通常被困在它们所构建的特定语言生态中(如 Python、R、C#)。PRQL 的目标是创建一种通用语言,可以在所有这些环境中使用,并以通用方式序列化到数据库。

例如,Ibis 项目采用了 Pandas API,并通过不同后端进行序列化,这与 PRQL 利用 SQLGlot 库的方式类似。它的一个缺点是仍然绑定在 Python 生态中。F# 中的计算表达式(本质上是 Monad 的巧妙说法)也提供了类似的管道体验。

为什么选择管道式查询?

为什么大家都在选择管道式查询?我认为有几个关键特性:

  1. 前缀属性:从查询开头开始的任何子查询本身都是有效的查询。
  2. 模式前置:一开始就指定表,便于 IDE 自动补全和类型检查。
  3. 局部上下文:管道中的每一步只转换到该点为止的数据,阅读时只需跟踪当前上下文,可读性更高。
  4. 可组合性与可扩展性:这是我们在数据世界中构建抽象所缺失的。

PRQL 是什么?

带着以上铺垫,现在让我们看看 PRQL 做了什么。正如 Omar 所说,“你来挑战王者,最好别失手。”我们很清楚 SQL 历史悠久,但迄今为止 PRQL 获得了不错的反响和采用,这鼓励我们继续前进。

PRQL 核心特性

PRQL 中的 “P” 代表 Pipeline(管道)。主要特性是管道,有两种构建方式:

  • 使用 Unix 管道符号 | 写成一行的形式:from table_a | filter condition | select columns
  • 大多数情况下,我们使用换行符作为管道符号,这样查询看起来是自上而下的,更易于人类阅读。

大多数内容都很直观,我重点指出几个关键方面:

  • 转换(函数):语言是规则的。例如,filter 可以在 group 之前或之后使用,没有 WHERE 和 HAVING 的区别。只要列兼容,你可以移动、注释掉代码行,这对实践工作非常灵活。
  • 正交的原语操作符:以 group 为例,它对数据集进行分区,并接受另一个管道作为第二个参数。你可以进行传统的 SQL GROUP BY 聚合(使用 aggregate,这是一种归约操作),也可以在组内进行其他转换,实现数据分析中常见的“拆分-应用-合并”模式。
  • 由于正交性,我们得到了一些很好的不变性select(类似于映射操作)从不改变行数,只添加列;filter 减少行数;aggregate 是归约操作,总是减少到单行。
  • 极少的原语:PRQL 只有大约 12 个原始操作符,其余语言特性都基于此构建。这使得学习、记忆和使用变得容易,无需频繁查阅文档。

  • 可组合性:PRQL 没有 DISTINCT 关键字。那么如何实现去重呢?逻辑上,去重就是按某些字段分组,然后每组取一个。在 PRQL 中,你可以这样写:group [columns] (take 1),这就会产生 DISTINCT 效果。
  • 高阶函数:PRQL 采用函数式风格语法。例如,你可以定义一个 take_smallest 函数,它接受数量、排序列以及最后的关系作为参数。我们将输入关系作为最后一个参数,以支持柯里化和部分应用。然后,你可以在全局使用这个函数,也可以轻松地将其放入 group 子句中,实现“每张专辑中时长最短的三首曲目”这类查询。用 SQL 实现同样的功能并不简单。

  • Loop 构造:用于递归 CTE。PRQL 编译为 SQL,因此不能做 SQL 做不到的事情。但递归 CTE 在实践中常常难以理解,而且它们实际上更像是迭代或循环。因此 PRQL 选择了 loop 这个名称。通过简化语法,它使更多人能够使用这个功能。
  • 人体工程学改进
    • 日期字面量专用语法。
    • 字符串插值(类似 F-string)。
    • 空值合并运算符(来自 TypeScript)。
    • 易于注释代码行。
    • 数字中可使用下划线分组(例如 1_000_000),防止误读。
      这些是从过去 40-50 年编程语言发展中汲取的微特性,要一致地添加到所有 SQL 方言中是不可行的。
  • 可测试性:因为可以在 PRQL 中定义转换函数,所以可以轻松地用字面量数据测试这些函数。而在 SQL 中,你需要搭建一个包含正确表的测试数据库,这使得测试在实践中非常困难。
  • 解决方言问题:PRQL 编译为不同的 SQL 方言。虽然我们无法解决底层语义的差异,但这确实让生活变得更轻松。当然,你仍然需要检查生成的 SQL 是否符合预期行为。

PRQL 示例

现在让我们看一些例子,这也能突出一些特性。

首先,我们的在线演练场有一个文档完善的示例,你可以在大约 5 到 10 分钟内自学 PRQL。在 Python 或 Jupyter Notebook 中使用 PRQL 也很容易,只需安装 py-prql 包即可。

以下是一些准备好的例子:

  1. 百分比排名和累积和
    这个例子定义了两个函数:percent_range(使用 MIN 和 MAX 聚合计算在范围内的排名)和 add_cum_sum(为现有关系添加累积和列)。在查询中,percent_range 应用于全局作用域,而 add_cum_sumgroup 内部应用,因此累积和是按客户 ID 进行的。这展示了如何轻松构建抽象来完成实际工作。

  2. 会话化函数
    在数据分析中,对用户事件进行会话化是常见操作。利用高阶函数,我们可以定义一个 sessionize 函数,指定按哪个客户 ID 分组以及会话的最大间隔时间。这提供了与其他编程语言相同的抽象能力,可以一次编写,多处复用。

  3. JSON 函数示例
    PRQL 目前没有内置的 JOIN 支持,但我们有一个称为 S-strings(SQL 字符串)的转义机制。你可以轻松定义函数来包装目标 SQL 方言支持的 JSON 函数,然后在 PRQL 中调用它们。

正在进行和未来的工作

现在我想谈谈一些正在进行和未来的工作。

回到之前提到的 Peterson 论文,关于 DataFrame API,我们从用户反馈中注意到,用户期望有序表。DataFrame 通常在内存数据集上工作,具有隐式顺序,用户经常利用这一点。但 SQL 处理的是无序数据集。我们有一些关于此的未解决问题。最近有一篇关于“Order-Sensitive Query”的论文提出了在不同 SQL 方言中一致实现排序语义的解决方案,这是我们正在尝试解决的问题。

以下是一些正在开发或计划中的功能:

  • 类型系统和 LSP 支持:为了快速启动,我们最初没有考虑目标模式。但因此我们错过了 LSP 补全和类型检查。这是我们想要添加的。
  • 模块和包系统:目前有一个初具雏形的模块系统,但还需要完善的打包和分发系统。
  • 物化注解:由于函数式流程,像 DBT 或 Airflow 这样的工具会构建数据转换的 DAG。目前 PRQL 编译时会内联所有函数,生成一个大型查询。我们希望能够添加物化注解,在某个点设置内联边界,将中间结果物化为视图。
  • Option 类型:左连接经常带来问题。如果两个表都有非空字段,左连接后右侧的字段可能为空。如果能有像 Rust 那样的 Option 类型来跟踪可空性,编译器就可以提醒你处理可能为空的字段。
  • 更多后端支持:目前我们编译到 SQL,但这个想法可以更通用。可以编译到 DataFrame、M 语言(Power Query)等。
  • 通用性:像 PRQL 或 SanQL 这样的管道语言可以更加通用,作为处理对象集合的 DSL(领域特定语言)使用。无论是持久化在数据库中的数据,还是内存中的对象,我们处理的都是记录的集合。如果有一个 DSL 能高效地处理这些集合,那将非常理想。

现在何处可以使用 PRQL?

PRQL 已经获得了一定的采用:

  • 在线演练场:可以直接在浏览器中试用,查看编译后的 SQL。
  • 数据库集成:越来越多的数据库开始支持 PRQL,如 ClickHouse、DuckDB,PostgreSQL 也有扩展。
  • 编辑器支持:有 VS Code 扩展。
  • 语言绑定:有多种语言绑定。
  • CLI 工具:可以在命令行中使用,并管道传输到 psql 或其他工具。

关于项目

最后简单介绍一下项目。这是一个完全开源(Apache 2 许可证)、由志愿者驱动的项目,没有人因此获得报酬。我们非常认真地对待社区,不进行货币化。我们在 GitHub 上有超过 1 万颗星,74 位贡献者,维护者全球分布。我们有一个包含更多功能的路线图,并且正在寻找更多的贡献者,特别是编译器开发者,但也欢迎任何形式的贡献。

问答环节

问:PRQL 在转译为 SQL 时,是否总是简单的一对一翻译?是否存在多种翻译方式,如何选择?
答:我们总是以一种方式翻译,大量使用 CTE。但我们的编译器实现并非终极方案。我更希望看到像 Sane IR 或 Substrate 这样的通用中间表示。一个有趣的问题是反向翻译(从 SQL 到 PRQL),目前我们没有很好的解决方案。最好的现有工具可能是 Ibis 的 SQLGlot,它可以翻译 20 多种方言。我们曾尝试将 PRQL 连接到他们的 IR,以实现双向转换,但目前还没有。

问:为什么没有支持 ANSI SQL?
答:实际上支持。如果不指定方言,默认就是 ANSI SQL。

问:是否计划支持 Spark SQL?
答:目前没有专门研究。这真的取决于用户贡献。如果 Databricks 联系我们那就太好了,否则目前维护者们的待办清单已经很长了。欢迎提交 PR。

问:是否考虑过支持 PostGIS 等地理空间数据库及其几何谓词和函数?
答:目前没有。但通过 S-strings 功能,可以轻松包装现有系统(如 PostGIS)中的函数并暴露出来。

问:PRQL 与 Google 的 PiSQL 相比如何?
答:PiSQL 很棒,Jeff 强烈主张扩展现有 SQL。我认为 PRQL 在一些语法细节和人体工程学上略有优势。但 PiSQL 可能更局限于 Google 生态系统。最终,拥有像 Sane IR 这样的中间表示可能是方向,然后你可以选择不同的前端(PiSQL、SanQL 或 PRQL)。

问:未来预计会增加哪些变化?PRQL 看起来已经相当成熟了。
答:模块系统、类型系统、实现更多后端以测试想法的可移植性、修复错误和回归问题等。还有很多工作要做。如果有人想赞助或有 VC 对此感兴趣,我有很多想法愿意交流。

问:PRQL 是否适用于流处理系统(如 Flink)?
答:我认为流处理系统会是很好的应用场景。例如微软的 Kusto 在语义上就非常接近这种管道流。

演示

最后,我快速演示一下在线演练场。它展示了管道流和前缀属性。我有一个复杂的查询,大部分被注释掉了。当我逐行取消注释时,右侧的 SQL 会随之实时变换。这非常适用于交互式工作,你可以轻松地启用或禁用某些行,重新排序,探索数据。

总结

本节课中,我们一起学习了 PRQL(流水线式关系查询语言)。我们从 SQL 的优势与不足谈起,探讨了为什么需要一种新的查询语言。接着,我们深入了解了 PRQL 的核心设计理念:基于关系代数的函数式、管道式语法。我们看到了 PRQL 如何通过极少的正交原语、高阶函数、局部上下文和强大的可组合性,来提升查询的可读性、可维护性和可测试性。此外,PRQL 还引入了现代语言的人体工程学改进,并致力于通过编译到不同 SQL 方言来解决碎片化问题。最后,我们了解了 PRQL 的现状、未来发展方向以及如何开始使用它。PRQL 的目标不是取代 SQL,而是为数据工作者提供一个更友好、更强大的工具,用于构建和理解复杂的数据转换逻辑。

007:StarRocks 查询优化器

在本节课中,我们将学习 StarRocks 查询优化器的核心原理与实践。课程将涵盖优化器的整体架构、三种代表性的优化技术,以及应对常见代价估算挑战的解决方案。最后,我们将分享构建 StarRocks 优化器过程中的一些经验与教训。

优化器概述

首先,让我们从概念上了解 StarRocks 的发展历程。

在过去的五年中,StarRocks 发布了三个主要版本。StarRocks 版本 1 构建了一个极快的 OLAP 数据库,利用了向量化执行引擎和基于代价的优化器。StarRocks 版本 2 演变为一个湖仓一体的高性能统一数据库,引入了主键模型、数据湖分析和全面的查询优化等功能。StarRocks 版本 3 构建了一个开放的湖仓架构,由存算分离、物化视图和高级分析驱动。

下图展示了 StarRocks 的整体架构,它包含两个组件:前端节点和计算节点。前端节点负责管理元数据、查询优化和查询调度,使用 Java 实现。计算节点负责从存储层获取数据和执行查询,使用 C++ 实现。

StarRocks 优化器的整体框架基于 Cascades 和 Columbia 论文。优化器的核心数据结构、搜索框架、规则框架和属性推导与 Cascades 框架保持一致。

下图展示了 StarRocks 优化器更详细的优化过程,同时也反映了与 Cascades 框架的几个不同之处。StarRocks 优化器采用智能优化策略,包括逻辑重写、基于代价的优化、物理重写和反馈调优。

具体来说,逻辑重写阶段执行一系列常见的重写规则,例如子查询重写和公共表表达式内联。基于代价的优化阶段利用 Cascades 的 Memo 数据结构,并包含重要的基于代价的规则,例如连接顺序、连接分布策略选择和聚合分布策略选择。基于代价的优化阶段的输出是一个分布式物理计划树。随后,物理重写阶段执行一些不影响代价的物理重写,包括全局字典重写和公共表达式复用。最后的反馈调优是 2024 年的新功能,它基于运行时反馈信息来调整执行计划。

现在,我们对 StarRocks 优化器有了一个概括性的了解。接下来,让我们深入探讨其核心内容。我将重点介绍两个关键领域:第一,三种代表性的优化技术;第二,应对常见代价估算挑战的解决方案。

以下是本课程将要介绍的三种代表性优化技术:

  • 多表左连接共置优化
  • 全局字典优化
  • 分区物化视图联合重写

以下是本课程将要介绍的三种应对代价估算挑战的解决方案:

  • 查询反馈
  • 自适应执行
  • SQL 计划管理

代表性优化技术

上一节我们介绍了优化器的整体框架,本节中我们来看看三种具体的优化技术。

多表左连接共置优化

在分布式数据库中,连接的分布式执行策略包括 Shuffle Join、Broadcast Join 和 Colocate Join。Shuffle Join 涉及根据连接键将两个表的数据重新分布到相同的计算节点,这需要网络数据传输。相比之下,Colocate Join 利用预先分布的数据,两个表已经根据连接键位于相同的节点上,从而实现了无需网络传输的本地连接执行。

在 StarRocks 中,连接操作的分布策略由分布属性强制器决定。在左侧,我们可以看到 Shuffle Join 强制器的例子。连接操作符要求 H(A)H(B) 的分布属性。由于 T1 和 T2 是普通表,本身不满足这些分布要求,因此在 Scan T1 和 Scan T2 操作符上强制添加了 Shuffle Exchange 操作符。在右侧,我们有一个 Colocate Join 的例子。连接操作符仍然要求 H(A)H(B) 的分布属性。然而,在这种情况下,T1 和 T2 是根据 H(A)H(B) 列共置的表。因此,所需的属性已经得到满足,无需添加 Shuffle Exchange 操作符,可以直接进行本地连接执行。

现在让我们看看多表内连接共置的情况。如下图所示,在 T1、T2 和 T3 根据各自的桶列共置之后,两表连接和多表连接的共置过程变得相同,都支持直接本地连接执行。这是因为在 T1 和 T2 进行内连接之后,内连接结果的物理分布与 T1 或 T2 完全相同。

在介绍多表左连接共置之前,我们首先确认左连接与内连接在共置方面的区别。我们知道,左连接会保留左表的所有行,对于左表中在右表没有匹配的行,右表中的对应列会被填充为 NULL 值。如下图所示,表 T2 的 C1 列的第二行被赋予了 NULL 值。因此,当应用多表左连接的分布属性强制时,左连接操作符之后的 T2 表的 C1 列与原始 T2 表的 C1 列不同,导致所需的分布属性无法满足,从而阻止了 Colocate Join 的执行。

然而,让我们进一步分析,看看这个左连接查询是否真的无法进行共置连接。首先,我们可以观察左侧的表格,它显示了左连接查询的真实结果。接着,查看右侧的图表,思考 T2 的 C1 列中的 NULL 值是否会导致 Colocate Join 和 Shuffle Join 产生不同的结果。我们知道,NULL 值与任何值比较的结果始终是 NULL。因此,无论 T2 的 C1 列中的 NULL 值及其对应的 T3 的 C1 列值如何分布,无论它们是否位于相同的计算节点上,最终结果都将是 NULL。所以,我们可以确保 NULL 值的分布不会影响 T2 和 T3 之间进行 Colocate Join 的可行性。考虑到 T2 和 T3 中的 NULL 值满足等价性要求,T2 和 T3 确实可以进行共置连接。

为了使分布属性强制器支持这种情况,StarRocks 在分布属性中引入了 NULL 宽松(NULL relax)和 NULL 严格(NULL strict)模式。对于非等值连接谓词,StarRocks 将生成 NULL 宽松模式,在比较属性时忽略 NULL 值。相反,对于等值连接,StarRocks 生成 NULL 严格模式,确保在比较属性时考虑 NULL 值。

通过实现 NULL 宽松和 NULL 严格模式,我们现在可以检查多表左连接的强制过程。由于父节点要求 H(C1 of T2) 且为 NULL 宽松模式,而子节点也产生 H(C1 of T2) 且为 NULL 宽松模式,因此它满足了父节点的分布属性要求,从而实现了共置连接。

全局字典优化

首先,让我们定义字典优化。字典优化是指直接基于编码数据(字典编码数据)执行操作,而无需先进行解码。如下图所示,platform 列在存储层直接进行了编码。因此,对 platform 的过滤操作将直接基于编码数据进行。platform = ‘iOS’ 的条件会变成 1 = 01 = 1,整数比较比字符串比较要快得多。

字典优化在 StarRocks 版本 1 的存储层就得到了支持,但仅支持过滤操作,优化场景非常有限。因此,在 StarRocks 版本 2 中,我们支持了全局低基数字典优化。这里的关键词是“低基数”,因为如果基数很高,在分布式系统中维护全局字典的成本会非常高。因此,我们当前的基数限制相对较低,默认配置为 256。对于许多维度表的列,我们认为 256 已经足够。重要的是,StarRocks 的全局字典实现对用户完全透明,用户无需关心哪些列、何时以及如何构建全局字典。

利用全局字典,我们的字典优化扩展到支持更广泛的操作和函数,包括扫描、谓词下推、排序、连接和字符串函数。

下图展示了全局字典优化应用于分组操作的一个例子。StarRocks 为 cityplatform 列维护全局字典。在分组操作期间,StarRocks 将基于字符串的分组转换为基于整数的分组。在这个例子中,你可以看到分组键从 Android, BeijingiOS, Shanghai 变成了 1, 10, 2。对于诸如扫描、哈希等值比较和内存复制等操作,这种优化通常能带来高达 3 倍的查询性能提升。

考虑到本次课程的重点是优化器,让我们聚焦于 StarRocks 如何使用全局字典来重写执行计划。下图提供了全局计划重写过程的高级示意图。首先,重写操作发生在物理重写阶段,会将本地的字符串列转换为整数列。要成功重写,必须满足两个条件:第一,计划中的字符串列是低基数列;第二,元数据中存在与该字符串列对应的全局字典。重写成功后,全局字典和修改后的计划会被传递给查询执行器。

下图提供了全局字典计划重写过程的详细表示。目前,StarRocks 支持对字符串和定长字符串类型进行优化。整数重写过程对物理执行计划树进行操作,采用自底向上的遍历方式。如果一个字符串列可以进行字典优化,它会被视为整数列,并且所有表示该列的字符串和函数都会被相应修改。相反,如果在某个操作符处无法进行字典优化,则会插入一个解码操作符。在这个解码操作符处,整数列被转换回字符串列。

分区物化视图联合重写

StarRocks 从版本 1 开始就支持同步物化视图,在版本 2 中评估了物化视图的性价比,并支持了新的异步物化视图,将能力从单表物化视图扩展到多表物化视图。在自动查询重写方面也有显著的优化增强,今天的演示将重点介绍物化视图查询重写。

StarRocks 目前支持在广泛场景下自动进行物化视图重写,包括谓词重写、连接重写、联合重写、嵌套物化视图重写、基于测试的复杂查询重写以及基于视图的查询重写。今天,我们将重点介绍联合重写。

首先,让我们思考为什么需要自动物化视图联合重写。第一种情况是查询分区表场景。StarRocks 可以直接查询分区表格式,但如果期望获得亚秒级性能,可以使用物化视图来加速对分区表的查询。然而,大多数用户只关注近期数据的查询性能,因此他们可能只为最近一周或最近一个月的数据构建物化视图。但是,用户偶尔可能会查询最近两周或最近两个月的数据。这时,优化器需要自动重写查询,使用最近的物化视图数据与历史分区数据联合查询。第二种情况是实时场景。我们期望使用物化视图实现毫秒级、数万 QPS 的查询,但刷新物化视图存在延迟,无法保证最新数据与基表一致。为了确保用户获得准确和最新的查询结果,我们需要将物化视图数据与基表中的最新数据进行联合。

首先考虑物化视图所有分区都已刷新的情况。如下图所示,查询和物化视图都涉及简单的内连接,但具有不同的谓词范围。由于物化视图结果集是查询结果集的子集,我们必须生成补偿谓词来打开查询的范围,随后形成一个联合操作。在这个例子中,补偿谓词是 c1 > 10 AND c1 < 20。谓词分类和补偿逻辑与微软研究论文中描述的“利用物化视图”的实用可扩展解决方案方法一致。

接下来,考虑物化视图部分分区已刷新的情况。如果物化视图和查询都是简单的分组聚合查询,物化视图和基表都有四个分区,其中物化视图的 P1 和 P2 分区已刷新,但 P3 和 P4 分区未刷新。此查询的重写将变为物化视图查询与原始表查询的联合。联合的左子节点是物化视图的扫描,右子节点是原始查询的计划。原始查询的唯一区别是扫描的分区变成了物化视图中未刷新的分区,在这个例子中是 P3 和 P4 分区。

最后,考虑带谓词重写的条件物化视图情况。让我们用一个例子来说明。假设我们的基表按日期分区为 2 月 1 日和 2 月 2 日。2 月 1 日分区的物化视图已更新和刷新,而 2 月 2 日的物化视图尚未刷新。物化视图的定义是 SELECT * FROM T WHERE a > 20,而我们的查询是 SELECT * FROM T WHERE a > 10。第一步,我们首先假设所有物化视图分区都已刷新。在这个阶段,我们应用微软论文中概述的重写技术。然后,在第二步中,我们考虑每个未刷新的分区,刷新它,并补偿第一步中物化视图扫描的谓词。最后,将前两步的结果合并得到最终结果。在最终的计划中,你可以看到有两个联合操作。

代价估算挑战与解决方案

在过去的几年里,我们在代价估算方面持续遇到许多挑战,我认为这些也是所有基于代价的优化器都会遇到的挑战。例如,没有统计信息、采样统计信息收集不准确、即使统计信息准确,由于列间独立性的基本假设无效或多层连接误差逐层累积,代价估算也会出错。在生产环境中也存在认知偏差问题,过去一年中一些用户解释的生产环境计划变化,会导致一些小查询变成大查询,从而引发生产环境事故。

今天,我将主要介绍查询反馈、自适应执行和 SQL 计划管理。

在深入这些高级功能之前,让我们简要回顾一下 StarRocks 统计信息的基础。首先,StarRocks 的列统计信息包括基本计数、最大值、最小值、不同值数量,也支持直方图。统计信息支持手动和自动收集,每次收集都支持全量和抽样方法。为了最小化查询规划延迟,统计信息缓存在前端内存中,允许优化器直接从内存中高效检索列统计信息。默认情况下,StarRocks 自动收集统计信息,采用对小表执行全量分析、对大表执行抽样分析的策略。

收集统计信息的难点在于在资源消耗和准确性之间做出权衡,同时避免统计信息收集任务影响正常的导入和查询。StarRocks 在以下四个方面优化了统计信息收集的准确性和问题。第一点是元数据扫描。StarRocks 使用元数据扫描来自动化计数、最小值和最大值的统计信息,减少资源消耗。第二点是表抽样。在尽可能保证准确性的同时,只扫描少量数据。StarRocks 通过减少物理收集的桶数和列数来降低统计信息收集的成本。第三点是预测列。预测列功能即将推出。最后一点是减少统计信息收集对正常查询和导入的影响。StarRocks 目前支持在单独的计算资源组中执行收集任务,确保这些收集作业不会影响查询和导入。

让我们介绍一下表抽样。StarRocks 的抽样将遵循表数据的实际物理分布。从图中可以看出,StarRocks 表首先通过范围、列表或表达式进行分区,然后通过哈希或随机进行分桶,每个桶会根据大小进一步划分为多个段,每个列的页面会根据 64KB 的大小划分为多个页。当 StarRocks 收集统计信息时,它将从一些桶和一些段中读取数据。至于在每个段中读取哪些页的数据,StarRocks 将根据不同的数据分布特征使用不同的抽样算法。StarRocks 首先会根据每个页的最大最小值对页面进行排序。如果发现数据高度聚集,将使用基于直方图的均匀抽样算法。如果发现数据聚集性较低,将使用蓄水池抽样算法。

前面提到的预测列的核心思想非常简单。我们认为,当一个表有数百或数千列时,只有关键列的统计信息会影响最终计划的质量。在 OLAP 分析中,这些列通常是维度列,例如过滤列、分组列、排序列和连接列。因此,我们只需要为预测列收集统计信息。

查询反馈

如前所述,查询反馈用于解决因任何原因导致的代价估算不准确问题。其核心思想是利用真实的运行时信息来纠正错误的查询计划,以便优化器下次能生成更好的计划。

StarRocks 的查询反馈不更新统计信息,而是为每种类型的查询记录一个不良计划调优指南。如下图所示,查询反馈有两个核心模块:计划分析和查询调优顾问。计划分析根据查询的真实运行时信息和执行计划检查是否存在明显的不良计划。如果检测到明显的不良计划,它将被记录下来。当优化器生成最终计划时,如果发现查询调优顾问中记录了相应的调优指南,它将应用调优指南中的更好计划。目前,查询反馈的第一个版本主要可以优化三种类型的查询。第一是优化连接的左右表顺序。第二是优化连接的分布式执行模式,例如将广播连接改为 Shuffle 连接,或将 Shuffle 连接改为广播连接。第三是针对第一阶段聚合效果不佳的情况,禁用第一阶段的聚合,我们将很快介绍流式聚合。

这是一个查询反馈的用例,它纠正了左右表的不正确顺序。在左侧的查询配置文件中,右侧有一个 500 亿行的大表,左侧有一个 1100 万行的小表。当使用大表构建哈希表时,性能会非常差,应该使用小表来构建哈希表。应用反馈调优后,连接顺序变为正确,同时你也可以在右侧的查询配置文件中看到,左表只有 119 行。这是因为运行时过滤器生效了,左表的数据被过滤掉了,我稍后会介绍运行时过滤器。你可以看到,两种不同计划的查询时间相差一个数量级。

这是查询反馈的第二个用例,解决了不正确的 Shuffle 连接改为广播连接的问题。如图所示,左连接的左右子节点都是 Exchange 操作符,这是一个 Shuffle 连接。但我们发现左表只有 11998 行,广播连接显然是更好的执行策略。应用反馈调优后,Shuffle 连接被调整为广播连接,运行时过滤器也生效了。同时,我们也可以看到,两种不同计划的查询时间也相差一个数量级。

自适应执行

接下来,让我们了解一下自适应执行。

首先,让我们了解自适应流式聚合。对于简单的分组查询 SELECT SUM(c2) FROM table GROUP BY id,如果是一个非共置表,StarRocks 将生成两种分布式执行计划。第一种计划是分布式两阶段执行,如图所示。在第一阶段,StarRocks 首先根据 id 列使用哈希表聚合数据,然后通过哈希 Shuffle 将具有相同 id 的数据发送到同一个节点。在第二阶段,执行最终的哈希聚合,然后将结果返回给客户端。为什么需要在第一阶段执行本地聚合?因为我们期望通过第一阶段的聚合来减少通过网络 Shuffle 传输的数据量。显然,这里的假设是第一阶段的本地聚合会有良好的聚合效果,例如输入行数是 100 万,经过哈希聚合后哈希表大小只有 1000。因此,这种两阶段聚合计划通常适用于中低基数的分组列。

StarRocks 分组查询的第二种分布式执行计划是单阶段聚合,即扫描数据后直接 Shuffle 数据,只进行最终聚合。显然,单阶段聚合适用于本地聚合没有聚合效果的情况,例如输入行数是 100 万,经过哈希聚合后哈希表大小仍然是 100 万。因此,单阶段聚合适用于高基数的分组列。理论上,只要优化器能准确估计分组的基数,就可以直接选择正确的一阶段或两阶段计划。然而,我们都知道对于多列分组,基数估计非常具有挑战性,因为多列分组通常不是独立的,而是相关的。因此,在 StarRocks 中,我们没有在优化器本身解决这个问题,而是希望通过自适应执行来解决。

StarRocks 优化器默认会生成两阶段聚合计划,然后根据执行过程中获得的实际聚合效果自适应地调整聚合策略。如下图所示,StarRocks 自适应执行的原理如下。在本地聚合中,对于每批数据(假设 100 万行),StarRocks 将首先使用哈希表聚合数据。当发现哈希表没有聚合效果时,它将不再将数据添加到哈希表中,而是直接进行 Shuffle,以避免无意义的哈希计算。这是自适应执行的核心思想。具体的算法非常复杂,在过去的五年里,我们已经迭代了几个变体,今天不会深入探讨,如果你感兴趣,我们可以在线下讨论。

接下来,让我们看看自适应执行的另一个案例:自适应运行时过滤器选择。首先,让我们快速了解什么是运行时过滤器。在一些论文中,运行时过滤器也称为侧信息传递。该图是一个简单的连接运行时过滤器扫描示意图。扫描右表数据后,StarRocks 将为右表数据构建一个运行时过滤器,并将其下推到左表。运行时过滤器可以在连接之前过滤左表中的数据,从而减少 I/O、网络和 CPU 资源消耗。对于复杂查询,运行时过滤器可以带来数十倍的性能提升。在过去的五年里,StarRocks 一直在持续改进运行时过滤器。左侧列出的是 StarRocks 运行时过滤器的一些特性。然而,当存在许多连接表和许多连接条件时,运行时过滤器可能会产生负面影响,许多运行时过滤器可能会被下推到扫描计划中,这将导致以下问题。第一,一些运行时过滤器选择性低,这会浪费 CPU 资源。第二,低选择性的过滤器最后使用,而一些高选择性的过滤器先应用,这也会浪费 CPU 资源。一些数据库可能依赖优化器来估计运行时过滤器的选择性,但 StarRocks 选择使用自适应执行来解决这个问题。如下图所示,StarRocks 在处理一定数量的数据行后,计算每个过滤器的实际选择性。然后自适应地调整运行时过滤器的应用。以下是一些基本的调整原则:第一,只设置低选择性的过滤器。第二,只设置选择性最高的三个过滤器。第三,如果某个过滤器的选择性特别低,则只保留一个过滤器。

SQL 计划管理

最后,让我们介绍一下 SQL 计划管理。我们目前正在开发此功能,并将很快发布第一个版本。我们决定开发此功能是因为在过去一年左右的时间里,一些用户的生产环境中由于计划变化导致小查询变成大查询,从而引发了生产事故。我们开发 SQL 计划管理的目标是应对两个挑战。第一,防止升级后的性能回归。我们希望保证核心性能保持稳定和可预测,消除系统升级后因计划变更导致的显著性能下降。第二,为关键在线工作负载保持稳定的性能。我们希望确保持续运行的关键业务应用程序的最佳计划始终保持高效,避免性能随时间推移而下降。

SQL 计划管理在原理上分为两部分。第一,如何生成和保存基线计划。第二,如何应用基线计划。生成基线计划分为三个步骤。第一步,常量参数化:用特定的 SPM 函数替换 SQL 中的常量,SPM 是 SQL 计划管理的缩写,它是常量值的占位符。第二步,生成计划:基于参数化后的计划生成分布式物理计划。你可以看到这是一个 Shuffle 连接和 Bucket Shuffle 连接。Bucket Shuffle 连接意味着只 Shuffle 一个表的数据到另一个表,另一个表的数据保持不变。第三步,生成基线 SQL:我们将物理计划转换为带提示的 SQL。在 StarRocks 中,如果一个连接 SQL 带有连接提示,StarRocks 就不会进行连接顺序优化。因此,Shuffle 和 Bucket 提示几乎决定了这个计划的两个关键点:连接顺序和连接分布执行策略。那么,为什么 StarRocks 保存的是带提示的 SQL,而不是物理计划呢?因为第一,如果我们存储物理计划,存储量可能非常大。如果我们只保存带提示的 SQL,可以节省存储空间。第二,带提示的 SQL 易于验证。

使用基线计划分为四个步骤。第一步同样是常量参数化。第二步是匹配业务 SQL,我们将计算参数化 SQL 的摘要,并根据摘要在计划存储中找到对应的业务 SQL。第三步是用影响流 SQL 的常量替换基线 SQL 中的 SPM 函数。第四步是,我们将根据正常的查询流程处理带提示的 SQL。

经验与总结

最后,让我们分享一下来自 StarRocks 优化器的一些经验教训。

第一,我认为优化器中代价估算的误差是不可避免的,执行器需要能够自主决策并提供及时反馈。因此,我认为自适应执行和查询反馈对于基于代价的优化器是必要的。

第二,在工程上,优化器测试系统与优化器本身同等重要。优化器需要正确的测试、格式测试和计划稳定性测试等。我相信,我们的开源数据库生态系统将受益于优化器和查询引擎的开源测试系统,以及公开可用的测试数据集,这可以特别加速数据库优化器从演示到成熟的过渡。

第三,我认为 NULL 值既有趣又烦人。在性能和语义上,当需要特殊处理 NULL 值时,我们可以从共置连接的案例中看到这一点。特别是如果你正在创建向量化执行引擎,你还需要为 NULL 列做许多特殊的优化。为了正确性,我们需要在执行器中注意 NULL 值。坦白说,在过去的几年里,因为我们从零开始编写执行引擎,我们遇到了许多关于 NULL 值的错误。

本节课中,我们一起学习了 StarRocks 查询优化器的核心架构、三种关键优化技术(多表左连接共置、全局字典优化、分区物化视图联合重写),以及应对代价估算挑战的三大解决方案(查询反馈、自适应执行、SQL 计划管理)。这些技术共同构成了 StarRocks 高性能查询引擎的重要基石。

008:来自 Oxide 计算机公司的血氧仪查询语言

在本节课中,我们将学习 Oxide 计算机公司为何以及如何构建他们自己的查询语言 OxQL。我们将探讨传统 SQL 在处理特定类型的时间序列数据时遇到的挑战,并了解 OxQL 如何通过一种更贴合其数据模型和用例的语法来解决这些问题。

概述:为什么需要新的查询语言?

Oxide 是一家硬件公司,提供集成的“自有云”解决方案。他们的系统会产生大量异构的遥测数据,包括硬件传感器读数、网络指标、虚拟机使用情况等。为了有效监控和调试这些系统,他们需要一个强大的查询系统。

最初,他们尝试使用 SQL,因为它具有熟悉、文档丰富、工具链成熟等优点。然而,在处理其特定的时间序列数据模型时,他们遇到了显著的困难。本节我们将探讨这些挑战,并看看 OxQL 如何提供一种更简洁、更直观的解决方案。

数据挑战:SQL 的局限性

上一节我们介绍了 Oxide 的数据背景,本节中我们来看看他们具体遇到了哪些 SQL 难以处理的问题。Oxide 的数据有几个关键特征:数据点是累积计数器,需要处理重启和采样丢失;数据天然具有时间顺序和依赖性;并且广泛使用直方图进行性能分析。

以下是几个具体的例子,展示了 SQL 在处理这些数据时的复杂性。

示例 1:计算时间窗口内的平均值

一个常见需求是绘制虚拟机 CPU 使用率随时间变化的图表,并按固定时间窗口(如5分钟)进行聚合。在理想化的独立数据点模型中,SQL 查询相对简单:

SELECT
    timestamp,
    vcpu_usage,
    AVG(vcpu_usage) OVER (
        PARTITION BY FLOOR(EXTRACT(epoch FROM timestamp) / 300) * 300
    ) AS avg_usage
FROM vcpu_usage_table
WHERE ...;

然而,Oxide 的实际数据是累积计数器。这意味着每一行的值都包含了之前所有计数的总和。直接对这样的数据求平均会导致重复计算和结果失真。正确的做法是首先计算连续行之间的差值(相邻差分),使每个数据点独立。

在 SQL 中实现相邻差分需要使用窗口函数,这使得查询变得复杂:

WITH diffs AS (
    SELECT
        *,
        vcpu_usage - LAG(vcpu_usage) OVER (
            PARTITION BY vm_id, server_sn, cpu_id, state
            ORDER BY timestamp
        ) AS usage_diff
    FROM vcpu_usage_table
)
SELECT
    timestamp,
    ...,
    usage_diff
FROM diffs
WHERE ...;

如果考虑虚拟机或监控代理重启导致计数器归零的情况,逻辑会更加复杂,需要识别不同的单调递增区间并进行偏移补偿。这在 SQL 中表达起来非常困难。

示例 2:获取最近的数据点

在调试时,工程师经常需要查看某个指标最近几个数据点。在 SQL 中,这通常通过 ROW_NUMBER() 和过滤来实现:

WITH numbered_rows AS (
    SELECT
        *,
        ROW_NUMBER() OVER (
            PARTITION BY vm_id, ...
            ORDER BY timestamp DESC
        ) AS rn
    FROM vcpu_usage_table
    WHERE ...
)
SELECT *
FROM numbered_rows
WHERE rn <= 5;

虽然功能上可行,但对于需要快速进行交互式调试的用户来说,编写和记忆这样的查询并不友好。

示例 3:生成直方图

直方图对于识别延迟等指标的异常值非常有用。Oxide 将许多指标(如 API 延迟)存储为按桶划分的累积直方图。在 SQL 中生成直方图需要将数据分桶并计数,查询会变得异常冗长和复杂。

以下是一个模拟的复杂 SQL 查询,用于从累积数据生成直方图:

WITH diffs AS (
    -- 先计算相邻差分
    SELECT
        ...,
        counter - LAG(counter) OVER (...) AS diff
    FROM histogram_table
),
bucketed AS (
    SELECT
        ...,
        CASE
            WHEN diff < 10 THEN '0-9'
            WHEN diff < 100 THEN '10-99'
            -- ... 更多条件
            ELSE '1000+'
        END AS bucket_label
    FROM diffs
)
SELECT
    CAST(SUBSTRING(bucket_label FROM ‘^(\d+)’) AS INTEGER) AS bucket_start,
    COUNT(*) AS count
FROM bucketed
GROUP BY bucket_label, bucket_start
ORDER BY bucket_start;

这个查询不仅冗长,而且难以维护和修改。如果桶的边界需要调整,修改 CASE WHEN 语句很容易出错。

转向 OxQL:设计动机

面对 SQL 的上述挑战,Oxide 团队决定基于第一性原理设计自己的查询语言 OxQL。他们的目标是创建一个能更好适应其数据模型和常见操作的语言。

以下是 OxQL 的核心设计目标:

  • 简洁且富有表现力的语法:让常见操作易于表达。
  • 原生支持时间序列操作:如相邻差分、时间对齐、直方图处理等。
  • 贴合数据模型:数据被直观地视为“元数据键值对” + “时间戳-数值数组”。
  • 便于调试:支持交互式、即席查询。
  • 易于实现和优化:采用管道架构,便于将操作下推至底层数据库(Clickhouse)或在自研的 Rust 引擎中执行。

OxQL 语法与实践

上一节我们了解了 OxQL 的设计目标,本节中我们来看看它的具体语法和如何解决之前提到的问题。OxQL 采用管道(|)操作符将不同的处理步骤连接起来,这与 Unix shell 管道或现代数据框架(如 Pandas)的操作方式类似,非常符合数据处理流程的直觉。

基础查询与过滤

最基本的操作是获取数据并进行过滤。

get vcpu_usage
| filter instance_id == “123e4567-e89b-12d3-a456-426614174000” and server_sn == “ABC123”

get 操作符从指定表获取数据。filter 操作符用于根据条件筛选数据,支持各种比较运算符和逻辑组合。OxQL 会自动处理累积计数器的差分计算,用户无需在查询中显式编写复杂逻辑。

时间对齐与聚合

对于按时间窗口聚合的需求,OxQL 提供了 align 操作符。

get vcpu_usage
| filter instance_id == “…”
| align mean within 5m

这行查询会将数据按5分钟窗口对齐,并计算每个窗口内使用率的平均值。align 操作符内部处理了时间戳的舍入和聚合逻辑,语法非常直观。5m 这种时长字面量的使用也使得表达更加清晰。

获取最新数据

获取最近几个数据点在 OxQL 中非常简单。

get vcpu_usage
| filter instance_id == “…”
| last 5

last 操作符直接返回指定数量的最新数据点,省去了在 SQL 中排序和编号的步骤。

直方图查询

OxQL 将直方图视为一等公民。查询直方图数据与查询普通指标一样简单,语言内部会处理累积直方图的差分和分桶逻辑。

get api_request_latency_histogram
| filter method == “POST”

查询返回的就是已经处理好的、按桶划分的计数分布,可以直接用于绘制图表或分析。

架构优势:灵活性与控制力

OxQL 的管道架构带来了重要的架构优势。查询引擎在解析 OxQL 查询后,会进行初步规划和优化。

关键策略是谓词下推和限制下推:引擎会尽可能多地将 filter 条件和 last 限制转换为高效的 SQL 语句,发送给底层的 Clickhouse 数据库执行。这样可以最大限度地减少从数据库拉取的数据量,提高查询性能。

对于无法高效映射到 Clickhouse SQL 的操作(或者在新功能开发初期),OxQL 引擎可以退回到使用自研的 Rust 代码来处理数据。这种“逃生舱”设计提供了极大的灵活性,允许团队快速迭代语言功能,而不必受限于底层数据库的能力。

总结

本节课中我们一起学习了 Oxide 公司创建 OxQL 查询语言的历程。我们从 Oxide 面临的独特数据挑战出发,探讨了传统 SQL 在处理累积计数器、时间序列对齐和直方图等场景时的局限性与复杂性。

通过对比 SQL 的繁琐查询和 OxQL 的简洁语法,我们看到了为特定数据模型和用例定制查询语言所带来的巨大收益。OxQL 通过管道操作符、原生支持时间序列操作、以及隐藏底层数据复杂性(如自动差分)等设计,显著提升了查询的易写性、易读性和易用性。

最后,我们还了解了 OxQL 的混合执行架构如何结合底层数据库的性能优势和自研引擎的灵活性。这个案例深刻地表明,虽然 SQL 是强大的通用工具,但当业务场景存在高度特化的需求时,设计和实现一个领域特定语言(DSL)可能是更优的选择。关键在于充分评估需求、明确现有工具的不足,并有信心基于第一性原理构建解决方案。

009:一款功能各异的多面手

在本节课中,我们将学习MariaDB查询优化器的核心工作原理与独特设计。我们将从历史背景开始,了解其发展脉络,然后深入探讨其关键特性,如常量表优化、范围分析以及基于精确成本估算的查询计划选择。课程旨在让初学者理解一个现代数据库优化器是如何工作的。

历史背景与设计哲学

MariaDB(及其前身MySQL)的优化器诞生于一个注重实用性的环境。其创建者蒙蒂·维德纽斯自1981年开始处理数据,早期工具Unireg并无优化器,查询路径需要预先定义。

当开始开发MySQL时,创建优化器成为首要任务。早期的优化器设计遵循几个核心原则:优先考虑用户易用性,在遵循SQL标准的同时不排斥更好的实践方法,并支持不同的存储引擎以适应不同场景。最初的优化器代码相对基础,但致力于解决实际问题,并确保不会因捷径而限制未来的功能扩展。

MariaDB架构概览

理解优化器之前,需要先了解MariaDB的架构。其独特之处在于存储引擎与SQL解析器、优化器的解耦。

用户可以根据需求选择不同的存储引擎,例如:

  • InnoDB:支持事务的聚集索引引擎。
  • MyISAM/Aria:非事务性引擎,具有较低的存储开销。
  • Memory引擎:纯内存存储,支持哈希或树结构。
  • S3引擎:允许将数据存储在对象存储中。
  • Federated/Connect引擎:可访问远程数据源。

这种灵活性是MariaDB的一大优势,但也为优化器的成本估算带来了复杂性,因为不同引擎的数据访问成本差异巨大。

优化器核心机制:常量表与范围分析

MariaDB优化器包含两个独特且强大的机制:常量表优化和范围分析。

常量表优化

如果查询涉及一个通过唯一键定位的单行表,优化器会在规划初期读取该行,并将其所有列值作为常量替换到查询的各个部分。

例如,对于查询 SELECT * FROM t1, t2 WHERE t1.id = 1 AND t1.col = t2.col,如果 t1.id 是唯一键,优化器会先获取 id=1 的行,然后用其 col 的具体值替换 t1.col,从而可能极大地简化对 t2 表的查询。这项优化对于驱动应用程序配置或主实体查询非常有效。

范围分析

范围分析是MariaDB的一项古老而核心的技术,用于高效处理带有范围条件(如 BETWEEN>IN)的查询。

其工作流程如下:

  1. 构造范围序列:优化器将 WHERE 子句中的条件转换为一系列不重叠的区间。
  2. 索引探测估算:对于每个可能的索引,优化器通过“潜入”B+树来估算每个区间覆盖的行数百分比。这通过定位区间起始和结束值在B+树中的位置来实现,比传统的直方图统计更精确。
  3. 提供精确估算:这个过程为优化器提供了关于每个索引选择性的高质量估算,无论是单列还是多列索引,都能帮助其选择最佳访问路径。

优化器的演进:基于时间的成本模型

传统的MySQL优化器使用基于磁盘I/O次数的简单成本模型。在MariaDB 10.x及以后版本,优化器团队进行了一次重大重设计,引入了基于时间的成本模型。

新的成本模型将一次优化单位定义为 1微秒。团队深入分析了服务器各个组件(特别是不同存储引擎)的微观操作耗时,例如:

  • 键查找:在索引中定位一个条目。
  • 键下一个:在索引中获取下一个条目。
  • 行复制:将整行数据复制到内存。
  • 块复制:在扫描时复制索引页。

通过为这些基本操作分配精确的时间成本,优化器能够更真实地比较不同查询计划(如使用索引扫描还是全表扫描)的执行开销。用户甚至可以通过调整 information_schema 中的相关成本常数来适配特定的硬件配置。

优化器跟踪与调试

为了帮助开发者理解和调试优化器的决策过程,MariaDB提供了 OPTIMIZER_TRACE 功能。

执行 SET optimizer_trace=‘enabled=on’; 后运行查询,再查询 INFORMATION_SCHEMA.OPTIMIZER_TRACE 表,即可获得一份详细的JSON格式报告。这份报告展示了优化器考虑过的各种计划、其估算成本以及最终做出选择的原因,是性能调优的利器。

当前局限与未来展望

尽管功能强大,MariaDB优化器仍有其局限。一个典型的例子是处理“多星型查询”,即连接两个大型事实表并通过一个关联表链接。当前的嵌套循环连接策略在此类场景下可能效率不高。

此外,预处理语句的早期设计为了检测表结构变更并重用执行计划,引入了不必要的复杂性,虽然后续已简化,但代码中仍有残留。存储引擎API虽然强大,但在协调像列存引擎这样能够下推大量计算的智能引擎方面,仍有优化空间。

总结

本节课我们一起深入探讨了MariaDB查询优化器。我们从其注重实用的历史起源讲起,了解了其独特的存储引擎抽象架构。我们重点学习了两个核心优化技术:常量表优化 能提前固定单行数据以简化查询;范围分析 则通过精确的索引探测来获得最佳的选择性估算。随后,我们探讨了优化器从简单的I/O成本模型演进到基于微秒的精确时间成本模型的过程,这使得查询计划选择更加准确。最后,我们也了解了通过 OPTIMIZER_TRACE 进行调试的方法,并认识了当前优化器存在的一些局限性。MariaDB优化器是一个持续演进、在工程实践与理论结合中不断发展的复杂系统。

010:Gel - 取代SQL并改进关系型数据库模型

概述

在本节课中,我们将学习 Gel(前身为 EdgeDB)数据库系统。Gel 旨在通过其查询语言 EdgeQL 来取代 SQL,并改进传统的关系型数据库模型。我们将探讨其设计动机、核心的“图关系”数据模型、EdgeQL 查询语言的基本语法与特性,以及它如何在 PostgreSQL 之上实现。最后,我们也会了解 Gel 为何以及如何仍然支持 SQL。


为什么需要取代 SQL?🤔

上一节我们介绍了本次研讨会的背景。本节中,我们来看看 Gel 团队认为 SQL 存在哪些问题,从而需要新的解决方案。

SQL 已有半个世纪的历史,其最初的设计目标是让商业分析师编写即席分析查询。如今,它显露出一些时代局限性:

  • 缺乏正交性:难以从子查询构建复杂查询,经常因为标量表达式和表表达式之间的区别而需要大幅重构。
  • 语言庞大且不一致:经过长期演化,语言表达方式往往不一致。
  • NULL 值的怪异行为:NULL 可以属于任何类型,其行为方式古怪。
  • 对象-关系阻抗失配:在 SQL 中是数据行,而在应用程序中通常是带有指针的对象,两者不匹配。

尽管如此,SQL 仍然比许多后续的数据库查询语言更优秀。然而,许多 NoSQL 数据库在尝试改进时,往往“把婴儿和洗澡水一起倒掉了”——这里的“婴儿”就是关系模型

以下是 NoSQL 数据库常见的一些问题:

  • 弱模式或无模式:通常只有单一类型,可视为“无类型”数据库。
  • 查询能力有限:查询语言功能较弱或缺失。
  • N+1 查询问题:获取关联数据(如用户及其所有帖子)需要多次查询,效率低下。这个问题在使用 SQL 的对象关系映射器(ORM)中也很常见。
  • 较弱的一致性保证:通常比 SQL/关系型数据库提供更弱的一致性保证。

当然,这些是工程上的权衡,但 Gel 团队认为他们可以站在更好的权衡点上。


什么是 Gel?🚀

上一节我们讨论了 SQL 的不足和 NoSQL 的权衡。本节中,我们来认识一下 Gel 数据库本身。

Gel 是一个新型数据库,它建立在坚实的关系型核心之上,并由 PostgreSQL 提供支持。其核心创新在于 “图关系” 数据模型。

“图关系”模型是关系数据库模型的演进。它围绕以下核心概念构建:

  1. 具有唯一身份的强类型对象
  2. 对象之间通过“链接”连接
  3. 对象包含具有指定基数的集合值属性

虽然 Gel 的市场宣传已逐渐淡化“对象关系”这个说法(因为发现它不易引起潜在用户的共鸣),但从技术角度看,这仍然是理解其设计理念的精确框架。

以下是关系模型概念到 Gel 模型的映射:

  • 表/关系 -> 对象类型
  • 列/属性 -> 属性链接
  • 行/元组 -> 对象

图关系模型详解 🧩

上一节我们介绍了 Gel 的“图关系”模型。本节中,我们来深入探讨该模型的三个核心要点。

1. 唯一身份

所有对象都有一个通过全局唯一标识符实现的唯一身份。这相当于每个对象的全局、自动生成的主键。

2. 链接

对象可以通过“链接”相互连接。链接可以指向其他对象,并且链接本身也可以拥有属性(称为“链接属性”)。

以下是一个 Gel 模式定义语言(SDL)的示例片段:

type Person {
  required name: str;
}

type Movie {
  required name: str;
  required release_year: int64;
  multi actors: Person;
}

在这个例子中,Movie 类型有一个指向 Person 类型的多链接 actors,表示每部电影可以关联零个或多个人物对象。

3. 属性基数

对象的每个属性或链接都有指定的基数,这由两个维度决定:

  • 可选性:可以是 optional(可选)或 required(必需)。
  • 多重性:可以是 single(单值)或 multi(多值)。

例如:

type Movie {
  optional description: str;      # 可选,单值
  required title: str;            # 必需,单值
  multi alternate_titles: str;    # 可选,多值
  required multi actors: Person;  # 必需,多值(至少一个)
}

这与关系模型不同,关系模型中的属性总是单值的(可为空或非空),而 Gel 允许属性是多值的集合。


EdgeQL 查询语言初探 🔍

上一节我们了解了 Gel 的数据模型。本节中,我们来看看如何使用 EdgeQL 查询语言与数据交互。

在 EdgeQL 中,几乎所有东西都是集合值。这意味着任何表达式都可以返回多个值。操作符的默认语义是将其提升到输入集合的笛卡尔积上执行。

以下是一些基础查询示例:

选择字面量:

select 'Hello';
# 返回包含该字符串的集合

集合运算:

select {10, 20} + {1, 2};
# 笛卡尔积运算,返回 {11, 12, 21, 22}

变量绑定:

with x := {10, 20}
select x + {1, 2};

空集处理:
EdgeQL 没有 NULL 的概念。缺失的值表示为空集。空集参与运算会产生空集。

select {1, 2} + {};
# 返回空集 {}

为了处理可能为空集的情况,EdgeQL 提供了诸如 ??(合并操作符)等函数,以及 count()sum() 等聚合函数。

select <int64>{} ?? 0;       # 返回 0
select count({10, 20} + {1, 2}); # 返回 4

查询实际数据 🎬

上一节我们学习了 EdgeQL 的基本运算。本节中,我们将其应用于实际的电影数据库查询。

假设我们有 MoviePerson 类型,并且电影通过 actors 链接关联演员。

基本选择与形状:
仅选择对象会返回其唯一ID。我们需要使用“形状”来指定要获取的属性。

select Movie {
  title,
  release_year
};

过滤:
使用 filter 子句来缩小结果范围。

select Movie {
  title,
  release_year
} filter .title = 'Dune';

执行连接(嵌套结果):
在关系模型中,连接是核心。EdgeQL 通过嵌套形状来实现类似功能,返回的是结构化结果,而非扁平化的行。

select Movie {
  title,
  release_year,
  actors: {
    name
  }
} filter .title = 'Dune';

这个查询会返回电影对象,每个电影对象内部包含一个演员列表。相比之下,传统的 SQL 连接会返回电影标题与每个演员名字配对的重复行。

包含链接属性:
如果 actors 链接上有 character_name 属性,可以一并获取:

select Movie {
  title,
  release_year,
  actors: {
    name,
    @character_name
  }
} filter .title = 'Dune';

更复杂的查询示例:

select Person {
  name,
  films := (select Movie filter Person in .actors)
} filter .name = 'Timothée Chalamet';

高级查询功能 ⚙️

上一节我们看到了如何获取和连接数据。本节中,我们探索 EdgeQL 更强大的功能:计算字段和反向链接。

计算字段

可以在查询中动态添加计算字段。

select Movie {
  title,
  cast_size := count(.actors)
};

计算出的字段可以用于后续操作,例如过滤:

select Movie {
  title,
  cast_size := count(.actors)
} filter .cast_size > 50;

反向链接

遍历链接的反方向是一个常见需求。EdgeQL 提供了内置的反向链接支持。

select Person {
  name,
  starred_in := .<actors[is Movie]
};

.<actors[is Movie] 表示“所有通过 actors 链接指向我的 Movie 对象”。为了高效支持反向链接,Gel 会为所有链接自动创建索引。

模式中的计算定义

计算属性和链接是 EdgeQL 的核心,因此也可以直接在模式定义中声明它们,作为类型的固有部分。

type Person {
  required name: str;
  multi films := .<actors[is Movie]; # 在模式中定义反向链接
}

这些定义是即时计算的,并未物化。


模式继承与其他功能 🏗️

上一节我们探讨了查询语言的特性。本节中,我们回到模式定义,看看 Gel 提供的其他重要功能,如继承。

继承

Gel 支持类型继承,这在实现“混入”风格时特别有用,即多个类型共享一些公共属性。

abstract type HasDOI {
  required exclusive doi: str;
}

abstract type Named {
  required name: str;
}

type Movie extending HasDOI, Named {
  release_year: int64;
}

type TVSeries extending HasDOI, Named {
  season_count: int64;
}

虽然用户非常喜欢继承功能,但它给实现带来了相当大的复杂性。

其他模式功能

  • 访问策略:可以定义复杂的规则,控制对象的选择、插入、更新权限,这些规则可以引用全局变量。
  • 触发器与重写:可以在更新时触发操作或重写字段值。
  • 默认值:为属性设置默认值。

所有这些功能都是通过任意的 EdgeQL 查询定义的,并在编译器中实现,而不是依赖于 PostgreSQL 的类似功能,因为 PostgreSQL 的功能通常不足以表达 EdgeQL 查询所需的复杂性。


实现原理:编译到 PostgreSQL 🛠️

上一节我们了解了 Gel 丰富的模式功能。本节中,我们深入其实现核心:如何将 EdgeQL 编译到 PostgreSQL。

Gel 的核心实现理念是:所有模式都转换为 PostgreSQL 模式,所有 EdgeQL 查询都编译为单个 PostgreSQL 查询。它运行在标准的 PostgreSQL 之上,无需安装特殊扩展。

模式映射

让我们回顾并细化之前的概念映射,看看它们是如何具体实现的:

  • EdgeQL 对象类型 -> SQL 表
  • 单值属性或链接 -> SQL 列
    • 可选属性 name: str -> TEXT 列(可为空)
    • 必需链接 fo: ObjectType -> UUID 列(非空),并创建索引以支持反向查找。
  • 多值属性或链接 -> SQL 表
    • 多链接会创建一个连接表,包含 sourcetarget 的 UUID,并有唯一约束和索引。
    • 链接属性存储在这些链接表中。
    • 多值属性也会创建一个类似的表,但通常没有唯一约束。

值得注意的是,Gel 没有使用外键约束,而是通过编译器在语言层面保证引用完整性。表中的列名和表名实际使用的是内部 UUID,而非用户定义的名称。

编译器架构

EdgeQL 编译器采用经典的两阶段架构:

  1. 前端:将 EdgeQL 源代码编译为中间表示(IR)。这个过程包括:
    • 名称解析类型推断
    • 基数推断:判断表达式结果是“零或一”、“恰好一”、“至少一”还是“多”。
    • 形状推断:确定返回给客户端的字段结构。
    • 语法糖展开、重载函数解析、计算属性/链接的内联等。
  2. 后端:将 IR 编译为 SQL。这个过程包括:
    • 基于 IR 和“作用域树”生成 SQL。
    • 生成将结果序列化为特定格式(如自定义二进制格式或直接 JSON)的 SQL。
    • 处理特殊的变更操作,并强制执行 SQL 不支持的约束(如“必需的多值”属性)。

生成的 SQL 可能看起来复杂且不直观,但 PostgreSQL 能够有效地执行它。


性能与 SQL 支持 📊

上一节我们剖析了 Gel 的编译器。本节中,我们讨论两个实际问题:性能表现,以及为何最终仍需支持 SQL。

性能

Gel 的性能表现良好。与使用 ORM(如 Hibernate 或 ActiveRecord)生成的、常导致 N+1 查询问题的代码相比,Gel 有显著优势。即使与那些执行 2-3 次查询来获取嵌套数据的更优 ORM 方案相比,Gel 通常也表现更好。

与手工编写的最佳 SQL 相比(这些 SQL 也使用了类似的数组聚合技巧来返回嵌套数据),Gel 可能稍逊一筹,但差距不大。对于更常见的查询模式,Gel 通常能做得更好。

支持 SQL 的原因

尽管 Gel 的初衷是取代 SQL,但现实需求迫使其增加了 SQL 支持。主要原因包括:

  1. 集成现有商业智能工具:许多用户希望将现有的 BI 工具连接到 Gel。
  2. 提供迁移路径:允许用户逐步迁移或与现有 SQL 系统交互。

实现 SQL 支持的挑战

实现 SQL 支持(特别是读写操作)出乎意料地困难:

  • 命名问题:Gel 内部使用 UUID 作为标识符,但需要向 SQL 用户暴露友好的名称。
  • 模式自省:工具需要通过 information_schemapg_catalog 来探查模式,这实现起来非常繁琐。
  • 继承映射:需要让 SQL 查询也能自然地处理继承层次结构。
  • 保证一致性:Gel 的许多约束(如访问策略、触发器、必需的多值属性)是在编译器中实现的,而不是通过 SQL 约束。允许通过 SQL 写入时,必须确保不违反这些约束。

解决方案是使用一个混合编译器:将用户编写的 SQL 部分编译,并与 Gel 编译器生成的、用于安全数据修改的 EdgeQL 部分结合起来,共同生成一个保证一致性的 SQL 查询。


总结

在本节课中,我们一起学习了 Gel 数据库系统。我们从 SQL 的局限性出发,探讨了 Gel 提出的“图关系”数据模型,该模型强调具有唯一身份的对象、对象间的链接以及丰富的属性基数。我们深入了解了 EdgeQL 查询语言,它采用集合语义,支持嵌套结果、计算字段和反向链接,使得查询表达更加直观和强大。

我们还揭示了 Gel 的实现原理:它是一个将 EdgeQL 编译为单个 PostgreSQL 查询的编译器,建立在坚实的关系型基础之上。尽管目标是“取代 SQL”,但为了满足实际集成和迁移需求,Gel 也巧妙地实现了对 SQL 的读写支持,虽然这个过程充满挑战。

Gel 代表了一种在保留关系模型核心优势的同时,试图提供更符合现代应用开发思维的数据库查询体验的探索。

posted @ 2026-03-28 12:23  布客飞龙V  阅读(2)  评论(0)    收藏  举报