CMU-15-721-高级数据库系统笔记-全-

CMU 15-721 高级数据库系统笔记(全)

01:课程概述与安排 🗓️

在本节课中,我们将要学习卡内基梅隆大学《高级数据库系统》课程的整体介绍、课程目标、核心主题以及本学期的项目安排。

课程概述

卡内基梅隆大学的高级数据库系统课程正在现场观众面前录制。由于一些个人原因,我无法在第一周亲临匹兹堡的课堂,目前身处加利福尼亚。因此,本次讲座不会深入课程具体内容,而是重点介绍课程的整体方向以及本学期的项目安排,因为项目将是本学期课程的重要组成部分。

为何选择这门课程?

数据库系统仍然需求旺盛,它们构建和维护起来极其复杂,优化难度高。数据处理、查询执行和分析领域存在大量未解决的有趣问题。本课程将为你从事数据库管理系统相关的职业或研究做好准备。即使你未来不打算从事数据库系统工作,本学期学习的内容也将对你未来的职业生涯大有裨益。如果你能为数据系统编写代码,那么你几乎可以为任何关注性能的系统编写代码。你需要理解数据、工作负载以及如何充分利用硬件。此外,从事这方面工作通常能获得丰厚的报酬。过去五六年里,修读721课程的学生最终都进入了优秀的公司,从事数据系统相关工作并获得高薪。

课程核心目标

本课程旨在探讨如何构建现代数据管理系统的实践、技术和方法,重点是系统编程。本学期我们将特别关注分析型工作负载,即如何处理大型数据集并快速执行查询以从中提取新信息和知识。课程目标不仅在于理解构建现代分析型数据库系统的方法和技术,还在于学习如何编写高效、正确的代码,以及如何为代码编写文档和测试计划。我们还将涉及代码审查以及在大型代码库上工作的经验。

课程主题特色

本课程专注于前沿主题,不会重复介绍性课程或教科书中的内容。我们将研读最新的研究论文和文献,探讨如何将这些思想应用到我们自己的系统中。课程的核心模型在高层面上类似于经典数据库系统,但重点是融入过去十年左右出现的、用于加速查询执行的新思想和方法。

本学期核心主题

以下是本学期将要涵盖的主要主题或章节。

  • 数据格式与编码:讨论数据应呈现的格式,以及如何进行编码和压缩,以加速数据访问并减少数据库的存储占用。
  • 查询处理加速:探讨向量化查询执行、代码生成或查询编译等方法,学习如何最高效地在数据上执行物理查询计划。
  • 系统调度:从更宏观的视角审视系统,探讨如何处理单个查询内部以及整个工作负载的调度。
  • 连接算法与网络协议:学习如何高效运行连接算法,以及如何处理节点之间、节点与客户端之间的网络协议。
  • 查询优化:花费大量时间讨论查询优化。无论我们构建的数据库系统有多快,如果查询计划不优或质量低下,之前的所有努力都将白费。
  • 实际系统剖析:用大约四分之一的课时,通过阅读业界主要参与者和初创公司的论文,来研究真实数据系统的实现,并观察他们如何应用我们讨论过的技术。

先修知识与课程政策

先修知识要求

本课程假设你已经修读过CMU的15445入门课程或具有同等水平的本科背景。这是一门关于现代数据库系统的研究生级别课程。我们会讨论经典算法(例如哈希连接),但重点是在现代硬件环境下的实现方式。我们不会涵盖关系代数、存储模型、基本内存管理、缓冲池等背景知识,默认你已经掌握。

课程政策与安排

请始终参考课程网页获取最新的课程政策和时间表。时间表在本学期初已基本确定,后续部分阅读材料可能会有所调整。请遵守学术诚信,不要抄袭、剽窃或作弊。如有疑问,请与我沟通。

我的办公时间定在每周课后,地点在Gates楼九层。如果时间不便,可通过邮件与我另约时间。在办公时间,我们可以讨论项目进展、论文中超出课堂范围的深入问题,或者职业规划(例如寻找数据库相关的工作)。

本学期我们有一位助教,我的博士生William Zhang。他本科毕业于CMU,曾在大二时修读此课程并表现出色。他将与我一同为各项目小组提供帮助。

关于课程的技术问题,请在Piazza上发帖,以便全班共同参与讨论。个人问题或与项目无关的后勤事宜,请直接发送邮件给我。

课程评分构成

本学期成绩由四个部分组成,其中贯穿整个学期的项目占主要比重。

1. 阅读摘要

除小组进行项目汇报的课程外,每节课都有一篇指定的阅读论文(课程表上带有皇冠图标)。这是你需要负责精读的主要文献。在每节课前,你需要通过指定的Google表单提交一份摘要。摘要的目的是迫使你思考并理解论文的核心内容。它不应是一篇长篇报告,而应简洁明了,包含以下部分:

  • 核心思想:用三句话总结论文的主要观点、提出该方法的背景以及关键发现或结论。
  • 评估系统:用一句话描述论文中用于评估的系统(例如,他们是在Snowflake的背景下讨论,还是修改了Postgres或某个DBMS)。
  • 工作负载/基准测试:描述他们在评估中使用了哪些工作负载或基准测试来验证其想法。这部分很重要,因为它将为你自己项目的测试和评估提供参考。

请注意,严禁使用ChatGPT等工具直接生成摘要或抄袭网络上的总结。这无助于你的学习,且一旦发现将构成学术不端行为。

2. 课堂笔记

每位学生需要负责为一节课撰写详细的课堂笔记。这可以看作是阅读摘要的扩展版,但需涵盖课堂上讨论的所有观点,包括可能未在单篇指定论文中涉及的内容。笔记应基于课程幻灯片,总结其中的关键要点。我们会将笔记发布在GitHub和课程网站上,供本届和未来的学生参考。

每位学生在本学期只需完成一次。我们会通过管理表格分配日期。你可以使用ChatGPT等工具辅助整理(例如处理视频转录文本),但必须对笔记的最终内容和准确性负责。如果AI工具产生错误信息(例如编造不存在的技术),而你没有核实并提交,你将为此负责。

3. 期末考试

期末考试为开卷、长答题形式。我将在课程最后一天发布试题,并在期末考试周进行项目最终演示时提交。考试目的不是复述论文内容,而是考察你是否能够综合整个学期讨论的各种材料中的思想,并将其应用于新的情境或理论系统中,以检验你对这些思想如何协同工作构成更大系统的理解。

4. 学期项目(核心部分)

学期项目是本课程的核心。我们的宏观目标是开始在卡内基梅隆构建一个新的数据库管理系统。本课程将是充实这个更大系统中某些组件的起点。

项目总体设计

我们决定本学期使用Rust语言进行开发。课程不会专门教授Rust,我们需要在实践中学习。系统的名称尚未确定。项目的 overarching theme(总体主题)是自适应性,即系统能否在运行时根据所见数据或硬件特性自动调整查询计划,并能否随时间推移进行增量式调整。

项目组织方式

学生将被分为3人一组。共有五个项目主题(系统组件)。我们将有大约30名学生,组成10个小组,其中每两个小组会被分配构建相同的组件。他们需要协作确定该组件的规范(API),但同时也要竞争,努力构建出更快、更好的实现。学期末,我们将通过投票决定两个实现中哪一个胜出,胜出的实现将被用于未来的研究和项目中。

查询优化器组件是个例外。由于查询优化极其复杂,一个学期内很难由一个小型团队构建出可用的原型。因此,优化器小组需要比其他组进行更紧密的协作。

五个项目主题

  1. 调度器:负责决定如何将查询分解并发送到不同节点,以及在查询执行过程中如何持续向节点输送任务以保持其饱和忙碌状态。
  2. 执行引擎:负责接收查询计划的任务并实际执行数据处理。为简化,我们假设执行引擎在单节点上运行。
  3. 目录服务:作为内部数据库,跟踪数据库文件、模式等信息,为查询优化器和调度器提供元数据以生成物理计划。
  4. I/O服务:负责从磁盘或对象存储中检索系统所需的数据块,并将其分发到各个需要的地方,同时维护本地临时缓存以减少昂贵的远程调用。
  5. 查询优化器:接收SQL查询,结合基于规则的优化/启发式方法和基于成本的搜索,确定该查询的最佳物理计划。我们有一个上学期学生开发的现有原型(OpD,基于DataFusion的分支),优化器小组将在此基础上协作开发。

对于前四个主题,我们鼓励从零开始实现,但可以大量借鉴开源项目(如DataFusion、Velox、Meta的Blocks)的思想。对于优化器,则是在现有原型上协作开发。

项目里程碑

本学期项目分为四个里程碑:

  1. 项目提案:1月31日截止。需要提交一份1-2页的Markdown设计文档,并在课堂上进行5分钟演示,阐述实现计划、使用的库以及测试方法。
  2. 两次状态更新:每月一次,在课堂上进行5分钟演示,汇报进展、计划变更、遇到的挑战、测试覆盖率等,并可进行演示。
  3. 最终演示与代码提交:在期末考试周进行最终演示,比较两个竞争实现的性能。最后,必须在GitHub上提交完整的代码,包括所有注释、解决的代码审查问题、测试用例和文档,以便后续交接。

API规范与协作

每个组件都需要定义清晰的API(应用程序编程接口),以便不同组件像微服务一样相互调用。构建相同组件的两个小组必须实现相同的API。为此,每个小组需指定一名联络员,与另一组的联络员协商确定统一的API规范。联络员还需要与其他组件的联络员沟通,确保数据格式和输出符合预期。

为了节省时间,我们鼓励尽可能重用现有的成熟API(例如,目录服务可复用Apache Iceberg的API,执行引擎可参考Velox或DataFusion的接口)。这将简化集成和测试。

测试与评估

在状态更新和最终演示中,需要报告测试覆盖率。学期末,我们将使用CMU的专用机器,在相同的基准测试下,对构建相同组件的两个实现进行“苹果对苹果”的性能比较。联络员将负责协调测试时间。

学术诚信

再次强调,严禁抄袭。可以参考开源项目的设计思想,但不得未经许可直接复制代码并声称是自己的作品。我们必须清楚所有添加到项目中的源代码的来历。

总结与下一步安排

本节课我们一起学习了《高级数据库系统》课程的总体框架、学习目标、核心主题以及贯穿本学期的团队项目详细安排。项目是本课程的重中之重,旨在通过实践构建一个现代分析型数据库系统的核心组件。

对于下一节课,我们将开始第一讲,讨论关于现代分析型系统的第一篇论文。请在课前通过指定链接提交第一篇阅读材料的摘要。同时,请关注Piazza上关于如何分组以及启动项目提案的进一步说明。请检查分配给自己的课堂笔记撰写日期,并做好相应准备。

希望本周的课程介绍对大家有所帮助。我们下周一课堂上见。

02:现代OLAP数据库系统概述

在本节课中,我们将要学习现代在线分析处理数据库系统的基本背景、架构演变以及核心设计理念。我们将从历史系统谈起,逐步过渡到当前主流的共享存储架构,并概述一个查询在这些系统中的执行流程。

系统架构的演变

上一节我们介绍了OLAP系统的基本目标。本节中,我们来看看其系统架构是如何从早期的单一系统发展到今天的云原生形态的。

早期:单一数据库与数据立方体

最初,人们使用单一数据库系统来处理分析工作负载。这类系统(如PostgreSQL、MySQL)将所有组件(存储、执行引擎)集成在一个软件中,并采用面向行的存储和基于页面的磁盘管理。对于分析查询,这种架构效率低下,因为它需要读取整行数据,即使查询只涉及少数几列。

为了提升分析查询性能,人们引入了数据立方体。其核心思想是预先计算聚合查询(如GROUP BY)的结果,并将其以类似数组的形式物化存储。当分析查询到来时,系统可以直接查询这些预计算好的立方体,从而避免昂贵的全表扫描。

以下是数据立方体的基本使用模式:

-- 管理员预先创建数据立方体(物化视图)
CREATE MATERIALIZED VIEW sales_cube AS
SELECT region, product, SUM(revenue)
FROM sales
GROUP BY CUBE(region, product);

-- 用户查询时,优化器会重写查询以利用立方体
SELECT region, SUM(revenue) FROM sales WHERE product = ‘X‘ GROUP BY region;

然而,数据立方体需要管理员手动定义和定期刷新(例如通过夜间定时任务),且难以处理增量更新。

发展:专用数据仓库

21世纪初,人们开始构建专门为分析工作负载设计的数据仓库系统。这些系统(如Vertica、Redshift的前身ParAccel)大多源自PostgreSQL等行存储系统,但对其内部存储和执行引擎进行了彻底改造,转向面向列的存储

这些系统通常是无共享架构,即集群中的每个计算节点都拥有本地连接的磁盘、内存和CPU,并负责存储数据库的一部分数据。数据通过提取、转换、加载工具从操作型数据库定期同步到数据仓库。

现代:共享存储与湖仓一体

2010年代左右,随着云计算的普及,共享存储架构成为主流。其核心思想是将存储职责卸载到独立的服务(如云对象存储S3),而计算层则专注于查询处理。

这种架构带来了关键优势:

  • 存储与计算分离:可以独立扩展计算和存储资源,计算节点变得无状态。
  • 降低运维复杂度:存储的持久性、可用性由云服务商保障。
  • 灵活的数据接入:数据可以作为文件(如Parquet)直接放入对象存储,然后被系统“发现”并查询,这催生了湖仓一体的概念。

湖仓一体架构在数据湖(灵活的文件存储)之上,提供了类似数据仓库的管理能力(如事务性更新、模式演进、目录服务),试图统一数据科学与数据分析的体验。

现代OLAP系统的设计考量

在构建或选择现代OLAP系统时,需要牢记以下几个关键趋势和挑战:

以下是构建现代OLAP系统时需要考虑的三个核心方面:

  1. 支持多样化工作负载:用户不仅需要运行SQL查询,还可能运行机器学习(如PyTorch)或其他自定义计算。系统需要提供灵活的数据访问接口(如Apache Arrow格式),以高效支持这些模式。
  2. 拥抱开放存储格式:得益于共享存储架构,数据以开放文件格式(如Parquet、ORC)存储在对象存储中。这意味着数据可以绕过数据库前端直接写入,但也要求系统具备强大的目录服务来跟踪数据资产、模式及其版本。
  3. 处理半结构化/非结构化数据:大量数据是JSON日志、文本或多媒体。系统需要高效地处理这些半结构化数据,例如通过动态解析、自动生成列或其他优化技术。

系统内部组件概览

了解了宏观架构后,我们深入看看一个现代OLAP系统内部如何处理一个查询。其内部组件通常以服务化方式构建,并通过定义良好的API进行协作。

下图展示了一个查询请求在系统中的流转过程:

用户 -> 前端/SQL解析器 -> 查询规划器 -> 调度器 -> 执行引擎 -> I/O服务 -> 对象存储
                     ↑          ↑          ↑         ↑
                     └───目录服务────────────┘         └───目录服务

以下是各核心组件的职责:

  1. 前端/SQL解析器:接收用户SQL查询,将其解析为初始的中间表示。
  2. 查询规划器
    • 绑定器:验证表名、列名等标识符的有效性。
    • 优化器:基于从目录获取的统计信息和成本模型,进行基于成本的优化,寻找最优执行计划。
  3. 目录服务:系统的元数据中心。存储关于表、列、数据类型、文件位置、数据统计等信息,供规划器和调度器使用。
  4. 调度器:接收物理执行计划,根据目录中数据的位置信息,决定在哪个计算节点上执行哪个任务,并负责协调整个分布式查询的执行状态。
  5. 执行引擎:在计算节点上实际执行查询计划中的物理操作符(如扫描、过滤、连接、聚合)。执行过程中可能需要从存储读取数据。
  6. I/O服务:负责与底层存储(对象存储或本地磁盘)交互,获取数据块。在一些系统中,它还可以将谓词下推到存储层(如S3 Select)。

查询执行模型

现在,我们具体看一个查询是如何在计算集群中执行的。现代系统通常采用流水线执行模型

执行过程可以概括为以下步骤:

  1. 查询计划被组织成一系列流水线阶段
  2. 工作节点从持久化存储(对象存储)读取数据,开始执行流水线的第一个阶段,并产生中间结果。
  3. 流水线中断器处(如需要进行数据重分发的Shuffle阶段),中间结果根据分区键被发送到Shuffle节点
  4. Shuffle节点将数据重新分发到下一组工作节点,进行后续阶段的处理。现代系统通常支持流式Shuffle,无需等待前一阶段全部完成。
  5. 最后一个阶段的结果被发送到协调节点进行最终聚合或整理,然后返回给用户。

在这个模型中,需要区分两种数据:

  • 持久化数据:存储在对象存储中的源数据文件(如Parquet)。它们是数据的唯一来源,具有持久性和容错性。
  • 中间数据:查询执行过程中产生的临时数据。它们是短暂的,通常缓存在计算节点的内存或本地磁盘中,查询结束后即被丢弃。

数据访问模式:推与拉

在分布式查询中,数据如何在计算节点间移动是一个关键设计决策。主要有两种模式:

  • 将查询推向数据:将查询计划(体积小)发送到存有数据的节点执行。这适合无共享架构,能减少网络传输的数据量。公式可以表示为:传输成本 ≈ 查询计划大小 + 中间结果大小
  • 将数据拉向查询:将所需数据从存储拉到计算节点执行。这在共享存储架构中更常见,尤其是当存储层(如S3)无法执行复杂计算时。公式可以表示为:传输成本 ≈ 原始数据大小

实际上,界限正在模糊。例如,云对象存储(如S3 Select)现在支持简单的谓词下推,使得共享存储架构也能实现一定程度的“查询下推”。

共享存储 vs. 无共享架构

最后,我们系统性地对比一下两种核心架构:

特性 无共享架构 共享存储架构
存储位置 数据分区存储在计算节点的本地磁盘。 数据集中存储在独立的对象存储中。
计算节点状态 有状态。节点存储部分数据,故障会导致数据不可用(需复制)。 无状态。节点不持久化数据,可以随时启停。
扩展性 扩展计算容量需要添加节点并重新分布数据,操作复杂。 计算和存储可独立扩展。添加计算节点无需数据迁移。
数据局部性 。计算直接在数据所在节点进行,网络开销低。 。计算节点需要从远程存储获取数据,但可通过缓存缓解。
运维管理 数据库系统需自行管理数据复制、重新平衡等,运维复杂。 存储的持久性、可用性由云服务商保障,运维简化。
典型系统 早期Vertica, Teradata Snowflake, BigQuery, Databricks Lakehouse

现代OLAP系统普遍采用共享存储架构,因为它提供了卓越的弹性、可扩展性和运维简便性,其潜在的性能损失可以通过智能缓存、高效网络协议和存储层优化来弥补。

总结

本节课中我们一起学习了现代OLAP数据库系统的演进历程与核心架构。我们从早期的单一数据库和数据立方体出发,经历了专用数据仓库阶段,最终到达当前主流的、基于共享存储和湖仓一体理念的云原生系统。我们剖析了这类系统的内部组件及其协作方式,了解了查询的流水线执行模型,并对比了“推”与“拉”两种数据访问模式以及无共享与共享存储两种架构的优劣。这些基础知识为我们后续深入探讨存储格式、执行引擎、查询优化等具体技术细节奠定了坚实的上下文基础。下一节课,我们将从底层开始,深入研究现代OLAP系统使用的列式存储文件格式:Parquet和ORC。

03:数据格式与编码(第一部分)

在本节课中,我们将从数据库系统栈的最底层开始,探讨数据在物理存储和内存中的实际形态。我们将从存储模型入手,逐步了解如何为分析型工作负载设计和优化数据布局。

概述

我们正在构建一个概念上的数据系统。从系统栈的底部开始,我们将逐步向上,最终能够运行查询并产生结果。本节课,我们从系统的最底层开始,描述数据的实际形态。

首先,我们需要再次理解我们针对的工作负载类型。我们一直在讨论OLAP系统,其工作负载与OLTP系统不同。这将指导我们如何设计数据,包括如何在磁盘或内存中布局数据,以及需要哪些辅助功能来支持这种设计。

存储模型

上一节我们介绍了课程目标,本节中我们来看看数据存储的基础——存储模型。存储模型定义了如何在物理上(磁盘和内存中)存储元组。这不仅仅是存储的实际字节,而是指如何组织同一元组内的属性以及跨元组的属性。

以下是三种主要的存储模型:

行存储模型

大多数系统默认的存储模型是N-ary存储模型,即行存储。这是PostgreSQL、MySQL、SQLite、Oracle等系统使用的模型。其核心思想是将单个元组的所有属性连续地存储在页面中。这种模型非常适合OLTP工作负载,因为这类应用的事务和查询通常只关心获取单个元组(例如,获取Andy的订单记录)。插入、更新和删除操作也很简单,只需在页面中找到空闲槽位并连续写入即可。在这种模型中,页面大小通常是4KB的常数倍(例如,PostgreSQL默认为8KB)。

然而,行存储对于OLAP工作负载来说效率低下。在OLAP中,我们通常对表的一个子集列进行大规模顺序扫描。如果表有100列,但查询只需要其中4列,我们仍然需要将其他96列读入内存,因为它们都打包在同一个页面中。

列存储模型

分解存储模型是一种纯粹的列存储。人们认识到,对于OLAP这类不同的工作负载,连续存储所有属性并不合理。相反,应该根据属性来分解元组,然后将该属性在所有元组中的数据连续存储。这为压缩和其他优化技术打开了大门。

我们能够这样做,是因为OLAP工作负载主要运行只读查询,通常不担心如何对数据库进行增量插入。如果必须更新一条包含100个属性的记录,在分解存储模型中,我们可能需要更新至少100个页面,这在事务处理中会非常缓慢。

在列存储中,文件通常更大(可达数百MB)。文件内部会被分割成更小的块(称为行组),以标识实际需要处理的部分。

PAX模型

PAX模型是行存储和列存储的混合体,旨在同时获得两者的优势。它解决了纯列存储的一个问题:大多数OLAP查询很少只访问表中的单个列。如果采用分解存储模型,每个列存储为单独的文件,那么在需要将结果重新组合以处理查询时(例如,WHERE子句可能引用四个列),就不得不在不同文件间跳转。

PAX的解决方案是:将表水平分区为行组。在每个行组内,我们连续布局单个列(或属性)的数据,完成该列所有元组的数据布局后,再跳转到下一列。这样,我们既获得了列式存储的所有好处(如更好的压缩、向量化执行),又保持了行存储的空间局部性(即与单个元组相关的数据彼此靠近)。

现代文件格式

十年前,早期的列存储系统(如Vertica、Greenplum)拥有自己的专有数据格式。大多数数据库系统(如SQLite、PostgreSQL、MySQL、Oracle)在磁盘上存储数据时,也使用专有格式。这意味着无法在不同系统间共享数据。将数据从一个系统转移到另一个系统的唯一方法是使用SQL查询将其转储出来,然后转换为CSV、TSV、JSON或XML文件,再通过批量插入操作将其转换到另一个系统的专有格式中。

随着Hadoop和云计算的兴起,出现了对通用文件格式的需求。这就是Parquet和ORC出现的原因。它们旨在成为一种通用文件格式,允许上游应用生成数据,而无需进行额外的转换即可读取,同时获得列式存储或PAX布局的二进制编码优势。

以下是两种主流的现代文件格式:

  • Apache Parquet:由Cloudera和Twitter开发,采用PAX布局,默认使用字典编码压缩所有类型的数据。
  • Apache ORC:由Meta(原Facebook)为Apache Hive开发,同样采用PAX布局,但默认仅对字符串进行字典编码。

这些格式都是自描述的,意味着解释文件中字节所需的所有信息都包含在文件本身中,无需读取外部目录。它们使用Thrift或Protocol Buffers等框架来序列化表模式(schema)信息。

文件格式设计要素

设计文件格式时,我们需要考虑以下几个关键要素:

元数据

文件需要包含解释数据所需的元数据。这包括表模式、行组的偏移量和长度、每个行组中的元组数量以及区域映射图。区域映射图存储了每个行组中列的最小值和最大值,可用于在读取文件其余部分之前确定是否需要读取该行组。

一个重要的设计选择是将元数据放在文件末尾(页脚)。这是因为这些文件通常很大,并且在批量加载数据时,只有在处理完所有数据后才知道完整的元数据(如全局最小/最大值)。此外,这源于HDFS等仅追加文件系统的传统,在这些系统上无法进行原地更新。

类型系统

类型系统定义了如何存储类型本身以及字节的表示形式。

  • 物理类型:是给定值的最低级表示。对于整数和浮点数,通常使用IEEE 754标准。字符串的处理则更为复杂。
  • 逻辑类型:建立在物理类型之上,定义了如何将逻辑类型映射到物理类型。例如,时间戳可以存储为从某个起点开始的秒数或毫秒数(一个int64物理类型),然后通过逻辑类型说明如何解析这些位。

Parquet的类型系统非常精简,只包含少数几种物理类型(如int32int64floatdoublebyte array),字符串被解释为字节数组。ORC的类型系统则更为丰富和复杂。

编码方案

编码方案指定了如何为列块内连续或相关的元组存储物理和逻辑类型的实际比特位。目标是减少存储空间。

以下是几种常见的编码方案:

  • 字典编码:这是最常见的编码方案。用较小的固定长度字典码替换列中经常出现的值,这些字典码来自一个较小的域。这允许我们将可变长度数据(如字符串)转换为可以存储在列中的固定长度值。字典本身存储在行组的头部。
  • 游程编码:当连续出现多个相同值时,不重复存储该值,而是存储该值及其出现的次数。
  • 增量编码:存储连续值之间的差值,而不是值本身。
  • 帧偏移编码:一种增量编码的变体,选择一个起始点(如列块的最小值),然后存储与该全局值的差值。
  • 位打包:当值的范围远小于其类型允许的最大范围时(例如,值在0到20之间的int32),可以使用更少的比特位(如5位)来存储每个值。

不同的格式在触发这些编码方案的策略上有所不同。例如,ORC在出现3个或更多相同值时就会使用RLE,而Parquet则需要8个或更多。

块压缩

在编码之后,还可以使用通用的压缩算法(如Snappy、Zstandard)对整个行组块进行压缩。这可以进一步减少存储空间,但会增加压缩和解压缩的计算开销。在现代硬件上,网络和磁盘速度已经很快,而CPU可能成为瓶颈,因此需要权衡是否使用块压缩。

过滤器

为了在读取数据前跳过不相关的部分,文件格式可以包含过滤器。

  • 区域映射图:如前所述,存储列的最小值和最大值,用于范围查询。
  • 布隆过滤器:一种概率数据结构,用于检查某个值是否可能存在于行组中。它可以明确判断某个值不存在,但可能会误判某个值存在(假阳性)。

嵌套数据支持

对于半结构化的嵌套数据(如JSON、Protocol Buffers),简单的将其存储为文本字段并在查询时解析效率很低。现代文件格式采用了一种称为“记录粉碎”的技术。

基本思想是将嵌套数据按路径拆分,每个路径级别被视为一个单独的列。同时,存储额外的“重复”和“定义”级别列,以跟踪每个值在原始文档层次结构中的位置。这样,我们就可以像处理普通列一样,对这些拆分后的列进行编码、压缩和快速扫描,而无需每次都解析整个文档。

实验评估与启示

通过对真实数据集(而非合成基准测试)的实验评估,我们得到了一些有趣的发现:

  1. 字典编码对所有类型都有效:不仅对字符串,对浮点数等类型进行字典编码也能获得很好的压缩效果,这有些反直觉。
  2. 简单的编码方案更适合现代硬件:Parquet由于编码方案更简单(主要使用字典编码和位打包),在运行时需要更少的分支判断,从而减少了CPU的分支预测错误,在现代超标量CPU架构上表现更好。ORC支持多种编码方案,运行时需要根据元数据选择不同的解压路径,这种复杂性可能导致性能下降。
  3. 考虑避免通用块压缩:在现代高速网络和存储硬件上,使用Snappy或Zstandard进行额外压缩所带来的CPU开销可能超过其减少I/O带来的收益,原生编码方案可能更优。
  4. 现有格式的局限性:尽管Parquet和ORC非常成功并被广泛使用,但它们设计时未考虑某些对OLAP查询处理有益的特性,例如更丰富的统计信息(直方图、草图)、增量模式反序列化能力等。此外,各种编程语言的实现库对规范的支持程度参差不齐。

总结

本节课中,我们一起学习了数据库系统底层的数据表示。我们从存储模型(行存储、列存储、PAX)开始,探讨了它们如何适应不同的工作负载(OLTP vs OLAP)。接着,我们深入了解了现代通用文件格式(如Parquet和ORC)的设计,包括其自描述性、类型系统、关键的编码与压缩方案(尤其是字典编码),以及用于数据跳过的过滤器。我们还简要探讨了嵌套数据的处理策略。最后,通过实际评估,我们认识到编码方案的简单性对现代硬件性能的重要性,并指出了当前主流文件格式的一些局限性。下一节课,我们将学习为现代硬件设计的更先进的编码方案。

04:数据格式与编码(第二部分)

在本节课中,我们将继续探讨数据库系统中的数据格式与编码。我们将深入了解如何高效地存储和访问复杂数据(如JSON),并学习几种前沿的编码方案,这些方案旨在利用现代硬件特性(如SIMD指令集)来提升性能。我们将重点介绍数据分片、Better Blocks、FastLanes以及位交织等技术。


数据分片与嵌套结构处理

上一节我们介绍了行存储、列存储以及混合存储模型。本节中,我们来看看如何处理现实世界中常见的半结构化数据,例如JSON文档。

如果我们将JSON文档作为文本或二进制大对象直接存储在一个列中,虽然可以使用JSON函数进行查询,但会丧失列式存储和向量化执行的所有优势。因此,我们需要一种方法,将JSON文档“分解”并存储为独立的列。

分片的基本概念

分片的基本思想是,将JSON文档中的每条路径存储为一个单独的列。我们通过两个额外的整数列来记录结构信息:

  • 定义层级:记录到达当前路径需要经过多少个可选元素。
  • 重复层级:记录在当前层级上,重复结构(如数组)已经重复了多少次。

以下是处理一个简单嵌套文档的示例:

{
  "docId": 1,
  "name": [
    {
      "language": {"code": "EN", "country": "US"},
      "url": "example.com"
    },
    {
      "language": {"code": "FR"},
      "url": "example.fr"
    }
  ]
}

通过分片,我们会为 docIdname.language.codename.language.countryname.url 等路径创建独立的列,并辅以定义层级和重复层级列来重建原始结构。这样,当执行类似 SELECT ... WHERE name.language.code = ‘EN’ 的查询时,我们可以高效地扫描 name.language.code 列,而无需解析整个JSON文档。

分片的优势与开销

这种方法虽然增加了存储列的数量,但我们可以通过之前讨论的编码和压缩技术有效减少空间占用。主要的优势在于查询性能:针对特定路径的查找可以快速在单个列上完成。当然,将分片后的数据重新拼接回原始形式会有一定开销,但这是为了优化最常见的查询模式(即按路径查找)所做的权衡。


现代编码方案:应对硬件变迁

传统的文件格式(如Parquet、ORC)设计于约十年前,当时网络和磁盘是主要瓶颈。如今,网络速度已极大提升,硬件格局发生了变化,我们需要重新审视编码设计。

这些传统格式存在几个对现代数据库系统不利的问题:

  1. 变长数据块:导致解码时需要条件判断,不利于SIMD向量化。
  2. 急切解压:将压缩数据完全解压后才暴露给查询引擎,无法在压缩状态下进行操作。
  3. 值间依赖:如差值编码,使得相邻值相互依赖,难以并行处理。
  4. 可移植性:依赖特定的低级SIMD指令,难以在不同硬件架构间移植。

接下来,我们将介绍三种旨在解决这些问题的现代编码方案。


Better Blocks:智能嵌套编码

Better Blocks 是一种类似Parquet++的文件格式,其核心思想是采用更积极的嵌套编码策略,并通过一个贪心算法为每个列块选择最优编码方案。

编码方案选择算法

该算法会从整个列块中均匀采样(例如,跳转到10个不同位置,每个位置读取64个连续值),然后尝试所有可用的轻量级编码方案(如字典编码、帧偏移编码、游程编码等)在样本上的效果。选择压缩率最高的方案应用于整个列块。

某些编码方案(如游程编码)会产生新的派生列(如“值”列和“长度”列)。算法会递归地对这些派生列再次应用选择过程(最多递归三次),以进一步压缩。

支持的编码方案

Better Blocks 支持多种编码:

  • 游程编码:适用于连续重复值。
  • 频率编码:存储最常见值及其出现位置的位图,其余值单独存储。
  • 帧偏移与位打包:存储最小值,然后存储每个值与最小值的差值并进行位打包。
  • FSST字符串压缩:将频繁出现的子字符串(最长8字节)替换为1字节代码,支持快速随机访问。
  • Roaring位图:一种高效的压缩位图索引,用于处理稀疏数据。

Better Blocks 避免了不利于SIMD的差值编码,并致力于在列块内部保持编码的一致性,以减少解码时的条件分支。


FastLanes:为SIMD而生的重排序编码

FastLanes 不是一个完整的文件格式,而是一种低层编码方案。它通过巧妙的重排数据,确保在利用SIMD指令时总能最大化有效工作量。

核心思想:统一转置布局

关系模型基于无序集合,这给了我们在物理层自由组织数据的权力。FastLanes 利用这一点,对列中的值进行重新排序,使得解码过程(即使是像游程编码、字典差值编码这类有值间依赖的编码)能够被完美地向量化。

它定义了一套虚拟的SIMD指令集(假设有1024位寄存器),并基于此设计所有操作。在实际运行时,这些操作可以映射到真实的AVX-512或SVE指令,或者通过标量代码模拟。

工作示例

假设一个列经过游程编码和字典差值编码后,其索引向量是 [0, 7, 14, ...]。传统上,解码需要串行计算每个差值。FastLanes 会将这些索引值重新排序,使得在SIMD寄存器中,可以同时对一个“窗口”内的多个值进行差值加法运算,并将结果“散射”到输出内存的正确位置。尽管这可能会增加一些存储开销,但换来了极快的解码速度。


位交织:基于位片的查询优化

前面讨论的方案都是基于“整个值”进行扫描。位交织则采用了截然不同的思路:在比特级别拆分数据,以实现查询时的早期剪枝。

位切片

位切片是一种古老的技术。它将一个列中所有值的第1个比特连续存储,然后是所有值的第2个比特,依此类推。这可以看作是列式存储的极端形式。

位交织:水平与垂直布局

位交织技术在此基础上进行了扩展,提出了两种布局以适应向量化:

  1. 水平位交织:在存储段内,以行为单位存储比特,并预留一个“填充比特”来记录操作结果。它使用标量位操作就能实现类似SIMD的数据并行效果。
  2. 垂直位交织:即传统的位切片,但按处理器字长对齐。它可以直接利用SIMD指令,并且能在比较操作中实现早期终止。

例如,要查询 zip_code < 15217,系统可以从最高位比特片开始检查。如果发现某个元组在最高位为1(而15217的最高位为0),那么无论低位比特是什么,该元组都不满足条件,可以立即跳过,无需检查该元组剩余的比特片。

优势与应用

位交织特别适合范围查询和聚合查询。对于求和操作,可以通过计算每个比特片中“1”的数量(使用POPCNT指令),再乘以相应的2的幂次,快速得到总和。虽然这项技术目前未被主流系统广泛采用,但它提供了一种极具启发性的数据存储和查询处理视角。


总结

本节课中我们一起学习了处理半结构化数据的分片技术,以及三种面向现代硬件的先进编码方案:

  1. Better Blocks 通过智能的、递归的编码选择算法,在保持列式存储优点的同时,实现了高效的压缩。
  2. FastLanes 通过重排数据顺序,巧妙地将有依赖的编码解码过程向量化,充分发挥了SIMD的并行能力。
  3. 位交织 从比特层面重新组织数据,使得查询能够提前终止,特别适用于过滤和聚合操作。

这些技术都深刻体现了数据库系统中逻辑与物理数据独立性的重要性:应用程序员使用SQL进行查询,而数据库系统可以在底层采用任何最优的存储和编码策略,无需修改上层应用,即可获得巨大的性能提升。同时,利用SIMD实现数据并行已成为提升数据库性能的关键工具,我们将在后续课程中继续看到它的应用。

05:查询执行与处理(第一部分)

在本节课中,我们将要学习查询执行与处理的基础知识。我们将从理解现代CPU架构如何影响数据库性能开始,然后探讨不同的查询处理模型,包括迭代器模型、物化模型和向量化模型。最后,我们会简要介绍数据在运算符间传递时的表示方法。

CPU架构与数据库性能

上一节我们讨论了数据编码和压缩,本节中我们来看看如何高效地执行查询。核心目标是充分利用硬件资源,以更低的成本更快地运行查询。这需要我们理解CPU的工作原理。

现代CPU是乱序执行超标量的。这意味着:

  • CPU通过流水线阶段组织指令执行,目标是始终保持流水线繁忙。
  • 它可以同时运行多个流水线,并可能乱序执行指令,但最终会确保结果与顺序执行一致。
  • 当遇到数据依赖(一个指令需要另一个指令的结果)或分支预测错误时,流水线会暂停或刷新,这非常耗时。

对于数据库系统,这意味着我们编写的代码需要适应CPU的偏好,而不是仅仅考虑人类可读性。例如,在顺序扫描中,包含大量条件分支(if语句)的代码可能导致频繁的分支预测失败,从而降低性能。

一个优化技巧是使用无分支技术。考虑一个简单的过滤操作:

// 传统方式(有分支)
for (tuple in table) {
    if (key > low && key < high) {
        copy_to_output(tuple);
    }
}

可以重写为:

// 无分支方式(概念性)
for (tuple in table) {
    // 总是先复制
    copy_to_output_buffer(tuple);
    // 计算条件,结果为1(匹配)或0(不匹配)
    delta = (key > low) && (key < high);
    // 如果不匹配,通过偏移量“覆盖”掉刚才的复制
    output_buffer_offset -= (1 - delta);
}

虽然看起来浪费(总是复制),但由于消除了不可预测的分支,CPU可以更高效地执行,在某些选择性条件下性能反而更好。

另一个关键点是代码特化。我们希望避免在热循环中使用巨大的switch语句或函数指针跳转(例如,根据数据类型选择不同的加法函数)。理想情况是,对于特定的查询和数据,生成或调用专门、直接的指令序列,减少间接开销。

查询处理模型

理解了CPU的期望后,我们现在来探讨数据库系统组织查询执行的几种方式,即查询处理模型。它定义了控制流(如何触发运算符执行)和数据流(数据如何在运算符间传递)。

以下是三种主要的处理模型:

迭代器模型(火山模型)

这是大多数传统行存数据库使用的方法。

  • 控制流:自顶向下。从查询计划根节点开始,调用其 next() 方法,该方法会调用子节点的 next(),以此类推,直到叶子节点(扫描)。
  • 数据流:自底向上。每个 next() 调用返回单个元组。数据像火山喷发一样,一个元组一个元组地向上传递。
  • 优点:易于实现输出控制(例如 LIMIT),天然支持流水线执行(一个元组被尽可能多地处理)。
  • 缺点:每个元组都会产生多次 next() 函数调用(虚函数开销),对现代CPU不友好。

物化模型

  • 控制流:同样是自顶向下触发。
  • 数据流:每个运算符的 next() 调用会一次性处理完所有输入,并将全部结果物化后传递给父运算符。
  • 优点:函数调用开销小,适合OLTP(通常只处理少量元组)。
  • 缺点:对于OLAP,可能在运算符间传递大量中间数据,破坏缓存局部性。

向量化模型

这是现代OLAP系统的标准,旨在结合前两者的优点。

  • 控制流:通常为自顶向下(拉取),但也可以是自底向上(推送)。
  • 数据流:运算符的 next() 调用处理并返回一批元组(例如1024个),称为一个向量或批次。
  • 优点
    1. 大幅减少函数调用开销(每批一次,而非每个元组一次)。
    2. 批处理循环是紧凑的,对CPU缓存和预取友好。
    3. 便于编译器进行自动向量化(如使用SIMD指令)和优化。
    4. 保持了流水线执行的优点。

向量化模型使得数据库引擎能够更好地利用现代CPU的乱序执行和超标量特性。

处理方向:拉取 vs. 推送

除了数据传递的粒度,控制流的方向也是一个重要设计选择。

拉取模型

即前面提到的自顶向下模型。查询执行由根运算符驱动,通过不断拉取子节点数据来推进。这是最常见的方式。

推送模型

由HyPer数据库系统推广。

  • 控制流:自底向上。一个调度器从叶子运算符(扫描)开始,触发一个流水线的执行。
  • 数据流:数据被“推送”过一系列融合的运算符。一个流水线内的处理可能是一个紧密的循环,直接对一个元组(或批次)应用所有操作,直到产出结果或到达流水线边界。
  • 优点:可以实现极致的运算符融合,将多个操作合并到一个紧密的循环中,最大化数据在CPU寄存器和L1缓存中的驻留时间,减少中间结果 materialization。
  • 挑战:动态查询计划生成和代码生成(通常使用JIT编译)更复杂;对某些操作(如带状态的流合并)实现起来更棘手。

推送模型与向量化批次处理并不矛盾,可以在推送模型中使用向量化批次。

向量化执行中的数据表示

在向量化模型中,当一个批次的数据经过过滤操作后,可能只有部分元组满足条件。我们需要一种高效的方式来表示批次中哪些元组是“活跃的”,而不必立即进行昂贵的复制和压缩操作。

以下是两种常见方法:

选择向量

一个选择向量是一个整数数组,存储了当前批次中所有活跃元组的偏移量(位置)。

例如,一个批次有5个元组,偏移为[0,1,2,3,4]。经过过滤后,只有偏移为1,3,4的元组满足条件。那么选择向量就是 [1, 3, 4]。下游运算符将根据这个向量来处理数据。

位图

一个位图是一个比特数组,长度等于批次大小。每个比特对应批次中的一个元组:1表示活跃,0表示无效。

对于上述例子,位图将是 [0, 1, 0, 1, 1]

比较与选择

  • 选择向量 通常更紧凑(只存储有效索引),并且在许多算法中处理起来更快。
  • 位图 与某些SIMD指令(如AVX-512中的掩码操作)能更好地协同工作。
  • 当前的研究和实践表明,在许多场景下,选择向量性能更优。

使用这些表示法,运算符可以高效地跳过无效数据,仅在必要时才进行物化,从而在向量化执行中保持了高性能。

总结

本节课中我们一起学习了查询执行与处理的核心概念。我们首先了解了现代CPU的乱序执行和超标量特性,以及它们对数据库代码编写的启示:减少分支、实现代码特化。接着,我们探讨了三种查询处理模型:迭代器模型、物化模型和向量化模型,并指出向量化模型是现代OLAP系统的基石。我们还对比了拉取和推送两种处理方向,后者通过运算符融合能实现极高的缓存效率。最后,我们介绍了在向量化执行中表示部分过滤结果的两种方法:选择向量和位图。这些基础知识为我们后续深入学习向量化算法、并行执行和查询编译打下了坚实的基础。

06:查询执行与处理(第二部分)

在本节课中,我们将继续探讨查询执行,重点学习如何并行执行查询、数据在算子间的表示与传递、表达式求值,以及自适应查询执行的基本概念。


并行执行概述

上一节我们讨论了查询处理模型,本节我们来看看如何利用并行性来加速查询执行。现代硬件(多核CPU、多节点集群)要求数据库系统能够同时运行多个任务。

并行执行主要分为两类:

  • 查询间并行:系统同时执行多个不同的查询。
  • 查询内并行:将一个查询分解,在多个资源上同时执行。

对于查询内并行,最常见的是算子内并行(水平并行),即创建同一个算子的多个实例,每个实例处理数据的不同分区。

以下是实现算子内并行的核心组件:

  • 交换算子:作为并行执行管道的“断点”,用于合并或分发来自多个并行算子实例的结果。它有三种主要变体:
    • 收集:将多个工作线程的输出合并为单个流。
    • 分发:将单个输入流分发到多个工作线程。
    • 重分区:在多个输入流和多个输出流之间重新组织数据(例如,基于哈希键进行洗牌)。

另一种并行类型是算子间并行(垂直/流水线并行),即查询计划中不同的流水线阶段可以同时运行,形成生产者-消费者模型。这在流处理系统中更为常见。


数据表示与传递

在向量化、推式执行的系统中,我们需要决定数据在算子间传递的形式。核心决策点是物化时机

  • 早期物化:在扫描数据时(查询计划底部)就将整个元组的所有列组合好并向上传递。后续算子无需回查原始数据,但可能传递了大量不需要的列。
  • 晚期物化:仅向上传递查询计划当前阶段所需的最少列(通常是记录ID或偏移量)。当后续算子需要更多列时,有能力向下游请求获取。这减少了不必要的数据移动,是现代列式存储系统的常见做法。

为了高效处理来自不同文件格式(如Parquet、ORC)的数据,系统需要一种内部统一的数据表示格式。理想情况下,这种格式应支持:

  • 固定长度编码以利于随机访问。
  • 零拷贝内存访问,避免序列化/反序列化开销。
  • 在不同系统或进程间高效共享数据。

Apache Arrow 项目正是为此而生。它是一个用于内存数据的跨语言开发平台,定义了列式内存格式,支持高效的向量化计算和数据零拷贝共享。

一个关于字符串存储的优化是 “German-style”字符串存储(源自Umbra数据库)。其核心思想是:在固定长度的列值中,不仅存储字符串大小和指针,还存储一个短字符串前缀。这样,对于许多字符串操作(如前缀匹配),无需解引用指针即可完成,显著提升了性能。


表达式求值

表达式求值负责处理WHEREJOIN等子句中的谓词和计算。SQL解析器会将表达式转换为表达式树

原始的遍历树方法对每个元组进行求值效率很低。优化方法是将表达式树编译为可执行的函数。系统可以:

  1. 预编译原语:为常见数据类型(如int32, float)和操作(如相等比较)生成高效的机器码函数。
  2. 即时编译:将整个表达式树编译成机器码(例如,通过LLVM)。虽然编译有开销,但对于长时运行的查询是值得的。

即使不进行完全编译,执行引擎也可以进行优化,例如:

  • 常量折叠:预先计算表达式中的常量部分。
  • 公共子表达式消除:识别并复用重复计算的子表达式。

自适应查询执行

查询优化器依赖成本模型和统计信息来生成执行计划。如果估计不准(例如,在数据湖环境中缺乏统计信息),计划可能很差。自适应查询执行允许系统在运行时根据观察到的数据动态调整计划。

本节课我们聚焦于表达式级别的自适应优化技巧:

  • 谓词重排序:根据谓词的计算成本和选择性,动态调整多个谓词的求值顺序。
  • 公共子表达式预取:在计算一个表达式时,异步预取另一个表达式所需的数据。
  • 空值快速路径:如果检测到某列没有空值,则跳过所有空值检查逻辑。
  • ASCII快速路径:如果检测到字符串列全是ASCII字符,则使用更快的ASCII处理函数,而非通用的UTF-8函数。
  • 缓冲区复用:对于原地修改数据的操作(如UPPER()函数),直接覆写输入缓冲区,避免分配新内存。

总结

本节课我们一起学习了构建现代查询执行引擎需要考虑的几个关键方面:利用并行性(算子内/间并行)来充分利用硬件;通过晚期物化和统一的内存格式(如Arrow)来高效表示和传递数据;将表达式编译为高效的原语函数来加速求值;以及引入自适应机制(如谓词重排序、快速路径)来应对优化器估计不准的情况,提升执行鲁棒性。这些概念为理解后续更深入的算子实现和查询优化打下了基础。

07:使用SIMD的向量化查询执行

概述

在本节课中,我们将要学习向量化查询执行。这是现代数据库系统获取高性能查询的关键方法之一。我们将探讨如何将标量算法转换为向量化形式,并利用CPU提供的SIMD指令,在单个操作符或表达式中同时运行多个操作。


从任务并行化到数据并行化

上一节我们介绍了如何将查询计划划分为流水线并并行运行,这被称为任务并行化。本节中,我们来看看数据并行化。其核心思想是,我们希望将每次处理单个元组的标量算法,转换为向量化形式,并依赖CPU的SIMD指令,在单个操作符内同时处理多个数据。

公式数据并行化 = 多个计算 + 多个数据 + 同时发生

这很重要,因为它能带来额外的性能提升。例如,在一台32核机器上,如果能将任务完美划分为32个独立任务,理论上可获得32倍加速。如果部分计算能使用SIMD指令(例如一次处理4个数据),则理论加速可达 32 x 4 = 128 倍。当然,由于数据移动等开销,实际很难达到理论峰值,但即使获得1.4倍的加速也是值得的。


SIMD简介

SIMD(单指令多数据)是一类CPU指令,允许处理器同时对多个数据执行相同操作。它依赖于特殊的SIMD寄存器来输入和输出数据。

我们的目标是尽可能长时间地将数据保留在SIMD寄存器中,进行尽可能多的处理,只在必要时才将数据写回CPU缓存或内存。

我们将主要关注AVX-512指令集,它提供了对数据库系统更友好的新特性,例如谓词掩码,允许我们指定操作应用于哪些特定的“通道”。


向量化的三种方法

以下是实现向量化的三种基本方法:

  1. 自动向量化:依赖编译器识别紧密循环并将其重写为向量化指令。这只适用于简单循环,且编译器在指针别名等问题上可能非常保守。
  2. 编译器提示:通过关键字(如C中的 restrict)或编译指令(如 #pragma ivdep)给编译器提示,告知其内存不重叠等信息,鼓励其进行向量化。
  3. 显式向量化:使用内部函数直接调用特定的SIMD指令。这提供了最精确的控制,但代码与特定CPU架构(如x86)绑定。

代码示例(显式向量化)

// 使用AVX2内部函数进行向量加法
__m256i vec_a = _mm256_loadu_si256((__m256i*)a);
__m256i vec_b = _mm256_loadu_si256((__m256i*)b);
__m256i vec_c = _mm256_add_epi32(vec_a, vec_b);
_mm256_storeu_si256((__m256i*)c, vec_c);

向量化原语与操作

为了构建更复杂的数据库操作,我们需要理解一些基本的SIMD原语。AVX-512引入了关键特性,如谓词掩码,允许操作只应用于掩码位为1的通道。

以下是核心原语:

  • 谓词操作:使用位掩码控制操作应用于哪些通道。
  • 置换:根据索引向量,将输入向量中的值复制到目标向量的指定位置。
  • 选择性加载/存储:根据掩码,将内存中的数据加载到向量寄存器,或将向量寄存器的数据存储到内存。
  • 压缩/扩展:压缩操作根据掩码将有效数据紧凑地排列在向量前端;扩展操作是其逆过程。
  • 聚集/散播:聚集操作根据索引向量从多个内存地址收集数据到单个向量;散播操作将向量数据分散到多个内存地址。

这些原语使我们能够高效地在SIMD寄存器和内存之间移动并重组数据。


基本操作:选择扫描

让我们看一个基本操作——选择扫描的向量化。标量代码逐个检查元组是否满足谓词。

向量化方法是:将一批元组的键值加载到SIMD寄存器,使用SIMD比较指令生成位掩码,然后通过“与”操作组合多个谓词的掩码。最终得到的掩码指示了哪些元组满足所有条件。

核心步骤

  1. SIMD比较(key >= low) -> 掩码1
  2. SIMD比较(key <= high) -> 掩码2
  3. SIMD与操作(掩码1 & 掩码2) -> 最终掩码
  4. 使用SIMD压缩操作,根据最终掩码生成匹配元组的列表。

挑战:通道利用率与向量重填充

向量化处理中的一个关键挑战是通道利用率。当一批元组中部分不满足谓词时,对应的SIMD通道就被浪费了。简单地携带这些“无效”元组会降低效率。

解决方案是向量重填充。其思想是在流水线中引入“人工断点”。当发现向量中有无效通道时,不立即将结果传递给下一个操作符,而是先将有效结果暂存到缓冲区。然后,返回获取更多元组来填充这些无效通道。一旦缓冲区填满(即获得一批全有效的向量),再将其送入流水线的下一阶段。

这种方法可以减少计算浪费,并可能为软件预取提供机会。


哈希连接的向量化

哈希连接的传统实现(线性探测)对SIMD不友好。有两种向量化方法:

  1. 水平向量化:在每个哈希桶中存储多个键值对(例如4个)。查找时,将查询键复制多份,与整个桶的内容进行SIMD比较。这种方法简单,但桶内可能未填满,造成利用率问题。
  2. 垂直向量化:同时处理多个查询键(例如4个)。使用SIMD哈希函数为这些键计算哈希值,然后使用SIMD聚集指令从哈希表的不同位置获取对应的键。接着进行SIMD比较。对于未匹配的键,需要记录其状态并在下一轮迭代中继续查找。

垂直向量化通常更优,但它可能改变输出元组的顺序,这在调试时需要注意。


一个巧妙的技巧:直方图构建

构建直方图时,多个输入键可能映射到同一个直方图桶,导致更新冲突。

一个巧妙的SIMD方法是:为每个SIMD通道维护一个独立的直方图副本。这样,通道0的键更新副本0,通道1的键更新副本1,依此类推,完全避免了冲突。最后,使用SIMD加法指令将所有副本的计数汇总,得到最终的直方图。

这种方法充分利用了SIMD的并行性,同时避免了同步开销。


现实考量:AVX-512与降频

虽然AVX-512功能强大,但在实际使用中需要注意一个关键问题:降频。某些CPU在运行密集的AVX-512指令时,可能会降低时钟频率以防止过热,这反而可能导致整体性能下降,甚至不如使用AVX-2指令集。

因此,数据库系统需要谨慎选择:

  • 检测CPU支持的指令集和特性。
  • 在代码中可能包含条件分支,针对不同CPU选择不同的实现(如使用AVX-2而非AVX-512)。
  • 对于通用软件,可能倾向于使用更稳定的AVX-2。

总结

本节课中我们一起学习了向量化查询执行。我们了解到:

  • 向量化通过数据并行化,利用SIMD指令同时处理多个数据,是提升数据库性能的重要技术。
  • 实现向量化主要有自动向量化、编译器提示和显式向量化三种方法,通常需要结合使用。
  • 我们学习了一系列SIMD原语(如置换、聚集、散播、谓词掩码),它们是构建向量化算法的基础。
  • 我们探讨了选择扫描、哈希连接和直方图构建等操作的向量化实现,并分析了其中的挑战(如通道利用率)和解决方案(如向量重填充)。
  • 最后,我们认识到在实际部署中需要谨慎考虑硬件特性,例如AVX-512可能引发的CPU降频问题。

向量化是构建现代高性能数据库引擎的核心技术之一,它需要与任务并行化、查询编译等技术协同工作,才能充分发挥硬件潜力。

08:JIT查询编译与代码生成

概述

在本节课中,我们将学习如何通过即时查询编译与代码生成技术来提升数据库系统的查询性能。我们将探讨两种主要方法:源码到源码编译和基于LLVM的即时编译,并分析各自的优缺点。


背景:为何需要代码生成

上一节我们讨论了如何通过向量化技术实现数据并行。本节中,我们来看看另一种提升性能的核心技术:代码生成。

其核心思想是:与其让数据库系统在运行时通过庞大的 switch 语句或虚函数表来动态解释查询计划,不如为每个具体的查询“硬编码”生成一个只执行该查询的专用程序。由于SQL是声明式的,我们可以预先知道数据的模式、查询的确切意图以及数据类型,因此可以生成高度优化的、无分支跳转的代码。

这种专门化可以显著减少CPU需要执行的指令数量。一个关键目标是:减少不必要的指令。公式化地看,我们希望最小化执行查询所需的总指令数 I_total

I_total = I_necessary + I_overhead

代码生成的目标就是尽可能消除 I_overhead,这部分开销来自于类型检查、虚函数调用、表达式树遍历等通用逻辑。


两种代码生成方法

以下是实现查询编译和代码生成的两种主要技术路径。

方法一:源码到源码编译

这种方法涉及在数据库系统内部编写代码,以生成另一种高级编程语言(如C++)的源代码。然后,使用传统的编译器(如GCC)将此源代码编译为机器码,并作为共享库链接到数据库进程中执行。

优点

  • 易于调试:生成的代码是高级语言,可以使用标准调试器(如GDB)进行调试,堆栈跟踪清晰。
  • 系统集成简单:生成的代码可以直接调用数据库系统的其他部分(如缓冲区管理器、网络层),无需特殊的桥接代码。

缺点

  • 编译成本高:启动外部编译器(如GCC)进程开销大,包含读取配置文件、初始化等步骤,不适合在查询关键路径上执行。

早期系统如Haiku采用了这种方法。它将查询计划转换为C++代码,调用GCC编译,并链接结果。实验表明,其生成的代码性能甚至优于手工优化的代码,但编译延迟可能高达数百毫秒。

方法二:低级IR与即时编译

这种方法不生成高级语言代码,而是直接生成一种低级的中间表示,然后使用嵌入式编译器框架(如LLVM)将其即时编译为机器码。

核心流程

  1. 查询优化器产生物理计划。
  2. 代码生成器将计划转换为LLVM IR。
  3. LLVM的JIT编译器将IR编译为优化后的机器码。
  4. 数据库系统执行生成的机器码。

Hyper系统是这种方法的代表。其关键创新在于结合了“推送式”执行模型,它不再是操作符间通过迭代器“拉取”元组,而是将查询计划组织成一系列嵌套循环(即流水线),尽可能让单个元组在CPU寄存器中完成所有操作,减少内存访问。

性能对比:在TPC-H基准测试中,Hyper的LLVM JIT方法相比传统的解释执行或向量化系统(如VectorWise),以及相比其自身早期的C++源码生成版本,都取得了显著的性能提升,特别是在复杂查询上。


编译延迟的挑战与解决方案

无论是哪种方法,一个共同的挑战是:编译本身需要时间。对于短查询,编译开销可能超过其执行时间,得不偿失。

解决方案:自适应执行
Hyper在后续工作中提出了自适应执行策略:

  1. 立即启动:生成LLVM IR后,首先用一个轻量级字节码解释器开始执行查询。
  2. 后台编译:同时,在后台线程启动LLVM的优化编译流程。
  3. 热切换:如果查询执行时间较长,当优化后的机器码编译完成时,系统可以在处理完当前一批数据后,无缝切换到编译版本继续执行。

这种方法确保了短查询能快速启动,长查询能获得最优性能,同时编译过程对用户透明。


各系统实践概览

以下是不同数据库系统在代码生成方面的实践,按技术路线分类。

源码到源码编译派

  • System R (1970s):最早使用代码生成的系统之一,将查询编译为IBM System/370汇编代码。但因工程维护困难而被放弃。
  • Amazon Redshift:采用类似Haiku的方法,生成C++代码。其关键创新是维护一个全局查询片段缓存。任何Redshift实例上运行过的查询片段都会被编译、缓存,并可供其他实例复用,命中率极高,有效分摊了编译成本。
  • Oracle:将PL/SQL存储过程转换为受限制的C方言(Pro*C),然后编译为本地代码执行。

低级IR与JIT编译派

  • Hyper / Umbra:先驱和集大成者。从生成LLVM IR演进到直接生成汇编代码,再辅以自适应执行和高级调试工具,将性能推向极致。
  • SQLite:采用轻量级虚拟机(VM)模型。查询计划被编译为一组字节码操作码,由内置的解释器执行。这种方式牺牲了一些性能,但获得了极佳的可移植性和简洁性。
  • Java生态数据库 (如Spark Tungsten, QuestDB):在JVM平台上,它们生成Java字节码,然后依赖JVM的HotSpot JIT编译器将其优化为机器码。

预编译原语派

  • VectorWise:不走运行时编译路线。它在系统构建时,就预编译好所有可能用到的数据操作原语(如各种数据类型的比较、算术运算),形成庞大的函数库。运行时,查询计划通过组合调用这些预编译好的函数来执行。这避免了运行时编译开销,但需要维护庞大的预编译代码库。

总结

本节课我们一起学习了即时查询编译与代码生成技术。我们了解到,通过为特定查询生成专门的、无分支的代码,可以极大减少指令开销,提升CPU执行效率。我们分析了源码到源码编译低级IR即时编译两种主要技术路径的优缺点,并看到编译延迟是主要挑战,可通过自适应执行全局缓存等策略缓解。最后,我们概览了从早期的System R到现代的Redshift、Hyper、Spark等系统在该领域的实践,认识到没有一种方法适用于所有场景,工程上的可维护性、调试便利性与极致性能之间需要权衡。对于现代OLAP系统,结合向量化与智能的即时编译策略,往往是获得最佳性能的关键。

09:查询调度与协调

概述

在本节课中,我们将学习如何将查询计划分配给系统中的不同工作线程并实际执行。我们将探讨调度系统的目标、不同的调度模型,并深入分析几种有影响力的调度器实现。

上一节我们讨论了如何优化执行引擎以尽可能快地运行顺序扫描查询。本节中,我们来看看如何调度这些查询计划。

背景知识

首先,我们需要明确一些术语:

  • 查询计划:一个由关系运算符组成的有向无环图。
  • 运算符实例:在要扫描的部分数据上实例化的一个运算符。
  • 任务:一个计算工作单元,通常包含同一流水线中的多个运算符实例,可以分配给工作线程执行。
  • 任务集:为给定查询需要执行的所有任务的集合。

调度系统的核心思想是:我们知道流水线中断点在哪里,因此可以将这些流水线转换为独立的任务,然后分发和执行。调度系统需要决定使用多少任务、将它们分配到哪些CPU核心上,以及任务生成的中间结果应该存储在哪里。

调度系统的目标

以下是构建高性能数据库调度系统的目标:

  1. 最大化吞吐量:处理尽可能多的查询,保持系统持续运行。
  2. 维持公平性:确保没有查询因资源不足而饿死,即使长查询优先级较低,最终也应完成。
  3. 保证响应性:减少尾部延迟,特别是对于短查询,使用户能快速获得结果。
  4. 降低开销:调度器本身的开销应尽可能低,避免复杂的调度计算占用过多时间。

进程模型与工作线程

现代数据库系统通常是多线程的。工作线程是一个通用术语,指可以被分配任务以执行查询或数据库内部操作的计算资源。

关于CPU核心分配,主要有两种方法:

  • 单工作线程/核心:每个CPU核心只运行一个工作线程,避免了缓存争用和上下文切换开销。
  • 多工作线程/核心:当一个线程因I/O或缓存未命中而停滞时,其他线程可以运行。但对于CPU密集型的数据库工作负载,关闭超线程并采用单工作线程/核心的模式通常能获得最佳性能。

任务分配模型:推送 vs 拉取

将任务分配给工作线程有两种基本方法:

  • 推送模型:一个集中的调度组件掌握全局视图,主动将新任务推送给空闲的工作线程。
  • 拉取模型:一个调度组件维护所有可能任务的队列。工作线程在需要工作时,主动从队列中拉取下一个任务。

拉取模型更简单、更常用,因为它减少了协调开销,并且工作线程可以自主决定下一步做什么,无需中央调度器持续跟踪每个线程的状态。

数据放置与分区

调度时,确保工作线程处理的数据在本地(局部性)至关重要。

  • 分区:根据某些键值将数据集分割到不同文件中,以便在并行查询时均匀分配工作。
  • 放置策略:决定分区实际存放的位置。在共享磁盘的“数据湖”架构中,通常采用基于文件的轮询分布。

理想情况下,我们希望将任务分配给拥有该数据本地副本的工作线程或节点。

从查询计划到任务执行

对于OLTP查询,通常只有一个流水线,调度很简单。对于OLAP查询,由于流水线之间存在依赖关系,调度更复杂。不能在一个流水线完成并产生中间结果之前,启动依赖它的下一个流水线。

最简单的调度类型是静态调度:优化器或调度器在开始时静态地将任务分配给工作线程。但这种方法无法应对任务执行时间的意外变化(例如,数据倾斜导致某个任务成为“掉队者”),从而拖慢整个查询。

调度器实现分析

接下来,我们分析三种有影响力的调度器实现。

1. HyPer的Morsel驱动并行

HyPer的Morsel论文旨在动态调整任务分配,以应对任务运行时间的变化。

以下是其核心设计:

  • Morsel:数据表的水平分块(类似于行组)。
  • 架构:每个核心一个工作线程,关闭超线程。一个任务负责处理一个Morsel。
  • 拉取模型:使用全局任务队列进行拉取式任务分配。
  • 数据局部性:任务标注了其处理的Morsel所在的NUMA区域。工作线程优先选择处理本地Morsel的任务。
  • 工作窃取:如果没有本地任务,工作线程可以窃取处理非本地数据的任务,以避免资源空闲。

优点:动态性好,能有效缓解掉队者问题。
缺点

  • 全局任务队列可能成为可扩展性瓶颈。
  • 假设Morsel内所有元组的执行成本相同,这不总是成立。
  • 缺乏服务质量保证,长查询可能阻塞短查询。

2. Umbra的优先级感知调度

Umbra的调度器是对HyPer Morsel的改进,旨在克服其缺陷。

以下是其核心特性:

  • 动态任务大小:任务与Morsel不是一对一关系。一个任务可以在其时间片内处理多个Morsel。目标是让每个任务运行约1毫秒,以平衡开销与灵活性。
  • 优先级衰减:查询的优先级随时间呈指数衰减。长查询优先级降低,为短查询让路,确保系统响应性。
  • 可扩展的任务队列:避免单一的全局锁。使用全局任务槽数组和每个工作线程本地的位掩码(变化掩码、返回掩码)来高效通知变更,减少了争用。

优点:引入了优先级,更好地处理了混合工作负载;任务队列设计更具可扩展性。

3. SAP HANA的精细化线程管理

HANA的方法代表了另一个极端,它试图对线程进行极其精细的控制,甚至替代操作系统的部分调度功能。

以下是其核心设计:

  • 工作窃取与池化扩展:支持在单个NUMA区域内动态增加线程。
  • 软/硬队列
    • 硬队列:任务必须在其套接字或NUMA区域内运行(如垃圾回收)。
    • 软队列:任务可以被工作窃取(如扫描)。
  • 多状态工作线程池
    • 活跃:正在运行任务。
    • 非活跃:在内核中阻塞(如等待锁)。
    • 空闲:主动寻找工作。
    • 暂停:被解除调度,进入操作系统内核休眠;需要时可唤醒。
  • 主张关闭工作窃取:在大型NUMA机器上,他们认为跨NUMA区域窃取工作的成本太高,不如关闭窃取。

优点:对硬件资源控制力极强,适合超大规模系统。
缺点:实现复杂。

分布式调度

分布式环境下的调度面临相同的基本问题,但增加了网络因素。核心思想依然是:决定在何处运行任务,以及中间结果去向何方。像Snowflake这样的系统会在每个流水线中断后进行“洗牌”阶段,以重新组织和校准工作线程。

总结

本节课我们一起学习了数据库查询调度的核心概念与不同实现。关键要点如下:

  • 自主调度:现代高性能数据库系统倾向于自己管理调度,而非依赖操作系统。
  • 核心权衡:需要在任务粒度、局部性、工作窃取、优先级管理和调度器开销之间进行权衡。
  • 演进路径:从HyPer的Morsel(基础动态调度),到Umbra(引入优先级和可扩展队列),再到HANA(极致的线程控制),体现了调度策略的不断精细化。
  • 通用原则:无论是单节点还是分布式系统,调度的核心问题都是决定在何处运行任务以及数据的存放位置。

数据库系统是复杂的,但通过精心设计的调度器,我们可以充分发挥硬件潜力,高效地处理各种工作负载。

09:并行哈希连接算法

概述

在本节课中,我们将学习如何在现代硬件上实现高性能的并行哈希连接算法。我们将重点讨论单节点系统,并假设数据已通过某种方式(例如分布式调度)被移动到所需位置。核心目标是理解如何最大化并行性,同时最小化线程间的同步开销和内存访问成本。

并行连接算法背景

上一节我们介绍了如何将查询计划分解为更小的数据单元(称为“morsels”)进行调度。本节中,我们将聚焦于连接操作,特别是哈希连接,并探讨其并行化策略。

在传统的数据库课程中,我们讨论连接算法时通常不考虑线程或工作线程,只计算基于磁盘页读写的复杂度。然而,在现代硬件(多核、大内存)环境下,我们需要利用所有可用资源(如CPU核心)来尽可能快地运行连接操作。

我们将专注于二元连接(两个关系)。下一节课会讨论多路连接(三个或更多关系)。目前,两种主要的连接方法是哈希连接和排序合并连接。由于哈希连接在绝大多数情况下(约99%)性能更优,我们将主要讨论它,而忽略嵌套循环连接(因其性能通常最差)。

哈希连接 vs. 排序合并连接:历史视角

哈希连接并非一直被认为优于其他方法。在1970年代,早期的数据库系统运行在原始硬件上,处理大于内存的表时,Grace哈希连接(一种可以溢出到磁盘的分区连接)尚未发明,而外部归并排序算法已经存在。因此,排序合并连接是处理大数据集的首选。

到了1980年代,硬件改进,日本的一个名为“Grace数据库机”的项目发明了Grace哈希连接,这是今天我们将讨论的分区连接的前身。当时还有专门为哈希连接设计的定制硬件(数据库机)。

1990年代,Volcano模型、迭代器模型、交换操作符的发明者Goetz Graefe发表论文,指出在当时硬件条件下,这两种算法是等效的。

自21世纪以来,哈希连接被证明更具优势。当前的核心问题变成了:是否应该进行分区?你们阅读的论文表明分区通常更快,但也有其他研究(例如Hyper团队)指出,虽然分区更快,但正确实现它非常困难,因此不分区的方法对大多数场景来说“足够好”。

现代哈希连接算法设计原则

设计并行连接算法时,我们需要考虑两个主要目标:

  1. 最小化同步:由于涉及多个并行工作的线程,我们希望减少线程间的通信,避免一个线程因等待另一个线程填充缓冲区或哈希表而被阻塞。这并不意味着要实现无锁或无闩锁,而是需要谨慎地获取锁,避免频繁争用。
  2. 最小化内存访问成本:我们希望每个线程操作的数据尽可能本地化,最好在其缓存中。如果不在L1/L2缓存,则在共享的L3缓存中;如果不在最后一级缓存中,则应在本地内存中。目标是避免跨NUMA(非统一内存访问)节点的互连访问。

设计思路类似于我们在介绍性课程中的原则:当从磁盘将数据页读入内存时,我们希望在丢弃该数据前尽可能多地对其进行操作,以避免再次访问磁盘。同样,我们希望在一个数据元组位于CPU寄存器中时,尽可能多地在流水线中对其进行操作,然后再获取下一个元组。

哈希连接的基本步骤

哈希连接通常包含三个步骤,其中第一步是可选的:

  1. 分区阶段(可选):对构建侧(内关系)和探测侧(外关系)的元组,基于连接键的哈希值,将它们划分到不同的“分片”或分区中。然后,工作线程可以基于这些子集构建哈希表或进行探测。
  2. 构建阶段:使用内关系(构建侧)的数据构建哈希表。我们假设存在一个逻辑上的单一哈希表(尽管底层可能有多个物理表)。
  3. 探测阶段:使用外关系(探测侧)的数据探测哈希表。如果找到匹配项,则将内关系和外关系的匹配元组组合,并作为输出向上传递。

你们阅读的论文正确地强调了最后这个物化成本(将匹配的元组组合成输出)实际上非常重要。许多早期论文忽略了这一步,但这在真实系统中是必须的,并且会给CPU缓存带来额外压力。

接下来,我们将深入探讨分区阶段,尽管在最终实现中可能选择不分区,但理解其机制对于把握系统设计中的权衡(如缓存一致性)非常有用。

分区阶段详解

分区阶段的目的是获取内外关系,基于连接键将它们放入分区缓冲区。这些分区将被重新分配到不同的核心上,这样在构建和探测阶段,工作线程只需处理分配给它们的分区内的数据,进行顺序扫描,从而获得更好的数据局部性。

目标是:在现代新架构上,执行额外分区步骤所带来的指令开销,能够被因数据局部性提升而减少的缓存未命中开销所抵消,从而整体上更快。

以下是两种高级分区方法:

非阻塞分区

这种方法允许多个线程同时访问数据并填充分区,无需复杂的预分割。线程可以直接写入共享的全局分区缓冲区(需要锁同步),或者先写入线程私有的分区,最后再合并。

以下是具体变体:

  • 共享分区:所有线程直接写入全局分区缓冲区。为了防止数据损坏,在写入每个桶时必须使用锁(闩锁)进行同步。
  • 私有分区:每个线程维护自己的一组私有分区(例如,最终需要10个分区,每个线程就维护10个迷你分区)。线程写入自己的私有分区时无需同步。之后,需要一个单独的合并阶段,由某个线程负责将所有线程中属于同一分区的数据合并到全局分区中。

私有分区的缺点是合并阶段可能需要访问位于其他NUMA节点的数据,导致远程内存访问。如果进行早期物化(携带所有列),移动的数据量会很大,开销更显著。

基数分区

这种方法(也称为Radix Join)的目标是只物化结果一次。它通过多次扫描输入关系来实现无锁分区。

  1. 第一遍扫描:计算直方图。对于每个元组,根据其连接键哈希值的某些位(称为“基数”)确定其所属的分区,并统计每个分区中的元组数量。
  2. 计算前缀和:根据直方图计算前缀和。前缀和是一个累加序列,用于确定每个分区在最终输出缓冲区中的起始偏移量。
  3. 第二遍扫描:再次扫描数据,根据前缀和提供的偏移量,将每个元组直接写入输出缓冲区中正确的位置。由于每个线程都知道其写入的偏移量是唯一的,因此无需任何同步。

如果数据分布严重倾斜,导致某个分区仍然很大,可以递归地对该分区再次应用此过程(查看哈希值的下几位)。

为了优化性能,可以采用以下技术:

  • 软件写合并缓冲区:在分区阶段,不直接写入最终的远程位置,而是先写入一个小的私有缓冲区,当缓冲区满时再批量写入。这有助于减少缓存污染。
  • 流存储(非临时存储)指令:使用特殊的CPU指令直接将数据写入内存,绕过CPU缓存。

构建阶段:哈希表设计

分区完成后(或者不分区直接开始),我们进入构建阶段。此阶段使用内关系的数据构建哈希表。哈希表包含两个核心部分:哈希函数和解决冲突的数据结构。

哈希函数

哈希函数将任意字节的值映射到较小范围(通常是32或64位)的整数值。选择哈希函数时需要在速度和冲突率之间权衡。

  • 最快的哈希函数可能总是返回同一个值,但冲突率极高。
  • 完美哈希函数能保证每个唯一键都有唯一哈希值,但通常速度较慢,实现复杂。

大多数系统使用现成的哈希函数。例如:

  • CRC32:历史悠久,但有专用的CPU指令,速度很快,适用于整数。
  • MurmurHash:一种现代、快速的哈希函数,某些实现支持向量化查找。
  • xxHash:Facebook开发,在性能和冲突率方面表现优异。

通常,根据数据类型(如整数、字符串)选择不同的哈希函数。

哈希方案

我们需要一种数据结构来处理哈希冲突。以下是几种常见方案:

  • 链式哈希:每个桶指向一个链表。发生冲突时,将新条目添加到链表末尾。实现简单,但指针追逐可能导致缓存不友好。Hyper团队曾利用x86指针中未使用的位来存储布隆过滤器,以加速查找。
  • 开放寻址哈希:只有一个大的条目数组。发生冲突时,按某种探测序列(如线性探测、二次探测)查找下一个空槽。这是哈希连接中最常用的方案。
  • 罗宾汉哈希:开放寻址的一种变体。在插入时,如果新条目的“理想位置”距离比当前位置的条目更近,则“窃取”该位置,被替换的条目则继续寻找新位置。目标是减少所有条目的平均探测距离,优化查找性能。但插入成本更高。
  • 霍普斯科奇哈希:罗宾汉哈希的扩展,但限制条目只能在固定的“邻域”内移动。如果邻域已满,则需要调整哈希表大小。设计目标是使查找一个邻域的成本恒定(因为整个邻域可能在一个缓存行中)。
  • 布谷鸟哈希:使用两个(或多个)哈希函数。插入时,如果两个位置都被占用,则随机踢出一个现有条目,并尝试重新插入被踢出的条目。查找只需检查两个位置,速度很快,但插入过程可能复杂。

在实际系统中,线性探测(开放寻址)因其简单性和良好的缓存局部性而被广泛使用。罗宾汉哈希在某些情况下能提升性能,但并非总是有效,需要根据具体工作负载评估。

哈希表内容

在哈希表中存储什么也是一个设计选择:

  • 可以存储完整的元组,但如果是变长数据则不适合开放寻址。
  • 可以只存储指向元组(在独立堆中)的指针或偏移量,这样调整哈希表大小时只需移动指针,但查找时需要间接引用。
  • 通常,除了存储连接键或整个元组,还会存储计算出的哈希值,以便快速比较,避免昂贵的字符串比较。

探测阶段优化

探测阶段相对直接:扫描外关系,对每个元组计算哈希值,查找哈希表,如果找到匹配则输出。

一个重要的优化是使用布隆过滤器。在构建哈希表的同时,可以构建一个布隆过滤器。在探测时,首先检查布隆过滤器。如果过滤器指示键不存在,则肯定不在哈希表中,可以跳过昂贵的哈希表查找。这是一个典型的“侧信息传递”优化,被许多系统(如Hyper、Umbra)采用。

性能总结与工程实践

根据相关论文的基准测试,可以得出以下观察:

  1. 分区 vs. 不分区:进行基数分区通常比不分区更快,但正确实现它非常具有挑战性,需要对硬件有深入了解。
  2. 哈希方案选择:对于大多数工作负载,简单的线性探测(开放寻址)哈希表在实现得当的情况下,性能已经“足够好”。更复杂的方案(如罗宾汉哈希、霍普斯科奇哈希)可能带来边际收益,但增加了实现复杂性。
  3. 哈希连接并非唯一瓶颈:在完整的查询执行中,哈希连接本身可能只占总时间的10%-30%。其余时间花费在数据读取、过滤、物化输出等其他操作上。因此,虽然优化哈希连接很重要,但它不一定是系统性能的“主要瓶颈”。

因此,许多生产系统选择一种简单、稳健的实现(如非分区线性探测哈希连接),并将其性能优化到可接受的水平,而不是追求极端复杂、难以维护的优化。

总结

本节课我们一起深入探讨了并行哈希连接算法。我们回顾了哈希连接与排序合并连接的历史演变,明确了在现代硬件上哈希连接的主导地位。我们详细分析了并行哈希连接的三个核心阶段:分区、构建和探测,并重点讨论了分区阶段的不同策略(非阻塞与基数分区)及其权衡。在构建阶段,我们探讨了哈希函数的选择和各种哈希方案(链式、开放寻址、罗宾汉、霍普斯科奇、布谷鸟)的特点。最后,我们了解了探测阶段的布隆过滤器优化,并认识到在工程实践中,简单、稳健的实现往往比极端复杂的优化更具实用价值。理解这些底层机制和权衡,对于设计和实现高性能的数据库系统至关重要。

11:多路连接算法与最坏情况最优连接

概述

在本节课中,我们将要学习多路连接算法,特别是最坏情况最优连接。我们将探讨当连接操作的中间结果急剧膨胀时,如何避免传统二元连接算法的性能瓶颈。课程将从最坏情况最优连接的基本概念开始,介绍其早期实现如Leapfrog Triejoin,然后分析其优化版本如Uber的哈希Trie连接,最后讨论DuckDB中的相关优化技术。

背景:最坏情况最优连接

上一节我们介绍了如何尽可能快地执行哈希连接,重点在于并行化。然而,那些都是二元连接,即连接两个表。

二元连接是关系数据库系统中的首选方法,特别是当连接操作符的输出预计小于其输入时。问题出现在当连接操作符的输出远大于其输入时,这是数据库最不希望遇到的最坏情况,因为我们必须物化这些中间结果,即使最终会丢弃其中大部分数据。

另一种思考方式是,无论选择何种表连接顺序,中间结果都可能膨胀。这会导致存储浪费和大量无效计算。

因此,高层次的目标是解决中间结果的爆炸性增长问题。多路连接的核心思想是,不再以“表与表连接”的方式思考,而是以“属性”为中心。我们不再关心属性来自哪个表,而是直接在这些属性上进行比较,并以此合成输出结果。

多路连接的思想在文献中早已存在,但“最坏情况最优”的实现则出现在21世纪末。最坏情况最优连接旨在同时连接三个或更多的表。其工作方式是,我们不是获取整个元组并进行全量比较,而是获取连接键中的单个属性(可能来自多个列),进行匹配,并且只有在确认连接键的子集值已经匹配的情况下,才继续进行给定元组的其他属性比较。这样可以在早期识别出不匹配的元组,避免无效计算。

最坏情况最优连接的有趣之处在于,其性能边界由输出大小和需要比较的属性数量决定,而不是简单地由输入元组大小决定。此外,投入连接的表格越多,相对于输入,其性能可能越好,因为它试图一次性进行尽可能多的比较。

实现:Leapfrog Triejoin

上一节我们介绍了最坏情况最优连接的概念,本节中我们来看看其早期实现之一:Leapfrog Triejoin。

Leapfrog Triejoin的基本思想是,为了执行多路连接,它假设数据已经预排序,或者在执行连接之前为连接键构建索引数据结构(如Trie)。在Trie中,每一层对应连接键中的一个属性。

逻辑是,我们需要一种方法来同时遍历所有要连接的表,并在属性之间进行比较以查看是否匹配。因为数据已排序,我们不需要在连接键上回溯。

以下是其工作原理的简化步骤:

  1. 为每个表在连接键上构建Trie。Trie的每一层对应一个属性。
  2. 从第一个表(例如R)的根节点开始遍历。
  3. 获取第一个属性(例如A)的值,并用此值探测另一个表(例如T)的Trie,寻找匹配。
  4. 如果找到匹配,则进入下一层属性(例如B),用该值探测第三个表(例如S)的Trie。
  5. 最后,对于最后一个需要匹配的属性(例如C),我们在已匹配路径的叶节点链表上进行扫描,取交集以确定最终的匹配元组。
  6. 通过迭代器在Trie的不同层级和表之间“跳跃”,仅当当前值小于其他迭代器指向的最大值时,才向前跳转到大于或等于该最大值的下一个位置(即“蛙跳”)。

这种方法避免了物化庞大的中间结果。然而,挑战在于,无论是预先计算所有可能的连接顺序的Trie,还是在每次连接时动态构建Trie,成本都可能很高。对于海量数据,动态构建的代价巨大,而预计算所有可能性则可能造成浪费。

优化:哈希Trie连接

Leapfrog Triejoin存在一些缺陷,例如处理变长键的效率问题以及哈希表缓存不友好。Uber提出了一种优化方案:哈希Trie连接。

哈希Trie连接的核心思想与Leapfrog Triejoin类似,但它不在Trie节点中存储属性的实际值,而是存储值的哈希(64位整数)。这样可以用极低的成本快速比较两个属性值是否可能匹配,从而尽早丢弃不匹配的数据,只有在必要时才去检查实际数据。

在哈希Trie中,每个节点是一个哈希表,它将属性值的哈希映射到Trie下一层的指针或实际元组的指针。由于所有操作都基于固定长度的哈希值,无需处理不同数据类型的逻辑分支,实现了代码特化。

此外,哈希Trie连接引入了两项关键优化:

1. 单例路径优化
当Trie中某条路径上的每个节点都只有一个条目时,可以绕过中间节点,直接通过一个“快速路径”指针跳转到叶节点。通过指针中的特定位(单例标志位)来标识这种情况。

2. 延迟子节点扩展
不同于一开始就构建完整的Trie,哈希Trie连接采用惰性物化策略。初始时只构建根节点和叶节点(实际元组)。仅当查询实际需要遍历某条路径时,才动态分配和填充路径上的中间节点。这通过指针中的“扩展标志位”和“链长度”信息来实现,可以显著减少内存开销和构建时间。

实验表明,在处理大规模、高度连接的图数据集时,采用延迟物化的哈希Trie连接性能优于需要构建完整Trie的Leapfrog Triejoin。

系统集成与权衡

尽管多路连接在最坏情况下表现优异,但并非所有场景都适用。实验表明,对于TPC-H等基准测试中的许多二元连接,即使没有过滤条件,多路连接的性能也可能不如优化的二元连接。

因此,理想的数据库系统应该同时支持二元连接和最坏情况最优连接。查询优化器需要能够根据统计信息,动态决定对查询计划中的每个连接使用哪种算法。这类似于优化器选择排序连接、哈希连接或嵌套循环连接。

Uber的论文讨论了如何通过启发式方法扩展优化器来实现这一点。例如,如果连接都基于主外键关系,可以知道数据不会膨胀,应使用二元连接;而对于涉及大量自连接或非主外键属性连接的图遍历查询,则可能触发最坏情况最优连接。

DuckDB的优化:因子化

来自DuckDB团队的一项独立优化技术是“因子化”。其思想非常简单:与其反复物化连接中重复的元组,不如找出所有唯一的实际值,并维护一个计数器列,记录该元组出现的次数。

这样,在面对中间结果膨胀的连接时,可以以因子化的形式存储数据,避免物化所有重复结果。然而,挑战在于系统中的所有操作符都需要感知它们正在操作因子化的元组,并在进行计数等聚合操作时正确处理计数器列。

DuckDB团队在支持SQL PGQ(图查询)扩展时,集成了最坏情况最优连接以及向量化执行、压缩等现代优化技术。他们的实验显示,与专门的图数据库(如Neo4j)相比,这种基于成熟关系型数据库技术栈的扩展方案在性能上具有显著优势。

总结

本节课中我们一起学习了多路连接算法,特别是最坏情况最优连接。我们从二元连接在中间结果膨胀时面临的问题出发,探讨了最坏情况最优连接的核心思想:基于属性进行比较并尽早短路不匹配的计算。我们分析了Leapfrog Triejoin这一早期实现及其工作原理,然后深入研究了Uber哈希Trie连接的优化,包括使用哈希值加速比较、单例路径优化和延迟子节点扩展。最后,我们讨论了在数据库系统中集成多种连接算法的必要性,并简要介绍了DuckDB的因子化优化。随着SQL PGQ标准的推广,支持高效图查询的关系型数据库将越来越需要这类技术,而专门的图数据库可能面临来自融合了这些先进优化技术的关系型系统的强大竞争。

12:用户定义函数优化

概述

在本节课中,我们将学习如何优化数据库系统中的用户定义函数。我们将探讨UDF带来的性能挑战,并深入研究三种优化技术:内联、转换为公共表表达式以及批处理。这些技术旨在将原本不透明的过程式UDF逻辑转换为数据库查询优化器能够理解和优化的声明式形式,从而显著提升查询性能。


背景与挑战

上一节我们讨论了连接算法和并行哈希连接。本节中,我们来看看如何将更复杂的逻辑嵌入到数据库系统中执行。

我们通常假设的应用场景是:用户通过应用程序或工具与数据库交互,发送SQL查询,数据库计算整个查询并返回结果。这些查询所能执行的计算受限于数据库系统本身支持的功能。然而,常见的情况是,用户会执行 SELECT * 查询将所有数据取出,在外部(如Jupyter Notebook或Pandas)进行计算,然后再将结果推回数据库。如果能够避免这种模式,让数据库系统完整地了解你试图对数据执行的操作,它就能进行相应的优化。将计算推送到数据所在的位置,通常比将数据拉到外部应用处理更好。

这就是“嵌入数据库逻辑”的含义。其好处显而易见:

  • 减少网络往返:通过单个查询完成所有计算。
  • 数据新鲜度:直接处理数据库中的数据,能立即看到新到达的数据更新。
  • 简化开发:避免开发者在不同代码库中重复实现相同功能。
  • 扩展功能:允许开发者扩展系统功能,而无需重新编译数据库二进制文件。

嵌入式数据库逻辑主要有两类:用户定义函数存储过程。它们概念相似,都是可以在数据库服务器中运行的过程代码。主要区别在于,存储过程可以在SQL查询外部调用,而UDF必须嵌入在SELECT语句或SQL查询内部。某些系统(如SQL Server)还规定UDF不能更新表,而存储过程可以。

我们将重点关注用户定义函数。UDF是应用程序开发者编写的函数,用于扩展数据库系统内置操作之外的功能。例如,SQL标准规定了SUBSTRING函数,但如果你需要一个特殊版本,可以将其编写为UDF。UDF接受标量输入参数,执行一些计算,然后返回标量或表格式的结果。

为了简化讨论,我们假设UDF是纯函数,不会调用外部服务,所有计算都在函数内部完成。

UDF的性能问题

尽管UDF很有用,但它们存在一个主要问题:查询优化器对用外部编程语言(如PL/SQL)编写的UDF内部逻辑一无所知

SQL是声明式的,优化器可以推理查询计划中操作符的表达式,估算各计算步骤的选择性。但是,如果调用一个用非SQL语言编写的UDF,优化器无法知道其内部成本。例如,在WHERE value = my_udf(1,2,3)中,优化器无法估算my_udf的选择性。

另一个挑战是难以并行化UDF,也无法利用向量化查询处理模型。优化器可能最终执行隐式的嵌套循环连接,因为外部查询为每个元组调用一次函数,而函数内部可能又查询了另一个表。由于SQL端和UDF端是分离的,优化器无法从整体上审视整个查询并进行我们熟知的优化(如切换为哈希连接)。

更复杂的情况是,有些UDF会在内部动态构建SQL字符串并执行。在这种情况下,优化器在运行函数之前完全无法预知其行为。

这些问题导致UDF可能使查询显著变慢。在Microsoft SQL Server中,这被称为“逐行 agonizing 行”执行模式。一个著名的例子是,在TPC-H Query 12中,将一个简单的检查逻辑封装为UDF后,查询时间从0.8秒激增至13小时。

那么,如何优化UDF呢?主要有四种基本方法:

  1. 编译:将解释执行的UDF编译为本地代码,使其运行更快。但这不解决优化器将其视为黑盒的问题。
  2. 扩展编程语言:为UDF语言引入编译指示或指令,向数据库服务器提示可优化的部分。但优化器通常仍将其视为黑盒。
  3. 内联:将UDF转换为某种声明式形式,使其能够像普通SQL查询一样嵌入查询计划,并由优化器进行优化。这是本节课的重点。
  4. 批处理:将UDF转换为一批SQL查询,一次性处理多个元组,避免单次函数调用的开销。这种方法实际上早于内联技术。

本节课我们将重点讨论内联和批处理这两种方法。


技术基础:子查询与横向连接

在深入UDF优化技术之前,我们需要回顾两个重要的SQL概念:子查询和横向连接。内联方法将大量依赖这些概念,因为它假设优化器能够有效地处理它们。

子查询处理

子查询是嵌套在另一个SQL查询内部的查询。处理子查询主要有两种方式:

  • 重写为连接:将子查询去相关并扁平化为连接操作。这是最理想的情况。
  • 物化为临时表:将子查询单独执行一次,将其结果物化到临时表中,然后与主查询进行连接。当查询优化器无法支持查询计划中的有向无环图时,可能需要采用这种方法。

以下是一个重写示例:
原始查询在WHERE子句中包含一个关联子查询,用于检查订单对应的用户是否存在。优化器可以识别出订单表和用户表之间的关系,并发现实际上无需访问用户表,因为所需信息都在订单表中。通过重写,可以消除对用户表的访问,从而大幅提升性能。

横向连接

横向连接允许FROM子句中的子查询引用同一嵌套层级中其他子查询的值或属性。在普通的连接中,子查询之间通常不能互相窥探。横向连接使你能够保证查询按照UDF中指定的顺序执行。

可以将其想象成一系列嵌套的for循环,每个横向连接子句迭代每个元组,并在需要时对前一个表进行查找。

例如:

SELECT ...
FROM orders o
INNER JOIN LATERAL (
    SELECT ...
    FROM users u
    WHERE u.id = o.user_id -- 这里引用了外部查询的 o.user_id
) AS user_info ON true;

在这个例子中,内部的子查询可以引用外部查询orders o中的o.user_id。优化器知道这种依赖关系,并通过横向连接将它们链接起来。


方法一:Froid - UDF内联

Froid是Microsoft提出的一种UDF内联方法。其核心思想是:将UDF转换为关系代数表达式,然后将其内联到SQL查询本身中,并在进入基于成本的连接优化之前完成这一转换

你可以将其视为一组静态转换规则,在重写阶段将UDF转换为关系代数,而不需要成本模型。转换后的查询会被交给查询优化器,就像处理任何其他查询一样。

Froid方法主要包含五个步骤:

步骤1:语句转换

首先,将UDF中的T-SQL(或PL/SQL)语句转换为对应的SQL查询。UDF中的每一行代码(以分号分隔)都会被映射到一个SQL查询。

例如,对于一个计算客户等级的函数:

  • DECLARE level VARCHAR(20) = 'regular'; 转换为 SELECT 'regular' AS level
  • 计算订单总金额并赋值给变量total的语句,转换为一个计算聚合的嵌套查询,并将输出重命名为total
  • IF条件语句转换为SQL标准的CASE WHEN语句。

步骤2:划分区域

将UDF代码划分为多个区域,以便分析其内容并理解区域间的依赖关系。这些依赖关系将通过后续的横向连接来表达。

每个区域内的语句会被转换为对应的SQL查询,并将输出赋值给一个临时表(如ER1, ER2)。

步骤3:合并表达式

尝试合并同一区域内的多个表达式。

步骤4:通过横向连接链接

使用横向连接将各个区域链接起来,确保它们按照UDF中指定的顺序执行。这是处理区域间变量引用的关键。

步骤5:内联到调用查询

将转换并链接好的整个SQL块(包含所有横向连接)嵌入到原始调用该UDF的查询中。然后,将这个组合查询交给查询优化器处理。

经过优化器处理后,复杂的横向连接结构通常会被简化为更高效的连接形式,例如左外连接。这样,原本隐藏在UDF黑盒中的隐式连接就变成了优化器可以识别和优化的显式连接操作。

内联的优势

  • 可优化:UDF内部的逻辑对优化器变得可见,优化器可以利用统计信息进行选择性估算。
  • 可并行化:消除了函数调用间的奇怪依赖,查询可以并行执行。
  • 消除调用开销:避免了为每个记录设置函数调用栈的开销。
  • 利用现有优化器:Froid方法的一个主要优点是它不需要修改查询优化器本身,降低了工程复杂性和回归风险。

此外,内联后的查询还能受益于优化器已有的高级优化能力,例如:

  • 常量传播与折叠:如果使用常量调用UDF,优化器可以提前计算并传播结果。
  • 死代码消除:优化器可以移除永远不会执行到的代码分支。

局限性与支持范围

Froid并非支持所有UDF结构。截至2019年,它支持DECLARESETSELECT查询、IF...THEN...ELSERETURN以及基本的关系操作符(EXISTSINANY等)。但它不支持:

  • 异常处理
  • 动态SQL查询
  • 循环(如WHILE
  • 更新操作(在SQL Server的UDF中本身就不允许)

根据Microsoft对Azure上真实客户数据库的调查,大约60%的UDF可以通过Froid进行内联。性能提升非常显著,有些客户获得了近1000倍的加速,而无需更改任何应用程序代码。


方法二:UDF2SQL - 转换为公共表表达式

这是另一组德国研究人员提出的方法,称为UDF2SQL或“Auf F”。其核心思想与Froid类似,但实现路径不同:将UDF转换为公共表表达式和一系列SQL语句,完全在SQL层面进行操作

与Froid作为数据库内部功能不同,Auf F实现为一个独立的中件间编译器。它能够处理一些Froid无法处理的构造,例如循环。

其转换管道更为复杂:

  1. 转换为SSA形式:将UDF代码转换为静态单赋值形式,这是一种编译器中间表示,便于分析。
  2. 转换为ANF形式:将SSA转换为管理范式,利用尾递归函数来简化代码块。
  3. 转换为直接递归:将相互尾递归转换为直接递归。
  4. 转换为SQL:使用递归CTE将直接递归形式的代码转换为SQL查询。

这种方法通过递归CTE来处理UDF中的循环结构,这是Froid目前不支持的。然而,其性能提升可能不如Froid在成熟优化器上那么显著,部分原因在于像PostgreSQL这样的系统,其查询优化器本身对复杂子查询去相关和优化的能力可能不如SQL Server强大。


方法三:批处理

批处理是另一种优化UDF的方法,它实际上早于内联技术。其核心思想是:不通过横向连接链接子查询,而是将UDF转换为一系列更新语句,通过一个状态表来模拟UDF中的变量,并一次性处理所有输入元组

具体步骤如下:

  1. 创建一个临时表,其列对应UDF中定义的所有变量,以及一个特殊的“返回布尔”列,用于标记哪些元组应该被返回。
  2. 使用一系列UPDATE语句来更新这个临时表,每个UPDATE对应UDF中的一个计算步骤。这些更新操作会作用于临时表中的所有行。
  3. 通过一个生成序列的查询来“播种”计算,模拟调用UDF的输入。

这种方法将原本逐行执行的过程(为每个输入元组调用一次UDF)转换为对整个输入集的批量操作。所有元组独立地更新它们在状态表中的对应行。

批处理方法的优势在于,它不依赖于查询优化器对复杂横向连接和子查询去相关的能力。因此,它可能适用于更多种类的UDF,甚至包括一些包含动态SQL的UDF(通过将拼接的SQL字符串存储在状态表中实现)。

现实挑战与系统支持

Microsoft在提出Froid后,还发布了一个名为“Proc Bench”的开源基准测试,旨在反映真实UDF的工作负载。有趣的是,尽管Microsoft发明了Froid内联技术,但在这个基于他们自己调查的基准测试中,Froid只能内联其中一小部分UDF。原因在于,内联后产生的复杂子查询和横向连接结构,超出了SQL Server查询优化器去相关和扁平化处理的能力。

相反,采用“德国风格”(如HyPer/Umbra)查询优化器的系统(如DuckDB),由于其优化器能够处理任意复杂的子查询去相关(通过支持查询计划DAG、引入依赖连接等高级技术),因此能够成功内联并优化Proc Bench中几乎所有的UDF。事实上,CMU的学生已将横向连接扁平化等功能贡献到了DuckDB中。

这表明,内联技术的成功严重依赖于底层查询优化器的 sophistication。如果优化器能够处理Froid或Auf F生成的大型复杂查询,那么内联将带来巨大的性能提升。否则,批处理可能是一个更通用的备选方案。


总结

本节课我们一起学习了数据库用户定义函数的优化技术。

我们首先了解了UDF的价值与带来的性能挑战,核心在于查询优化器将其视为黑盒,导致无法优化、难以并行化以及产生昂贵的逐行调用开销。

接着,我们深入探讨了三种主要的优化方法:

  1. 内联:以Microsoft的Froid为例,将过程式UDF转换为关系代数表达式并内联到查询中,使优化器能够全局优化。其效果取决于优化器处理复杂子查询的能力。
  2. 转换为SQL:以Auf F为例,将UDF(包括循环)通过编译器技术转换为递归CTE等纯SQL形式。
  3. 批处理:将UDF转换为对状态表的一系列批量更新操作,避免逐行调用,对优化器要求较低。

关键要点是,内联技术潜力巨大,能在不修改应用代码的前提下实现数量级的性能提升。然而,其成功与否取决于数据库系统查询优化器是否足够强大,能够处理内联后产生的复杂查询结构,特别是对于子查询去相关和扁平化的支持程度。以HyPer/Umbra为代表的“德国风格”优化器在这方面表现突出。

下一节课,我们将转向一个相关但方向相反的主题:数据库连接协议。我们将探讨如何高效地将数据移入移出数据库,这与“将逻辑推送到数据”形成了有趣的对比。

13:数据库网络协议

概述

在本节课中,我们将学习数据库系统与客户端应用程序之间如何进行通信。我们将探讨不同的数据库访问API、网络协议的设计选择,以及如何优化数据传输效率,特别是针对现代数据分析场景。

上一节课我们介绍了如何将用户定义的函数内联到数据库系统中执行。本节中,我们来看看相反的过程:如何将数据从数据库系统高效地传输到应用程序端进行处理。

数据库访问方法

大多数查询并非在终端中运行并返回人类可读的文本。应用程序通常需要二进制格式的数据,以便进行进一步处理。因此,我们需要通过编程接口与数据库交互。

以下是几种主要的数据库访问方法:

  • 原生C API:数据库系统通过C库暴露的专有API。通常用于编写驱动程序,而非直接编写应用程序。
  • ODBC:一种基于C语言的、数据库系统无关的API标准。它采用设备驱动程序模型,数据库厂商提供符合ODBC规范的驱动程序。
  • JDBC:Java语言的数据库连接标准,概念与ODBC类似,旨在为Java应用程序提供数据库无关的访问方式。
  • 对象关系映射器:如Django、Rails、SQLAlchemy等,它们为特定编程语言提供了更高层次的抽象。

我们主要关注ODBC和JDBC这类标准。它们的核心思想是提供一个数据库系统无关的API。理论上,更换数据库服务器时,应用程序代码无需更改。当然,不同系统的SQL方言差异是一个实际问题。

ODBC采用驱动程序模型。应用程序向ODBC驱动程序发送请求,驱动程序负责与数据库服务器通信,并将结果整理成ODBC规范要求的格式返回给应用程序。我们关心的核心部分是驱动程序与服务器之间的通信,即网络协议

网络协议基础

客户端与数据库服务器之间的通信通常基于TCP/IP,使用专有的网络协议。流程一般如下:

  1. 客户端连接数据库,进行身份验证。
  2. 客户端发送查询。
  3. 服务器执行查询,序列化结果,通过网络发送回客户端。

我们关注的重点是结果数据的序列化和网络传输效率。对于大型数据导出场景,这通常是性能瓶颈。

协议设计的关键决策

我们将讨论网络协议设计的四个关键方面,这些决策会影响性能和工程复杂度。

1. 行格式 vs. 列格式

ODBC和JDBC本质上是面向行的API,因为它们诞生于列式数据库普及之前。即使数据库内部使用列式存储,为了满足客户端协议,服务器也需要将数据重新组装成行格式。

解决方案是采用向量化或批处理模型。Apache Arrow及其数据库连接标准ADBC提供了向量化的API,允许数据以原生的列式格式传输,避免了转换开销。

2. 压缩策略

在传输前压缩数据可以减少网络带宽消耗。有两种主要方法:

  • 通用压缩:如GZip、Snappy。简单易用,但可能带来CPU开销。
  • 轻量级编码:如字典编码、增量编码。针对数据类型,效率更高,但需要在客户端驱动程序中实现相应的解码逻辑,增加了工程复杂度。

选择取决于网络速度与CPU能力的权衡。网络慢时,压缩收益大;网络快时,压缩的CPU开销可能不划算。

3. 序列化与编码

这决定了数据在网络上以何种二进制格式表示。

  • 二进制编码:以数据库内部存储的二进制形式发送数据。客户端负责处理字节序等问题。可以自定义序列化格式,也可以使用现有库。
    • 自定义格式:更高效,但需要自行处理空值、数据类型等元数据。
    • 使用库:如Protocol Buffers、FlatBuffers。提供版本管理等功能,但可能引入额外开销。
  • 文本编码:将所有数据转换为字符串形式发送。不关心字节序,但会显著增加数据量,不过结合压缩后可能效果不错。

4. 性能权衡

实验表明,不同的设计选择会导致显著的性能差异。例如:

  • 使用Thrift等RPC框架会因额外的缓冲拷贝和元数据而变慢。
  • 某些专有协议可能过于“啰嗦”,在慢速网络上性能下降严重。
  • 简单的文本编码配合压缩,有时能获得不错的性能。
  • 原生支持Apache Arrow格式进行传输,能获得最佳性能,因为它避免了数据格式转换。

内核旁路优化

网络协议本身并非唯一的瓶颈。操作系统的TCP/IP栈和上下文切换可能带来巨大开销。内核旁路技术旨在绕过操作系统内核,直接与硬件交互。

以下是三种主要方法:

  • DPDK:允许用户空间程序直接与网卡交互。需要自己实现TCP/IP栈等网络功能,工程复杂度高。
  • RDMA:允许直接读写远程机器的内存,仿佛访问本地内存。性能极高,但设置复杂,通常用于后端服务器间通信。
  • io_uring:Linux的异步I/O接口,通过环形缓冲区提交和完成I/O请求,减少了系统调用的开销。它并未完全绕过内核,但提供了一种高效的异步方式。

内核旁路技术潜力巨大,但实现复杂,且并非在所有场景下都能带来稳定收益。

用户空间旁路与eBPF

另一种思路是将部分数据库逻辑下沉到内核中,即用户空间旁路。传统上通过内核模块实现,但编写困难且危险。

eBPF 是一项变革性技术。它允许将经过验证的安全代码动态加载到内核中执行。这为数据库优化提供了新可能,例如,可以在内核中实现网络协议处理、代理等功能,避免数据在用户空间和内核空间之间的拷贝。初步研究表明,这对于某些特定任务能带来显著的性能提升。

客户端优化

即使服务器高效地发送了数据,客户端仍需将数据转换为应用程序所需的格式。对于数据分析场景,将数据载入Pandas DataFrame可能非常耗时。

如果数据库支持ADBC并返回Arrow格式,客户端可以直接在Arrow数据上操作,无需转换。否则,可以使用像Connector-X这样的工具,它通过将查询拆分为多个并行子查询,并让多个线程并行构建DataFrame,来加速数据加载过程。

总结

本节课我们一起学习了数据库网络协议的核心内容。我们了解了ODBC/JDBC等标准API的作用,深入探讨了网络协议在设计时面临的格式、压缩、序列化等关键抉择及其性能影响。我们还审视了通过DPDK、RDMA、io_uring进行内核旁路以提升性能的复杂性和潜力,并介绍了利用eBPF进行用户空间旁路的新思路。最后,我们看到了客户端数据接收和转换的优化手段。理解这些协议和优化技术,对于构建高性能的现代数据库系统至关重要。

14:查询优化器实现 1

概述

在本节课中,我们将要学习查询优化器的核心实现方法。查询优化器是数据库管理系统中最重要也最复杂的部分,其目标是为给定的SQL查询生成一个正确且成本最低的物理执行计划。我们将从最简单的启发式方法开始,逐步深入到基于成本的搜索策略,并探讨现代优化器生成器的设计理念。

逻辑计划与物理计划

上一节我们概述了优化器的目标,本节中我们来看看其核心工作流程中的关键概念:逻辑计划与物理计划。

逻辑计划是查询的高层表示,它基于关系代数,指明了需要执行哪些操作(例如扫描表、连接表),但并未指定执行这些操作的具体算法。

物理计划则定义了如何实际执行查询。它依赖于数据的物理存储方式(例如是否已排序、是否压缩),并指定了执行每个操作的具体算法(例如使用哈希连接还是归并连接)。

优化器首先将SQL解析树转换为逻辑计划,然后通过一系列转换(逻辑到逻辑,或逻辑到物理)来寻找最优的物理执行计划。一旦进入物理形式,通常不会再转换回逻辑形式。

启发式优化器

对于从零开始构建的新数据库系统,启发式优化器通常是首选方案,因为它实现简单。

以下是其基本工作原理:

  • 它基于人类对查询优化的领域知识,将经验编码为一系列规则。
  • 这些规则通常是寻找SQL查询或逻辑计划中的特定模式,然后无条件地应用转换以改进计划。
  • 常见的启发式规则包括:尽早执行最具选择性的谓词(谓词下推)、下推投影以最小化传递的数据量、将笛卡尔积转换为等值连接等。

历史上,Ingres和Oracle的早期版本都采用了这种方法。它的优点是简单快速,对于OLTP类简单查询足够有效。但缺点是无法处理复杂的连接排序,也难以扩展以支持更复杂的查询特性。

基于成本的优化器:System R

在同一时期,IBM的研究人员构建了System R,它引入了第一个基于成本的查询优化器。

其工作流程分为两个主要阶段:

  1. 启发式重写阶段:首先应用一系列逻辑转换规则(类似于纯启发式优化器),将查询转换为更好的初始形式。
  2. 计划枚举与成本搜索阶段:这是核心。优化器为逻辑操作符生成所有可能的物理实现(例如不同的连接算法、访问路径),并尝试找到成本最低的组合。

为了控制搜索空间,System R采用了自底向上的动态规划方法,并且通常只考虑左深连接树,而忽略右深或浓密连接树。

搜索过程示例
假设查询涉及表A、B、C的连接。

  1. 首先,独立地为每个表选择最佳访问路径(例如顺序扫描或索引扫描)。
  2. 然后,自底向上构建连接计划:
    • 枚举所有两表连接(A⋈B, B⋈C, A⋈C)的可能物理实现(哈希连接、归并连接等),计算每种组合的成本,并为每个连接结果保留成本最低的计划。
    • 基于上一步的结果,枚举所有三表连接的可能物理实现,同样保留成本最低的计划。
    • 最终,从根节点回溯,即可得到完整的、成本最低的物理执行计划。

处理物理属性
System R的一个局限是,其动态规划搜索最初并未原生考虑数据的物理属性(如排序顺序)。例如,如果查询要求结果按某个字段排序,优化器会分别跟踪“已排序”和“未排序”的最佳子计划,并在最后比较“未排序计划+显式排序操作”的成本与“已排序计划”的成本,从而做出选择。

优化器生成器:声明式规则

前述方法通常将优化规则硬编码为过程式的if-then-else语句,难以编写、维护和扩展。

更好的方法是使用优化器生成器。其核心思想是:

  • 使用一种高级领域特定语言来声明式地定义优化规则(要匹配的模式和要应用的转换)。
  • 由系统自动生成执行模式匹配和转换的代码。
  • 将搜索策略与规则定义解耦,使得优化器引擎和优化规则可以独立开发和扩展。

这催生了两种主要的现代优化器架构:分层搜索和统一搜索。

分层搜索

分层搜索将优化过程清晰地分为多个阶段。

以下是其典型阶段:

  • 阶段1:基于启发式的逻辑重写:应用一组确定的逻辑-逻辑转换规则,不考虑成本模型。这些规则通常是已知总是有益的转换。
  • 阶段2:基于成本的搜索:将重写后的逻辑计划转换为物理计划,并使用成本模型和动态规划等方法搜索最优的物理执行计划。

IBM的Starburst项目(其技术仍用于DB2)是分层搜索的代表。它甚至引入了一个额外的“关系演算”中间表示层以进行更高级的重写。

统一搜索与Cascades优化器

统一搜索试图将逻辑转换和物理实现的选择融合在一个统一的、基于成本的搜索框架内。最著名的实现是Cascades优化器框架

Cascades采用自顶向下的搜索方式,并引入了几个关键概念来高效管理搜索空间:

  1. 表达式与组

    • 表达式:查询计划中的一个操作单元。
    • :所有能产生相同逻辑结果的逻辑物理表达式的集合。组是记忆化和等价性管理的核心单元。
  2. 多表达式:作为组内具体表达式的占位符,允许在未完全展开子树的情况下进行成本估算和决策,支持增量式搜索。

  3. 规则与任务

    • 转换规则:定义逻辑表达式到逻辑表达式的转换。
    • 实现规则:定义逻辑表达式到物理表达式的转换。
    • 每个优化任务都是一个自包含的数据结构,包含模式、转换逻辑、所需的物理属性以及搜索优先级。
  4. 记忆表:记录对于每个组,在给定物理属性要求下,目前找到的最佳物理表达式及其成本。这避免了重复计算和无限循环(例如,反复应用交换律)。

搜索过程简述
优化器从目标(查询的根组)开始。它应用规则为当前组生成新的逻辑或物理表达式(子任务)。当探索一个物理表达式时,它递归地要求其子组提供满足特定属性要求的最佳计划,子组则通过查找记忆表或触发新的优化任务来响应。通过始终优先探索有希望的低成本分支,并在当前路径成本超过已知最佳计划成本时剪枝,Cascades能够高效地搜索庞大的计划空间。

搜索终止

由于查询优化是NP难问题,优化器需要明智地决定何时停止搜索。

以下是几种常见的搜索终止策略:

  • 挂钟时间:设置一个固定的超时时间。
  • 成本阈值:当在给定时间内找到的计划改进幅度低于某个阈值时停止。
  • 穷举搜索:当确认没有更多可考虑的变换时停止。
  • 变换次数:基于已考虑的优化规则应用次数来终止,这使得策略与硬件性能解耦。

总结

本节课中我们一起学习了查询优化器的多种实现方法。我们从最简单的启发式优化器开始,了解了其快速但有限的能力。接着,我们深入研究了System R开创的基于成本的自底向上动态规划方法,它引入了成本模型来指导搜索,但为了效率做出了妥协(如左深连接树)。最后,我们探讨了现代优化器生成器的理念,特别是分层搜索和统一搜索架构。以Cascades为代表的统一搜索框架,通过自顶向下的搜索、基于组的记忆化以及声明式的规则定义,提供了强大而灵活的优化能力,成为许多现代数据库系统的选择。下一节课,我们将继续深入Cascades的细节,并探讨随机化搜索、超图动态规划等更前沿的优化技术。

15:查询优化器实现 2

概述

在本节课中,我们将继续深入探讨查询优化器的实现。我们将回顾上一节课未完成的Cascades优化器内容,并详细介绍其工作原理。接着,我们将了解随机化搜索算法,并探讨一些开源与闭源查询优化器的实际案例。最后,我们将学习如何将嵌套子查询转换为连接操作,以提升查询性能。


回顾:Cascades优化器

上一节我们介绍了分层搜索与统一搜索的区别。本节中,我们来看看Cascades优化器的具体实现,这是一种采用自顶向下方法的统一搜索优化器。

Cascades是查询优化器的第三代版本,由Goetz Graefe提出。其核心思想是使用分支定界搜索,并增量式地物化查询计划中表达式的可能表示方式,以避免搜索空间爆炸。

Cascades的四个关键思想如下:

  1. 数据结构表示:所有内容(如规则、模式)都表示为数据结构。
  2. 属性显式定义:明确定义运算符所需的属性(如数据排序方式),以确保查询计划的一致性。
  3. 动态重排序:基于已知的查询计划成本,动态调整搜索顺序以更快找到最佳计划。
  4. 统一模式匹配:在同一个搜索引擎中,使用相同的规则定义语言来处理表达式转换和逻辑到物理运算符的转换。

核心概念定义

在Cascades中,我们需要理解几个核心概念:

  • 表达式:表示查询计划中的一个操作,可以有零个或多个输入。例如,一个连接A、B、C的逻辑计划。
    • 逻辑表达式:Join(A, Join(B, C))
    • 物理表达式:HashJoin(SeqScan(A), IndexScan(B))
  • :将基于关系代数规则等价的表达式组合在一起的容器。一个组包含所有能产生相同输出的等价逻辑表达式和物理表达式。
  • 多表达式:一个占位符,表示查询计划中下方存在某个子表达式,但在当前搜索节点尚不清楚其具体形式。这有助于避免过早物化所有可能性。

规则与记忆表

规则用于定义如何将逻辑运算符转换为其他逻辑运算符或物理运算符。每条规则包含一个要匹配的模式和一个要执行的转换。

为了避免转换规则陷入无限循环(例如,反复左右交换连接顺序),Cascades使用了记忆表。记忆表记录对于给定的多表达式,目前找到的最佳物理运算符及其成本。如果在搜索过程中发现某个转换路径的成本已经高于记忆表中的最佳记录,就可以剪枝该路径,无需继续探索。

最优性原理与搜索过程

Cascades依赖于最优性原理:如果一个计划是最优的,那么它的任何子计划也必须是最优的。基于此,可以进行分支定界搜索。

搜索过程是自顶向下的:

  1. 从根节点(最终查询输出)开始,应用逻辑转换规则生成不同的逻辑多表达式。
  2. 选择其中一个逻辑多表达式,向下探索,将其转换为物理多表达式并估算成本。
  3. 在向下探索子节点时,如果记忆表中已有该子表达式的最佳成本,则直接复用,避免重复计算。
  4. 在搜索过程中,如果当前路径的累积成本已超过已知最佳计划的总成本,则剪枝该路径。
  5. 通过这种方式,逐步构建并比较不同的完整物理计划,最终选出成本最低的计划。

现实世界中的优化器

了解了Cascades的理论后,我们来看看它在实际系统中的应用以及其他流行的优化器。

Microsoft SQL Server

Microsoft在1995年左右聘请Goetz Graefe构建了基于Cascades的查询优化器,并用于其多款数据库产品(如SQL Server、Cosmos DB)。他们的实现采用分层策略:

  1. 基础简化与规范化:首先应用总是有益的启发式规则(如谓词下推、常量折叠)。
  2. 预探索:在开始成本搜索前,用一些可能有益的表达式“种子”填充记忆表,以引导搜索方向。
  3. 基于成本的搜索:这是一个多阶段过程。首先考虑简单计划(如单表查找),如果时间允许,再逐步扩展搜索范围,考虑更复杂的连接和并行计划。
  4. 引擎特定转换:最后应用针对特定存储或执行引擎的转换规则。

SQL Server的优化器使用转换次数而非挂钟时间作为超时依据,这确保了在不同负载的硬件上,对于相同的查询和数据库,总能生成相同的执行计划。

Calcite 与 Orca

接下来,我们看看两个作为独立服务存在的查询优化器。

Apache Calcite 是一个用Java编写的流行框架,提供SQL解析、优化和查询生成功能。许多大数据系统(如Apache Flink, Apache Beam)使用Calcite。用户可以通过Java代码定义自己的规则、成本模型和运算符。

Orca 是Greenplum数据库的优化器,后来也发展为一个独立的优化服务。Orca的一个有趣特性是能够导出优化器在生成某个查询计划时的完整搜索状态,这有助于开发人员调试为什么优化器做出了特定选择。此外,Orca会运行成本排名前几的查询计划,以验证其成本模型的准确性,并据此进行调整。

CockroachDB

CockroachDB使用Go语言从头编写了一个类Cascades优化器。它更纯粹地遵循Cascades模型,提供了一个领域特定语言来定义转换规则。对于无法用DSL表达的复杂转换,可以回退到Go代码中实现。


随机化搜索算法

除了自顶向下或自底向上的系统化搜索,另一种思路是使用随机化算法来探索查询计划空间。

遗传算法

PostgreSQL在连接超过12个表(默认阈值)的查询中,会使用一种遗传算法进行优化。

  1. 初始化:随机生成第一代查询计划(不同的连接顺序和算法)。
  2. 评估:计算每个计划的估算成本。
  3. 选择:保留成本较低的计划,淘汰成本最高的计划。
  4. 交叉与变异:对保留的计划进行“基因”交换和随机改动,产生下一代计划。
  5. 迭代:重复评估、选择、生成的过程,直到达到迭代次数限制。

这种方法在连接表非常多时,可能比穷举搜索更快地找到一个足够好的计划,尽管不保证最优。目前,这种随机化方法并未被广泛采用。


子查询解嵌套

嵌套子查询是SQL中一个强大但可能导致性能问题的特性。本节中,我们来看看如何将嵌套子查询,特别是关联子查询,转换为高效的连接操作。

关联 vs 非关联子查询

  • 非关联子查询:子查询不引用外层查询的任何列。可以独立执行一次,结果物化后供外层查询使用。相对容易优化。
  • 关联子查询:子查询引用了外层查询的列。逻辑上,需要为外层查询的每一行都执行一次子查询,可能导致性能极差(O(N²)复杂度)。

传统方法:基于规则的改写

过去几十年,数据库系统通过大量手写的启发式规则来解嵌套特定模式的子查询。例如,SQL Server有大约22条规则来处理不同情况。这种方法有效,但难以覆盖所有复杂的嵌套情况,且维护成本高。

统一方法:基于依赖连接

2015年的一篇论文提出了一种通用的、基于关系代数转换的方法,可以将所有关联子查询转换为常规连接。核心思想是引入一个逻辑运算符——依赖连接

依赖连接 在逻辑上类似于笛卡尔积,但它标记了右侧表达式对左侧表达式的依赖关系(即关联性)。优化器的目标是通过一系列代数变换,将这个依赖连接“下推”并最终消除,将其转换为普通的等值连接或外连接等。

转换示例
考虑查询:“查找每个专业中成绩最高的学生”。

  1. 初始计划:外层查询扫描学生表,WHERE条件中包含一个关联子查询(计算每个专业的最高分)。
  2. 引入依赖连接:将子查询提取出来,与外层扫描构成一个依赖连接。
  3. 下推与转换
    • 将依赖连接下推到子查询内部。
    • 在子查询内,通过添加分组聚合(GROUP BY major)和去重操作,确保每个专业只返回一个最高分。
    • 继续下推依赖连接,直到它作用于基表。
    • 此时,依赖连接可以安全地转换为一个普通的笛卡尔积,然后与其上的过滤条件合并,最终形成一个标准的等值连接(学生表与按专业分组聚合的结果进行连接)。
  4. 最终效果:将原本可能需要为每个学生执行一次子查询的嵌套循环,转换成了一个高效的哈希连接或归并连接。

这种方法首次提供了一种系统性的方案来处理所有类型的关联子查询。目前,已知完全实现该方法的系统包括HyPer、Umbra和DuckDB,Databricks实现了大部分功能。


总结

本节课我们一起深入学习了查询优化器的核心实现机制。

  1. 我们回顾并详细分析了Cascades优化器的自顶向下、基于记忆表的分支定界搜索过程。
  2. 我们探讨了现实世界中的优化器,包括Microsoft SQL Server的分层Cascades实现、以及Calcite和Orca这类独立优化服务。
  3. 我们了解了使用遗传算法进行随机化搜索的替代方案。
  4. 最后,我们学习了将嵌套子查询(特别是关联子查询)通过引入和消除依赖连接的方式,统一转换为高效连接操作的先进方法。这代表了现代查询优化器处理复杂SQL语义的一个重要方向。

下一节课,我们将探讨当成本模型估计不准或缺乏统计信息时,查询优化器如何通过自适应查询执行来动态调整计划。

16:查询优化器实现 3

在本节课中,我们将学习自适应查询优化技术。传统的查询优化器在查询执行前,基于统计信息生成一个固定的执行计划。然而,由于数据分布、物理设计或参数的变化,这个预先确定的计划可能并非最优。自适应查询优化技术允许数据库系统在执行过程中,根据实际观察到的数据特性动态调整执行计划,以获得更好的性能。

传统优化器的局限

上一节我们讨论了查询优化器的基本架构。本节中我们来看看传统优化器面临的一个核心挑战:其决策基于执行前的静态统计信息

  • 查询计划在执行前生成。
  • 优化器基于数据库的统计信息(如直方图、采样)和成本模型做出决策。
  • 然而,许多因素可能导致这些预先的假设不准确:
    • 物理设计变更:管理员可能添加或删除索引,改变分区方案。
    • 数据变更:数据的插入、删除会改变列值的分布。
    • 参数变化:对于预处理语句,不同的参数可能导致查询行为截然不同。
    • 统计信息过时:统计信息未及时更新。

因此,优化器认为最优的计划,在实际执行时可能表现不佳。一个常见的问题是连接顺序错误,这通常源于基数估计不准确。我们以为一个连接操作会产生 X 条元组,但实际上可能产生 X * Y 条。

自适应查询优化概览

既然我们知道计划可能出错,理想情况是:在执行过程中检测到计划不佳时,能够调整计划以适应真实数据

核心思想是:利用查询执行时收集的实际数据信息,来修正或优化当前及未来的查询计划。这被称为自适应查询优化自适应查询处理

以下是实现此目标的三种主要方式:

  1. 优化未来查询:收集运行时信息并更新全局统计信息,使后续查询受益。
  2. 优化当前查询:在本次查询执行中,根据观察到的情况重新规划部分或全部计划。
  3. 计划切换点:在查询计划中预设多个备选子计划,根据运行时条件动态切换。

方法一:优化未来查询

这种方法侧重于从每次查询执行中学习,并将经验用于改进后续查询的优化。

基于回归的计划修正

最简单的形式是基于回归的计划修正。许多商业数据库(如DB2、Oracle、SQL Server)会维护一个查询执行历史仓库

其工作流程如下:

  1. 系统缓存已生成的查询计划。
  2. 每次执行查询时,记录该计划的实际执行成本(如CPU、内存使用、返回元组数)。
  3. 当数据库环境发生变化(如新建索引)时,优化器会为同一查询生成新计划。
  4. 系统比较新计划与历史中旧计划的实际性能
  5. 如果新计划性能出现回归(即比旧计划差),则系统会回退到已知性能更好的旧计划。

这是一种粗粒度的启发式方法,核心是:“如果新计划更差,就换回旧的”。

计划缝合

更高级的技术是计划缝合,它允许从不同查询的执行计划中,“缝合”出最优的子计划片段。

关键步骤如下:

  1. 识别逻辑等价的子计划:利用关系代数的交换律、结合律等规则,判断不同查询中的子计划是否产生相同的结果集。这是一个复杂问题,系统通常使用启发式方法进行近似判断。
  2. 构建搜索空间:使用一个特殊的 OR 运算符(仅用于搜索,不用于执行)来编码所有可选的、逻辑等价的子计划路径。OR 节点表示其下的子计划是等价的,可以任选其一。
  3. 动态规划搜索:采用自底向上的动态规划算法(类似System R),在构建的搜索图中为每个节点计算最优成本路径,最终“缝合”出一个全新的、理论上更优的执行计划。

这种方法的美妙之处在于,即使整个计划因环境变化(如索引被删除)而失效,其中某些高效的子计划片段仍然可以被识别并复用到新计划中。

代码示例:计划缝合搜索空间的概念表示

查询计划树:
    JOIN (A,B,C,D)
        OR
        /       \
    HashJoin    NestedLoopJoin
    /    \      /           \
   ...    ...  OR            ...
               /  \
        SeqScan IndexScan

代码生成与片段复用

Amazon Redshift 采用了类似“计划缝合”的思想,但作用在代码生成层面。

  1. 它将物理计划编译成 C++ 代码片段(如扫描、连接操作)。
  2. 编译后的机器码片段被缓存。
  3. 当新的查询需要相同模式的操作(例如,对某列的等值过滤扫描)时,系统直接复用已缓存的编译后代码片段,避免了重复编译的开销
  4. 这些片段甚至可以在不同用户、不同表的相似查询间共享。

学习型优化器

IBM DB2 的 LEO 优化器是一个早期商用自适应优化例子。它建立了一个反馈循环

  1. 执行查询时,对比优化器的估计值(如基数、成本)和实际观测值
  2. 如果发现显著差异,则在查询结束后,用观测到的真实信息去更新系统内部的统计模型
  3. 更新后的、更准确的统计信息将用于优化未来所有的查询,从而持续提升优化器的决策质量。

方法二:优化当前查询

这种方法旨在正在执行的查询内部进行即时调整。

重新规划当前查询

如果发现当前执行计划的观测行为与预估严重不符,可以决定重新规划。这有两种策略:

  • 完全重启:中止当前查询,丢弃所有中间结果,用优化器重新生成全新计划后再次执行。
  • 部分保留:保留已完成的、代价高昂的部分结果(例如,一个大型连接),仅对查询的剩余部分进行重新优化。

难点在于权衡:何时重新规划的收益能超过中断和重启的代价。

前瞻信息传递

Apache Quickstep 提出了一种针对星型模型的前瞻信息传递技术。

  1. 在执行事实表扫描和连接之前,先扫描维度表并构建布隆过滤器
  2. 将布隆过滤器传递给事实表扫描端,并采样评估每个过滤器对事实表数据的过滤选择性。
  3. 根据采样得到的实际选择性,动态调整连接顺序,优先执行选择性更高的连接,从而尽早过滤掉更多元组。

这种方法在查询真正开始核心工作(事实表扫描与连接)之前,利用少量预计算来指导优化决策。

方法三:计划切换点

这种方法在初始优化时,就为可能表现差异巨大的操作准备多个备选方案,并在执行时根据数据特征动态选择。

参数化查询优化

这是最早的自适应优化技术之一,源自 Volcano 项目。

  1. 优化器为查询中的某些关键点(如连接算法选择)生成多个备选子计划。
  2. 在查询计划中插入一个 CHOOSE 计划运算符
  3. CHOOSE 运算符包含一个运行时条件判断(例如,if (input_cardinality < threshold))。
  4. 执行时,根据上游操作符输出的实际基数等运行时信息,动态选择走哪一条分支路径。

例如,如果输入数据量小,选择嵌套循环连接;如果数据量大,则选择哈希连接。

主动重新优化

这是更复杂、更综合的技术,结合了重新规划和动态切换。

  1. 初始优化时,不仅生成带有切换点的计划,还为这些决策点设定边界框,即预估值的可接受范围。
  2. 执行时,持续监控实际数据特征。
  3. 如果数据在边界框内,按预设逻辑切换路径。
  4. 如果数据特征远超边界框(说明初始估计严重错误),则触发主动重新优化:回到优化器,基于已观察到的真实数据,重新生成后续部分的计划。它可以决定是保留还是丢弃已计算的中间结果。

总结

本节课中我们一起学习了自适应查询优化技术。我们认识到,传统静态优化器因依赖不完美的统计信息和成本模型,可能生成次优计划。为了克服这一局限,数据库系统可以采用多种自适应策略:

  • 优化未来查询:通过记录执行历史、缝合计划片段、更新统计模型,从经验中学习。
  • 优化当前查询:通过重新规划或利用前瞻信息,在本次执行中即时调整。
  • 预设切换点:在计划中嵌入条件分支,根据运行时数据动态选择最优路径。

这些技术的核心在于打破优化与执行之间的壁垒,建立双向反馈循环,使查询计划能够适应真实的数据和运行环境,从而持续提升数据库系统的性能。实现这些技术需要优化器与执行引擎的紧密协同设计。

17:Google BigQuery 与 Dremel 架构解析 🗄️

在本节课中,我们将学习 Google 的 Dremel 系统及其商业化产品 BigQuery。我们将探讨其核心架构、关键技术,以及它对现代数据湖/湖仓一体架构的深远影响。

概述

从本节课开始,我们将转向研究基于本学期所讨论技术和算法的真实工业系统。目标是学习如何解读工业界的论文,理解它们如何应用我们所学的知识解决实际问题,并能够透过营销术语洞察其技术本质。我们将以 Google Dremel/BigQuery 作为第一个案例。

系统背景与影响

在2000年代,Google 对数据库系统的设计和开发产生了巨大影响。每当 Google 发布一篇关于其内部系统的研究论文,都会引发广泛关注和开源克隆项目的出现。Dremel 是其中专注于分析型工作负载的系统。

为什么关注 Dremel 而非其他 Google 系统?
因为 Dremel 是 Google 体系中专注于在分离式存储上进行数据分析的系统。其他系统如 Megastore、Spanner 主要处理事务,而 Dremel 的架构是现代湖仓引擎的基础。

Dremel 始于2006年的一个“20%时间”项目,旨在解决 Google 内部大量工具和服务在 GFS 上生成数据后,用户需要运行 SQL 查询而非编写复杂 MapReduce 作业的需求。其2010年重写版本转向了分离存储架构,并于2012年商业化成为 BigQuery。

核心架构与技术

现代湖仓引擎的许多特性已成为“标配”,Dremel/BigQuery 也不例外。以下是其核心组件:

  • 分离式存储:计算与存储分离,数据存放在 Colossus(GFS 的演进)等共享存储上。
  • 向量化执行:采用向量化查询处理以提升性能。
  • 专有文件格式:使用名为 Capacitor 的列式存储格式,支持嵌套数据(Protocol Buffers),并能在压缩数据上直接进行谓词下推和部分表达式求值。
  • 索引与压缩:利用区图、布隆过滤器、字典编码和游程编码等技术。
  • 连接算法:主要使用哈希连接。
  • 查询优化器:结合基于规则的优化器和轻量级的基于成本的优化器,并在缺乏统计信息时严重依赖运行时自适应优化。

分布式执行与 Shuffle 服务 🔄

Dremel 架构中最独特和关键的部分是其基于内存的 Shuffle 服务,这使其能够进行一些其他系统难以实现的优化。

查询会被转换为逻辑计划,并划分为多个阶段。每个阶段包含多个任务,分发到不同的工作节点执行。关键要求是每个任务必须是确定性和幂等的,这为容错和应对慢任务(拖尾任务)奠定了基础。

执行流程如下:

  1. 协调器(根节点)批量从元数据目录获取所有文件信息,并嵌入逻辑计划中,避免工作节点直接冲击目录服务。
  2. 第一阶段的工作节点从共享存储读取数据并进行处理。
  3. 处理后的中间结果不是直接发送给下一阶段,而是写入一个分布式、可水平扩展的内存 Shuffle 服务(可视为内存键值存储)。
  4. Shuffle 服务将数据统计信息反馈给协调器。
  5. 协调器根据这些统计信息,动态决定下一阶段需要的工作节点数量,并调度它们启动。
  6. 下一阶段的工作节点从 Shuffle 服务中拉取所需数据继续处理。

为什么需要独立的 Shuffle 服务?

  • 容错与弹性:工作节点可以设计为无状态且易于销毁。如果节点故障或任务执行过慢,可以安全地终止并在新节点上重新执行幂等任务,数据可从 Shuffle 服务重新获取。
  • 全局视图与自适应优化:Shuffle 服务集中了中间结果,使得协调器能够在阶段之间“暂停”观察,根据实际数据特征动态调整后续执行策略(如缩放工作节点数、改变连接算法)。
  • 简化通信:避免了工作节点间复杂的点对点通信和数据依赖关系管理。下一阶段节点只需知道从 Shuffle 服务的哪个分区获取数据。
  • 工程简化:将复杂的分布式数据交换逻辑抽象到一个独立服务中,简化了工作节点的实现。

自适应查询优化

面对经常查询从未见过的数据(缺乏统计信息)或查询外部数据源的情况,Dremel 采用自适应查询优化技术,其可行性正是建立在 Shuffle 服务提供的“检查点”能力之上。

以下是两种主要的自适应优化场景:

1. 连接算法动态选择
假设一个查询需要连接表 A 和表 B。初始计划可能采用 Shuffle Join(双方都重分区)。

  • 如果第一阶段后发现表 A 的数据量远小于预期,协调器可以将计划改为 Broadcast Join。将小表 A 的数据广播到所有处理大表 B 分区的工作节点上,从而避免对大表进行昂贵的 Shuffle 操作。

2. 动态分区调整
如果在处理过程中发现某个数据分区异常巨大(可能导致溢出到磁盘而变慢),系统可以执行动态递归分区

  • 协调器会指示负责该热分区的工作节点,将其输出数据进一步哈希到两个新创建的分区中。
  • 随后,可以添加一个新任务来专门处理这个热分区的重新分布,从而平衡负载。

文件格式与生态系统

BigQuery 使用内部列式文件格式 Capacitor。它与 Parquet/ORC 类似,但支持更高效的谓词下推,可直接在压缩数据上进行过滤。其元数据也以列式格式存储,便于快速扫描。

在 SQL 方言方面,Google 内部曾推动统一的 Google SQL 标准,并开源了其实现 ZetaSQL。然而,该开源项目活跃度不高,这反映了当前 SQL 生态的碎片化,即使像 Google 这样的巨头也难以确立事实标准。目前,PostgreSQL 的 SQL 方言因其广泛的采用和开源解析器,成为了更常见的兼容基准。

影响与衍生系统

Dremel 论文催生或深刻影响了许多开源系统:

  • Apache Drill:直接受 Dremel 启发,旨在为 HDFS 提供查询引擎。
  • PrestoDB / Trino:Facebook 开发,用于替代 Hive,提供更快的交互式查询,支持多数据源连接器。
  • Apache Impala:Cloudera 开发,特点是在数据存储节点(HDFS)上部署轻量级执行引擎,以实现更极致的谓词下推。
  • Dremio:一个商业化产品,直接借鉴 Dremel 架构,并提供了名为“Reflections”的物化视图加速功能。

此外,独立 Shuffle 服务 的概念也发展起来,例如阿里巴巴的 Apache Celeborn 和 Uber 的 Uniform,它们为 Spark/Flink 等计算框架提供高性能、容错的数据交换层。

总结

本节课我们一起深入探讨了 Google Dremel 和 BigQuery 系统。其核心贡献在于将向量化执行基于内存 Shuffle 服务的分离式架构相结合。尽管 Shuffle 服务看似引入了额外开销,但它带来了显著的工程优势和运行时优化灵活性,例如容错、弹性伸缩和自适应查询优化。这种架构模式,结合高效的文件格式,奠定了现代“湖仓一体”分析引擎的基础。Dremel 的成功案例也展示了在云原生时代,通过解耦系统组件、专注优化独立服务(如 Shuffle),从而构建高性能、可维护大型系统的有效路径。

18:Databricks Photon 与 Spark SQL

在本节课中,我们将学习 Databricks Photon 系统,这是一个为加速 Spark SQL 而设计的向量化执行引擎。我们将回顾 Spark 的历史背景,理解其早期 SQL 支持(如 Shark)的局限性,并深入探讨 Photon 的设计目标、架构、关键优化技术(如表达式融合和自适应执行),以及它如何与 Spark 运行时集成。最后,我们会简要了解其他 Spark 加速方案和数据湖表格式(如 Delta Lake、Iceberg)如何改善查询优化。


Spark 的历史背景与 SQL 支持演进

上一节我们讨论了 Dremel 的系统架构。本节中,我们来看看 Spark 的演进历程,这有助于理解 Photon 的设计动机。

Spark 起源于 2000 年代末,作为对 Hadoop MapReduce 模型的改进。它将计算与存储分离,并支持迭代算法。由于 Spark 使用 Scala 编写,因此运行在 Java 虚拟机(JVM)上。早期 Spark 仅支持基于弹性分布式数据集(RDD)的低级 API。

随着 Spark 的流行,用户开始要求 SQL 支持。最初的解决方案是 Shark,它复用了 Facebook Hive 的代码,将 SQL 查询转换为 Spark 程序。然而,这种方法存在限制:SQL 无法与 Spark API(如 Python 代码)灵活混合,且 Hive 的优化器是为 MapReduce 设计的,无法充分利用 Spark 更丰富的 API,导致生成的查询计划效率不高。

2015 年,Databricks 团队推出了 Spark SQL,实现了 SQL 与 Spark 运行时的原生集成。它引入了列式内存缓冲区、字典编码、位打包压缩等技术,并对 WHERE 子句进行了部分查询编译(将 Scala AST 编译为 JVM 字节码)。然而,Spark SQL 仍面临挑战:复杂的查询编译受限于 JVM,系统逐渐从 I/O 密集型变为 CPU 密集型,且需要大量工程工作来规避 JVM 垃圾回收和内存管理的开销。

Photon 的设计目标与高层架构

上一节我们看到了 Spark SQL 的局限性。本节中,我们来看看 Databricks 如何通过 Photon 来解决这些问题。

Photon 不是一个独立系统,而是一个可以嵌入现有数据库系统(如 Spark 运行时)的库。它甚至比我们之前讨论的 Velox 更底层,提供的是细粒度的算子内核,而线程、内存管理等仍由上层运行时处理。

Photon 的核心设计目标如下:

  • 无缝集成:与 Databricks 运行时(DBR)集成,无需重写现有基础设施。
  • 语义兼容:完全支持 Spark SQL 和 DataFrame API 的语义,确保结果一致。
  • 性能透明:对用户透明地加速查询,仅在支持的操作上启用加速。
  • 向量化执行:将 Spark 运行时逐行处理的数据转换为列式或向量化处理,以提升性能。

其基本工作原理是:查询的某部分可以由 Photon 加速执行。如果某个算子没有对应的 Photon 加速实现,则自动回退到原有的 Spark SQL 慢速路径。通过 Java 本地接口(JNI)在 JVM 中调用 C++ 代码,调用开销与 C++ 虚函数查找相当。

Photon 的高层架构与 Dremel 类似:查询提交给驱动器(协调器),驱动器生成查询计划并调度任务到执行器。执行器从分布式文件系统读取数据,进行计算,并通过洗牌阶段将数据传递给下一阶段。Photon 的核心则运行在每个执行器内部。

Photon 的核心执行模型

了解了 Photon 的整体定位后,本节我们深入其核心执行模型。

Photon 采用基于拉取的向量化执行引擎。它使用预编译的算子内核(或称原语)对数据向量执行小型计算任务。这些原语用 C++ 编写,并根据数据类型和操作进行模板化。

以下是 Photon 做出的一些关键工程决策:

1. 选择向量化而非即时编译
尽管 Spark SQL 已尝试过即时编译(JIT),但 Photon 团队选择了向量化模型(类似 VectorWise)。原因在于,维护和调试一个 JIT 引擎需要编写复杂的工具来定位由生成代码引发的崩溃,这需要高度专业化的工程师。而使用预编译的原语,虽然初始性能可能不如 JIT,但更易于开发、调试和团队协作,并能通过迭代达到接近甚至超越 JIT 的性能。

2. 使用位置列表而非位图
在向量化执行中,需要跟踪批次中哪些行是有效的。Photon 选择了位置列表(一个存储有效行偏移的向量),而非记录有效性的位图。论文指出,除极端情况外,位置列表性能更优。这一结论也被后续研究(包括 CMU 的工作)所证实。

3. 表达式融合
Photon 没有采用 Hyper 那样的垂直融合(将管道中的多个算子融合),因为这会使性能剖析和调试信息难以映射回用户可理解的查询计划。相反,它采用了表达式融合(或称水平融合):在单个算子内部,将连续使用的多个原语融合成一个单一的原语。例如,一个检查日期范围的过滤器,可以将“大于等于”和“小于等于”两个原语融合为一个。Databricks 作为云服务商,可以分析所有查询日志,智能地决定哪些表达式需要融合。

代码示例:表达式融合思想

-- 原始查询片段
WHERE date_column >= ‘2023-01-01‘ AND date_column <= ‘2023-12-31‘

未融合时,需要分别调用两个比较原语,然后取交集。融合后,生成一个单一的原语直接执行整个范围检查。

内存管理与自适应执行

上一节介绍了 Photon 的核心执行策略。本节中,我们来看看它如何管理内存并适应动态变化的工作负载。

内存管理
Photon 本身不实现独立的内存管理器。它复用 Spark 现有的、基于 JVM 的内存管理器。Spark 的内存管理器分配内存块,并将指针传递给 Photon 的 C++ 代码使用。这样可以利用现有的内存分析基础设施。所有算子都支持溢出到磁盘的能力。当算子需要更多内存时,它会向内存管理器请求。内存管理器会找出当前占用内存最少的任务,要求其释放内存(可能涉及溢出到磁盘),然后将内存重新分配给请求的算子。

自适应执行
Photon 在两层上实现自适应:

  • 查询级自适应:在洗牌阶段之间,根据收集到的统计数据(如数据分布),动态调整后续阶段的执行策略,例如在广播连接和洗牌连接之间切换,或调整执行器数量以处理数据倾斜。
  • 批次级自适应:在算子内部处理单个数据批次时,根据实际数据特征动态选择最优的原语实现。例如:
    • 如果字符串列全是 ASCII 字符,则切换到更快的 ASCII 专用字符串函数。
    • 如果向量中无效行很多,则进行压缩,使有效数据更紧凑,提高缓存效率。
    • 利用原语的模板特性,如果已知某批次数据没有空值或所有行都有效,则调用无需进行相应检查的特化版本函数,消除分支预测开销。

公式/代码示例:基于模板的原语优化

// 模板函数,根据编译时常量选择优化路径
template <bool has_nulls, bool has_active_rows>
void process_batch(...) {
    for (int i = 0; i < size; ++i) {
        // 编译时,如果 has_active_rows 为 false,此条件判断会被优化掉
        if (has_active_rows && !is_active(position_list[i])) continue;
        // 编译时,如果 has_nulls 为 false,此条件判断会被优化掉
        if (has_nulls && is_null(null_vector[i])) continue;
        // ... 实际处理逻辑
    }
}
// 运行时根据批次特征调用特化版本
if (batch_has_no_nulls && batch_all_active) {
    process_batch<false, false>(...);
}

处理溢出分区
与 Dremel 动态增加分区不同,Photon 采用另一种策略:它最初分配比预期更多的分区。当某些分区即将被填满时,系统会识别出未被充分利用的空闲分区,将溢出的数据重新导向这些空闲分区,从而实现动态平衡。

查询优化与集成

了解了执行引擎的细节后,本节我们看看 Photon 如何集成到 Spark 的查询优化流程中。

Spark SQL 使用名为 Catalyst 的级联优化器。由于数据湖环境中缺乏准确的统计信息,Catalyst 需要高度自适应。其独特之处在于,在生成物理计划后,还有一个物理到物理的转换阶段。

Photon 的集成正是在这个阶段完成的:

  1. Catalyst 先生成一个标准的 Spark SQL 物理计划。
  2. 然后进行一个自底向上的额外遍历,识别其中哪些算子可以被 Photon 加速版本替换。
  3. 替换时,一个关键目标是最小化 JVM 与 C++ 代码之间的切换次数。因此,优化器会尝试在查询计划底部形成尽可能长的、连续运行的 Photon 算子段。
  4. 在 Java 与 C++ 边界处,需要适配器将行数据转换为列格式,以及转换器将结果转回 Java 期望的格式。这些操作涉及数据复制,应尽量减少。

性能、替代方案与数据湖演进

最后,我们来审视 Photon 的性能表现,看看市场上的其他替代方案,并了解数据湖表格式如何改善整体生态。

性能表现
根据论文,在 TPCH 3000GB 基准测试中,启用 Photon 后,查询性能获得显著提升,部分查询加速效果尤为明显。Databricks 也公布了其官方审计的 TPCDS 基准测试结果,并位居榜首。不过,需要注意的是,获取官方 TPC 审计是一项耗时的工作,其市场影响力如今已不如前。

其他 Spark 加速方案
Photon 是 Databricks 的专有技术。开源社区也有其他加速方案,但其集成方式通常与 Photon 不同:

  • Apache Gluten:将整个查询计划从 Spark 运行时卸载到其他后端执行引擎(如 Velox、ClickHouse)。
  • NVIDIA RAPIDS:利用 GPU 加速。
  • Apple Comet:基于 Data Fusion 执行引擎。
    这些方案大多是“全有或全无”的卸载,而非像 Photon 那样进行细粒度的算子替换。

数据湖表格式的作用
数据湖的一个核心挑战是缺乏统计信息。数据湖表格式(如 Delta Lake、Apache Iceberg、Apache Hudi)的出现改善了这一点。它们充当了数据摄入与管理层:

  • 在数据写入对象存储(如 S3)时,会将其转换为高效的列式格式(如 Parquet)。
  • 在此过程中,系统可以收集并维护详细的表统计信息(如最小值、最大值、空值计数),并更新到元数据目录中。
  • 查询引擎(如 Spark、Presto、Snowflake)在优化时,可以查询这些目录获取统计信息,从而生成更好的查询计划。
    这为 Photon 这类引擎提供了更丰富的优化上下文。

本节课中,我们一起学习了 Databricks Photon。我们回顾了 Spark SQL 的发展及其面临的 JVM 瓶颈,深入探讨了 Photon 作为向量化加速库的设计哲学、其基于预编译原语和表达式融合的执行模型、巧妙的内存管理与多级自适应机制,以及它如何与 Catalyst 优化器集成。我们还看到了其他加速方案的不同思路,以及数据湖表格式如何为优化器提供关键统计信息。Photon 展示了如何在兼容庞大现有生态的前提下,通过精细化的工程实现性能突破。

19:Snowflake 数据仓库内部原理

概述

在本节课中,我们将深入探讨 Snowflake 数据仓库的内部架构与设计原理。Snowflake 是一个完全在云上运行的、托管式 OLAP 数据库系统,它实现了我们整个学期讨论的许多概念。我们将重点关注其独特的共享存储架构、向量化查询执行、计算层缓存策略以及查询优化技术。


课程管理与期末安排

在开始今天的讲座之前,先处理一些管理事务。项目的最终演示将安排在我们预定的期末考试时间进行,预计在周四上午 9 点开始。

书面期末考试将于 4 月 24 日发布。这不是关于特定论文的多选题考试,而是要求你们综合整个学期讨论的各种思想,并将其应用于新的场景。这是本课程希望你们掌握的核心能力。

期末考试允许使用 ChatGPT 等工具辅助作答,但直接复制提示或输出内容而不加检查将导致问题。去年我们设置了一个匿名 Google 表单,让学生告知是否使用了 ChatGPT,我会尝试猜测答案,这很有趣。


背景:Snowflake 诞生时的数据库格局

在深入 Snowflake 之前,有必要回顾一下它诞生时的数据库领域格局。

  • 2000年代:出现了专为 OLAP 工作负载设计的专用系统,许多都采用了列式存储、数据压缩和向量化处理等思想。例如 Vertica、Greenplum、MonetDB 以及 ParAccel(后来成为 Amazon Redshift 的基础)。
  • 同时期:Hadoop 变得流行,人们尝试在 HDFS 上存储大量数据,形成了早期的“数据湖”概念。出现了 Presto、Hive 等系统。
  • 当时的商业模式:主要供应商的销售模式是让客户下载软件并在本地硬件上运行(On-Premise)。

2011年 Dremel 论文发表,展示了在云原生环境中构建系统、直接处理对象存储上文件的可行性。Facebook 在 2012 年开始构建 Presto。Amazon 在 2011 年获得了 ParAccel 的许可,并于 2013 年发布了 Redshift,比 Snowflake 早几个月面市。

大约在同一时间,硅谷风投公司 Sutter Hill 决定创建一个新的云原生数据库初创公司。他们汇集了来自 Oracle 的两位杰出工程师和来自 Vectorwise 的 Marcin Żukowski,投入大量资金,指示他们去构建一个云数据仓库,这就是 Snowflake 的起源。


Snowflake 是什么?

Snowflake 是一个用 C++ 编写的、托管的 OLAP 数据库系统,在云上运行。这在十多年前是非同寻常的决策。他们决定从头开始编写一切,以实现完全控制。

其核心特点包括:

  • 共享存储架构:类似于 Dremel。
  • 基于推送的向量化查询处理:依赖于预编译的原语,类似于 Vectorwise。
  • 计算侧缓存:在计算节点上进行积极的缓存,以减少从 S3 等对象存储读取数据的成本和延迟。
  • 独立的元数据:将表数据与元数据分离。
  • 专有列式存储格式:使用微分区(Micropartition)进行数据组织。
  • Cascades 风格的查询优化器:利用自适应优化技术。

整体架构

Snowflake 采用三层架构:

  1. 云服务层:这是系统的前端,包含协调器、调度器、目录(Catalog)和查询优化器等所有组件。目录构建在 FoundationDB 之上,以提供事务语义。
  2. 计算层:由“虚拟数据仓库”组成。用户指定计算容量,Snowflake 会分配相应数量的工作节点。每个工作节点是一个云实例(如 EC2),拥有本地 SSD 缓存,用于存储查询的中间结果和从持久存储读取的文件。
  3. 存储层:使用云提供商的对象存储(如 S3、Azure Blob Storage)。最初只支持 S3,现在支持所有主流云存储。

虚拟数据仓库是核心计算抽象。传统模式下,一旦开启就会持续计费。2022年后,Snowflake 增加了无服务器部署支持,可以在不运行查询时自动缩减,但会收取溢价。

工作节点与进程:工作节点是云实例。当查询到达时,会在节点上启动一个新的工作进程来执行任务。查询结束后,进程终止。工作节点上的本地缓存使用一致性哈希来管理,以确定哪个节点负责缓存哪些持久化数据文件。


查询执行

Snowflake 采用基于推送的向量化执行模型,使用预编译的 C++ 模板原语。

  • 序列化/反序列化:仅对节点间传输的数据进行代码生成(Codegen),以实现快速序列化/反序列化。
  • Shuffle 策略:与 Dremel 和 Spark 不同,Snowflake 没有显式的、集中式的 Shuffle 阶段。工作进程可以直接将数据推送给下一个需要处理的节点,或者本地继续处理。
  • 容错:如果一个工作节点失败,Snowflake 通常会终止整个查询并重新开始,而不是尝试局部重试。这是因为查询通常运行很快,故障概率较低,增加复杂的容错机制会带来工程复杂性。
  • 工作窃取:类似于 Morsel 驱动执行,空闲的工作进程可以从落后于进度的其他进程那里“窃取”任务。为了避免加重落后节点的负担,窃取任务的工作节点会直接从 S3 读取所需数据,而不是请求落后节点从其本地缓存发送。

弹性计算与查询结果缓存

Snowflake 支持一种称为“弹性计算”的优化。

  • 原理:当系统识别出一个查询的某部分可能需要较长时间时,可以将这部分子计划的任务分配给其他客户闲置的计算节点(这些客户已付费但未使用)。
  • 执行:在“借用”的节点上执行的任务,其输出会作为表写回 S3,而不是写入借用节点的本地磁盘(因为该节点随时可能被其所有者使用)。上层查询操作符像读取普通表一样从 S3 读取这些结果。
  • 优势:查询运行更快,Snowflake 无需额外成本,闲置资源得到利用,对客户透明。
  • 缓存复用:写入 S3 的中间结果可以更新到目录中,作为物化结果供后续查询复用。

存储与数据格式

Snowflake 最初使用其专有的列式存储格式。

  • 微分区:数据被分割成微分区,类似于 Parquet 的行组。每个微分区压缩后大约 16MB。系统会在后台根据查询模式(如连接键、访问键)自动重组和排序微分区以优化性能。
  • 半结构化数据处理:支持 VARIANTARRAYOBJECT 类型来处理 JSON 等数据。与 Dremel(运行时识别)和 Photon(运行时自适应)不同,Snowflake 在数据摄入时尝试解析并推断数据类型,转换为内部二进制格式,同时保留原始字符串以备需要回退。
  • 外部表支持:随着数据湖概念发展,Snowflake 增加了对 Parquet、ORC、Apache Iceberg 等开放文件格式的支持,通过“外部表”功能进行查询。
  • 混合表:2022年推出的 Unistore 服务支持事务性工作负载。数据以行式格式写入日志,后台转换为列式存储,为 OLTP 和 OLAP 查询提供统一视图。

一致性哈希与缓存管理

Snowflake 使用一致性哈希来组织系统,确定哪个工作节点是某个表微分区文件的“所有者”。

  • 优势:当添加或移除计算节点时,只需移动少量文件,无需全局重新洗牌数据。这有利于快速弹性伸缩。
  • 缓存策略:工作节点优先在其本地 SSD 缓存中保存查询的中间结果,因为这对查询性能至关重要。对于持久化文件,则使用 LRU 等策略管理。缓存已满时,中间结果可以溢出到 S3。

查询优化器

Snowflake 的查询优化器是 Cascades 风格的自顶向下优化器,他们称之为“编译器”。

  • 统计信息:对于其专有格式的数据,会收集分区级别的简单统计信息(如区域映射 Zone Map,即 Min-Max),但不维护直方图或草图。对于外部表,统计信息可能更少或没有。
  • 运行时自适应:严重依赖运行时自适应来调整查询计划。优化器会注入一些默认禁用的特殊操作符(如下推聚合),当运行时触发器检测到数据量超过预期时,再启用它们。
  • 表达式评估:优化器需要评估谓词表达式以决定能否剪裁微分区。Snowflake 尝试复用运行时表达式评估的代码库,以便在优化器内部也能以相同语义推理复杂表达式,而无需实际执行查询。
  • 聚合下推优化:这是一个重点优化。在确定连接顺序后,优化器会判断是否可以将聚合操作下推到连接之下,先在扫描侧进行部分聚合,减少连接的数据量。这通过注入可动态启用的下推聚合操作符来实现。

性能基准争议

2021年,Databricks 发布博客,宣称其 Photon 引擎在 TPCDS 基准测试中击败了 Snowflake,并附上了对比图表。

Snowflake 很快回应,指责 Databricks 的测试方法不当,例如使用了 Snowflake 的企业版但未启用企业级功能,且测试的是经过其专有格式优化预处理的数据,而非原始数据。

Databricks 则反驳称,TPCDS 规范要求包含数据准备时间,而 Snowflake 展示的优异性能依赖于其专有格式的预处理,如果使用原始文件,性能会如他们所示。这场争论凸显了不同系统设计哲学(专有优化存储 vs. 开放数据湖查询)和基准测试方法论的差异。


总结

本节课我们一起深入学习了 Snowflake 数据仓库系统的内部原理。作为最早商业化的云原生、存储计算分离的 OLAP 系统之一,Snowflake 的创新之处在于其三层架构、虚拟数据仓库抽象、基于一致性哈希的缓存管理、专为云设计的弹性计算与工作窃取机制,以及在查询优化和半结构化数据处理上的独特方法。

尽管其设计始于十多年前,并且如今核心的向量化引擎已趋于 commoditized,但 Snowflake 通过其生态系统(如 Snowpark、Snowpipe)、对开放格式的支持以及向混合事务/分析负载的扩展,持续保持其竞争力。它展示了如何将学术思想(如 Dremel)成功转化为商业产品,并深刻影响了现代数据仓库和云数据库的发展方向。

20:DuckDB嵌入式数据库系统

概述

在本节课中,我们将学习DuckDB,一个与之前讨论的分布式云数据仓库截然不同的嵌入式数据库系统。我们将探讨其设计哲学、核心架构、向量化执行模型、文件格式以及与数据科学生态系统的集成。


系统背景与设计目标

上一节我们讨论了Dremel等云原生OLAP引擎。本节中,我们来看看DuckDB的起源。DuckDB的诞生源于CWI研究所尝试构建嵌入式版MonetDB(称为MonetDB Lite)时遇到的挑战。该项目旨在让数据科学家能在Python或R进程中直接使用列式存储数据库,避免通过网络(如JDBC)传输数据和格式转换的开销。然而,MonetDB的遗留代码和架构过于复杂,难以精简为嵌入式库。这一经验促使研究人员从零开始,专门设计一个嵌入式分析型数据库系统。

DuckDB的目标是成为分析领域的SQLite。它旨在提供一个轻量级、高性能的SQL执行引擎,能够直接查询用户可能拥有的任何数据文件。

以下是DuckDB的一些关键设计决策:

  • 嵌入式/进程内模型:DuckDB作为一个库运行在宿主应用程序的进程内,而非独立的守护进程。这减少了开销,并便于与Python/R等环境集成。
  • 基于Postgres的SQL方言:其SQL语法基于Postgres,并添加了一些易用性增强。
  • 自定义C++实现:核心引擎使用自定义C++代码编写,最小化第三方依赖,这使得系统更轻量、更易于管理,并能轻松编译到WebAssembly在浏览器中运行。
  • 扩展生态系统:通过扩展机制来增加功能,保持核心引擎的精简。

核心架构与执行模型

DuckDB采用共享一切架构,这与之前讨论的存储计算分离架构不同。其核心是一个高性能的查询引擎。

从拉取模型到推送模型的演进

DuckDB最初(2021年前)采用基于拉取的向量化执行模型,配合预编译原语。然而,团队发现维护和扩展该模型存在困难:

  1. 每次添加新的并行运算符都需要修改控制平面逻辑。
  2. 当需要从网络(如S3)读取远程数据时,拉取模型的问题尤为突出。在拉取(火山)模型中,执行状态隐含在调用栈里。如果叶子节点因等待网络I/O而阻塞,整个调用栈都会被卡住,无法轻松暂停当前管道去执行其他就绪的管道。

因此,DuckDB切换到了基于推送的执行模型。这种模式下,一个集中式的调度器(使用Morsel进行任务划分)管理所有管道任务。调度器可以决定哪些任务就绪可以运行,哪些因等待I/O需要暂停。这使得控制流与数据流分离,更容易处理异步操作和实现高级优化。

推送模型带来的优化机会

切换到推送模型后,DuckDB能够实现一些在拉取模型中难以完成的优化:

  • 背压与缓冲:当下游算子消费速度跟不上上游生产速度时,可以暂停上游任务,在算子间引入缓冲,避免传递半满的向量。
  • 扫描共享:查询计划是有向无环图。一个扫描算子产生的结果可以填充到缓冲区内,供多个父算子共享使用。在拉取模型中,这需要复杂的横向信息传递。
  • 流水线暂停:如果查询计划顶部的算子处理不过来,可以暂停整个流水线,防止中间结果无限膨胀。
  • 异步I/O:进行远程数据读取时,可以在后台执行I/O操作,填充缓冲区,数据就绪后再触发任务执行,避免线程阻塞。

内存中的数据表示:统一向量格式

DuckDB在内存中算子间传递数据时,使用轻量级的编码向量。最初有四种向量类型:

  1. Flat向量:未压缩的列式值数组。
  2. Constant向量:整个向量只有一个值。
  3. Dictionary向量:字典编码。
  4. Sequence向量:一种特殊的增量编码(如自增序列)。

为了处理组合爆炸问题(数据类型 x 向量类型),DuckDB引入了统一向量格式。其核心思想是将Constant、Dictionary和Flat向量都表示为一种类似字典编码的统一形式:一个数据数组加一个选择向量(指示每个元组选择哪个数据项)。这样,预编译原语只需处理这种统一格式,无需解码或内存拷贝,即可获得压缩带来的好处(如Constant向量传递数据量少)。


与数据科学生态系统的集成

DuckDB的一个关键优势是能与Python(Pandas)和R等数据科学工具无缝集成。目标是让数据科学家继续使用熟悉的数据框API,但底层计算由高性能的DuckDB引擎完成。

实现方式

  • 通过ibisdbplyr等库,将数据框操作转换为DuckDB内部的逻辑计划,而非生成SQL字符串。
  • 利用Apache Arrow作为零拷贝数据交换格式。DuckDB的内存布局与Arrow兼容,允许数据在DuckDB和Python/R之间高效传递,无需序列化/反序列化。

这降低了使用门槛,用户无需重写SQL即可获得现代OLAP引擎的性能优势。


存储格式与扩展

DuckDB拥有自研的磁盘存储格式,核心数据库是一个单文件。

  • 行组:大小设置为120,000个元组。
  • 积极压缩:相比内存中的轻量编码,磁盘格式采用更积极的压缩算法(如位打包、帧间偏移),并在每个行组内按列选择最佳编码方案。
  • 多格式支持:除了自有格式,DuckDB可直接读取Parquet、Arrow、JSON、CSV等文件,甚至能附加到SQLite数据库或通过JDBC连接Postgres并读取其目录。

通过扩展系统来增加功能(如ICU国际组件、加密、特定文件格式支持),保持了核心引擎的轻量。用户可以通过LOADCREATE EXTENSION命令动态加载扩展。


Mother Duck:云集成

DuckDB Labs孵化了创业公司Mother Duck,提供云集成方案。这并非将DuckDB重写为分布式系统,而是提供一种远程计算能力。

工作原理

  1. 用户本地安装的DuckDB加载Mother Duck扩展。
  2. 扩展连接到Mother Duck云服务,云服务展示远程对象存储中的数据目录。
  3. 本地DuckDB优化器在生成执行计划后,会进行二次遍历,通过成本模型(主要基于数据传输成本)决定计划的哪些部分应在本地执行,哪些应推送到云端的DuckDB实例执行。
  4. 计划中会插入特殊的桥接算子来处理本地与云端之间的数据发送与接收。

这种设计得益于推送模型,使得异步的远程查询执行更容易实现。


总结

本节课我们一起学习了DuckDB嵌入式数据库系统。我们探讨了其作为“分析型SQLite”的设计目标、从拉取模型转向推送模型的原因及其带来的优化优势、统一向量格式的内存数据表示、与数据科学工具链的无缝集成、自研的存储格式以及通过Mother Duck实现的云集成方案。DuckDB成功的关键在于在正确的时间(SQL复兴期)解决了正确的问题(嵌入式分析),并出色地整合了现代OLAP引擎的先进思想。

21:Yellowbrick 数据仓库系统 🧱

概述

在本节课中,我们将学习 Yellowbrick 数据仓库系统。这是一个非常独特的系统,它最初是为专用硬件设备(一体机)设计的,后来成功转型到云端,并在此过程中实现了一系列极致的底层系统优化。我们将探讨其架构、查询处理、存储管理,以及它如何通过绕过操作系统、编写自定义驱动等方式来最大化性能。


系统背景与演变

上一节我们讨论了 DDB,一个广泛使用但并非为横向扩展设计的系统。本节中,我们来看看 Yellowbrick,一个在专用硬件和云端都追求极致性能的系统。

数据库系统利用专用硬件加速的历史可以追溯到 20 世纪 70 年代的“数据库机器”。当时的挑战在于,定制硬件的开发周期长,等产品上市时,通用 CPU 的性能提升可能已经抵消了定制硬件的优势。因此,除了大型云厂商,如今很少有人尝试定制硬件,更常见的是利用 FPGA 和 GPU 等硬件加速器。

另一种方式是销售“一体机”,即预配置和调优的硬件机架,例如 Oracle 的 Exadata。Yellowbrick 正是以此起步。在其一体机版本中,他们不仅使用了现成的 CPU 和 SSD,还集成了 FPGA 加速器,用于哈希计算、数据解压缩和行列转换等操作。

本课程阅读的论文聚焦于其云版本。其核心动机是:如何将原本在自有硬件上获得的加速优势,在转向云端共享磁盘架构时依然保持。这就是 Yellowbrick 的背景:一家成立于 2014 年的公司,其云版本大约在 2020-2021 年推出。


核心架构概览

Yellowbrick 是一个 OLAP 数据库系统。它最初采用经典的无共享架构,在转向云端后,切换为共享磁盘架构,并部署了类似于 Snowflake 的客户端缓存机制。

以下是其关键架构方面的概述:

  • 计算与存储分离:采用共享磁盘架构。
  • 查询执行:基于推送模型的向量化查询处理。
  • 查询编译:全程使用代码生成查询编译,采用类似 Hyper 的“源码翻译”方法,将查询计划转换为 C++ 代码并编译。
  • 缓存:在计算侧进行缓存,类似 Snowflake。
  • 存储格式:独立的行存储和列存储组件。支持以行格式进行数据摄取,后台进程再将其转换为优化的列格式。
  • 连接操作:支持排序归并连接、哈希连接和嵌套循环连接。
  • 查询优化器:基于 PostgreSQL 9.5 分支,在其优化器基础上注入自己的优化规则。
  • 系统优化:进行了大量底层系统级优化。

其中,查询编译和底层系统优化是本文讨论最有趣的部分。


组件与服务

Yellowbrick 的云版本严重依赖 Kubernetes 来编排所有服务。

主要组件包括:

  • 前端服务:管理整个数据仓库实例。包含 PostgreSQL 的部分组件,用于连接处理、SQL 解析、查询优化,以及行存储。
  • 工作节点:轻量级的容器,执行被分配到的查询任务,维护本地缓存(使用 NVMe 驱动器),并在需要时在节点间移动数据。
  • 后台服务:运行编译、ANALYZE、批量加载等维护任务。

高层架构流程如下:

  1. 查询到达,经过 PostgreSQL 前端层进行解析和规划。
  2. 查询计划被交给调度器编译器服务
  3. 集中式调度器每 100 毫秒协调一次,将任务分发给各个工作节点。
  4. 编译器服务将查询计划转换为 C++ 源代码,利用 LLVM 并行编译,并维护编译缓存。
  5. 工作节点执行任务,若缓存未命中则从对象存储(如 S3)获取数据。
  6. 使用类 LRU-K 算法管理缓存淘汰。
  7. 支持工作节点间的数据移动。
  8. 批量加载服务允许直接将大量文件写入对象存储。

关于架构的一个关键点是:论文中称其为“无共享”,但根据其描述(数据主要驻留在对象存储,计算节点通过缓存获取),它实际上更接近 Snowflake 的共享磁盘模型。


存储管理

与之前讨论的“湖仓一体”系统不同,Yellowbrick 采用托管存储。用户不能直接在 S3 中的任意文件上运行 SQL,而必须将数据批量导入到 Yellowbrick 系统中,由系统管理存储格式和元数据。

存储特性包括:

  • 专有格式:使用自己的列式存储格式(包含字典编码等优化)。
  • 数据组织:可以指定分片键和局部排序列。文件大小约为 100 GB,内部包含 2 MB 的块(这个大小与后续性能优化有关)。
  • 批量加载:支持 Parquet 文件直接批量加载到列存储。
  • 行存储:用于处理新的插入/更新操作,后台会将其转换为列格式并写入 S3。
  • 事务更新:支持对列存储数据进行事务性更新,通过维护更改日志实现,并定期进行压缩。

文件到工作节点的分配:Yellowbrick 使用** rendezvous 哈希**(一种一致性哈希的变体,更简单)而非 Snowflake 使用的经典一致性哈希。其基本思想是:对每个文件,将其名称与每个工作节点标识符拼接后哈希,得到一个“优先级”分数,然后将文件分配给分数最高的工作节点。当节点增删时,只需重新分配少量文件,避免了大规模数据重排。


查询处理与优化

执行引擎采用推送模型的向量化处理。扫描操作基于列式数据向量化执行,但在数据向上传递进行连接等操作前,会通过一个转置运算符将数据转换为行格式(即早期物化),目的是让处理一个元组所需的所有数据都能放入 CPU 的 L3 缓存。节点间数据传输也采用行格式,以减小数据块,使其能适配接收端的 L3 缓存。

查询编译作为一个独立服务进行。他们将查询计划拆分为多个片段,并行编译,然后动态链接起来,以克服 LLVM 核心编译器单线程编译的限制。编译服务会缓存编译好的代码片段以供复用。

查询优化器基于 PostgreSQL 的优化器,但增加了自己的成本模型扩展和优化规则。特别包括更积极的对象存储文件过滤(利用区域映射等信息)。由于采用托管存储,Yellowbrick 可以像传统数据库一样收集详细的统计信息(直方图、高频值、HyperLogLog 等),供优化器使用。它目前似乎没有像 Dremel 或 Snowflake 那样的运行时自适应优化能力。


极致的系统级优化 🚀

Yellowbrick 最引人注目的特点是其极致的底层系统优化,其哲学是:将操作系统视为敌人,并尽可能绕过它。他们构建了一个“unikernel”风格的运行时,启动时进行少量系统调用获取资源,之后便不再与操作系统交互。

以下是他们实现的一系列自定义组件:

1. 自定义内存分配器

  • 启动时即从操作系统申请并锁定所有所需内存。
  • 在用户空间实现自己的分配器,管理这块大内存池,避免运行时调用 malloc
  • 声称比 Linux 的 glibc malloc 快 100 倍。
  • 使用大页(2 MB 或 1 GB)来减少 TLB 未命中,提升内存访问效率。他们警告不要使用 Linux 的透明大页,因为其后台重组会导致不可预测的性能下降。

2. 自定义缓冲区管理器

  • 使用近似 LRU-K 算法进行页面淘汰,类似于 MySQL 的方法:维护年轻子列表和老年子列表,首次访问的页面进入老年列表,再次访问则提升到年轻列表。

3. 自定义任务调度器

  • 基于协程实现非抢占式的协作式多任务。
  • 集中式调度器每 100 毫秒协调集群任务。
  • 为了最大化缓存利用率,设计上倾向于让集群在同一时间只全力执行一个查询,并且一个工作节点上的所有线程执行相同的任务(处理不同数据分片),使得指令缓存高度有效。
  • 声称其线程调度比 Linux 线程调度快 500 倍。

4. 自定义设备驱动与网络协议

  • 编写运行在用户空间的 NVMe 和网卡驱动,避免内核态到用户态的内存拷贝。
  • 因为觉得 TCP 太慢,基于 UDP 实现了自己的可靠网络协议,在用户态处理可靠性保证。
  • 利用 DPDK 进行内核旁路,让线程直接管理硬件队列。
  • 为 S3 访问实现了自定义客户端库,声称比亚马逊官方库快 3 倍。
  • 在 TPC-DS 基准测试中,这些网络优化带来了平均约 20% 的性能提升(某些查询可达 70%)。

性能与总结

论文提供了 TPC-DS(SF=1)基准测试结果,比较了 Yellowbrick、Snowflake、Redshift、BigQuery、Synapse 和 Databricks。Yellowbrick 在总执行时间和计算成本上都显示出优势。

然而,需要辩证地看待这些数字:基准测试配置(硬件抽象)、查询计划差异、工作负载特性等因素都会影响结果。真正的性能取决于用户的实际工作负载。

本节课总结
本节课我们一起深入探讨了 Yellowbrick 数据仓库系统。我们学习了它从一体机到云端的演变,其基于 Kubernetes 的共享磁盘架构,以及独特的行列混合存储和查询处理模型。然而,最核心的内容是其一系列极致的底层系统优化:自定义内存分配器、缓冲区管理器、任务调度器、设备驱动和网络协议。这些优化体现了通过绕过操作系统、深度控制硬件来榨取极限性能的设计思想。虽然对于新数据库创业公司而言,这种深度优化路径风险很高,但 Yellowbrick 的成功实践展示了在数据库系统中追求极致性能的可能性与代价。最终,所有这些底层优化都必须建立在优秀的查询优化之上,否则低效的查询计划将使得任何底层加速变得毫无意义。

22:Amazon Redshift 数据仓库系统

在本节课中,我们将学习 Amazon Redshift 数据仓库系统。Redshift 是亚马逊的旗舰级 OLAP 数据库管理系统,它基于 ParAccel 构建,并演变为一个共享磁盘架构的系统。我们将探讨其独特的架构设计、查询执行策略以及一些关键的优化技术。

系统背景与演变

上一节我们介绍了 Yellowbrick 系统,它通过绕过操作系统来获得极致性能。本节中,我们来看看 Amazon Redshift,一个在云端提供的数据仓库服务。

Redshift 源于 ParAccel,这是一个基于 PostgreSQL 的共享无状态(shared-nothing)分支。在 2010 年左右,亚马逊希望为其云服务增加一个数据仓库产品。当时,许多早期的 OLAP 系统(如 Greenplum、Vertica)已被收购,而 ParAccel 是少数未被收购的系统之一。亚马逊选择投资 ParAccel 并获得了其源代码的使用许可,而非直接收购。这个决策被证明非常成功,Redshift 如今为亚马逊带来了巨大的收入。

Redshift 的设计目标与 Snowflake 类似,旨在尽可能地将管理职责从用户身上移除,实现自动化。最初,它是一个共享无状态的系统,数据存储在计算节点本地。后来,它逐渐演变为支持共享存储(如 S3)和 Serverless 部署模式。

以下是 Redshift 架构的几个关键版本和组件:

  • Redshift 经典版:基于 ParAccel 的共享无状态架构。
  • Athena:基于 Presto 的独立服务,用于直接查询 S3 上的数据。
  • Redshift Spectrum:作为 Redshift 的扩展,允许通过 Redshift 接口直接查询 S3 上的数据,而无需先加载到 Redshift 托管存储中。

核心架构与执行引擎

Redshift 的整体架构融合了多种技术。其高层设计包括共享磁盘存储、基于推送的向量化查询处理,以及独特的代码生成策略。

系统架构图展示了其核心组件:底层的存储层(包括 S3 和 Redshift 托管存储)、可选的硬件加速层(Aqua)、用于查询 S3 的 Spectrum 节点、计算节点集群以及独立的编译服务。

向量化执行与代码生成

Redshift 的查询执行引擎采用基于推送(push-based)的模型。与基于拉取(pull-based)的模型相比,这减少了需要维护的状态量。在执行过程中,系统会小心安排操作顺序,以避免耗尽 CPU 寄存器。

在代码生成方面,Redshift 采用了一种混合策略,这与其他系统不同。

以下是其代码生成策略的两个核心部分:

  1. 预编译原语:类似于 VectorWise,系统为扫描、过滤等底层操作准备了手工编写的、使用 AVX2 指令集内在函数(intrinsics)优化过的预编译代码块。这些原语被内联到生成的查询代码中。
  2. 整体查询编译:类似于 Hyper,系统也会为整个查询计划进行即时编译(JIT)。为了降低编译开销,它并非编译所有内容,而是将预编译的原语“缝合”到生成的代码中。

为了减少因数据未就绪导致的 CPU 停顿,Redshift 在生成的扫描循环中使用了软件预取技术。系统通过启发式方法,在代码中精确地插入预取指令,以便在需要下一批数据之前,就将其加载到 CPU 缓存中。这通常在一个软性的流水线断点(例如,一个缓冲器被填满时)处完成。

自适应执行

与其他一些现代系统(如 Snowflake、BigQuery)相比,Redshift 在查询执行过程中的自适应能力似乎不那么激进。

以下是论文中提到的两个主要自适应优化点:

  • 字符串函数选择:系统可以选择向量化的 ASCII 字符串函数实现,如果不适用(例如遇到 Unicode 数据),则回退到较慢的通用版本。
  • 布隆过滤器大小调整:在哈希连接中,如果构建端发现哈希表过大并可能溢出到磁盘,系统会动态调整布隆过滤器的大小,以减少假阳性并避免不必要的磁盘读取。

编译即服务与全局缓存

Redshift 的一个显著特点是其“编译即服务”架构。与 Yellowbrick 类似,它将查询编译任务卸载到独立的专用服务节点上,而不是在 worker 节点上完成。

该服务维护着一个多级缓存系统,极大地减少了编译开销。

以下是缓存系统的层次结构:

  • 本地缓存:每个计算节点缓存它已编译过的查询计划片段。
  • 全局缓存:一个跨整个 Redshift 服务集群的共享缓存。如果一个查询计划片段在本地缓存中未命中,系统会查询全局缓存,看是否有其他用户编译过相同的片段。

根据论文数据,全局缓存的命中率非常高。对于所有查询,本地缓存命中率约为 99.95%。而在本地缓存未命中的情况下,有 87% 的几率能在全局缓存中找到。这种设计几乎消除了即时编译的性能成本,并且只有在云服务这种集中式、可控的环境中才能有效实现。

硬件加速与查询优化

Redshift 曾引入一个名为 Aqua 的硬件加速层。这是一个位于计算节点和存储层之间的计算存储层,包含 FPGA,可以执行谓词下推和聚合下推等操作。然而,有迹象表明该组件可能已被整合或替换为基于亚马逊 Nitro 系统(其定制硬件和虚拟化平台)的底层加速。

在查询优化方面,Redshift 的优化器仍然深度基于 PostgreSQL 的优化器,并进行了大量修改。它包含一个基于规则的查询重写框架和用于连接顺序选择的空间搜索算法。

一个特别的组件是查询重写框架,它是一种基于领域特定语言的系统,允许工程师(甚至实习生)轻松地定义模式匹配规则和相应的查询计划转换,以快速修复特定查询模式的性能问题。这对于处理“一次性”的查询性能问题非常有效。

对于存储在 Redshift 托管存储中的数据,系统会收集统计信息以进行基于成本的优化。对于通过 Spectrum 查询的 S3 外部数据,系统主要依赖文件元数据(如 Parquet 文件的区段图)进行过滤下推,并可以缓存这些元数据。

性能考量与总结

本节课中,我们一起学习了 Amazon Redshift 数据仓库系统。Redshift 是一个在商业上非常成功的云原生 OLAP 系统,其架构体现了从传统共享无状态设计向云原生共享存储模式的演变。

其核心特点包括:

  • 混合代码生成:结合了预编译原语和整体查询编译。
  • 编译即服务:通过全局缓存极大降低编译开销。
  • 持续演进:基于全系统的遥测数据驱动优化(例如,优化更新查询性能)。
  • 查询重写框架:提供了灵活的方式来应对特定的性能问题。

需要指出的是,评估商业系统的基准测试数据需要保持谨慎。厂商提供的对比图表(例如,展示 Redshift 相对于 BigQuery、Snowflake 在 TPC-DS 上的性能)通常用于市场宣传,实际选型需要基于具体的业务需求、数据生态和成本进行综合评估。

Redshift 的成功也部分源于其进入市场较早,并且背靠亚马逊强大的云生态系统。它展示了如何通过持续投入和基于实际使用数据的迭代,将一个获得授权的初始代码库,发展成为一个行业领先的服务。

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