CMU-15-721-数据库进阶笔记-全-
CMU 15-721 数据库进阶笔记(全)
1:数据库历史 📜


在本节课中,我们将一起回顾数据库系统的发展历史,了解从早期模型到现代系统的演变过程,并理解为什么许多核心思想在今天依然至关重要。
概述
本节课将首先介绍CMU 15-721课程的总体安排与目标,然后深入探讨数据库系统的历史脉络。我们将看到,尽管硬件环境发生了翻天覆地的变化,但许多数据库领域的核心挑战和设计理念,如并发控制、索引和事务处理,自上世纪六七十年代以来一直存在并持续演进。
课程介绍与目标
本节将介绍CMU 15-721课程的结构、学习目标以及对学生未来职业发展的帮助。
为什么学习数据库系统进阶
当前,全球范围内对数据库系统开发者的需求都非常巨大。数据管理领域存在大量尚未解决的问题,许多公司急需相关人才。学习本课程将使你具备立即受雇于相关岗位的能力。
即使你未来的职业生涯并非专门从事数据库系统开发,你在本课程中学到的知识和技能也将广泛应用于计算机科学和信息技术的其他领域。具备数据库系统(以及操作系统或嵌入式系统)编码能力的人,几乎可以在任何应用领域找到工作。
课程核心目标
本课程的核心目标是让你掌握构建数据库系统的背景知识和最佳实践,并在此过程中提升你的底层系统编程能力。到学期结束时,你将学会如何在数据库的语境下编写正确且高性能的系统代码。
在实践中,虽然正确性优先于性能是理想情况,但现实世界有时并非如此。例如,MongoDB或MySQL等非常成功的数据库系统,最初就是优先追求性能,然后再解决正确性问题。
此外,本课程还将教你如何为数据库系统进行正确的文档编写和测试,学习如何进行代码审查,以及如何在大型代码库中协作。这些技能对于你在任何大型科技公司的职业生涯都至关重要。
课程核心主题
本学期将围绕一个核心主题展开:单节点内存数据库系统。这意味着我们将主要忽略将数据写入磁盘的挑战(这是磁盘数据库系统的一部分),也暂时不考虑分布式数据库系统带来的问题。
这并非说分布式数据库不重要,而是本课程将专注于让单节点系统尽可能快速且正确地运行。在尝试水平扩展(分布式)之前,先垂直扩展(单节点优化)通常是更好的选择。
本课程不是关于经典数据库管理系统的入门课。我们将假设你已经掌握了如两阶段锁、B+树等基础知识,转而关注现代系统中使用的前沿实现和新兴主题。
我们将讨论的主题包括:
- 并发控制
- 索引数据结构
- 存储模型
- 数据库压缩
- 并行与向量化执行模型
- 网络协议
- 日志与恢复
- 查询编译与优化
先修知识要求
我们假设你已经修读过数据库入门课程,并掌握以下核心知识:
- SQL:因为我们将专注于关系型数据库。
- 可序列化理论与并发控制理论。
- 关系代数。
- 经典数据库的基本算法和数据结构,如B+树、两阶段锁等。
课程政策与安排
本节将介绍课程的具体安排、评分方式以及学术诚信政策。
课程的所有政策与日程安排均可在课程网页上找到。关于学术诚信,请务必遵守学校的相关政策。如果你不确定某项行为(如复制代码)是否构成抄袭,请务必先询问我。我宁愿与你提前讨论,也不愿事后因抄袭问题对你进行处理。
我的办公时间是周一和周三下午1:30至2:30,地点在我的办公室。如果这个时间不合适,请通过邮件与我另约时间。在办公时间,我们可以讨论项目实现、论文理解、职业发展(如如何成为一名数据库工程师),甚至是一些个人事务。
本学期我们有一位助教:Matt Pavlovich。他是一名博士生,也是CMU正在开发的数据库系统的首席架构师/开发者,你们的所有项目都将基于这个系统。任何我无法回答的开发问题,都可以去问他。
课程评分构成
本学期成绩由以下几部分构成:
- 阅读作业:15%
- 编程项目一:10%
- 编程项目二:20%
- 编程项目三:45%
- 期末考试:10%
- 额外学分:最高10%
与往年相比,今年取消了期中考试,增加了第二个编程项目,并多了两篇阅读作业。
阅读作业
课程网页的日程表中,为每次课都列出了阅读材料。其中标有橙色或黄色星号的论文是必读材料,也是课堂讲解的重点。
在每次课前,你需要通过一个Google表单提交对该论文的综述,内容包括论文概述、主要结论、评估所用的系统与方法,以及他们使用了哪些工作负载进行评估。最后一点对于你完成期末项目非常重要,你可以参考这些论文中的工作负载来评估自己的系统。
综述需在上课当天上午11:59之前提交。没有补交机会,但整个学期你可以跳过4次提交。最终成绩将基于你实际提交的作业计算。
请勿抄袭。我们会使用MOSS等工具进行检查,一旦发现抄袭将按校规处理。
编程项目
我们正在CMU构建一个新的数据库管理系统(目前内部代号为Terrier)。这是一个用C++11/14/17编写的现代代码库,支持多线程,使用LLVM进行查询编译,完全开源,并设计为与PostgreSQL兼容,方便通过终端交互。
所有项目都将基于此系统,并使用GitHub进行管理。下周初将有一次辅导课,介绍系统源代码结构和如何开始第一个项目。
开发将在你的本地机器上进行。该系统可以在Linux和macOS上构建。对于Windows用户,我们提供了Vagrant配置文件,可以在Linux虚拟机中运行。
对于项目一和项目二(可能包括项目三),建议使用Amazon EC2机器进行性能测试和基准测试,因为它们通常比本地笔记本电脑拥有更多核心。我们将为每位学生提供亚马逊云服务的抵扣券。
- 项目一:个人完成。我们将提供测试用例、脚本和清晰的修改说明,并教你如何使用性能分析工具。
- 项目二:以三人小组形式完成。
- 项目三:小组项目。你需要选择一个与课程主题相关的、需要大量编程工作的独特项目,并需获得我的批准。在春假后,我也会提供一些示例项目主题供选择。
请勿在项目中进行抄袭或相互抄袭。如果对使用第三方库有疑问,请与我讨论。
其他事项
期末考试将是开卷考试,包含基于课程和阅读材料的长问答题。考试将于4月22日下发。
额外学分机会是撰写一篇关于某个特定数据库系统的维基百科风格文章。我们正在CMU编写一个数据库系统百科全书,目前已知有超过683个系统。你可以选择一个感兴趣的系统,描述其实现方式,并提供引用和出处。这是完全可选的。
课程的所有讨论将在Piazza上进行。关于项目的技术问题(如编译、代码理解)请发布在Piazza上,以便集体讨论。其他非学术事务(如请假)请直接给我发邮件。
数据库系统历史
上一节我们介绍了本课程的整体框架,现在让我们深入历史,看看数据库系统是如何一步步发展到今天的样子的。
本节内容主要基于两篇论文:Mike Stonebraker在2005年发表的《What Goes Around Comes Around》,以及我与一位行业分析师合著的《What’s Really New with NewSQL?》。核心观点是:早期数据库系统面临的许多问题在今天依然相关,只是硬件环境不同了。历史常常重演,例如当前关于SQL与NoSQL的争论,就与1970年代关系模型与CODASYL(网络数据模型)的争论如出一辙。
早期系统:IDS与IMS
最早的数据库系统之一是1960年代在通用电气(GE)内部开发的IDS。它采用了网络数据模型,并且在执行查询时,需要编写循环来一次处理一个元组。GE后来将其计算机部门卖给了霍尼韦尔(Honeywell)。
IDS的主要贡献者Charles Bachman后来帮助制定了CODASYL标准,这是一个为COBOL程序访问数据库定义的标准API,同样基于网络数据模型和单元组处理方式。Bachman因其工作获得了图灵奖。
网络数据模型要求程序员通过复杂的循环来遍历预设的“成员集”指针以查找数据。这不仅使查询编写复杂,而且由于当时磁盘不可靠,一旦这些指针集合损坏,整个数据库就可能无法恢复。
与此同时,IBM开发了IMS,用于管理阿波罗登月计划的零件供应链。IMS使用了层次数据模型,并允许程序员定义物理存储格式(如哈希表或有序树)。同样,查询也需要通过循环遍历层次结构,一次处理一个元组。
层次模型会导致数据冗余(例如,同一零件被多个供应商供应时,零件信息会被重复存储)。此外,它也没有实现物理数据独立,即逻辑数据模型与物理存储结构紧密耦合,一旦存储结构改变,应用程序代码就必须重写。
关系模型的革命
IBM的研究员Edgar F. Codd目睹了程序员因模式或布局变更而不断重写IMS和CODASYL程序的情况。他认为这是一种浪费,并提出了关系模型作为解决方案。
关系模型有三个关键思想:
- 简单数据结构:数据以关系(即表)的形式存储。
- 高级声明式语言:通过高级语言(后来发展为SQL)操作数据,该语言可以处理集合或包,而非单个元组。
- 物理数据独立:关系的物理存储方式由DBMS实现决定,对应用程序透明。这避免了因存储结构改变而重写应用代码。
回到之前的例子,在关系模型中,我们会有Supplier、Part和Supply三个表,通过外键关联。要查找某个供应商提供的所有零件,只需执行一个连接操作即可。这种基于集合的操作方式非常强大,也是本学期讨论的基础。
尽管关系模型在今天看来是理所当然的,但在当时却颇具争议。人们怀疑高级声明式语言生成的查询计划能否达到手写代码的效率。
关系数据库的崛起与固化
1970年代,关系模型在与CODASYL的争论中逐渐胜出。几个重要的关系数据库系统被开发出来:
- System R:IBM研究院开发,使用了SQL。
- Ingres:加州大学伯克利分校开发,由Mike Stonebraker领导,最初使用QUEL语言。
- Oracle:商业公司,早期就采用了SQL。
IBM最终发布了DB2(而非System R)作为其商业产品。由于IBM的市场地位,SQL成为了事实标准。Ingres后来也增加了对SQL的支持。
1980年代,许多其他商业关系数据库公司涌现,如Informix、Sybase、Teradata等。Stonebraker在Ingres的商业化经验基础上,回到伯克利开发了Postgres,即今天PostgreSQL的前身。
对象数据库与“历史重演”
1980年代末,随着面向对象编程语言的流行,出现了对象-关系阻抗不匹配问题:需要将内存中的复杂对象分解才能存入关系表,查询时再组装回来,这很麻烦。
作为回应,出现了对象数据库,旨在直接存储对象。然而,它们未能广泛流行,主要原因包括缺乏标准查询语言、与特定编程语言绑定等。但值得注意的是,它们的技术(如存储复杂嵌套数据)以另一种形式留存了下来——现代关系数据库中对JSON或XML字段的支持,本质上实现了类似的功能。
这正应了Mike Stonebraker“历史重演”的观点。
互联网时代与专业化趋势
1990年代,数据库领域相对平稳。微软获得了Sybase源代码的授权,移植到Windows NT上,发展成了SQL Server。MySQL作为mSQL的替代品出现。PostgreSQL增加了对SQL的支持。SQLite由一个开发者创建并广泛应用。
2000年代,互联网爆发式增长,数据量和并发需求远超从前。昂贵的传统企业级数据库和功能不完善的早期开源数据库(如MySQL最初不支持事务)难以满足需求。于是,大型公司(如Facebook、Google)开始开发自定义的中间件来分片和扩展数据库。
同时,针对海量数据分析的数据仓库兴起,如Netezza、Teradata(更早)、Greenplum、Vertica等。这些系统通常是分布式、共享无的,并且很多采用了列式存储,这对分析型负载更高效。
NoSQL与NewSQL
2000年代末,Google、Amazon等公司为了满足Web应用对高可用性和可扩展性的极致需求,选择牺牲传统数据库的事务、联接和SQL支持,推出了如Bigtable、DynamoDB等系统,引发了NoSQL运动。
作为对NoSQL的回应,出现了NewSQL系统,它们旨在为事务型工作负载提供与NoSQL相当的性能和可扩展性,但同时不放弃事务、关系模型和SQL。例如Google Spanner、CockroachDB等。
现代趋势:混合系统、云原生与专业化
2010年代至今,数据库领域呈现以下趋势:
- HTAP系统:混合事务/分析处理系统,试图在一个系统中同时高效处理事务和分析,如SAP HANA、MemSQL(现SingleStore)、Hyper。
- 云原生数据库:专为云环境设计,通常采用共享磁盘架构,将计算与存储分离,可以独立扩展。例如Snowflake、Amazon Redshift、Google BigQuery、Azure Cosmos DB。
- 持续的专业化:针对特定场景优化的数据库不断涌现,如时间序列数据库(InfluxDB、TimescaleDB)、图数据库等。其中,ClickHouse是一个在分析领域表现卓越的开源列式数据库,我们会在课程中提及。
需要指出的是,许多专业化的系统仍然基于关系模型和SQL,只是在存储、执行等底层做了针对性优化。
总结与展望
本节课我们一起回顾了数据库系统从1960年代至今波澜壮阔的发展历史。我们看到,从早期的IDS、IMS,到关系模型的革命,再到互联网时代催生的NoSQL、NewSQL,以及如今的云原生与专业化系统,数据库领域始终在应对数据量、并发性、可用性、扩展性等核心挑战。
一个关键的启示是:许多核心思想历久弥新,硬件的变化推动了同一思想的不同实现。关系模型和声明式查询语言(SQL)因其强大的抽象能力和工程实践价值,依然占据主导地位。
展望未来,我认为专业化的数据库会随着用户增长而逐渐扩展其功能范围。同时,在一个组织内,使用关系模型和声明式语言进行数据工程,相比于一堆临时性的脚本,更有利于协作、维护和知识复用。


下一节课,我们将正式进入课程核心,介绍内存数据库,探讨它们与磁盘数据库的区别,以及为何它们成为现代数据库设计的重要基础。请记得查看课程日程,完成第一次阅读作业并提交综述。
2:内存数据库 🚀

在本节课中,我们将要学习内存数据库的核心概念。我们将探讨为何传统的磁盘数据库架构在数据完全能放入内存时,性能依然无法达到最优,并深入了解内存数据库的设计哲学、关键优势以及面临的挑战。
从磁盘数据库到内存数据库 🔄
上一节我们回顾了数据库系统的发展历史。本节中我们来看看硬件限制如何塑造了数据库架构。早期的关系型数据库系统设计基于当时的硬件条件:单核CPU、容量有限且昂贵的RAM,以及相对缓慢的磁盘。因此,整个系统架构都围绕着从磁盘高效检索数据来构建。
在现代,硬件已发生巨大变化。许多数据库可以完全放入主内存中。虽然像谷歌、Facebook这样拥有PB级数据的公司是例外,但对于99%的应用而言,其数据库大小在GB到TB级别,完全有可能放入内存。
要理解这一点,需要区分结构化数据和非结构化数据。结构化数据具有明确定义的模式和属性,通常规模较小。非结构化数据(如视频、音频)或半结构化数据(如日志文件)通常规模更大。本课程主要关注结构化数据,因为只有结构化数据才能应用我们讨论的查询优化技术。
磁盘数据库架构的瓶颈 ⚙️
你可能会想:如果我的数据库能完全放入内存,那么直接给一个传统的磁盘数据库系统分配足够大的缓冲池,让所有数据常驻内存,不就能获得最佳性能了吗?答案是否定的。我们需要理解原因。
首先,我们来定义什么是磁盘数据库系统。这类系统的架构基于一个核心假设:数据库的主要存储位置在非易失性存储设备上,如硬盘或SSD。因此,系统的所有算法和数据结构都必须考虑到随时可能需要从磁盘获取数据。
数据库被组织成固定长度的页(或块),并使用内存缓冲管理器来缓存从磁盘读取的页。其工作流程的核心是缓冲池管理。
以下是查询访问一个元组时的简化步骤:
- 查询通过索引找到目标元组所在的页ID。
- 系统检查该页是否已在缓冲池中。
- 如果在,则直接返回内存指针。
- 如果不在,则需要选择一个空闲帧来存放该页。
- 如果没有空闲帧,则需根据淘汰策略(如LRU)选择一个页驱逐。
- 如果被选中的页是“脏”的(已被修改),则必须先写回磁盘。
- 完成驱逐后,从磁盘读取目标页到空闲帧。
- 更新页表,记录新页的内存位置。
- 释放相关锁存器,允许访问。
问题在于:即使数据全在内存中,每次访问元组时,系统仍需经历查找页表、地址转换、获取锁存器等过程。执行淘汰策略、更新内部访问统计信息也成了无谓的开销。此外,为处理磁盘I/O导致的线程停顿而设计的复杂并发控制、日志恢复机制(如维护独立的锁管理器、记录重做/撤销日志等),在纯内存环境下都可能显得冗余和低效。
一项2008年的MIT研究量化了这种开销。他们在一个数据全在内存的磁盘架构数据库上运行TPC-C基准测试,统计了CPU指令在不同组件的分布:
- 34% 用于缓冲池管理(页表查找、元数据更新)。
- 14% 用于锁存(保护内部数据结构)。
- 16% 用于加锁(两阶段锁定的开销)。
- 12% 用于日志管理(准备日志记录)。
- 16% 用于B+树遍历(必要的键值比较)。
- 仅有 7% 的指令用于执行事务逻辑等“实际工作”。
这清楚地表明,即使数据常驻内存,传统磁盘架构带来的固有开销仍会严重制约性能。
内存数据库系统设计 🧠
现在,我们转向内存数据库系统。这类系统假定数据库的主要(甚至永久)存储位置在主内存中。这意味着查询可以假设所需数据始终在内存中,从而避免了磁盘架构中的大部分检查和管理开销。
内存数据库的概念并非新事物,早在20世纪80年代就已提出,但直到近十年DRAM容量和成本达到合适水平后才真正可行。早期的商业系统包括TimesTen、DataBlitz和Altibase。
在内存数据库中,数据仍被组织成块或页,但处理方式不同:
- 直接内存指针:索引可以直接返回内存地址,而非磁盘页ID和偏移量。
- 固定长度与可变长度数据:固定长度数据(如整数、日期)存储在固定长度的块中,通过简单的内存算术即可定位。可变长度数据(如字符串)则存储在独立的数据池中,固定长度块中仅存储指向它们的指针。这与磁盘上使用槽式页(Slotted Page)来内联存储可变长度数据的策略不同。
以下是内存数据库在设计上的一些关键差异:
索引结构
- 现代内存数据库需要考虑CPU缓存与主内存之间的速度差异,因此索引结构需要优化以减少缓存未命中。
- 有趣的是,尽管B+树最初为磁盘设计,但由于其缓存友好性,在内存数据库中依然表现优异。
- 许多内存数据库在重启后选择重建索引,而非将索引更改记录到日志或写入磁盘。因为从磁盘加载数据后,在内存中重建索引的CPU开销,远小于在运行时维护索引日志的开销。
查询处理
- 磁盘I/O不再是瓶颈,因此需要关注函数调用、分支预测、数据移动和复制等CPU层面的开销。
- 顺序扫描与随机访问的性能差异变小,一些为最大化顺序I/O而设计的算法可能不再是最优选择。
日志与恢复
- 由于没有“脏页”需要写回磁盘,日志记录的内容可以更精简(例如,可能不需要存储撤销信息)。
- 标准技术如组提交(Group Commit)仍然适用,但可以设计更轻量级的日志方案。
内存环境下的并发控制挑战 ⚔️
当磁盘I/O不再是关键路径上的瓶颈时,其他瓶颈就会凸显出来。并发控制(锁、锁存器)便是核心挑战之一。
在磁盘数据库中,停顿主要源于等待磁盘I/O。而在内存数据库中,停顿则源于多个事务争抢同一数据对象上的锁。由于所有数据(包括锁信息)都在内存中,访问锁表的成本与访问数据本身的成本相近。因此,理想的设计是将锁信息与数据本身紧密结合,以便一次性完成访问。
现代CPU提供的原子操作,如比较并交换,是实现高效无锁(lock-free)或无锁存(latch-free)数据结构的关键。其基本逻辑用伪代码表示如下:
bool CompareAndSwap(int* address, int compare_value, int new_value) {
if (*address == compare_value) {
*address = new_value;
return true;
}
return false;
}
这条指令能原子性地完成“读取-比较-写入”操作,是构建高性能并发协议的基础。
接下来,我们回顾两类主要的并发控制协议:
1. 两阶段锁
这是一种悲观协议,假设冲突经常发生。事务在访问任何对象前必须先获得锁。
- 阶段:增长阶段(获取锁),收缩阶段(释放锁)。实践中,许多系统在事务提交时才释放锁(严格两阶段锁)。
- 死锁处理:可通过死锁检测(后台线程检测并解除)或死锁预防(如“无等待”或“等待-死亡”策略)来解决。
2. 时间戳排序
这是一种乐观协议,假设冲突很少发生。它使用时间戳来决定事务的串行顺序。
- 基本时间戳排序:每个数据记录维护读时间戳和写时间戳。事务的每次读写操作都需检查时间戳,若违反时序则中止事务。
- 乐观并发控制:事务在私有工作空间中读写数据的副本。提交时进行验证,若无冲突则将修改合并到全局数据库。
在低冲突场景下,OCC等乐观协议性能优于2PL,因为它们避免了不必要的锁开销。但在高冲突场景下,所有协议都可能退化为近似串行执行,浪费大量资源在冲突处理上。
高并发下的协议性能研究 📊
为了深入理解高并发下的瓶颈,我们借助一篇论文(基于DBX 1000可插拔并发控制测试平台)的研究结果。该实验在模拟的千核NUCA架构CPU上运行YCSB键值存储工作负载。
以下是核心发现:
1. 只读工作负载(无冲突)
- 死锁检测和“无等待”协议几乎能线性扩展,性能最佳,因为几乎没有锁开销。
- OCC性能最差,因为复制私有工作空间的开销较大。
2. 中等写冲突工作负载
- 死锁检测协议性能变差,因为死锁更频繁,检测和解除死锁成为瓶颈。
- “无等待”和“等待-死亡”协议表现最好,因为它们在检测到潜在死锁时立即中止事务,重启开销低。
- 时间戳排序类协议(基本T/O, MVCC, OCC)性能相近。
3. 高写冲突工作负载
- 所有协议的性能在千核规模下都急剧下降,接近零吞吐量。这表明在高争用环境下,现有协议的扩展性存在根本瓶颈。
- “无等待”协议在达到一定核心数前相对较好,但最终也崩溃了。
- OCC在低核心数时最差,但在高争用下反而相对最好,因为它本质上退化到确保至少一个事务能提交的串行验证阶段。
性能剖析显示,在高争用下,系统时间主要花费在:中止事务(“无等待”)、等待获取锁(2PL)、等待时间戳分配或死锁检测线程上。
识别与应对瓶颈 🎯
该研究指出了几个关键瓶颈及其潜在解决思路:
锁抖动
- 现象:一个事务等待锁的时间过长,会导致后续等待同一锁的事务形成“车队效应”,使系统吞吐量急剧下降。
- 实验验证:通过强制事务按主键顺序加锁的实验,可以观察到随着争用加剧,吞吐量出现经典的下滑曲线。
时间戳分配
- 在极高并发下,为事务分配全局唯一时间戳可能成为瓶颈。
- 解决方案包括使用批处理原子递增或硬件时钟,避免使用互斥锁。
内存分配
- 对于OCC等需要复制数据的协议,频繁的内存分配(如使用默认的
malloc)可能代价高昂。 - 应使用高性能内存分配器,如
jemalloc或tcmalloc。
总结与展望 🌟
本节课中我们一起学习了内存数据库的核心知识。我们了解到,内存数据库的设计与磁盘数据库有显著不同。虽然概念模型相似,但实现细节必须针对内存访问特性、CPU缓存、高并发争用等新瓶颈进行优化。
内存数据库已不再是 exotic 的系统,而是被广泛接受。然而,近年来DRAM容量和价格的进步似乎放缓,而SSD的发展迅猛。因此,未来的一个有趣方向是如何在利用SSD大容量优势的同时,避免引入传统磁盘数据库的全部开销,即设计出能智能利用存储层次结构的新型数据库系统。


下一节课,我们将深入探讨现代数据库中最主流的并发控制技术——多版本并发控制,它如何优雅地应对许多我们今日讨论的挑战。
3:多版本并发控制 1 [设计决策] 🧠

在本节课中,我们将深入学习多版本并发控制(MVCC)的核心概念与设计决策。MVCC是现代数据库系统中实现高并发的一种关键技术,它通过维护数据的多个版本来允许读写操作互不阻塞。
概述 📋
多版本并发控制(MVCC)并非一个具体的并发控制协议,而是一种数据库系统设计范式。其核心思想是:对于数据库中的一个逻辑对象(如表、元组或属性),系统会在底层维护其多个物理版本。当一个事务写入对象时,它不会直接覆盖现有值,而是创建一个包含本次更改的新版本。当读取数据时,数据库系统需要根据事务的上下文,决定应该读取哪个版本的数据。
多版本并发控制基础
上一节我们概述了MVCC的基本思想,本节中我们来看看其具体的工作原理和带来的优势。
MVCC的主要优势在于:
- 写操作不阻塞读操作,读操作也不阻塞写操作。 这与两阶段锁(2PL)不同,在2PL中,写操作需要获取排他锁,会阻塞所有其他操作。
- 对于只读事务,它们可以获得数据库的一个一致性快照。 这意味着事务可以看到在其开始时已提交的所有数据版本。
-
**支持“时间旅行”查询。** 理论上,如果保留所有旧版本且不进行垃圾回收,可以查询数据库在历史某个时间点的状态。
快照隔离是MVCC常提供的一种隔离级别。事务启动时,会看到数据库在该时间点的一个一致性快照。这意味着事务只能看到在其开始之前已提交的事务所做的更改。对于写-写冲突,通常采用“先写者获胜”的简单规则:第一个写入某个对象的事务成功,后续尝试写入同一对象的事务将中止。
然而,需要注意的是,快照隔离本身并不保证可串行化。它容易受到“写偏斜”异常的影响。我们将在后续课程中探讨如何增强MVCC以实现可串行化隔离级别。
MVCC设计决策 🔧
实现一个现代MVCC系统涉及一系列关键的设计决策,这些决策会影响系统的性能、存储开销和复杂度。主要包含以下四个方面:
1. 并发控制协议
尽管MVCC是一种版本管理机制,但它仍然需要与传统的并发控制协议结合使用。关键在于如何将这些协议适配到多版本环境中,特别是在内存数据库的上下文中,我们希望避免全局数据结构,而是将元数据(如锁信息)存储在元组本身。
我们将重点讨论时间戳排序(MVTO) 和两阶段锁(2PL) 在MVCC中的实现。
每个元组都需要一个头部来存储元数据。一个典型的头部可能包含:
TXN-ID: 创建此版本或正在修改此元组的事务ID。BEGIN-TS和END-TS: 此版本的生命周期时间戳。NEXT/PREV: 指向版本链中相邻版本的指针。
这些字段通常都是64位整数。虽然这带来了存储开销(例如,每元组约32字节),但为了获得细粒度的并发控制,这通常是必要的。
时间戳排序(MVTO)示例:
假设一个元组初始版本A1的BEGIN-TS=1,END-TS=INF。
- 事务
Txn@10读取A1:检查TXN-ID为0(无锁),且10在[1, INF)区间内,故版本可见。然后尝试用CAS操作将READ-TS更新为10(或更大值)。 - 事务
Txn@10写入B1:首先用CAS操作将B1的TXN-ID设为10(获取锁)。然后创建新版本B2,将B1的END-TS设为10,并将B2的BEGIN-TS设为10。最后,释放锁(将TXN-ID设回0)。
两阶段锁(2PL)示例:
使用READ-CNT字段作为共享锁计数器。
- 读操作:使用128位
CAS原子地检查TXN-ID为0并增加READ-CNT。 - 写操作:使用128位
CAS原子地将TXN-ID设为自身ID(获取排他锁)。完成后释放锁。
事务ID回绕问题:
当事务ID计数器达到最大值并回绕时,新事务的ID可能小于旧版本的时间戳,导致版本可见性判断错误。解决方案之一是像PostgreSQL那样,引入一个“冻结”位,标记非常旧的版本,使其在任何事务看来都可见(即属于过去)。
2. 版本存储
如何物理存储多个版本对性能有巨大影响。版本通常组织成无锁的单向链表,索引指向链表的头部。头部可以是最旧版本或最新版本。
以下是三种主要的版本存储方案:
追加存储
- 描述:新版本作为完整的元组追加到主表空间中。
- 优点:实现简单。
- 缺点:即使只更新一个字段,也需要复制整个元组,存储开销大。如果版本链从旧到新,读取最新版本需要遍历。
时间旅行存储
- 描述:主表空间始终存放最新版本。旧版本存储在单独的“时间旅行”表中。更新时,先将当前版本复制到时间旅行表,然后在主表空间原地创建新版本。
- 优点:对原有非MVCC架构侵入小。主表和历史表可以采用不同的存储格式(如行存 vs 列存)。
- 缺点:仍然需要复制完整元组。
增量存储
- 描述:主表空间存放最新版本的完整数据(或基础版本)。更新时,只存储被修改字段的增量记录(Delta),并将它们链接成版本链。
- 优点:存储空间最优,尤其是当更新只涉及少数字段时。垃圾回收更简单,只需清理Delta记录。
- 缺点:读取旧版本时需要“回放”Delta链以重构数据,增加CPU开销。但考虑到大多数查询访问最新版本,且存储优势明显,这通常是更好的选择。
对于包含指向变长数据池指针的元组,在追加存储模式下,即使字符串未修改,创建新版本也可能需要复制指针或整个字符串,这可以通过引用计数或字典编码等技术优化。
3. 垃圾回收
随着版本不断创建,需要回收不再被任何活跃事务访问的旧版本(即已“过期”的版本)所占用的空间。垃圾回收需要解决三个问题:如何找到过期版本、如何确定回收是否安全、以及在哪里寻找它们。
两种主要的回收策略:
元组级回收
- 后台清理:专用线程(如Vacuum)扫描表,根据活跃事务的最小时间戳,识别并回收过期版本。可以通过位图记录被修改的块来加速扫描。
- 协同清理:工作线程在执行查询遍历版本链时,就地回收遇到的过期版本。优点是无须后台线程,但可能增加查询延迟,且可能遗漏长期不被访问的“灰尘角落”。
事务级回收
- 描述:事务在提交时,记录下它使其失效的旧版本指针,并将其加入一个待回收队列。后台垃圾收集线程异步处理这个队列。
- 优点:将回收工作移出关键查询路径。但需要保证回收速度能跟上版本生成速度,否则可能耗尽内存。
4. 索引管理
索引如何指向正确的版本是一个挑战,其策略与版本存储方案紧密相关。
- 主键索引:始终指向版本链的头部。如果更新了主键属性,则视为删除旧元组并插入新元组。
- 二级索引:
- 物理指针:直接存储指向版本链头部的内存地址。优点:访问快。缺点:当版本链头部因更新而改变时(特别是采用“最新到最旧”链表时),需要更新所有相关的二级索引,开销巨大。这是PostgreSQL采用的方式。
- 逻辑指针:不存储物理地址,而是存储一个稳定的逻辑标识符(如主键值或合成的元组ID)。通过一个间接层(如主键索引或映射表)将逻辑ID转换为当前版本的物理地址。优点:版本链头部变化时,只需更新间接层,无需更新所有二级索引。缺点:引入了一次额外的查找开销。这是MySQL/InnoDB采用的方式。
重复键问题:
在MVCC中,即使在唯一索引中,相同的键值也可能在不同快照中存在(例如,一个事务删除键A后,另一个在更晚快照中启动的事务又插入了键A)。因此,索引查找可能返回多个指向不同版本链的指针,需要遍历每个链来找到对当前事务可见的版本。
总结 🎯
本节课我们一起深入探讨了多版本并发控制(MVCC)的核心机制与关键设计决策。我们了解到:
- MVCC通过维护数据多版本实现了读写不阻塞和高性能的快照隔离。
- 实现MVCC时,并发控制协议(如MVTO、2PL)需要与版本元数据结合。
- 版本存储方案(追加、时间旅行、增量)在存储开销和读取性能之间有重要权衡,增量存储通常是更优选择。
- 垃圾回收对于管理存储空间至关重要,有元组级和事务级等不同策略。
- 索引管理,特别是二级索引,需要谨慎选择物理指针或逻辑指针,以平衡更新开销和读取性能。


这些设计决策共同塑造了一个MVCC数据库系统的行为、性能和资源消耗。理解这些权衡对于构建或有效使用现代数据库系统至关重要。在接下来的课程中,我们将继续探讨如何增强MVCC以实现可串行化,并深入研究垃圾回收等主题。
4:多版本并发控制 2 [协议] 🧠

在本节课中,我们将深入学习现代多版本并发控制(MVCC)系统的具体实现。我们将以Microsoft Hekaton系统作为基线,探讨其核心设计,并分析其他系统(如Hyper、SAP HANA和Cicada)如何在其基础上进行优化和改进,以应对不同的工作负载挑战。
概述
上一节我们介绍了构建MVCC数据库系统时的四大核心设计决策。本节中,我们将深入探讨这些决策在真实世界系统中的具体实现,特别是并发控制协议本身。我们将首先详细分析Hekaton的设计,然后以此为基础,理解其他系统如何通过不同的优化策略来提升性能。
Hekaton:现代内存MVCC系统的基线 🏁
Hekaton是微软在2011-2012年推出的首个现代内存MVCC系统,旨在为SQL Server生态系统构建一个新的OLTP引擎。其设计核心是使用时间戳来确定事务顺序和版本可见性。
时间戳与版本管理
与上一节讨论的基于时间戳排序的MVCC不同,Hekaton中的事务将拥有两个时间戳:一个开始时间戳和一个提交时间戳。每个数据版本也包含两个时间戳:begin-ts(创建该版本的事务的开始时间戳)和 end-ts。
begin-ts:代表创建该版本的事务的开始时间戳(或提交时间戳,如果事务已提交)。end-ts:代表下一个版本(使其失效的版本)的begin-ts。如果这是最新版本,则end-ts为无穷大(INF)。
关键机制:当一个新版本被创建时,其 begin-ts 被设置为创建事务的开始时间戳,并将最高有效位设为1,以标记该版本来自一个未提交的事务。当事务提交后,系统会回溯并将该版本的 begin-ts 更新为实际的提交时间戳(同时将标记位清零)。
事务生命周期与全局状态映射
Hekaton维护一个全局哈希映射,用于追踪系统中每个事务的当前状态(活跃、验证中、已提交、已终止)。这对于实现可重复读隔离级别至关重要。
事务的生命周期如下:
- 开始:获取开始时间戳,状态设为“活跃”。
- 执行:进行读写操作,并记录读集、扫描集和写集。
- 预提交:应用请求提交,获取提交时间戳,进入验证阶段。
- 验证:检查读写冲突和幻读,确保可序列化。
- 日志写入:将重做日志记录写入内存缓冲区(仅在提交时刷盘)。
- 提交:状态设为“已提交”,并更新所有由本事务创建的版本的
begin-ts和end-ts。 - 终止:状态设为“已终止”,稍后被垃圾回收。
可序列化验证
为了实现可序列化隔离级别,Hekaton在验证阶段需要检查幻读。这是通过重新执行事务中的扫描操作(使用记录的扫描集,即查询的WHERE子句),并检查是否得到与最初相同的结果来实现的。如果结果不同,则存在幻读,事务必须中止。
设计要点与瓶颈
Hekaton的设计遵循了几个关键原则,例如尽可能使用无锁数据结构。然而,它也暴露出一些潜在的瓶颈,特别是在处理非OLTP工作负载时:
- 验证开销大:如果事务访问大量数据(如分析查询),重新执行扫描的验证成本会非常高。
- 版本链遍历效率低:采用仅追加、从旧到新的版本存储,对于需要扫描整个表的OLAP查询不友好,因为需要遍历可能很长的版本链才能找到可见版本,导致缓存局部性差和分支预测失败。
- 冲突检测粒度粗:仅通过版本指针的存在来判断读写冲突,可能导致不必要的级联中止。
Hyper:面向混合负载的优化 🚀
Hyper系统由慕尼黑工业大学开发,是一个内存列式存储数据库,采用从新到旧的增量记录版本化。它针对Hekaton的瓶颈进行了多项优化。
增量存储与版本向量
Hyper在数据块中为每行维护一个版本向量,指向一个按线程/事务分配的增量存储区。更新时,将旧值复制到增量区,并更新版本向量指针。这种方式减少了全局争用,因为增量区是线程本地的。
精确锁(Precision Locking)
为了解决幻读检查的昂贵开销,Hyper采用了“精确锁”技术。这是一种近似的谓词锁。在验证时,它不重新执行整个扫描,而是检查在事务开始后提交的其他事务的重做日志记录。通过将这些记录中修改的值代入本事务查询的WHERE子句,判断如果这些修改在事务开始时已提交,是否会影响查询结果。如果会,则存在冲突,需要中止。这种方法避免了重新扫描大量数据。
版本概要(Version Synopsis)
为了优化OLAP扫描,Hyper引入了版本概要。它是一个数据结构,用于记录数据块中哪些行范围没有需要检查的旧版本。扫描时,对于不在版本概要范围内的行,可以直接读取主数据,而无需检查版本向量,从而大大提升了扫描速度。
SAP HANA:混合存储与时间旅行 🗃️
SAP HANA是一个支持事务和分析的混合系统,采用从新到旧的“时间旅行”存储,并融合了行存储和列存储。
混合存储布局
HANA将最旧的版本保存在列式主表中(利于分析扫描),而将较新的版本保存在行式的时间旅行存储中。主表中的每一行都有一个标志位,指示是否有更新的版本需要去时间旅行存储中查找。时间旅行存储使用哈希表来快速定位版本链的头部(最新版本)。
集中式元数据管理
与Hekaton和Hyper在每个版本中存储时间戳不同,HANA将时间戳信息集中存储在一个辅助元数据对象中。每个版本只包含一个指向该元数据对象的指针。这样,当事务提交需要更新多个版本的时间戳时,只需更新这一个元数据对象即可,提高了更新效率,但增加了一层间接访问。
Cicada:针对高争用的优化 ⚡
Cicada是CMU开发的一个MVCC系统,专注于解决高争用场景下的性能问题,并引入了几项创新优化。
尽力内联(Best-Effort Inlining)
为了减少版本链指针追逐带来的缓存不命中,Cicada在版本指针旁预留了一块固定大小的空间,用于内联存储最新的版本数据。这样,在许多情况下,读取最新版本无需跳转到其他内存地址。
快速验证技术
Cicada提出了三种技术来加速验证阶段,减少无用功:
- 内容感知验证:维护一个热点数据记录,在验证时优先检查最可能引发冲突的数据。
- 早期一致性检查:在事务执行过程中(而非仅在提交时)就提前进行潜在的冲突检查,以便尽早中止注定失败的事务。
- 增量版本搜索:在读取某个旧版本后,在主版本中记录一个“快捷方式”指针,指向该旧版本的位置,后续读取可直接跳转。
索引即数据(Indexes as Data)
Cicada一个非常有趣的思路是将B+树索引的节点也作为普通的元组(数据块)存储在表中。这样,对索引的访问和更新也可以通过MVCC机制来管理,理论上可以免费获得对索引操作的可序列化保证,无需额外的幻读检查。这是一个激进但富有潜力的设计。
总结
本节课我们一起深入探讨了多种现代MVCC系统的实现细节。我们从作为基线的Hekaton出发,了解了其基于时间戳的协议、全局状态映射和验证机制。接着,我们分析了Hyper如何通过增量存储、精确锁和版本概要来优化混合工作负载下的性能。然后,我们探讨了SAP HANA的混合存储布局和集中式元数据管理。最后,我们研究了Cicada如何通过尽力内联、快速验证技术和“索引即数据”的创新理念来应对高争用场景。


这些系统的演变展示了MVCC设计的多样性和权衡:在存储效率、读取性能、写入速度、冲突检测粒度以及针对不同工作负载(OLTP vs. OLAP)的优化之间,需要根据具体需求做出选择。理解这些实现细节,有助于我们设计出更高效、更适应特定场景的数据库系统。
5:多版本并发控制 3 [垃圾收集]

概述
在本节课中,我们将深入探讨多版本并发控制(MVCC)系统中的垃圾收集机制。我们将了解为什么垃圾收集至关重要,讨论处理删除操作的方法,并分析垃圾收集的不同设计决策,包括版本跟踪、触发频率和回收策略。最后,我们将简要介绍性能分析工具,以帮助识别和优化系统瓶颈。
垃圾收集的必要性
在MVCC数据库系统中,垃圾收集是必不可少的。我们需要识别可回收的物理版本并将其移除,否则系统将耗尽存储空间。例如,早期的PostgreSQL为了支持时间旅行查询,最初没有实现垃圾收集,但随着数据更新频率增加,空间迅速耗尽,最终不得不添加垃圾回收功能。
可回收版本的定义是:系统中没有活跃事务能够看到该特定物理版本。这意味着在快照隔离级别下,该版本对任何事务都不可见。此外,由中止事务创建的版本也需要被清理。
MVCC的优势在于,我们用于提供快照隔离的时间戳同样可以用来确定元组何时可见。用于确定事务全局顺序的时间戳也定义了物理版本的生命周期。如果没有人能看到某个版本,我们就可以将其移除。
长事务带来的挑战
在OLTP环境中,事务通常是短期的。然而,当引入分析型工作负载和长时间运行的查询时,情况就变得复杂了。在快照隔离下,长时间运行的查询需要看到其开始时已提交的数据库快照。如果查询运行一小时,它就需要看到那一小时内数据库的状态。
这带来了传统垃圾收集方法的问题。传统方法通过比较时间戳和所有活跃事务的最小时间戳来判断版本是否可回收。但如果存在运行一小时的查询,那么很多版本在很长时间内都无法被回收。
HTAP与隔离级别
在2000年代,人们意识到需要为OLTP和OLAP工作负载分别构建专门的系统。HTAP(混合事务/分析处理)是一个较新的概念,其目标是在数据到达后立即运行分析查询,而无需通过ETL过程将数据从OLTP系统卸载到另一个数据库。
在学术界,通常假设事务在可序列化隔离级别下运行。然而,在现实世界中,大多数数据库管理员使用读已提交隔离级别,因为这是PostgreSQL和MySQL的默认设置。虽然某些分析查询确实需要快照隔离或可序列化隔离,但大多数情况下并不需要。
旧版本存在的问题
假设我们希望对分析查询实现快照隔离,旧版本会带来几个问题:
- 内存使用增加:创建新版本会使版本链变长,且无法回收内存,导致数据库存储空间无限增长。
- 内存分配开销:如果无法重用旧版本的内存,就需要通过
malloc向操作系统重新申请内存,这在多线程环境下可能成为瓶颈。 - 版本链遍历变慢:版本链变长意味着事务需要遍历更长的链来找到所需的版本。对于OLTP事务,如果采用从新到旧的遍历顺序,这通常不是大问题,但对于需要找到特定旧版本的分析查询,性能会下降。
- 性能波动:长时间运行的查询结束后,垃圾收集线程需要清理大量旧版本,可能导致CPU使用率激增,影响同时运行的其他查询的性能。许多组织对数据库性能的稳定性有很高要求。
- 缓存局部性与压缩:旧版本分散在表空间中会破坏数据的局部性。当我们需要压缩只读数据时,如果相关数据没有在物理上聚集在一起,就需要额外的工作来合并它们。
删除操作的处理
我们讨论了插入和更新,但尚未详细说明如何处理删除操作。删除操作有些棘手,因为需要记录逻辑元组已被删除,即使之后可能插入相同的元组,也不应重用旧的版本链。
以下是两种基本方法:
方法一:删除标志
在元组头或单独的位图字段中维护一个标志,表示该逻辑元组已被删除。事务在读取数据库时,需要首先检查此标志。
方法二:墓碑元组
在版本链的末尾(或开头,取决于方向)存储一个特殊的物理版本(墓碑元组),用以表示该元组已被删除。这个特殊版本包含时间戳信息,记录了删除发生的时间。任何早于该时间戳的快照仍然可以看到旧的版本。
对于仅追加存储的系统,如果为每个被删除的元组创建一个包含所有属性的墓碑元组,会浪费大量空间。更好的方法是使用一个特殊的、可跨表共享的数据池来存储墓碑元组,因为它不存储任何属性,只记录删除事件和时间戳。
索引清理
当事务创建新版本时,也需要更新索引,以便后续读取能够找到新写入的数据。问题在于,如果事务中止或需要清理版本时,必须确保从索引中移除与旧版本对应的键。
Hyper系统采用的方法是:每当更新一个被索引的属性时,就将其视为一次删除后跟一次插入。这样就不需要去查找并更新索引中的键指针。
在Peloton系统的旧版本中,我们采用了一种有问题的做法:对于同一逻辑元组的多次更新,会在索引中创建新条目,但后续更新会直接覆盖前一个索引条目。这导致在中止事务时,我们无法清理所有被插入的键,从而造成索引键“泄漏”。这是一个设计上的教训,凸显了正确跟踪所有索引变更的重要性。
垃圾收集的设计维度
接下来,我们讨论垃圾收集的几个关键设计决策:如何跟踪版本、触发垃圾收集的频率、检查版本的粒度以及如何判断版本是否可回收。
版本跟踪
主要有三种版本跟踪方法:
1. 基于元组的清理
垃圾收集线程或单独的后台线程(如“vacuum”线程)扫描表并识别需要修剪的版本。或者,查询在执行过程中如果遇到对任何事务都不可见的版本,就立即进行清理(“协同清理”)。Hekaton采用了后一种方法。
2. 事务级跟踪
事务记录其创建的所有版本,提交时将信息传递给垃圾收集器。垃圾收集器根据系统中活跃事务的时间戳信息,判断哪些版本可回收。Peloton的旧系统采用了这种方法。
3. 基于周期的跟踪
将事务分组到不同的“周期”中。当系统从一个周期前进到下一个周期,并且确认没有事务会访问前一个周期中的数据时,就可以回收该周期内失效的所有版本。这种方法在Bw-Tree等数据结构中也有应用。
触发频率
垃圾收集的触发频率需要在空间回收速度和事务性能之间取得平衡。
- 积极回收:可以更快地释放空间,但垃圾收集线程会消耗CPU周期,可能拖慢事务。
- 消极回收:数据库大小增长更快,版本链变长,查询找到正确版本的时间增加。
具体策略包括:
- 周期性触发:按固定时间间隔或在可回收版本达到一定比例时触发。
- 持续/协同清理:将垃圾收集过程集成到正常的事务处理或查询执行步骤中。例如,Hyper在事务提交时进行清理;Hekaton在查询遍历版本链时进行清理。这种模式具有“自我调节”特性。
回收判断与“区间”回收法
判断版本是否可回收时,系统需要检查活跃事务,理想情况下不应获取锁。Hyper论文中描述了一种无锁链表来高效维护活跃事务列表。
一个重要概念是,对于垃圾收集,我们可以允许一定的“宽松性”。如果一次垃圾收集运行中漏掉了一些可回收版本,下次运行时再回收即可,这不会影响正确性。
传统时间戳比较法
记录所有活跃事务的最小时间戳(低水位线)。任何时间戳小于此低水位线的版本都不可见,因此可回收。
区间回收法
这是Hyper论文的一个重点(概念源自HANA论文)。该方法不是简单地比较单个最小时间戳,而是检查时间戳区间。如果某个版本的生命周期区间与所有活跃事务的可见区间没有交集,那么即使其时间戳大于最小活跃时间戳,也可以被回收。这种方法可以更早地回收某些版本。
对于仅追加存储,实现区间回收相对容易,只需更新版本链指针。对于增量存储,则需要进行“合并”操作:将多个增量记录合并为一个新的记录,包含所有属性的最新修改,并赋予合并记录中最大的时间戳。然后更新版本向量指向这个合并后的记录。
内存碎片与压缩
回收内存后,如何处理这些空闲空间?是立即归还给操作系统,还是留在数据库内部重用?
对于变长数据池,我们总是可以重用内存空间。对于定长数据槽,如果立即重用,新老数据可能会在物理存储上交错,破坏“时间局部性”。如果数据在创建时间上相近,那么它们被更新的概率也相近。将“冷”数据(几乎不再更新)集中存储,可以对其应用压缩而无需担心解压更新。如果新旧数据混合存储,压缩效率会降低。
因此,通常需要一种“压缩”机制:将多个有“空洞”(空闲槽)的数据块合并,以提高空间利用率并减少碎片。确定哪些数据可以压缩的方法包括:
- 基于最后更新时间:利用元组头中存储的起始时间戳。
- 基于最后访问时间:如果系统记录了读取时间戳,可以利用它。否则可能需要维护额外的块级元数据。
- 基于数据关系:利用外键等语义信息,将可能被一起访问的数据放在一起,以便统一压缩。
TRUNCATE命令是一个特例,它可以被优化为直接删除并重建表,从而避免复杂的清理和碎片整理过程。
存储与计算的权衡
垃圾收集是计算机科学中经典的存储与计算权衡的体现。更积极的垃圾收集可以降低内存占用,但会增加计算开销,可能减慢事务速度。更消极的垃圾收集节省了计算资源,但消耗了更多内存。
在与运行MVCC系统(尤其是内存数据库)的从业者交流中,发现大家通常更愿意接受一定的性能损失,以换取内存占用的降低。因为内存不仅购买成本高,维护(能耗)成本也高。因此,像Hyper那样将垃圾收集集成到查询执行中的“协同清理”模式受到青睐。
性能分析简介
最后,我们简要介绍性能分析。为了优化系统,我们需要知道时间花在了哪里。一个简单但不切实际的方法是:在调试器中反复暂停程序,查看调用栈。
阿姆达尔定律可以用来估算优化某部分代码后系统的整体加速比。公式为:
整体加速比 = 1 / ((1 - P) + P/S)
其中,P 是可优化部分所占的时间比例,S 是该部分的加速比。

我们需要工具来获取P的信息。主要工具有:
- Valgrind/Callgrind:通过二进制插桩进行分析,可以给出代码级别的耗时分布,但会使程序运行变慢。
- perf:利用CPU硬件性能计数器,可以收集周期数、缓存未命中等低级信息,开销相对较低。

使用这些工具时,需要以“发布模式但包含调试符号”的方式编译程序,以便在分析结果中看到函数名和源代码行号。通过分析,可以识别系统中的瓶颈(例如,在项目一中,瓶颈可能是一个被频繁争用的自旋锁)。
总结



本节课我们一起深入学习了MVCC系统中的垃圾收集。我们理解了垃圾收集的必要性及其在长事务下面临的挑战。我们探讨了删除操作的实现方式、索引键的清理,并分析了垃圾收集在版本跟踪、触发频率和回收策略等方面的关键设计决策。我们还讨论了内存碎片整理、数据压缩以及存储与计算之间的权衡。最后,我们介绍了性能分析的基本概念和工具,为后续的性能调优奠定了基础。下一节课,我们将开始讨论索引结构。
6:OLTP索引 1

概述
在本节课中,我们将学习用于OLTP工作负载的索引数据结构。我们将首先介绍“全键”数据结构的概念,并以B+树为例。接着,我们会回顾一种历史性的内存数据库索引结构——T树。最后,课程的核心将深入探讨一种无锁的B+树变体——BW树的设计理念、核心机制(如增量记录和映射表)及其面临的性能挑战。
全键 vs. 部分键数据结构
本节课主要讨论“全键”数据结构。所谓“全键”,是指数据结构是顺序保持的,并且键的所有位都一起存储在数据结构的各个节点中。例如,对于一个键“ABC”,在叶子节点中,键“ABC”是完整存储的。当需要进行比较时,可以直接访问节点内完整的键值。
这与我们将在下节课讨论的“部分键”数据结构(或称字典树)形成对比。在部分键结构中,键的各位被拆分并分别存储在树的不同节点中。这种方式可能减少比较操作和存储开销,但有时可能需要回查原始元组来获取完整的键信息。
为了便于理解,今天我们将主要围绕B+树展开,并重点讨论BW树,这是当前我们数据库系统中使用的无锁数据结构。
历史背景:T树
在深入BW树之前,我们先了解一个历史背景。在20世纪80年代,随着内存数据库的早期研究,人们开始寻找比B+树更适合内存环境的数据结构。其中最著名的是T树。
T树类似于AVL树,其关键区别在于:键分散存储在树的各个节点中(包括内部节点和叶子节点),但节点内部存储的不是键的副本,而是指向原始元组中键的指针。
在20世纪80年代内存有限的环境中,这种方法通过避免存储冗余的键副本来节省内存。虽然这带来了额外的指针跳转开销以获取实际键值,但当时CPU缓存与DRAM的速度差异不像现在这样显著,因此这种开销是可以接受的。
T树由威斯康星大学的Toby Lehman等人于1986年提出。有趣的是,“T”来自于发明者Toby的名字,而非其形状。
T树节点结构
一个T树节点包含:
- 数据指针:指向表中实际元组的指针,这些指针按照其对应键的顺序排序。
- 子节点指针:指向左右子节点的指针。
- 父节点指针:用于反向遍历。
- 边界键:存储该节点所代表键范围的最小值和最大值。
查找过程示例:假设要查找键K2。从根节点开始,将K2与节点的边界键比较,决定进入哪个子树。到达叶子节点后,需要遍历该节点的数据指针:依次跟随指针获取实际存储的键,并与目标键K2进行比较。
T树的优缺点
优点:
- 节省内存:不在索引节点中存储键的副本。
- 潜在过滤优势:当跟随指针获取键时,已经访问了元组,因此可以同时评估查询中的其他谓词。
缺点:
- 维护复杂:平衡操作(如旋转)比B+树的分裂/合并更复杂,需要更重量级的锁。
- 缓存不友好:现代CPU体系结构中,指针跳转导致的缓存未命中代价很高。
- 性能问题:研究表明,对于现代内存数据库,B+树或其变体通常是更好的选择。
因此,除了某些内存极端受限的嵌入式场景外,T树如今已很少使用。
无锁B+树:BW树
现在,我们转向现代内存数据库索引的核心——如何实现一个无锁的B+树。直接构建无锁B+树的主要挑战在于,更新操作常常需要原子地修改多个内存地址(例如,更新父节点和子节点间的指针),而标准的原子指令(如CAS)一次只能更新一个位置。
BW树的核心思想是通过引入一个间接层来解决这个问题。
核心机制一:映射表
BW树引入了一个中央的映射表。该表存储了逻辑页ID到物理内存地址的映射。树中的节点只存储逻辑页ID,而不是直接的物理指针。
查找过程:当需要从一个节点访问其子节点时,首先读取子节点的逻辑页ID,然后查询映射表获取对应的物理地址,最后再跳转到该物理地址。
更新的关键:当需要改变一个节点的物理位置时(例如,应用了增量更新后),只需要使用CAS操作更新映射表中对应逻辑页ID的物理地址即可。这个单一的原子操作使得所有后续访问都能立即“看到”新的节点版本,而无需更新树中多个指针。
核心机制二:增量记录
为了避免就地更新节点导致的缓存失效问题,BW树采用了增量记录。
更新过程:当需要插入或删除一个键时,并不直接修改原有的“基础页”。而是创建一个增量记录,描述要做的更改(例如,“插入键K0”)。这个增量记录包含一个指向旧版本基础页的指针。然后,通过CAS操作,将映射表中该逻辑页的物理地址更新为指向这个新创建的增量记录。
查找过程:当访问一个节点时,首先到达增量记录链的头部。查找操作需要从最新的增量记录开始,按顺序“重放”所有的更改,以计算出该节点当前应有的状态。如果目标键在增量链中被找到(如插入),则查找成功;否则,需要继续向下查找基础页。
处理并发与合并
- 并发更新:如果两个线程同时尝试为同一节点添加增量记录,它们的CAS操作会竞争映射表中的同一个条目。只有一个会成功,失败的线程需要重试其操作。
- 增量链合并:随着操作增多,增量链会变长,影响查找性能。因此,需要定期进行合并:一个线程会读取当前的基础页和所有增量记录,在内存中创建一个合并后的新节点版本,然后通过CAS操作将映射表指向这个新版本。旧版本的基础页和增量链随后可以被垃圾回收。
垃圾回收
BW树需要安全的垃圾回收机制来回收旧节点。常用的方法有:
- 引用计数:每个节点维护一个计数器。访问时递增,离开时递减。当计数器为0时可安全回收。但每次访问都涉及原子写操作,性能开销大。
- 基于纪元的回收:系统维护一个全局递增的纪元号。线程在开始操作时注册当前纪元。当节点被合并替换后,其旧版本被标记为“垃圾”并关联到当前的纪元号。只有当系统确认所有活跃线程的纪元号都大于该垃圾节点的纪元时,才安全回收该节点内存。这种方法更为高效,也是BW树常用的方法。
分裂操作
BW树的分裂也通过增量机制实现:
- 创建两个新的逻辑页(例如,Page 105),并将原节点(例如,Page 103)的部分键移动到新节点。
- 在Page 103的增量链头部安装一个分裂增量记录。该记录包含原基础页的指针和到新节点(Page 105)的逻辑指针,并说明键的划分边界。
- 在父节点(例如,Page 101)中安装一个分隔增量记录,直接更新其指向子节点的逻辑指针,将一部分键范围指向新的Page 105。这一步不是正确性所必需的,但可以提升后续查找的效率。
BW树的实现挑战与性能
尽管BW树的设计非常精巧,但在实际工程实现中面临诸多挑战:
- 代码复杂度高:实现一个正确的、高效的无锁数据结构极其复杂,代码难以维护和调试。
- 增量链开销:即使有合并操作,遍历增量链的重放成本在写密集负载下依然显著。
- 缓存效应:增量记录通常存储在基础页的预留空间或附近,更新它们仍然会引起缓存行失效,并未完全实现最初“避免缓存失效”的目标。
在我们的性能测试中,一个精心优化的、使用锁的B+树实现,其性能显著优于开源的BW树实现。这也引出了下节课的内容:我们可以采用更智能的加锁策略来优化B+树,而不是追求复杂的无锁结构。
总结


本节课我们一起学习了OLTP索引的基础。我们从“全键”数据结构的概念出发,回顾了历史上为内存数据库设计的T树及其优缺点。然后,我们深入探讨了现代无锁B+树——BW树的核心设计,包括其通过映射表和增量记录来实现无锁更新的机制,以及相关的垃圾回收和分裂操作。最后,我们了解到,由于实现的复杂性和实际运行时的开销,一个设计良好的有锁B+树在实践中可能比BW树更具竞争力。下节课,我们将继续探讨如何优化B+树的锁机制,并介绍部分键索引结构。
7: OLTP索引 2

📚 概述
在本节课中,我们将继续探讨联机事务处理(OLTP)中的索引结构。我们将首先深入讨论锁存器(Latch)的实现方式及其在B+树中的应用,然后重点介绍一种名为Trie(前缀树)的索引结构及其变体,包括Judy数组和ART索引。我们将学习如何通过不同的锁存策略和数据结构优化来提升数据库系统的并发性能。
🔒 锁存器实现
上一节我们介绍了锁存器的基本概念。本节中,我们来看看在数据库系统中如何具体实现锁存器。
锁存器是用于保护数据结构关键部分的低级原语。在数据库系统中,我们需要选择或实现高效的锁存器。以下是几种常见的锁存器实现方式:
测试与设置自旋锁
这是最基本的锁存器实现方式。它使用一个内存位置(例如一个字节)来表示锁的状态。线程通过原子操作(如比较并交换)来尝试获取锁。
std::atomic<bool> latch{false};
while (latch.exchange(true, std::memory_order_acquire)) {
// 自旋或执行退避策略
}
// 进入临界区
latch.store(false, std::memory_order_release);
操作系统阻塞互斥锁
这种实现依赖于操作系统内核提供的互斥锁(如pthread_mutex)。当线程无法获取锁时,它会被操作系统阻塞并调度出去。
std::mutex mtx;
mtx.lock();
// 进入临界区
mtx.unlock();
自适应自旋锁
这是一种混合方法,结合了用户空间自旋和操作系统阻塞的优点。线程首先在用户空间自旋一段时间,如果仍未获得锁,则退回到操作系统阻塞。
队列自旋锁
为了解决多核环境下缓存一致性的问题,可以使用队列自旋锁(如MCS锁)。每个等待线程在本地自旋,从而减少跨CPU插槽的缓存失效消息。
读写锁存器
读写锁存器允许多个读取者同时访问,但只允许一个写入者访问。这对于数据库索引非常有用,因为读操作通常比写操作更频繁。
std::shared_mutex rw_latch;
// 读操作
{
std::shared_lock lock(rw_latch);
// 执行读操作
}
// 写操作
{
std::unique_lock lock(rw_latch);
// 执行写操作
}
🌳 B+树中的锁存策略
了解了锁存器的实现后,我们来看看如何在B+树中应用它们以实现高效的并发访问。
锁存螃蟹协议
基本思想是在遍历树时,按需获取和释放锁存器。一旦确定当前节点是“安全”的(即后续操作不会导致该节点分裂或合并),就可以释放其父节点的锁存器。
- 查找操作:沿路径获取读锁存器,到达子节点后可释放父节点的锁存器。
- 插入/删除操作:沿路径获取写锁存器。仅当确定当前节点不会发生分裂或合并时,才释放祖先节点的锁存器。
乐观锁存协议
假设在到达叶节点之前不会发生分裂或合并。因此,查找路径上只获取读锁存器。在叶节点处,获取写锁存器并验证假设。如果假设错误(需要分裂/合并),则中止并重试整个操作。
无锁读协议
这是一种更激进的方法,读取操作完全不获取锁存器。它们记录访问节点时的版本号。在操作完成后,检查版本号是否改变。如果改变,说明有写入者修改了节点,读取操作需要中止并重试。写入者仍然使用写锁存器,并配合类似Bw-tree的纪元垃圾收集来安全地回收内存。
🌲 Trie索引结构
现在,让我们转向另一种索引结构:Trie(前缀树)。与B+树存储完整键不同,Trie将键分解为数字(例如字节),并在树的每一层存储一个数字。
Trie的基本概念
- 键:不被完整存储,而是通过从根到叶的路径来隐含表示。
- 跨度:指从一个节点出发的可能路径数,决定了树的扇出和高度。
- 确定性:对于给定的键集,无论插入顺序如何,Trie的结构都是唯一的。
一个简单的例子是1位Trie,每个节点存储键的一个比特位。
Trie的压缩优化
为了减少内存占用,可以对Trie进行压缩:
- 水平压缩:对于节点中不存在的数字,不存储空指针,而是使用紧凑的表示(如位图)。
- 垂直压缩:对于没有分支的线性路径,可以合并节点,只存储一个指针和数字序列。
🧮 Judy数组与ART索引
以下是两种基于Trie的高效索引实现:
Judy数组
Judy数组是一种256路Trie,旨在作为通用的关联数组。它使用“胖指针”来存储节点元数据和子指针。Judy数组根据节点中键的密度,动态选择不同的节点类型以优化内存使用和访问速度。
ART索引
ART(自适应基数树)是专为数据库索引设计的Trie变体。与Judy数组类似,它也使用多种节点类型(如Node4、Node16、Node48、Node256),并根据子节点数量在它们之间动态转换。ART被设计为指向元组的索引,并采用了之前提到的无锁读协议来处理并发。
Masstree
Masstree是一种“树的树”结构。每个Trie节点内部使用一个B+树来管理子指针。这结合了Trie的高速查找和B+树的高效范围查询能力。Masstree被用于Silo等学术数据库系统中。
📊 性能对比与总结
本节课中我们一起学习了OLTP索引的进阶内容。
我们首先深入探讨了锁存器的多种实现策略及其权衡,从简单的自旋锁到复杂的读写锁和队列锁。接着,我们分析了在B+树中应用这些锁存器的不同协议,如锁存螃蟹协议和乐观协议,以支持高并发操作。
然后,我们引入了Trie索引结构,它通过按位或按字节存储键来提供确定性的查找路径。我们探讨了Judy数组和ART索引这两种高效的Trie变体,它们通过自适应节点和压缩技术来优化内存使用。最后,我们简要介绍了结合Trie和B+树的Masstree。
研究表明,对于某些工作负载,特别是点查询,ART等Trie索引的性能可以超越传统的B+树。然而,B+树在范围扫描方面通常仍具有优势。现代数据库系统正在越来越多地考虑采用这些新颖的索引结构来提升性能。


下一节课,我们将讨论系统目录、数据布局和存储模型,开始构建数据库系统的存储层。
8:数据库存储模型与布局 📚

在本节课中,我们将要学习数据库系统底层的存储模型与数据布局。我们将从最基本的数据类型表示开始,逐步深入到元组布局、行存储与列存储模型,并探讨混合存储方案。理解这些概念是构建高效数据库系统的基石。
数据类型表示 🔢
上一节我们介绍了数据库系统的整体架构,本节中我们来看看数据在内存中是如何被表示和存储的。数据库本质上是一个巨大的字节数组,我们需要为这些字节赋予意义,即根据预定义的模式(Schema)来解释它们。
数据库系统支持多种SQL标准定义的数据类型,每种类型在内存中都有其对应的表示方式。
以下是主要数据类型的表示方法:
- 整数:直接使用C++或硬件提供的对应类型(如
int32_t,int64_t)。例如,一个32位整数直接占用4个连续字节。 - 浮点数:遵循IEEE 754标准,由硬件直接支持。虽然计算快,但存在精度舍入问题。
- 定点小数:用于需要精确计算的场景(如金融)。数据库系统需自行实现其表示和运算逻辑,可能比浮点数慢,但无精度损失。
- 时间戳/日期:常用方法是从UNIX纪元(1970年1月1日)开始的毫秒或微秒数。例如,可以用一个64位整数表示。
- 变长数据:如
VARCHAR或TEXT。在定长数据区存储一个指向变长数据池的指针。如果数据本身小于指针大小(例如短字符串),则可能直接内联存储在定长区。
对于空值的处理,常见方法有三种:
- 特殊值标记:在类型的值域中指定一个特殊值(如
INT32_MIN)代表NULL。但需要在应用层防止用户插入此值。 - 空值位图:在元组头部或列数据块中,使用一个位图(Bitmap),其中每一位对应一个属性,1表示该属性为NULL。这是最主流的方法。
- 独立标志位:为每个可能为空的属性额外存储一个标志位。这种方法浪费空间且可能导致内存访问不对齐,效率较低。
元组布局与内存对齐 🧱
了解了单个数据的表示后,我们来看看如何将它们组织成一个完整的元组。元组的布局必须考虑内存对齐,这对性能至关重要。
在定长存储池中,元组通常按顺序存储其属性。例如,一个包含 ID(32位整数)和 Value(64位整数)的元组,其内存布局为:[头部 | ID | Value]。通过 reinterpret_cast,我们可以将字节数组解释为特定类型的值。
然而,直接顺序存储可能导致内存访问不对齐。现代CPU(如x86)通常以字(例如64位/8字节)为单位访问内存。如果数据没有对齐到字边界,CPU可能需要执行两次内存读取再拼接结果,这会显著降低性能。
以下是解决不对齐问题的两种主要策略:
- 填充:在属性之间插入空白字节,确保每个属性都从其类型所需的对齐边界开始。例如,在一个64位字中存储了32位的
ID后,填充32位,再开始存储64位的Value。 - 列重排序:在创建表时,根据属性的大小重新排列列的顺序,以最大化空间利用并减少填充。例如,将两个32位属性放在一个64位字中。系统需要在返回结果给用户时,将顺序还原为表定义时的顺序。
通过填充和重排序,可以确保高效的内存访问,从而提升数据插入和扫描的速度。
存储模型:行存储 vs 列存储 🗃️
现在,我们上升到更高的层次,探讨如何组织表中所有元组的存储。主要有两种模型:行存储和列存储。
行存储 是传统的关系数据库模型。
- 原理:将单个元组的所有属性连续地存储在一起。
- 优点:非常适合OLTP工作负载,因为事务通常只访问少量元组,且需要元组的所有或大部分属性。插入和点查询效率高。
- 缺点:对于分析型查询(OLAP)效率低,这类查询通常需要扫描大量元组但只涉及少数几个列。行存储会读取大量不需要的数据,浪费内存带宽和缓存空间。
列存储 是分析型数据库的主流模型。
- 原理:将表中所有元组的同一属性值连续存储在一起,形成一个个独立的列。
- 优点:极其适合OLAP工作负载。查询只需读取涉及的列,压缩效率高(因为同一列的数据类型一致),便于向量化执行。
- 缺点:点查询和更新操作可能更慢,因为需要从多个列中分别读取或修改数据来“组装”或“拆分”一个元组。
列存储的历史可以追溯到上世纪70年代,但在21世纪初随着MonetDB、VectorWise等系统的出现而成熟,现在已成为分析数据库的标准。
混合存储模型与更新策略 ⚖️
在实际应用中,许多系统需要同时处理事务和分析任务,即HTAP工作负载。这催生了混合存储模型。
混合存储的核心思想是识别数据的“冷热”程度:
- 热数据:最近被插入、更新或频繁访问的数据,适合用行存储,以优化事务性能。
- 冷数据:较少变动、主要用于分析的历史数据,适合转换为列存储,以优化扫描性能。
以下是两种实现混合存储的常见架构:
- 分形镜像:维护两份完整的数据副本,一份是行存储(主副本),另一份是列存储(镜像)。所有写操作进入行存储,后台进程异步将数据转换为列格式更新镜像。分析查询被路由到列存储镜像执行。Oracle、IBM等采用此方案。
- 增量存储:数据主要存储在列格式中,但近期的更新(增量)存储在一个行格式的“增量区”中。查询时需要合并列主存储和增量区的数据。这类似于MVCC中处理多版本的方式。SAP HANA等采用此方案。
在Peloton系统的早期设计中,我们尝试了更紧密的混合模型,让执行引擎能同时操作行和列格式的数据块。但由于工程复杂度太高,最终转向了纯粹的列存储,并利用MVCC机制来高效处理更新,这被认为是更简洁有效的方案。
对于列存储中的更新,C-Store论文提出了“浅层索引”和“涟漪插入”等技术,通过范围分区来平衡点查询和扫描的效率,并允许在分区内进行高效插入,避免全局重排序的开销。
系统目录 🗂️
最后,我们简要讨论一下系统目录。目录是数据库的“元数据数据库”,它存储了所有表、列、索引等对象的定义信息。
一个重要的设计原则是“自举”:目录本身也应该作为普通的表存储在数据库内部,从而享受事务(ACID)保障。但这带来了“先有鸡还是先有蛋”的问题——访问表需要目录,但存储目录又需要表。系统需要一段特殊的启动代码来初始化这个最基础的目录结构。
以下是几种常见的模式变更操作在行存储和列存储下的对比:
- 增加列:
- 行存储:通常需要重写所有元组,开销大。
- 列存储:只需创建一个新的空列,非常简单。
- 删除列:
- 行存储:可能需要重写元组,或仅做逻辑标记。
- 列存储:直接释放该列占用的内存即可。
- 创建索引:在已有数据的表上创建索引而不阻塞查询是一个挑战,通常需要复杂的后台构建逻辑。
- 序列:用于生成自增数字。需要注意的是,序列的递增通常不在事务回滚范围内,其值需要持久化到日志中,以保证崩溃恢复后序列的连续性。
总结 📝
本节课中我们一起学习了数据库存储的核心内容。我们从最基础的数据类型表示和空值处理出发,探讨了元组布局中的内存对齐问题及其优化策略。接着,我们深入对比了行存储和列存储两种核心模型各自的优缺点及适用场景。为了应对混合工作负载,我们分析了分形镜像、增量存储等混合存储架构。最后,我们了解了系统目录的自举设计以及不同存储模型下模式变更操作的差异。


理解这些存储层的设计抉择,是构建或选用一个能够适应特定工作负载的高性能数据库系统的关键。
9:数据库压缩 📚



在本节课中,我们将要学习数据库系统中的数据压缩技术。我们将探讨为何需要压缩数据,介绍几种不同的压缩方案,并了解数据库系统如何原生地处理压缩数据以提升查询性能。
概述
压缩的核心思想是减少数据库中数据的体积,从而节省内存和存储空间。在某些情况下,由于数据布局的优化,压缩甚至能让我们更快地执行查询。我们将从基础的压缩概念讲起,逐步深入到更复杂的列式存储压缩技术。
为什么需要压缩?💡
在基于磁盘的数据库系统中,压缩的好处显而易见,因为磁盘I/O非常昂贵。通过付出一些额外的CPU开销来压缩数据,可以显著减少从磁盘读取的数据量,这是一个很好的权衡。
然而,对于内存数据库,这个权衡就不那么明显了。因为所有数据都在内存中,我们不需要支付高昂的I/O代价。但压缩仍然有价值,原因如下:
- 降低成本:DRAM并不便宜,减少内存使用可以节省硬件和能源成本。
- 提升性能:压缩后的数据可能更适合放入CPU缓存,处理更少的数据量也能计算出相同的结果。
真实数据集通常具有倾斜性(某些值频繁出现)和高度相关性(同一元组中不同属性的值相互关联)。这些特性使得我们可以采用高效的压缩方案。
压缩方案的要求 🎯
一个理想的数据库压缩方案应满足以下几点:
- 产生固定长度的值:为了在固定长度的数据池中进行偏移跳转,大多数压缩值必须是固定长度的。
- 支持延迟物化:尽可能推迟解压数据的时机,在查询生命周期中尽量在压缩数据上完成操作,直到必须向外界输出结果时才进行解压。
- 必须是无损压缩:压缩后再解压必须能完全恢复原始数据。数据库系统本身不应进行有损压缩,这应由应用层根据业务逻辑决定。
压缩的粒度 📦
在决定如何压缩之前,我们需要确定压缩的粒度:
- 块级:压缩一个数据块(可能包含多行或多列)。
- 元组级:压缩单个元组内的所有值。
- 属性级:压缩单个元组内的单个属性(对于大文本字段很有用)。
- 列级:压缩单个属性在所有元组中的值(主要用于列式存储系统)。
不同的粒度将决定我们可以采用哪种压缩方案。
朴素压缩 🧱
朴素压缩是指使用现成的通用压缩算法(如Snappy、LZ4、gzip、Zstandard)对数据字节序列进行压缩。数据库系统本身并不“理解”压缩后的数据内容。
示例:MySQL的压缩
在MySQL中,可以创建压缩表。它在磁盘上存储压缩后的数据块,并在内存中维护一个“修改日志”。当需要读取未被修改的数据时,必须解压整个数据块。这种方法的主要缺点是,数据库失去了对数据语义的理解,每次访问都可能需要完全解压。
上一节我们介绍了基础的压缩概念和朴素压缩,本节中我们来看看更高级的、数据库系统原生支持的压缩方案。
列式压缩方案 🗂️
对于OLAP(联机分析处理)系统中的冷数据,我们可以采用更高效的列式压缩方案。以下是几种常见的技术:
1. 空值抑制与游程编码
空值抑制是游程编码的一种变体,专门处理稀疏数据(即大量值为NULL的列)。其基本思想是不重复存储NULL值,而是记录NULL值的数量和位置。
游程编码则更通用,它利用列中值经常重复出现的特性。对于连续重复的值,不存储每个副本,而是存储一个三元组:(值, 起始位置, 重复次数)。
示例:
假设一个“性别”列的数据为:[M, M, M, F, F, F, F]。
使用游程编码后,可以存储为:[(M, 0, 3), (F, 3, 4)]。
如果列预先按性别排序,压缩效果会极佳。
2. 位图编码
位图编码为列中每个唯一值创建一个位图。位图的长度等于元组数量,如果某一行具有该值,则对应位置为1,否则为0。
示例:
对于“性别”列 [M, M, F, M, F]。
- 为“M”创建位图:
[1, 1, 0, 1, 0] - 为“F”创建位图:
[0, 0, 1, 0, 1]
这种方法在列的唯一值基数很低时(如性别、状态码)非常高效。但对于高基数列(如邮编),创建大量长位图反而会浪费空间。
位图压缩:位图本身也可以压缩。例如,Oracle曾使用BBC(Byte-aligned Bitmap Code)方案,通过识别全零的“间隙字节”和包含1的“尾部字节”来压缩位图。不过,由于现代CPU分支预测问题,这种方案已逐渐被淘汰。
3. 增量编码
增量编码适用于连续值之间差异很小的列,例如时间序列数据。它不存储每个完整值,而是存储第一个值,后续值只存储与前一个值的差值(Delta)。
示例:
原始温度读数:[72, 73, 73, 74, 75]
增量编码后:[72, +1, 0, +1, +1]
可以进一步对+1进行游程编码以获得更好压缩比。
对于字符串,可以使用前缀编码。如果字符串按字典序排列,可以只存储每个字符串与前一个字符串的共同前缀长度以及不同的后缀部分。
4. 字典编码 🗃️
这是最常见和最重要的列式压缩技术。其核心思想是:
- 识别列中所有重复出现的值。
- 为每个唯一值分配一个更短的固定长度编码(如整数)。
- 在列中存储编码而不是原始值。
- 维护一个字典,用于编码和解码。
关键优势:
- 支持延迟物化:许多查询(如等值过滤、分组聚合)可以直接在编码上操作,无需解压。
- 支持范围查询:如果字典编码的顺序与原始值的顺序一致(使用顺序保留编码,如霍夫曼编码的一种变体),则可以直接在编码上执行范围查询。
- 快速聚合:计算唯一值数量只需统计字典大小。
字典数据结构选择:
- 数组:最简单,编码即数组索引。适用于静态数据。
- 哈希表:支持快速编码查找,但不支持范围查询。
- B+树:支持快速编码、解码以及范围查询,是功能最全的选择。
5. 其他方案
- ** Mostly Encoding**:当列中大多数值可以用更小的数据类型表示时,用小类型存储它们,对于少数“溢出”的大值,则用标志位指向一个查找表。Amazon Redshift 使用了此技术。
索引压缩 🔑
在OLTP(联机事务处理)系统中,索引本身可能占用大量空间。我们可以压缩索引以减少内存占用。
- 前缀压缩:在B+树节点中,不重复存储键的共同前缀。
- 后缀截断:在B+树内部节点中,只存储足以区分子节点路径的键前缀部分。
- 混合索引:将索引分为动态(可写、未压缩)和静态(只读、压缩)两部分,使用布隆过滤器来指导查询应先访问哪一部分。这是卡内基梅隆大学数据库组的一项研究成果。
总结


本节课我们一起学习了数据库压缩的多种技术。关键要点在于,数据库系统可以利用其对数据语义和查询模式的理解,进行比通用压缩算法更智能的压缩。通过字典编码等技术,系统可以在压缩数据上直接执行许多查询操作,实现延迟物化,从而在节省空间的同时,有时甚至能提升查询性能。对于不同的工作负载(OLTP vs OLAP)和数据特性,我们需要选择最适合的压缩粒度和方案。
10:恢复协议 🔄

在本节课中,我们将要学习数据库恢复协议,特别是针对内存数据库的恢复机制。我们将探讨不同类型的日志记录方案、检查点协议以及如何利用多版本并发控制(MVCC)的特性来优化恢复过程。课程内容将涵盖物理日志与逻辑日志的区别、运行时日志刷新的策略,以及如何设计系统以实现快速恢复。
概述 📋
数据库系统在发生崩溃后,必须能够恢复到正确的状态,以保证事务的原子性、一致性和持久性。恢复算法通常包含两个部分:运行时记录额外信息,以及崩溃后根据这些信息恢复数据库。对于内存数据库,恢复协议相对简化,因为主存储位置在内存中,崩溃后内存内容会丢失,我们只需从检查点重新加载并重放日志即可。
上一节我们介绍了恢复的基本概念,本节中我们来看看具体的日志记录方案。
日志记录方案 📝
日志记录方案主要分为物理日志和逻辑日志。
物理日志
物理日志记录在字节级别对数据所做的更改。例如,如果使用Delta存储,则记录被修改的列及其新值。崩溃后,系统只需重放这些日志记录,并将更改重新应用到相应的列上。
核心概念:物理日志记录的是数据在存储层面的具体变更。
逻辑日志
逻辑日志记录的是应用程序请求执行的高级操作,例如SQL的UPDATE、DELETE语句。如果一个查询需要更新十亿条元组,物理日志需要十亿条记录,而逻辑日志只需记录一条更新语句。
核心概念:逻辑日志记录的是导致数据变更的操作本身。
逻辑日志的日志文件更小,提交速度可能更快,但恢复过程可能更耗时,因为需要重新执行这些操作。
日志刷新时机 ⏱️
日志记录生成后,需要决定何时将其刷新到磁盘。主要有两种策略。
以下是两种主要的刷新策略:
- 一次性刷新:事务运行时,所有日志记录都缓存在内存的日志缓冲区中。只有当事务提交时,才将所有日志记录一次性交给日志线程刷新到磁盘。这简化了恢复过程,因为崩溃后很少能看到未提交事务的日志记录。
- 增量刷新:当事务的本地日志缓冲区填满时,就将其交给写入线程刷新到磁盘,然后获取新的缓冲区。这意味着磁盘上可能存在未提交事务的日志记录,恢复时需要额外工作来识别并跳过它们。
大多数系统采用增量刷新策略,以避免内存耗尽,并提高运行时性能。
标准优化技术 ⚙️
在内存数据库系统中,我们仍然可以应用一些标准的优化技术来提高日志记录效率。
以下是两种关键的优化技术:
- 组提交:将多个事务的日志记录填充到同一个日志缓冲区中,然后一次性刷新到磁盘。这可以分摊I/O操作的成本,提高吞吐量。
- 推测性锁释放:在事务提交后,不必等待日志记录持久化到磁盘再释放锁。其他事务可以立即开始修改这些数据,但需要跟踪哪些读取操作依赖于尚未持久化的写入,并在必要时阻塞。
这些技术有助于减少日志记录带来的延迟。
利用MVCC进行恢复 🔄
在多版本并发控制系统中,为MVCC生成的增量记录与为日志文件生成的记录非常相似。我们可以将两者结合,避免重复工作。
SQL Server的“持久化版本存储”协议就是一个例子。它利用MVCC的时间旅行表(版本存储)来充当恢复日志。系统将更改写入版本存储,并定期将其刷新到日志文件。崩溃恢复时,只需将版本存储加载回内存,数据库就回到了崩溃时的状态。然后,系统利用MVCC的可见性规则,在后台清理未提交事务的版本。
这种方法的目标是实现“恒定时间恢复”,即恢复时间仅取决于日志文件的大小,而无需花费时间回滚未提交的更改。
内存数据库的日志协议:SiloR 🚀
Silo是一个使用OCC的单版本内存数据库引擎。其日志协议SiloR旨在最大化并行化日志记录、检查点和恢复过程。
SiloR的关键设计是将日志分散到多个文件,存储在不同的磁盘上,以实现并行读写。每个CPU插槽都有一个专用的日志线程和日志文件,这有助于将内存写入本地化,避免跨插槽通信的开销。
由于日志分散在多个文件,一个事务的日志记录可能分布在不同的磁盘上。Silo使用持久化纪元机制来协调所有日志线程,确保一个事务的所有更新都安全刷新后,才认为该事务已持久化。
Silo的日志记录包含事务ID、表、键和值(可以是增量记录)。由于Silo提供可串行化隔离级别,仅凭事务ID就足以保证重放日志时能恢复到正确状态,无需额外的日志序列号。
在恢复时,Silo采用了一种独特的方法:从日志文件末尾开始,逆向重放日志记录。因为内存数据库没有脏页问题,我们只需确定每个元组的最终状态。如果遇到时间戳早于当前元组时间戳的日志记录(意味着该元组已被之后的事务更新过),则忽略该记录。这可以显著加快恢复速度,尤其是当少量元组被反复更新时。
检查点协议 📌
为了防止日志文件无限增长,并加速恢复,我们需要定期创建检查点。
检查点属性
一个理想的检查点协议应具备以下属性:
- 低运行时开销:对正常事务处理的影响应控制在10%-15%以内。
- 无巨大延迟尖峰:不应采用阻塞式检查点,导致事务排队。
- 低内存开销:尽量避免在内存中创建完整的数据库副本。
检查点类型
以下是检查点的几种分类:
- 一致性检查点 vs. 模糊检查点:
- 一致性检查点:快照只包含在检查点开始前已提交事务的更新。这对于MVCC系统很容易实现,只需扫描并写出对检查点事务可见的数据版本即可。
- 模糊检查点:快照可能包含检查点开始后提交事务的部分更新。恢复时需要额外工作来协调。
- 完整检查点 vs. 增量检查点:
- 完整检查点:写出数据库的完整快照。
- 增量检查点:只写出自上次检查点以来的更改。这可以节省存储空间,但管理更复杂(需要合并多个增量文件)。
- 触发频率:
- 基于时间:例如每5分钟触发一次。
- 基于日志大小:例如每写入100MB日志后触发。这有助于限制恢复时需要处理的日志量。
大多数内存数据库系统采用完整的一致性检查点。恢复时,加载检查点文件并重建索引。
快速重启技术 ⚡
有时我们需要有计划地重启数据库(例如升级软件),而非应对崩溃。Facebook的Scuba系统提出了一种利用共享内存实现快速重启的技术。
当需要重启Scuba进程时,系统会停止所有更新,将数据库的完整状态写入共享内存区域,然后关闭旧进程。新进程启动后,发现共享内存中存在数据库状态,便直接将其加载进来,而无需从磁盘读取检查点文件。这相当于将共享内存用作一个“RAM磁盘”,使得数据库状态能够跨越进程的生命周期。
这种方法可以极大缩短有计划重启的停机时间,特别是对于大型内存数据库。
总结 🎯
本节课我们一起学习了数据库恢复协议,重点探讨了内存数据库环境下的特殊考虑和优化技术。
- 我们比较了物理日志和逻辑日志的优劣,物理日志因其恢复简单直接而更受青睐。
- 我们分析了日志刷新策略(一次性 vs. 增量)及其对恢复的影响。
- 我们探讨了如何利用MVCC的版本存储来简化日志记录和恢复过程,实现接近恒定时间的恢复。
- 我们深入研究了SiloR日志协议,其通过分散日志、使用持久化纪元和逆向日志重放等设计,实现了高度并行化和快速的恢复。
- 我们介绍了检查点协议的不同类型和目标,强调了一致性检查点在MVCC系统中的优势。
- 最后,我们了解了利用共享内存实现有计划快速重启的创新技术。


关键要点是,对于内存数据库,我们可以专注于记录重做信息,利用其存储特性简化恢复逻辑,并通过巧妙的系统架构(如并行日志、MVCC集成、共享内存)来极大提升恢复性能。随着非易失性内存的普及,未来的恢复协议可能会进一步演变,但核心思想将保持不变。
11:网络协议

概述
在本节课中,我们将学习数据库系统中的网络协议。我们将探讨客户端如何与数据库服务器通信、数据传输的优化方法,以及一些绕过操作系统以提升性能的高级技术。
课程内容概览
在开始深入网络协议之前,我们先回顾一下本课程的整体架构。我们构建的数据库系统可以概念化地分为几个层次:应用层发送SQL查询,查询首先到达网络层,然后经过查询优化器生成物理计划,再通过编译器转换为机器码,最后由执行引擎在存储管理器之上执行查询。本节课,我们将聚焦于最顶层的网络层。
上一节我们介绍了存储和索引,本节中我们来看看客户端与数据库服务器之间的通信机制。
客户端访问方法
在真实的应用程序中,我们不会通过终端手动输入SQL来与数据库交互。相反,我们会使用编程接口(API)来发送查询并接收二进制格式的结果,以避免在应用层进行繁琐的文本解析和类型转换。
以下是两种主要的标准化数据库访问API:
- ODBC:一种早期的、主要用于C/C++应用的数据库访问标准。它采用“设备驱动”模型,数据库厂商提供特定的ODBC驱动,该驱动负责将标准的API调用转换为数据库专有的网络协议(即“有线协议”),并将结果转换回ODBC指定的格式。
- JDBC:专为Java应用程序设计的数据库访问API。JDBC驱动可以通过多种方式与数据库通信,包括通过ODBC桥接、调用数据库原生API、通过中间件转换,或者最理想的是,直接实现数据库的有线协议。
有线协议
几乎所有主要的数据库系统都实现了自己专有的有线协议,并且通常基于TCP/IP。客户端连接数据库的典型流程包括:建立连接、身份验证、发送查询、服务器执行并序列化结果,最后将结果返回给客户端。
然而,许多较新的数据库系统选择兼容现有的流行协议(如PostgreSQL或MySQL的协议),而不是从头开发。这样做的好处是可以直接利用现有的、成熟的客户端驱动生态,降低开发成本。但需要注意的是,“协议兼容”并不等同于“完全兼容”,可能在SQL方言、系统目录等方面存在差异。
查询本身通常不是性能瓶颈,因为大多数查询语句较小。真正的优化机会在于结果数据的序列化与传输。
数据传输优化
我们阅读的论文重点讨论了如何优化大量数据的导出。其核心思想借鉴了我们在存储层讨论过的技术。
首先,我们需要在行格式和列格式之间做出选择。传统的ODBC/JDBC API本质上是行导向的,服务器按行组织结果并发送。但对于分析型或机器学习工作负载,列格式更为高效。
论文提出的方法是:将多个元组组织成一个数据块,在块内使用列式存储(即PAX模型)。这样做的优势在于,可以对同一列中的相似数据应用高效的压缩算法。
关于数据压缩,有两种主要策略:
- 通用压缩:对整个数据块使用如Snappy或gzip等算法。这种方法简单,与数据类型无关。
- 列特定编码:使用如RLE、字典编码或增量编码等方法。这种方法压缩率可能更高,但需要在客户端实现相应的解码逻辑。
论文的实验表明,在网络延迟较低时,不压缩的二进制传输最快;而当网络变慢时,进行压缩(即使有CPU开销)能带来更好的整体性能。
数据的序列化格式也影响性能:
- 二进制编码:直接传输数据的原生二进制表示,效率最高。但需要注意字节序等问题,通常由客户端驱动处理。
- 文本编码:将数据转换为字符串(如JSON)传输。这种方式更灵活但体积更大、解析更慢,通常不是高性能场景的首选。
对于字符串的处理,常见方法有:空终止符、长度前缀和固定长度字符域。选择哪种方式取决于数据特征和是否启用压缩。
内核旁路技术
操作系统网络栈(TCP/IP)本身可能成为高吞吐量场景的瓶颈,因为涉及系统调用、上下文切换和数据拷贝。内核旁路技术旨在绕过OS,让应用程序直接与网卡交互。
以下是两种主要的内核旁路方法:
- DPDK:一个软件库,允许应用程序直接访问网卡,在网卡缓冲区中准备数据包,从而避免数据拷贝和系统调用。目前使用此技术的数据库系统较少,ScaliaDB是一个例子。
- RDMA:允许一台机器直接读写另一台机器的内存。这对于数据库集群内部通信非常高效(如Oracle RAC),但由于需要精确控制内存布局和一致性,很难用于通用的客户端-服务器通信。
实验表明,通过精心设计的内存直接访问,数据导出性能可以比经过传统数据库协议高出数个数量级。
总结


本节课我们一起学习了数据库网络协议的关键内容。我们了解了客户端如何通过ODBC/JDBC等标准API访问数据库,以及底层专有有线协议的作用。我们重点探讨了优化结果数据传输的技术,包括采用列式存储、数据压缩和高效的序列化格式。最后,我们简要介绍了DPDK和RDMA这两种绕过操作系统以追求极致性能的内核旁路技术。尽管优化网络协议存在巨大潜力,但由于需要考虑客户端驱动的兼容性,在实际系统中进行重大变更往往面临挑战。
12:调度规划 🧠

在本节课中,我们将要学习数据库系统中查询执行的调度规划。我们将探讨如何将查询计划分解为任务,如何根据硬件架构(特别是NUMA)高效地分配这些任务,以及不同的调度模型(如静态调度、动态工作窃取)如何影响系统性能。核心目标是理解数据库系统如何绕过操作系统,自主做出最优的资源调度决策,以实现最高效的查询执行。
进程模型 🏗️
上一节我们介绍了查询计划的基本概念,本节中我们来看看数据库系统支持并发请求的底层架构,即进程模型。它定义了系统如何将计算单元(工作者)分配给任务。
以下是三种主要的进程模型:
- 进程/工作者模型:每个数据库工作者是一个独立的操作系统进程。调度由操作系统管理(例如,通过
fork)。优点是工作者崩溃不会导致整个系统宕机(例如,PostgreSQL, Oracle, DB2)。缺点是需要进程间通信(IPC)或共享内存来协调,且调度控制权交给了操作系统。 - 进程池模型:系统维护一个预分配的进程池来处理请求,而非为每个请求创建新进程。这减少了进程创建的开销,并可能支持查询内并行。但如果不精心设计,可能破坏缓存局部性(例如,PostgreSQL在2015年后支持查询内并行)。
- 线程/工作者模型:在单个进程内使用多个线程作为工作者。这是现代数据库系统(如Peloton)最常用的模型。优点是上下文切换开销低,所有线程共享同一地址空间,通信简单。缺点是需要处理线程同步(锁、闩锁)问题。POSIX线程(pthreads)的标准化使其成为可行选择。
核心概念:在代码中,工作者通常被实现为线程。
std::thread worker_thread(execute_task, task_queue);
数据放置与NUMA架构 🧩
在决定了工作者模型后,我们需要确保工作者操作的数据是“本地”的。在现代多插槽(多CPU)服务器中,内存访问并非均匀,这引出了非统一内存访问(NUMA)架构的概念。
- 统一内存访问(UMA/SMP):所有CPU通过一个共享系统总线访问内存,访问任何内存地址的成本相同。这是较旧的架构。
- 非统一内存访问(NUMA):每个CPU插槽有本地连接的内存(本地NUMA节点)。访问本地内存速度很快,而通过互联(如Intel的QPI)访问远程插槽的内存则可能慢50%以上。现代多插槽系统普遍采用此架构。
对于数据库系统,这意味着我们需要有意识地控制数据放置和任务调度:
- 数据放置:当数据加载到内存时,我们希望将其分配到与将要处理它的线程相同的NUMA节点上。可以使用
first-touch策略(操作系统将内存页分配在首次访问它的线程所在的NUMA节点)或事后使用如move_pages的系统调用来迁移数据。 - 任务调度:调度器需要知道数据的位置,并尽量将任务分配给与该数据位于同一NUMA节点的线程上执行。
研究表明,与让操作系统随意放置数据相比,精心设计的数据放置和任务调度能带来显著的性能提升(在某些OLTP和OLAP工作负载中可达30%至2倍以上)。
核心概念:访问远程NUMA节点的内存比访问本地内存慢得多。
本地内存访问延迟 < 远程内存访问延迟(可能慢50%+)
调度方法 ⚙️
现在我们已经有了工作者(线程)并了解了数据布局,接下来看看如何调度查询任务。调度决定了任务如何被创建、分配给工作者以及如何执行。
静态调度
静态调度是最简单的方法。系统在查询执行前就确定好任务数量(通常与核心数一致)和分配方案,然后开始执行。这种方法实现简单,但缺乏灵活性,无法处理运行时数据倾斜导致的负载不均问题(例如,某个核心上的任务需要处理更多符合谓词条件的元组)。
动态调度(工作窃取)
动态调度允许系统在运行时根据负载情况调整任务分配。Hyper论文中提出的Morsel-Driven并行是一个典型例子。
- Morsel(数据块):将数据表水平分割成小块(例如,每块10万行),称为Morsel。这些Morsel被轮询分配到不同NUMA节点。
- 任务与队列:查询计划被分解为任务,放入一个全局任务队列。每个工作者线程(通常每核心一个)从队列中拉取任务。
- 工作窃取:工作者优先处理其本地NUMA节点上的Morsel。如果某个工作者完成任务后队列中仍有任务,而其他工作者(可能因数据倾斜而变慢)还在忙碌,空闲工作者可以“窃取”属于其他NUMA节点的任务来执行。这有助于平衡负载,避免出现拖后腿者。
- 局部性保持:处理结果通常写入线程本地缓冲区,减少同步开销。
这种方法通过共享队列和工作窃取实现了灵活的负载均衡,特别适合OLAP查询。
混合调度(HANA模型)
SAP HANA的研究提出了一种更复杂的混合模型,结合了工作窃取和资源动态调整。
- 多级任务队列:每个
调度组(一组共享本地内存的工作者)维护两个队列:- 硬队列:包含必须在本组内执行的任务,不允许被窃取。
- 软队列:包含可以被其他组工作者窃取的任务。
- 工作者状态池:工作者被分为不同状态池(运行中、非活跃、空闲、暂停),便于管理。
- 看门狗线程:一个全局监控线程观察所有组的负载。它可以动态地“暂停”或“唤醒”工作者线程,甚至在不同组间迁移线程资源,以实现更精细的资源分配和流控。
这种模型提供了极高的调度灵活性和资源控制能力,但实现复杂度也更高。
用户态调度(SQL Server SQLOS)
微软SQL Server采用了一种极端且独特的方案:在数据库引擎内实现了一个名为SQLOS的轻量级用户态操作系统。
- 抽象硬件:SQLOS抽象了底层硬件细节(如NUMA),使上层查询执行引擎无需关注这些复杂性。
- 协作式非抢占调度:SQLOS实现自己的线程调度器。它要求数据库引擎代码在长时间运行的操作中主动让出(yield)控制权(例如,在扫描循环中检查耗时)。调度量子(如4毫秒)由SQLOS管理。
- 优势:这使得SQL Server能够实现跨查询的公平调度、资源治理(如为不同租户分配不同CPU份额),并简化了向新平台(如Linux)的移植,因为只需修改SQLOS与操作系统的接口层。
流控制 🚦
当查询请求到达的速度超过系统处理能力时,系统会过载。数据库系统需要实现流控制机制来应对,而不是依赖操作系统。
以下是两种主要策略:
- 准入控制:当系统检测到资源(CPU、内存)不足时,直接拒绝新的查询请求。这是最常见的方法。
- 节流:不对请求立即拒绝,而是引入人工延迟后再开始处理或执行,以平滑负载,避免系统瞬间被压垮。
高级系统还可以结合优先级队列,确保高优先级用户(或管理员)的查询即使在过载时也能获得资源。
总结 📚
本节课我们一起学习了数据库查询执行中的调度规划。我们从底层的进程模型(线程 vs 进程)出发,探讨了现代硬件NUMA架构对数据放置和任务本地性的关键影响。接着,我们深入分析了多种调度方法:从简单的静态调度,到灵活的基于Morsel的动态工作窃取(Hyper),再到复杂的混合资源管理(HANA),最后了解了将调度推向极致的用户态操作系统方法(SQL Server SQLOS)。最后,我们讨论了防止系统过载的流控制机制。


贯穿始终的核心思想是:数据库系统掌握着查询语义、数据布局和系统资源的完整信息,因此应该(并且能够)比通用操作系统做出更优的调度决策,以最大化性能。 从数据局部性利用到细粒度的任务调度,现代数据库系统正越来越多地将操作系统视为一个需要谨慎管理的“伙伴”,而非全权委托的“管家”。
13:查询执行与处理 🚀

在本节课中,我们将要学习如何高效地执行查询。我们将探讨现代CPU架构如何影响数据库系统的设计,并介绍不同的查询处理模型,包括迭代器模型、物化模型和向量化模型。最后,我们会讨论如何通过并行执行来进一步提升查询性能。
CPU架构与数据库性能 ⚙️
上一节我们概述了课程内容,本节中我们来看看现代CPU架构对数据库系统设计的影响。在内存数据库系统中,磁盘I/O不再是主要瓶颈,因此我们需要关注CPU如何高效地执行指令。
现代CPU是超标量处理器,它们通过流水线阶段并行执行指令。CPU会尝试预测分支路径(分支预测)并提前执行指令,如果预测错误,则需要清空流水线,这会导致性能损失。对于数据库系统,尤其是在扫描元组和评估谓词时,会产生大量分支,可能引发分支预测错误。
此外,我们希望减少指令执行周期,这意味着需要最大化数据局部性,减少缓存未命中。CPU缓存层次结构(L1、L2、L3)的访问速度远快于主内存(DRAM),因此优化数据访问模式至关重要。
以下是影响数据库性能的CPU关键特性:
- 流水线:允许指令重叠执行以隐藏延迟。
- 乱序执行:CPU动态重新排序指令以保持执行单元繁忙。
- 分支预测:预测条件分支的走向,预测错误代价高昂。
- 缓存层次结构:快速但容量小的缓存,未命中会导致停滞。
查询处理模型 🔄
了解了CPU特性后,我们来看看如何组织数据库系统来处理包含多个操作符的查询。根据工作负载类型(如OLTP或OLAP),可以选择不同的处理模型。
迭代器模型(火山模型)
这是最常见的模型,也被称为火山模型或拉取模型。每个操作符都实现一个 Next() 函数,每次调用返回一个元组。查询计划树从根节点开始,通过递归调用子节点的 Next() 来获取并处理数据。
优点:
- 易于实现输出控制(例如
LIMIT子句)。 - 支持流水线执行,元组在处理后可以立即传递给下一个操作符。
缺点:
- 每个元组都需要一次函数调用,开销较大。
- 大量虚函数调用可能阻碍编译器优化。
使用此模型的数据库系统包括:SQLite、MySQL、PostgreSQL。
物化模型
在物化模型中,每个操作符一次处理并生成其所有输出元组(或列),然后将整个结果集传递给父操作符。这可以是自顶向下或自底向上的。
优点:
- 减少函数调用开销。
- 更适合OLTP工作负载,这类查询通常只涉及少量元组。
缺点:
- 对于需要处理大量数据的OLAP查询,中间结果集可能非常庞大,消耗大量内存。
- 可能阻碍流水线执行。
使用此模型的系统包括:MonetDB、VoltDB/H-Store、Hyper(旧版本)。
向量化模型
向量化模型是迭代器模型的扩展。操作符的 Next() 函数返回一批元组(一个向量),而不是单个元组。操作符内部可以使用SIMD指令并行处理这批数据。
公式:Next() -> Vector<Tuple>
优点:
- 大幅减少函数调用开销。
- 能够利用SIMD指令进行数据级并行,显著提升扫描、过滤等操作的性能。
- 非常适合分析型(OLAP)查询。
缺点:
- 实现更复杂。
- 需要仔细选择向量大小以匹配CPU的SIMD寄存器。
使用此模型的现代分析型数据库系统包括:Snowflake(源自VectorWise)、Amazon Redshift、ClickHouse以及我们正在开发的新系统。
并行查询执行 ⚡
最后,我们探讨如何利用多核CPU并行执行查询。并行性可以在两个维度上实现:操作符内并行和操作符间并行。
操作符内并行(水平并行)
将输入数据水平分区,每个工作线程在数据的一个分区上执行相同的操作符实例。这通常用于扫描、过滤等操作。
代码示例(概念):
# 假设将表R水平分为N个分区
partitions = partition_table(R, num_partitions=N)
for partition in partitions:
execute_scan_operator(partition) # 在并行线程中执行
需要使用交换操作符来协调多个工作线程的结果,它作为一个同步点,确保所有子任务完成后再进入下一阶段。
操作符间并行(垂直并行)
允许查询计划中不同管道(pipeline)的操作符同时执行。例如,在构建哈希表的同时,可以开始扫描另一张表。
优点:
- 更好地利用多核资源,重叠不同操作符的执行。
- 可以减少总体查询延迟。
挑战:
- 需要仔细管理操作符之间的数据依赖关系。
- 执行计划更复杂。
在实际系统中,通常会结合使用水平并行和垂直并行来最大化性能。
总结 📚

本节课中我们一起学习了构建高效查询执行引擎的关键知识。我们首先了解了现代CPU的特性(如流水线、分支预测、缓存),并认识到为人类易读而编写的代码可能对CPU执行并不友好。
接着,我们探讨了三种主要的查询处理模型:
- 迭代器模型:通用性强,但函数调用开销大。
- 物化模型:适合处理少量元组的OLTP负载。
- 向量化模型:通过批处理和SIMD指令,为OLAP查询提供了最佳性能,是现代分析型数据库的首选。

最后,我们讨论了如何通过操作符内并行和操作符间并行来利用多核架构,从而进一步提升查询执行速度。理解这些底层原理和设计权衡,对于构建或优化高性能数据库系统至关重要。
14:查询编译与代码生成

概述
在本节课中,我们将学习查询编译与代码生成的核心概念。这是现代数据库系统用于获取最佳性能的主要技术之一。我们将探讨为什么需要代码生成,以及两种主要的技术:源码到源码编译(Transpilation)和即时编译(JIT Compilation)。课程内容将涵盖从背景知识到实际系统实现的各个方面,并通过具体示例帮助初学者理解。
背景:为什么需要查询编译?
上一节我们讨论了如何让系统运行得更快,关键在于减少需要执行的指令数量,并提高每个时钟周期执行的指令数。然而,仅仅通过优化指令来获得显著的性能提升是非常困难的。
例如,如果想让数据处理速度提升10倍,就需要减少90%的指令执行。这虽然困难,但通过精心设计的数据库架构是可以实现的。但如果想提升100倍,就需要减少99%的指令,这变得极其困难。
因此,今天的课程以及后续关于向量化的课程,将介绍如何通过执行更少的指令来完成相同的工作量,从而尝试实现100倍的性能提升。英特尔不再单纯提高时钟速度,而是提供更宽的SIMD寄存器和更专业的指令。作为数据库系统开发者,我们需要设计系统来利用这些硬件特性。
公式:性能提升倍数 ≈ (1 / (1 - 指令减少百分比))。例如,减少90%指令,性能提升约10倍。
代码专门化(Code Specialization)的概念
代码专门化的核心思想是:与其在数据库系统中使用通用代码来处理查询或执行任务,不如生成专门针对特定任务(通常是查询)的代码。
这样做的性能优势在于,通用系统中存在大量的间接调用(如if条件判断或switch语句),用于处理各种可能的数据类型、操作数、谓词或聚合函数。而专门化的代码则去除了所有这些间接性,只包含执行该特定查询所需的确切指令。
当然,人们编写通用系统代码并非因为愚蠢,而是出于软件工程的考虑:代码可重用、易于维护。但问题在于,对人类来说易于理解的代码编写方式,对CPU来说往往效率低下。
解释执行模型的局限性
为了理解编译的优势,我们先看一个解释执行的例子。假设有一个简单的三表连接查询。
使用之前讨论过的迭代器模型(火山模型)执行此查询,会涉及大量的Next()函数调用和数据在操作符间的复制。每个元组都需要通过函数调用链向上传递,这会产生巨大的开销。
此外,谓词求值本身也很昂贵。谓词通常表示为表达式树,系统需要从根节点开始遍历这棵树,为每个正在处理的元组求值。对于一个有十亿个元组的表,这意味着要进行十亿次的树遍历和函数调用。
核心问题:
- 查询计划解释执行带来的操作符间间接调用开销。
- 谓词表达式树求值带来的间接开销。
代码生成的目标正是消除这两种开销。
查询编译流程总览
在一个真实的数据库系统中,查询处理管道大致如下:
- 解析:SQL查询进入,被解析成抽象语法树(AST)。
- 绑定:绑定器查阅目录,将数据库对象名称(字符串)替换为内部标识符。
- 优化:查询优化器接收注解后的AST,生成物理查询计划。
- 编译/代码生成:编译器(或转换器、代码生成引擎)接收物理查询计划(操作符树),并输出某种本地代码或字节码。
本课程重点在于最后一步:将物理计划转换为直接执行、无任何间接性的源代码或本地代码。
由于这个转换步骤由数据库开发者控制,我们无需担心安全沙盒问题,生成的代码可以直接在数据库进程内安全执行。
两种代码生成方法
主要有两种实现查询编译的方法:
1. 源码到源码编译(Transpilation)
这种方法中,数据库系统拥有能生成新源代码(如C++代码)的专门化代码。然后,通过常规编译器(如GCC)编译此新源代码,链接到数据库进程,并执行。
优点:
- 调试相对容易,可以使用GDB等标准工具。
- 生成的代码可以像普通数据库系统代码一样,调用系统内部的其他函数(通过暴露的API)。
缺点:
- 编译成本高:需要调用外部编译器进程,对于短查询,编译时间可能超过执行时间。
- 早期的HyPer系统论文中比较了这种方法与LLVM JIT,显示其编译耗时显著更长。
2. 即时编译(JIT Compilation)
这种方法不生成高级源代码,而是直接生成低级或中间表示(IR),例如LLVM IR或JVM字节码。然后可以即时编译或解释执行这些IR。
优点:
- 编译速度快:省去了调用完整C++编译器的开销,编译过程在进程内完成,速度比调用GCC快一个数量级。
- 同样可以获得消除间接性的所有性能优势。
缺点:
- 调试生成的机器码更困难。
- 需要处理C++函数名修饰等问题以调用系统其他部分。
两种方法的最终结果相同:生成针对特定查询计划的、无间接性的可执行代码。选择哪种取决于编译时间、软件工程复杂性和调试便利性的权衡。
深入源码到源码编译:以HyPer早期工作为例
早期的HyPer系统采用了类似“HighQ”思想的源码到源码编译。它会为给定的查询计划生成实现该计划的C++代码,其中所有谓词和类型转换都根据表模式硬编码。
工作流程:
- 系统接收查询计划。
- 使用C++模板生成对应的C++源文件。
fork/exec调用GCC编译该源文件为共享对象(.so)。- 将共享对象动态链接到数据库进程。
- 调用该共享对象中的特定函数来执行查询。
性能对比实验:
HighQ论文中比较了五种执行模型:
- 通用迭代器(教科书式火山模型)。
- 优化迭代器(针对特定列类型的迭代器,谓词下推)。
- 硬编码实现(手工优化版,对应通用迭代器)。
- 硬编码实现(手工优化版,对应优化迭代器)。
- HighQ的源码到源码编译器。
实验表明,通用迭代器性能最差。而HighQ生成的代码与手工硬编码优化版本的性能相当,这证明了自动代码生成的有效性。性能提升主要来自于消除了循环内核中的函数调用和间接内存访问,使得CPU能够快速连续处理数据。
主要缺点:编译延迟。对于短查询,调用GCC的耗时可能远超查询执行本身。
即时编译与流水线执行:以HyPer的LLVM方案为例
现代HyPer系统的关键创新之一是将即时编译与基于推送的流水线执行模型相结合。
流水线(Pipeline)概念
查询计划可以被划分为多个流水线。一个流水线是查询计划的一部分,能够获取单个元组并将其在计划中尽可能远地向上推送,直到遇到一个“流水线中断器”(例如哈希聚合,需要收集所有元组后才能继续)。
这种模型的优势在于,它允许数据更长时间地保留在CPU高速缓存甚至寄存器中,从而极大地提高了处理速度。
LLVM即时编译
HyPer使用LLVM进行即时编译:
- 系统将物理查询计划转换为LLVM中间表示(IR)。
- 使用LLVM的JIT编译器将IR编译为机器码。
- 执行生成的机器码。
与源码到源码编译相比,LLVM JIT省去了生成C++代码和调用外部编译器的步骤,编译速度更快。
性能结果:在TPC-H查询上,启用完全流水线的LLVM JIT版本比不流水线的版本性能更好,也优于传统的解释执行(如Oracle)。
编译成本问题:即使使用LLVM,编译耗时(约37毫秒)对于亚毫秒级执行的短查询来说仍然显著。为此,HyPer提出了阶梯式编译方案。
应对编译延迟:阶梯式编译(Staged Compilation)
为了解决编译延迟影响短查询的问题,HyPer在2018年提出了阶梯式编译。
核心思想:立即开始执行查询,同时在后台进行越来越优化的编译,并在编译完成后无缝切换。
三个阶段:
- 字节码解释器:首先将生成的LLVM IR转换为自定义字节码,并立即开始解释执行。这几乎零延迟。
- 未优化的LLVM编译:将IR发送给LLVM,但关闭所有优化通道,快速生成未优化的机器码。完成后替换解释器。
- 优化的LLVM编译:同时,在另一个线程进行完整的LLVM优化编译。完成后替换未优化的版本。
这种方法确保了查询总能立即开始执行,并且执行性能会随着时间的推移自动提升到最优水平。同时,字节码解释器也为调试生成的查询逻辑提供了便利。
现实世界中的查询编译
查询编译已成为现代数据库系统的标配,各种系统有不同的实现方式:
以下是不同系统的实现方式概览:
- IBM System R (1970s):最早的查询编译实践,生成汇编代码,但因维护困难后被放弃。
- Oracle:在其高端产品(如内存列存储)中使用编译技术,存储过程会编译为原生代码。
- Microsoft Hekaton:为内存OLTP编译存储过程和查询,生成C代码并编译为DLL。
- Vectorwise:采用“预编译原语”方式。将低级操作(如比较、加法)针对特定数据类型预先编译好。查询执行时,计划将这些原语像拼图一样组合起来调用,结合向量化处理以获得高性能。
- Spark:通过Tungsten引擎,将查询谓词转换为Scala AST,再生成JVM字节码执行。
- MemSQL (早期):类似HighQ,生成C++代码并调用GCC编译,依赖缓存来分摊编译开销。
- MemSQL (现代) / 我们的新系统:采用更优架构:先将查询计划转换为高级领域特定语言(DSL),再将DSL编译为操作码(OpCode)。可以立即解释执行操作码,同时在后台将DSL编译为优化后的机器码。这平衡了即时执行和最终性能,也降低了工程复杂度。
- PostgreSQL (v11+):增加了对表达式和部分查询计划的JIT编译支持,默认使用成本模型决定是否编译。
- Impala:使用LLVM编译谓词和特定文件格式(如Parquet)的解析器,以加速数据读取。
总结
本节课我们一起学习了查询编译与代码生成的核心技术。
- 目标:通过生成专门化的、无间接性的代码来最大化查询执行性能。
- 关键问题:消除解释执行模型中的操作符调用开销和谓词求值开销。
- 两种主要方法:
- 源码到源码编译:易调试,但编译延迟高。
- 即时编译(JIT):编译快,是当前主流方向,常与LLVM结合。
- 高级优化:
- 流水线执行:提升缓存利用率,是HyPer等系统高性能的关键。
- 阶梯式编译:HyPer提出的创新,通过即时解释执行和后台编译来隐藏编译延迟,兼顾了响应时间和最终性能。
- 业界实践:从早期的探索到现代,查询编译已成为高性能数据库系统的核心组件,并有多种成功的实现模式。


掌握查询编译技术,对于理解现代数据库系统如何突破性能瓶颈至关重要。
15:矢量化执行 🚀

概述
在本节课中,我们将要学习数据库系统中用于提升查询性能的两种主要方法之一:矢量化执行。我们将探讨什么是矢量化,如何利用SIMD指令实现它,并分析其在现代数据库算法中的应用与局限性。
什么是矢量化执行?🧠
矢量化执行是一种将原本按标量(一次处理一个数据项)设计的算法,转换为能够同时处理一个数据向量(多个数据项)的方法。通过使用单条SIMD指令,我们可以对向量中的所有数据项应用相同的操作,从而显著提升计算效率。
核心概念
在标量执行中,我们使用循环逐个处理数据:
for (int i = 0; i < N; i++) {
z[i] = x[i] + y[i];
}
而在矢量化执行中,我们可以使用SIMD指令一次性处理多个数据(例如4个):
// 伪代码,表示SIMD向量加法
vector_x = SIMD_LOAD(&x[i]);
vector_y = SIMD_LOAD(&y[i]);
vector_z = SIMD_ADD(vector_x, vector_y);
SIMD_STORE(&z[i], vector_z);
SIMD指令集简介 🖥️
SIMD代表单指令多数据,是现代CPU提供的一类特殊指令,允许在单个指令周期内对多个数据执行相同操作。不同的CPU架构有其特定的SIMD扩展:
- Intel/AMD x86: SSE, AVX, AVX2, AVX-512
- ARM: NEON
- PowerPC: AltiVec
- RISC-V: 向量扩展
上一节我们介绍了矢量化的基本概念,本节中我们来看看实现矢量化的关键技术——SIMD。
实现矢量化的方法 🛠️
在代码中实现矢量化主要有三种途径,从易到难分别是:
1. 编译器自动矢量化
编译器会尝试分析代码(特别是循环),自动将标量操作转换为SIMD指令。然而,对于复杂的数据库操作,编译器通常难以自动完成优化。
2. 编译器提示
我们可以通过向编译器提供提示,来引导其进行矢量化优化。
restrict关键字:向编译器保证指针指向的内存区域不重叠。- 编译制导语句:如
#pragma ivdep,指示编译器忽略潜在的向量依赖关系。
3. 显式向量化(使用内部函数)
这是最复杂但控制力最强的方法。开发者直接使用CPU提供的特定内部函数来编写SIMD指令。虽然性能最佳,但代码可移植性差。
以下是使用SSE内部函数实现向量加法的示例:
__m128i vec_x = _mm_load_si128((__m128i*) &x[i]);
__m128i vec_y = _mm_load_si128((__m128i*) &y[i]);
__m128i vec_z = _mm_add_epi32(vec_x, vec_y);
_mm_store_si128((__m128i*) &z[i], vec_z);
矢量化原语操作 ⚙️
为了构建更复杂的数据库操作,我们首先需要定义一些基础的矢量化原语。以下是几个关键操作:
选择性加载/存储
- 选择性加载:根据一个位掩码,从内存中只加载需要的元素到SIMD寄存器。
- 选择性存储:根据一个位掩码,将SIMD寄存器中的特定元素写回内存。
这些操作在x86架构中通常需要多条指令来模拟。
聚集/散落
- 聚集:根据一个索引向量,从内存中非连续的位置收集数据到一个连续的SIMD寄存器中。
- 散落:将SIMD寄存器中的数据根据一个索引向量,分散存储到内存中的非连续位置。
AVX2及更高指令集支持这些操作,但可能无法在一个周期内完成。
数据库算法矢量化案例 📊
掌握了基础原语后,我们可以将其应用于具体的数据库操作中。
1. 矢量化选择扫描
在传统的选择扫描中,我们需要逐行检查谓词条件。矢量化版本可以同时检查一个向量中的多个元组。
算法简述:
- 从表中加载一个向量大小的键值。
- 使用SIMD比较指令,一次性将所有键与谓词条件进行比较,生成一个位掩码。
- 根据位掩码,使用选择性存储将匹配的元组位置(或元组本身)写入输出缓冲区。
2. 矢量化哈希表探测
哈希表探测的矢量化有两种思路:
- 水平向量化:扩展每个哈希槽,使其包含多个键值对。探测时,用单个键同时与一个槽内的多个键比较。
- 垂直向量化:同时处理多个探测键。为每个键计算哈希值,然后使用聚集指令从哈希表的不同位置获取对应的槽,再进行批量比较。
需要注意的是,这些方法在数据无法完全放入CPU缓存时,性能提升有限。
3. 矢量化直方图构建
在分区或聚合操作中需要构建直方图。我们可以:
- 使用SIMD指令同时处理多个输入键,通过一个简单的哈希函数(如取键的某个字节)得到分区索引。
- 使用散落指令,将每个键的“计数1”添加到对应分区计数器的向量中。
- 如果多个键映射到同一个分区,可能会发生写冲突,需要通过多个临时向量来解决,最后再进行横向求和。
性能考量与局限性 ⚠️
尽管矢量化潜力巨大,但在实际数据库系统中应用时需注意以下几点:
- 数据移动开销:将数据移入/移出SIMD寄存器存在开销,难以达到理论最大加速比。
- 缓存重要性:讨论的许多优化算法都假设数据完全驻留在CPU高速缓存中。一旦数据超出缓存,内存带宽将成为主要瓶颈,矢量化收益可能大幅降低。
- 车道利用率:要确保SIMD寄存器中的每个“车道”都在做有用功,避免处理无效数据。
- 数据特性:算法通常针对均匀的数据类型(如32位键)进行优化,对于变长或复合键支持不佳。


总结
本节课中我们一起学习了矢量化执行的核心原理。我们了解到,通过使用SIMD指令,可以显著提升数据库查询处理中计算密集型操作的性能。我们探讨了实现矢量化的不同方法,从编译器自动优化到显式使用内部函数。接着,我们分析了几种关键的矢量化原语操作,如选择性加载和聚集操作,并展示了如何将这些原语应用于选择扫描、哈希表探测和直方图构建等具体的数据库算法中。最后,我们指出了在实际应用中需要考虑的性能局限性和挑战,特别是数据缓存和移动开销的影响。矢量化是构建高性能现代数据库系统的重要工具之一,但与查询编译、并行查询处理等技术结合使用时,才能发挥最大效力。
16:矢量化与编译

概述
在本节课中,我们将深入探讨两种加速查询性能的主要方法:矢量化执行与查询编译。我们将分析这两种方法各自的优缺点,并探讨在何种场景下一种方法会优于另一种。最后,我们将介绍一种结合两者优势的混合查询处理模型。
矢量化执行模型回顾
上一节我们介绍了矢量化执行的基本概念。本节中,我们来看看它的具体实现方式。
矢量化执行模型的核心思想是使用一系列预编译的原语函数。每个原语负责处理查询计划中的一个特定操作,例如评估谓词或进行聚合。这些原语被编译到数据库系统二进制文件中。在运行时,查询计划通过“拼接”这些原语来执行,每个原语调用会处理一批数据(一个向量),从而分摊函数调用的开销。
以下是一个简单的矢量化过滤操作的伪代码示例:
// 原语:对整型列进行等值过滤
void filter_int_vector(int* column_data, int constant, int* output_offsets, int batch_size) {
int output_idx = 0;
for (int i = 0; i < batch_size; i++) {
if (column_data[i] == constant) {
output_offsets[output_idx++] = i; // 记录匹配元组的位置
}
}
}
查询编译模型回顾
与矢量化不同,查询编译模型(以Hyper为代表)采用了一种不同的策略。
查询编译模型在运行时为每个具体的查询计划即时生成并编译专用的机器码。它采用自底向上的推送式处理模型。在一个流水线中,系统从底部(如扫描表)获取一个元组,然后立即将其向上推送,经过流水线中的所有操作符,直到遇到一个“流水线中断器”。这种方式旨在最大化缓存局部性,甚至可能将元组保留在CPU寄存器中。
以下是一个编译后查询代码的简化示例:
// 为特定查询编译的代码:扫描并过滤
void compiled_scan_and_filter(Table* table, int constant_val) {
for (Tuple& tuple : table->tuples) {
if (tuple.int_column == constant_val) { // 所有操作融合在一个循环中
process(tuple);
}
}
}
两种模型的比较分析
为了公平地比较矢量化与编译模型,研究人员构建了一个统一的测试平台,确保算法高层逻辑一致,仅在与架构相关的实现细节上有所不同。
以下是用于比较的TPC-H查询及其特点:
- Q1:简单扫描后进行定点运算和低基数聚合。
- Q6:带过滤条件的扫描。
- Q3:连接操作,构建端远小于探测端。
- Q9:连接操作,构建端与探测端大小差异不显著。
- Q18:高基数聚合(分组键唯一值很多)。
性能对比结果显示,没有一种模型在所有查询上都占优。例如,在计算密集型的Q1和Q18中,编译模型(Hyper)因指令数更少而表现更好。而在涉及哈希连接且可能发生缓存缺失的Q3和Q9中,矢量化模型(Vectorwise)能更好地分摊内存访问延迟,从而表现更佳。
核心结论是:两种模型都非常高效,其性能差异取决于具体的查询特征。编译模型更适合计算密集型、缓存缺失少的查询;而矢量化模型则更擅长隐藏哈希连接等操作中的缓存访问延迟。
SIMD指令集的作用评估
矢量化执行常与SIMD指令集的使用相关联。我们评估了在矢量化原语中使用SIMD(特别是AVX-512)带来的收益。
在孤立地测试原语(如哈希、数据收集、连接)时,使用SIMD能带来显著的性能提升(例如哈希原语提升2.3倍)。然而,当将这些原语放入完整的查询执行上下文中时,由于需要协调数据在不同缓冲区间的移动以及处理缓存未命中,SIMD带来的整体加速比会大幅降低(例如在完整查询中可能仅提升1.1倍)。
因此,虽然SIMD在微观层面能极大提升原语速度,但在宏观的查询执行中,其收益受到系统整体开销的限制。
编译器自动矢量化能力
除了手动编写SIMD内在函数,另一种途径是依赖编译器的自动矢量化功能。


我们测试了GCC、Clang和ICC编译器对矢量化友好代码的自动矢量化能力。其中,ICC(Intel编译器)最为激进,能成功将哈希、选择、投影等原语向量化为AVX-512指令。然而,自动生成的代码并不总是更快。例如,在某些查询中,ICC生成的矢量化代码反而比简单的标量实现慢14%,原因在于复杂的循环结构加剧了缓存缺失。
这表明,虽然编译器自动矢量化可以减少指令数量,但若不仔细考虑内存访问模式,可能无法转化为实际的性能提升,甚至可能导致性能下降。
混合模型:松弛操作符融合
前面的讨论似乎将矢量化与编译视为二选一的设计。然而,一种称为“松弛操作符融合”的技术可以将两者的优势结合起来。
RF的核心思想是将一个编译后的流水线分解为多个“阶段”。在阶段内部,我们仍然进行多个操作符的融合,并在CPU寄存器或高速缓存中处理数据。但在阶段之间,我们引入小的缓冲区来暂存一批元组(形成一个向量)。这样,我们可以在一个阶段内以矢量化方式处理一批数据,然后批量传递给下一个阶段。
这种方法的关键是结合了软件预取技术。在当前阶段处理一批数据时,我们可以预取下一批需要处理的数据,从而将计算与内存访问重叠,隐藏延迟。
以下是一个RF的简化代码结构示例:
// 第一阶段:矢量化扫描和过滤
void stage1_scan_filter(Table* table, int* stage_buffer) {
for (int chunk_start = 0; chunk_start < table->size; chunk_start += VECTOR_SIZE) {
prefetch(&table->data[chunk_start + VECTOR_SIZE]); // 软件预取下一批数据
// 使用SIMD指令处理当前chunk的数据
simd_filter(table->data + chunk_start, stage_buffer);
}
}
// 第二阶段:标量聚合
void stage2_aggregate(int* stage_buffer) {
for (int i = 0; i < BUFFER_SIZE; i++) {
update_hash_table(stage_buffer[i]); // 标量处理
}
}
实验表明,在Peloton数据库系统中,RF模型结合SIMD和预取技术,能够超越纯矢量化或纯编译模型,在连接等查询上获得最佳性能。这证明了将两种范式融合是可行且高效的。


总结
本节课中,我们一起学习了矢量化执行与查询编译这两种现代数据库查询加速技术。通过对比分析,我们了解到两者各有优势,其性能表现依赖于具体的查询负载。矢量化擅长分摊内存访问开销,而编译模型在计算密集型任务上更高效。更重要的是,我们看到了通过“松弛操作符融合”技术,可以将两者的优点结合,构建出性能更优的混合查询处理引擎。这为数据库系统的架构设计提供了新的思路。
17:哈希连接算法 🚀

在本节课中,我们将学习哈希连接算法。哈希连接是分析型数据库系统中执行等值连接最常用的算法之一。我们将探讨其工作原理、并行化策略、哈希表设计以及性能评估,帮助初学者理解如何高效地实现这一核心操作。
背景与重要性
上一节我们介绍了连接操作在分析型负载中的普遍性。本节中,我们来看看为什么哈希连接如此重要。
连接操作是分析型工作负载中常见的操作。哈希连接与排序合并连接是两种主要方法。在OLAP系统中,我们通常不会看到嵌套循环连接,除非表非常小。在OLTP系统中,由于经常进行基于索引的外键查找,可能会使用索引嵌套循环连接。哈希连接会为查询临时构建哈希表,查询完成后即丢弃;而索引嵌套循环连接则利用已存在的索引。
关于排序合并连接与哈希连接孰优孰劣的争论,在数据库领域已有数十年历史:
- 1970年代:由于内存有限且外部归并排序算法成熟,传统观点认为排序合并连接更优。
- 1980年代:随着专用数据库机器的出现,哈希连接被认为在特定硬件支持下更优。
- 1990年代:研究表明在当时的硬件上,两种算法性能基本等价。
- 2000年代至今:在分析型数据库兴起的背景下,哈希连接被证明在大多数情况下性能更优,成为主流选择。
并行连接的目标与挑战
上一节我们回顾了哈希连接的历史地位。本节中,我们来看看在现代内存数据库系统中并行执行连接的目标。
在内存系统中,我们不再受磁盘I/O限制,目标是最大化CPU的并行化利用率。并行连接算法需要最小化线程间的争用和同步开销。两个主要目标是:
- 最小化同步:避免使用锁存器保护关键部分,减少线程间等待。
- 优化内存访问:确保工作线程访问的数据尽可能位于同一核心的缓存中,以减少缓存未命中和跨套接字流量。
实现良好缓存行为的关键在于利用时间局部性和空间局部性。我们应确保在短时间内集中访问少量数据,并让这些数据在内存中彼此靠近。
并行哈希连接的三阶段
哈希连接是OLAP系统中最常见的连接算法。一个并行哈希连接算法包含三个阶段,以下是其概述:
- 分区阶段:此阶段是可选的。目标是根据连接键的哈希值,将待连接的表划分为更小的块,以便后续阶段中线程可以独立处理各自分区。
- 构建阶段:扫描外表,根据连接键构建哈希表。
- 探测阶段:扫描内表,对每个元组的连接键进行哈希,并探测哈希表以查找匹配项。如果找到匹配,则组合元组并输出。
在真实系统中,探测到匹配后,需要将组合后的元组物化到输出缓冲区中,这会影响性能。
分区阶段详解
上一节我们概述了哈希连接的三个阶段。本节中,我们深入探讨第一个可选阶段:分区。
分区阶段的目标是通过一次额外的数据扫描和复制,换取后续构建和探测阶段更少的缓存未命中和更高的局部性,从而提升整体性能。这在文献中有时被称为 Grace哈希连接、混合哈希连接 或 基数哈希连接。
物化策略取决于存储模型:
- 行存储:通常将整个元组复制到分区缓冲区。
- 列存储:通常只复制连接所需的键值以及用于查找其他列的偏移量,这更高效。
分区主要有两种方法:非阻塞分区 和 基数分区。
非阻塞分区
非阻塞分区允许一组线程进行分区的同时,另一组线程即可开始处理已分区的数据。它有两种实现方式:
共享分区
- 所有线程写入全局的共享分区桶。
- 需要使用锁存器来同步对桶的访问,以防止数据损坏。
- 优点:只需单次数据传递。
- 缺点:存在同步开销。
私有分区
- 每个线程拥有自己的一组私有分区桶,无需锁存器。
- 优点:无同步开销,写入速度快。
- 缺点:需要第二次传递来合并所有线程的私有分区,形成全局分区。
基数分区
基数分区采用多步传递的方法,所有线程完成分区后,才能进入下一阶段。
步骤
- 计算直方图:线程扫描数据,基于键值的某个基数位计算每个分区的元组数量直方图。
- 计算前缀和:根据直方图计算前缀和,确定每个线程在全局分区数组中的写入起始偏移量。
- 前缀和计算公式:
prefix_sum[i] = prefix_sum[i-1] + histogram[i-1]
- 前缀和计算公式:
- 重分布数据:线程再次扫描数据,根据前缀和确定的偏移量,将元组复制到全局分区数组的相应位置。
基数分区可以实现无锁写入,并且通过多轮分区,可以确保每个分区的数据量适应CPU缓存行大小。但在实践中,大多数系统只进行一轮分区。
构建阶段与哈希表设计
无论是否经过分区,接下来都会进入构建阶段。此阶段扫描外表并构建哈希表。哈希表的设计至关重要,它主要包含两个部分:哈希函数 和 冲突解决机制。
哈希函数
哈希函数将任意长度的键映射到固定范围的整数,用于定位哈希表中的槽位。我们需要在计算速度和低碰撞率之间取得平衡。
以下是一些常见的哈希函数:
- CRC32/CRC64:早期用于网络错误检测,现代CPU有专用指令。
- MurmurHash:一种通用的非加密哈希函数。
- CityHash/FarmHash:Google基于MurmurHash改进,针对短键或长键优化。
- xxHash:目前被认为是性能最好的通用哈希函数之一。
在数据库系统中,通常选择一种哈希函数并持续优化,而不会根据负载动态切换。
冲突解决机制
当两个键哈希到同一槽位时,需要冲突解决机制。以下是几种常见方案:
链式哈希
- 每个槽位是一个指向链表(桶)的指针。
- 冲突的键被放入同一个链表中。
- 链表可以无限增长,但极端情况下会退化为线性扫描。
- 一种优化是在链表头存储布隆过滤器,以快速判断键是否不存在于链中。
线性探测
- 使用一个巨大的槽位数组。
- 插入时,如果目标槽位被占用,则顺序向下查找第一个空槽。
- 查找时,从哈希到的槽位开始顺序扫描,直到找到键或遇到空槽。
- 实现简单,在许多情况下性能表现良好。
罗宾汉哈希
- 线性探测的变种。插入时,比较当前键与占用键的“富余程度”(即距离其理想位置的偏移量)。
- 如果新键更“穷”(偏移量更小),则驱逐占用键并插入自己,被驱逐的键重新寻找位置。
- 目标是平衡所有键的查找距离,但研究显示其移动成本可能抵消查找收益。
跳房子哈希
- 为每个槽位定义一个“邻域”(如连续的几个槽位)。
- 键必须插入其理想槽位所在的邻域内。
- 如果邻域已满,则在更远的地方找到空槽后,通过交换将空槽移入邻域,再将键插入。
- 限制了查找范围,但维护开销较大。
布谷鸟哈希
- 维护多个哈希表,每个表使用不同的哈希函数。
- 插入时,检查所有表对应的槽位,选择空槽插入。若无空槽,则随机驱逐一个现有键,并递归地为被驱逐键寻找新位置。
- 查找时,只需检查所有表中对应的一个槽位,保证O(1)查找复杂度,但插入过程可能复杂。
对于哈希连接这种临时性哈希表,线性探测 因其简单性和良好的综合性能而被广泛采用。
总结

本节课中,我们一起学习了哈希连接算法。我们从其背景和重要性开始,讨论了并行连接的目标。然后,我们详细剖析了并行哈希连接的三个核心阶段:分区、构建和探测。在分区阶段,我们比较了非阻塞分区和基数分区策略。在构建阶段,我们深入探讨了哈希表的设计关键,包括哈希函数的选择和多种冲突解决机制(如链式哈希、线性探测、罗宾汉哈希等)的优缺点。理解这些设计决策对于实现高性能的内存数据库连接操作至关重要。
18:并行排序-归并排序算法 🧠

在本节课中,我们将要学习并行排序-归并连接算法。我们将从排序-归并连接的基础概念开始,然后深入探讨如何利用现代硬件特性(如SIMD指令和多核架构)来并行化和向量化排序算法,最后比较几种不同的并行排序-归并连接实现方案的性能。
概述
排序-归并连接是数据库系统中执行连接操作的两种主要算法之一(另一种是哈希连接)。它包含两个主要阶段:首先,根据连接键对两个输入关系进行排序;然后,对排序后的关系进行归并扫描以找到匹配的元组。在内存数据库和现代多核CPU的背景下,优化排序阶段的性能至关重要。本节课将重点介绍如何设计高效的并行排序算法,并将其整合到排序-归并连接中。
排序-归并连接基础
排序-归并连接算法分为两个阶段。第一阶段是排序阶段,根据连接键对两个输入表(R和S)的元组进行排序。第二阶段是归并阶段,使用两个迭代器同步扫描已排序的表,比较连接键并输出匹配的元组对。
这种算法的核心优势在于,由于数据已预先排序,在归并扫描时无需回溯或进行暴力搜索,从而提高了效率。其高级概念流程如下:
- 排序阶段:对表R和表S按连接键排序。
- 归并阶段:同时扫描两个已排序的表,比较当前元组的连接键。若匹配,则组合输出;若不匹配,则移动具有较小键的迭代器。
并行化与硬件意识
在现代数据库系统中,我们需要利用多核CPU和向量化指令(SIMD)来加速计算。对于排序-归并连接,这意味着需要并行化排序阶段(最耗时的部分),并在可能的情况下对归并阶段进行向量化处理。
为了获得最佳性能,算法设计应遵循以下原则:
- 利用多核:尽可能使用数据库系统分配的所有CPU核心。
- NUMA感知:注意非统一内存访问架构的边界,尽量避免跨NUMA节点的内存访问,以减少延迟。
- 向量化:尽可能使用SIMD指令,以便在每个CPU周期内处理多个数据元素。
并行排序-归并连接的阶段
一个完整的并行排序-归并连接通常包含三个阶段,类似于并行哈希连接:
- 分区阶段:将数据分布到不同的工作线程或核心上。可以采用范围分区或基数分区。
- 排序阶段:每个线程对其本地分区的数据进行排序。这是本节课的重点。
- 归并连接阶段:扫描已排序的分区,执行连接操作。
本节课将主要关注排序阶段和归并连接阶段。
分区策略:隐式与显式
在连接算法中,分区可以分为隐式和显式两种:
- 隐式分区:数据在加载到数据库时已经按照连接键进行了分区。如果查询的连接键与预分区键一致,则可以跳过显式分区步骤。
- 显式分区:在执行连接时,根据连接键动态地将数据重新分布到不同的处理单元。对于排序-归并连接,通常采用范围分区,因为它能明确每个分区的键值边界。
缓存感知排序算法
在内存数据库中,排序的瓶颈从磁盘I/O转移到了内存层次结构(如CPU缓存)。因此,我们需要针对CPU缓存进行优化的排序算法。本节将介绍Intel提出的缓存感知排序方法,它根据数据运行的大小,采用不同的算法以适应不同的缓存层级。
该算法分为三个层级:
- 层级1:寄存器内排序:对能完全放入CPU寄存器的小数据块进行排序,使用排序网络实现,无分支且可向量化。
- 层级2:缓存内排序:将层级1产生的有序序列合并成更大的序列,使其能放入最后一级缓存。使用双调归并网络,同样可向量化。
- 层级3:缓存外排序:当数据运行超过缓存容量时,采用并行执行策略,协调多个线程工作,尽量让数据驻留在缓存中。
层级1:寄存器内排序与排序网络
排序网络是一种确定性的比较-交换网络,无论输入顺序如何,都执行固定序列的比较操作。这使得它没有条件分支,非常适合用SIMD指令进行向量化。
例如,对一个包含4个元素的序列进行排序,可以通过一系列固定的min/max比较和交换操作来完成。利用512位SIMD寄存器,我们可以同时处理多个这样的4元素序列,实现高效的向量化排序。
代码示例:向量化比较操作
// 假设我们有四个SIMD寄存器,每个包含4个待排序的键值
__m512i reg1, reg2, reg3, reg4;
// 通过一系列跨寄存器的min/max操作进行排序
__m512i min1 = _mm512_min_epi32(reg1, reg2);
__m512i max1 = _mm512_max_epi32(reg1, reg2);
// ... 更多比较和交换步骤
// 最后进行转置操作,将列优先的排序结果转换为行优先的有序序列
层级2:缓存内排序与双调归并网络
双调归并网络用于将多个小的有序序列合并为更大的有序序列。它也是由一系列固定的比较器组成,可以进行向量化。目标是将合并后的序列大小控制在最后一级缓存的一半容量以内,以避免溢出到主存。
层级3:缓存外排序
当数据运行过大,无法完全放入缓存时,算法进入层级3。此时采用一种复杂的并行策略,线程会动态地在归并网络的不同阶段“跳跃”工作,优先处理那些数据已驻留在缓存中的任务,以减少缓存未命中带来的停顿。这种方法假设系统能完全控制所有CPU核心,在实际复杂的数据库环境中较难实现。
归并连接阶段
排序完成后,进入归并连接阶段。多个线程并行扫描已排序的数据分区,比较内外表的元组。每个线程写入自己独立的输出缓冲区,以避免同步开销。如果连接键有重复值,可能需要进行回溯。
以下是三种不同的并行归并连接实现方案:
1. 多路排序-归并连接
此方案在排序阶段结束时,通过范围分区将数据重新分布,使得每个核心最终只包含全局有序数据的一个连续分区。在连接阶段,每个核心只需扫描本地对应的内外表分区,无需远程内存访问。
执行步骤:
- 本地排序(L1/L2)。
- 多路归并排序(L3),将数据按范围重新分布到目标核心。
- 每个核心对本地分区执行最终的归并连接。
2. 多趟排序-归并连接
此方案在排序后不进行数据重分布。在连接阶段,每个核心为了处理其本地外表分区,可能需要多次扫描整个内表(或内表的所有分区),可能导致大量的跨NUMA内存访问。
3. 大规模并行排序-归并连接
此方案仅对外表进行范围分区和重分布,内表则只在本地排序。连接时,每个核心需要扫描整个外表分区,但只扫描内表分区中相关的部分。论文作者认为顺序访问模式会触发硬件预取,从而掩盖跨NUMA访问的延迟,但实际效果不佳。
算法性能评估
根据ETH论文的实验结果,我们可以得出以下结论:
- 向量化排序的有效性:使用SIMD的缓存感知排序算法比非向量化实现(如STL的快速排序/堆排序混合算法)快约3倍。
- 多路方案优势:在多核环境下,多路排序-归并连接性能最佳。因为它将昂贵的跨NUMA内存访问提前到了排序阶段的数据重分布过程,而在最终的连接阶段,每个核心都只进行本地内存访问,从而获得了更好的扩展性。
- 与哈希连接对比:对于大多数OLAP场景,基数分区哈希连接的性能仍然优于排序-归并连接,尤其是在表的大小适中时。排序-归并连接的主要优势在于,如果查询本身需要按连接键进行
ORDER BY排序,那么它可以避免额外的排序开销。
性能对比图示:
- 多路方案:连接阶段极快(本地访问),总体性能高,且线程扩展性接近线性。
- Hyper方案:连接阶段涉及大量远程访问,性能随线程数增加而下降,扩展性差。
- 哈希连接:在多数情况下吞吐量更高,但随着表增大,分区成本增加,性能优势会缩小。
总结
本节课我们一起学习了并行排序-归并连接算法。我们回顾了算法的基础流程,重点探讨了如何利用缓存感知的排序技术(特别是向量化的排序网络和双调归并网络)来优化内存中的排序性能。我们还分析比较了三种不同的并行归并连接策略,其中多路排序-归并连接通过将数据重分布以本地化连接阶段的访问,获得了最佳的性能和扩展性。


尽管经过高度优化,但在典型的OLAP工作负载中,哈希连接通常仍是更优的选择。然而,排序-归并连接在查询本身需要有序输出时具有独特优势。因此,一个成熟的数据库系统通常会同时实现这两种算法,并由查询优化器根据具体情况选择最佳执行计划。
19:查询优化器实现 1

概述
在本节课中,我们将学习数据库系统中一个至关重要的组件——查询优化器。查询优化器的核心任务是将用户用声明式语言(如SQL)编写的查询,转换为数据库系统可以执行的高效执行计划。我们将探讨其基本概念、设计决策以及不同的实现策略。
查询优化器简介
查询优化器的目标是找到一个正确且成本最低的执行计划。这里的“正确”意味着计划必须能计算出查询的正确结果。“成本”则是一个内部相对度量标准,用于比较不同计划的优劣,它可能代表执行时间、内存使用量或I/O开销等。
由于查询优化问题被证明是NP完全问题,我们无法在合理时间内穷举所有可能的计划来找到绝对最优解。因此,查询优化器采用各种技术来缩小搜索空间,并使用成本模型来估算计划成本,而无需实际执行。
查询处理流程
一个典型的查询处理流程如下:
- SQL重写器(可选):接收SQL查询字符串,可能进行改写(例如,在分片系统中重定向查询)。
- SQL解析器:将SQL字符串解析为抽象语法树。
- 绑定器:查询系统目录,将查询中的表名、列名等标识符映射为内部ID和类型信息,输出逻辑计划。
- 树重写器(可选):对逻辑计划应用基于启发式的规则进行优化(如谓词下推)。
- 优化器(核心):基于成本模型,枚举多个可能的物理计划,并选择估算成本最低的一个。
- 执行:将选定的物理计划交给执行引擎(可能通过解释执行或编译为机器码)。
本节课及后续课程将主要关注树重写器和优化器这两个部分。
逻辑计划与物理计划
理解逻辑计划与物理计划的区别对掌握优化器至关重要。
- 逻辑计划:描述了查询要完成的高级别操作(例如,“扫描表Foo”),但不指定具体执行方法。它关注关系代数操作。
- 物理计划:描述了如何具体执行这些操作(例如,“使用索引XYZ扫描表Foo”或“对表Foo进行顺序扫描”)。它包含底层细节,如数据访问方法、算法选择,以及数据在处理过程中需要满足的物理属性(例如,数据是否按某列排序)。
两者之间并非总是一一对应。一个逻辑操作可能对应多个物理操作,多个逻辑操作也可能合并为一个物理操作。
关系代数等价性
查询优化的基础是关系代数的等价变换规则。利用结合律、交换律等性质,我们可以在保证结果正确的前提下,改变查询计划的形状。
例如,对于自然连接 (A ⋈ B) ⋈ C,等价于 A ⋈ (B ⋈ C)。优化器可以利用成本模型来估算不同连接顺序的成本,从而选择最优顺序。
优化器设计决策
在构建优化器之前,我们需要考虑几个关键的设计决策:
1. 优化粒度
- 单查询优化:最常见的方式。每次优化只针对一个查询,搜索空间较小,但无法跨查询复用结果。
- 多查询优化:同时优化一批查询,考虑查询间的资源共享。这在流处理或持续查询系统中更常见。
2. 优化时机
- 静态优化:查询到达后,优化器生成一个计划,然后始终执行该计划。计划质量完全依赖于优化时的成本模型准确性。
- 动态优化/自适应优化:执行过程中,如果发现实际数据与估算偏差很大,可以中断执行,返回优化器重新生成计划。
3. 预处理语句
对于需要反复执行的查询,可以使用预处理语句来分摊优化开销。系统可以缓存生成的计划。挑战在于,当查询参数变化时,一个固定的计划可能不是最优的。解决方案包括:始终使用上次的计划、基于参数值选择不同计划、或使用统计信息的平均值进行优化。
4. 计划稳定性
DBA希望查询性能稳定,避免因优化器选择不同计划而导致性能波动。保证稳定性的方法包括:
- 提供优化器提示,强制使用特定索引或连接顺序。
- 指定使用的优化器版本。
- 支持向后兼容的计划,从旧版本导入并强制使用已知的计划。
5. 搜索终止
优化器不能无限搜索。终止条件可以是:
- 时间阈值:运行超过指定时间后停止。
- 成本阈值:找到成本低于某个阈值的计划后停止。
- 改进阈值:一段时间内未找到显著优于当前最佳计划的方案后停止。
- 穷举完成:搜索完所有可能的排列(通常仅适用于简单查询)。
优化器搜索策略
接下来,我们按历史发展顺序,探讨几种主要的优化器实现策略。
启发式优化器(1970年代)
早期的系统(如INGRES、Oracle初版)使用基于规则的启发式方法。
- 原理:在代码中硬编码一系列转换规则(例如,“如果存在匹配的索引,就使用索引扫描”、“总是进行谓词下推”)。
- 优点:实现和调试相对简单,适合新系统快速起步。
- 缺点:没有成本模型,规则基于静态假设,难以处理复杂的、相互依赖的优化。随着系统复杂化,代码会变得难以维护。
INGRES示例:由于早期不支持连接操作,INGRES会将多表连接查询重写为一系列单表查询,通过临时表传递中间结果。这可以看作一种早期的“自适应”优化,因为它根据中间结果的值来规划后续查询。
System R 与动态规划(1970年代)
IBM System R首次引入了基于成本的优化和动态规划方法。
- 原理:采用自底向上的规划。对于N个表的连接,它从单个表开始,逐步计算连接2个表、3个表……直到N个表的最优方案,并记录每个子集的最优成本和计划。
- 左深树:为了减少搜索空间,System R只考虑左深连接树(形状像左侧一直加深的链表),而不考虑浓密树。
- 流程:
- 为每个基表选择最佳访问路径(索引扫描或全表扫描)。
- 枚举所有可能的连接顺序(排列)。
- 使用动态规划,自底向上计算每个子连接集的最优成本和实现算法(哈希连接、排序合并连接等)。
- 局限性:物理属性(如排序要求)没有被集成到搜索过程中,而是事后通过成本模型来考虑,这可能导致错失更好的计划(例如,能用排序合并连接同时完成连接和排序,却选择了哈希连接加额外排序)。
随机化算法
为了跳出局部最优解,一些优化器采用随机化算法。
- 模拟退火:从初始计划开始,随机扰动(如交换连接顺序),以一定概率接受更差的计划,从而有机会找到全局更优解。
- 遗传算法:PostgreSQL在连接表超过一定数量(默认12)时使用。它维护一个“种群”(多个计划),通过评估成本、选择“优秀个体”、进行“交叉”和“变异”来迭代进化出更好的计划。
- 优点:可能找到全局更优解,内存开销低。
- 缺点:需要确保随机性的可重复性以利于调试,仍需保证变换规则的正确性。


优化器生成器与统一搜索
为了更优雅、可维护地定义优化规则,研究者提出了优化器生成器框架。
- 核心思想:使用声明式的规则语言来描述逻辑到逻辑、逻辑到物理的转换。开发者定义规则,由框架引擎负责应用规则并进行搜索。
- 分层搜索:先应用所有基于规则的逻辑重写(无成本模型),再进行基于成本的物理计划搜索。IBM的Starburst系统是代表。
- 统一搜索:将逻辑和物理转换规则统一到一个搜索空间中处理。Volcano优化器是早期代表,它采用自顶向下的搜索方式。
- 自顶向下搜索:从查询的最终逻辑目标开始,应用规则向下展开,逐步用物理操作符替换逻辑操作符,同时考虑并维护所需的物理属性。在搜索过程中进行剪枝。
Volcano优化器的优势在于规则声明清晰、易于扩展,并且物理属性是搜索中的一等公民。但它需要记忆化表来避免重复计算,内存开销较大。
总结
本节课我们一起学习了查询优化器的基础知识和多种实现策略。我们从查询优化器的核心目标出发,了解了逻辑计划与物理计划的区别,以及关系代数等价性的重要性。然后,我们探讨了构建优化器时需要考虑的关键设计决策,包括优化粒度、时机、预处理语句处理、稳定性和搜索终止。
最后,我们回顾了优化器的发展历程:从简单的启发式方法,到System R开创性的基于成本的动态规划方法,再到采用随机化算法(如遗传算法)来探索更大搜索空间,最后介绍了使用声明式规则的优化器生成器框架(如Volcano),它为现代优化器(如Cascades)奠定了基础。


下一节课,我们将深入探讨更现代的优化器实现,特别是Cascades框架,并对比自底向上动态规划与自顶向下统一搜索这两种主流范式的优劣。
20:查询优化器实现 2

📋 概述
在本节课中,我们将继续深入学习查询优化器的实现。我们将探讨逻辑查询优化、Cascades优化器框架的核心概念,并了解其他现代优化器实现。课程的重点在于理解如何将逻辑查询计划转换为高效的物理执行计划。
🔍 逻辑查询优化
上一节我们介绍了查询优化的基本概念和不同搜索方法。本节中,我们来看看逻辑查询优化的具体技术。逻辑优化是在不考虑成本模型的情况下,对查询计划进行基于规则的等价转换,旨在为后续的物理优化创造更好的起点。
以下是四种常见的逻辑优化技术:
- 拆分合取谓词:将包含多个
AND条件的单个Filter操作符拆分为多个独立的Filter操作符,每个只包含一个条件。这使得后续的谓词下推等优化更容易进行。 - 谓词下推:将
Filter操作符尽可能移动到查询计划树中靠近数据源的位置。这样可以尽早过滤掉无关数据,减少后续操作的处理量。 - 用连接替换笛卡尔积:识别出
Cartesian Product操作符后紧跟一个包含等值连接条件的Filter操作符的模式,并将其合并为一个Join操作符。这通常是更高效的选择。 - 投影下推:识别查询计划中每个阶段真正需要的属性(列),并尽早通过
Projection操作符过滤掉不需要的列,减少数据在管道中传递的体积。
这些转换都基于关系代数的等价规则,确保转换后的计划在逻辑上与原始计划等价。
🏗️ Cascades 优化器框架
逻辑优化为我们准备了更好的查询计划结构。接下来,我们将深入探讨一种名为Cascades的、采用统一搜索和自顶向下策略的优化器框架。Cascades的核心思想是按需生成转换,而非预先枚举所有可能性,从而更高效地管理搜索空间。
核心概念
为了理解Cascades,我们需要掌握几个关键概念:
- 表达式:指查询计划树中的一个操作符节点及其子节点。它可以是逻辑的(如
Join),也可以是物理的(如HashJoin)。- 逻辑表达式示例:
Join(Scan(A), Scan(B)) - 物理表达式示例:
HashJoin(SeqScan(A), IndexScan(B))
- 逻辑表达式示例:
- 组:一个组包含所有能产生相同输出结果的逻辑等价表达式的集合。组是Cascades中组织和管理等价计划的核心数据结构。
- 多表达式:是一种占位符表达式,它代表一个子计划,但其具体的实现细节由下层另一个组决定。这避免了过早实例化所有可能的子计划,减少了状态存储。
- 规则:定义了如何将一个表达式模式转换为另一个逻辑等价的表达式。主要分为两类:
- 转换规则:将一个逻辑(或物理)表达式转换为另一个逻辑表达式(例如,改变连接顺序)。
- 实现规则:将一个逻辑表达式转换为一个或多个物理表达式(例如,将逻辑
Join转换为物理HashJoin或MergeJoin)。
记忆表与最优性原理
为了避免重复工作和无限循环,Cascades使用记忆表来缓存已探索过的组及其最优成本。这类似于动态规划中的记忆化技术。
搜索过程遵循贝尔曼最优性原理:如果一个整体计划是最优的,那么构成它的各个子计划也必须对于其各自的子目标是最优的。基于此,优化器可以进行剪枝:如果发现当前部分计划的成本已经高于已知的全局最优成本,则可以停止探索该分支。
搜索过程示例
假设我们需要优化一个三表连接查询 A ⋈ B ⋈ C。
- 初始化:创建根组,目标输出是
A, B, C的连接结果。 - 应用规则:应用转换规则,生成一个逻辑多表达式,例如
Join(Group(A,B), Scan(C))。其中Group(A,B)是一个占位符,指向一个负责计算A⋈B的新组。 - 递归探索:
- 进入
Group(A,B),应用规则生成如Join(Get(A), Get(B))的逻辑表达式。 - 对
Get(A)和Get(B)应用实现规则,生成如SeqScan(A)(成本=10)和SeqScan(B)(成本=20)的物理表达式,并将最优选择存入记忆表。 - 回到
Join(Get(A), Get(B)),应用实现规则生成物理连接方案,例如HashJoin(SeqScan(A), SeqScan(B)),并计算其总成本(10+20+50=80),更新Group(A,B)的记忆表。
- 进入
- 回溯与组合:将下层组的最优成本传递回上层。在根组中,结合
Group(A,B)的最优成本和Scan(C)的成本,评估不同的连接顺序和物理实现,最终选择总成本最低的完整物理计划。
这个过程通过记忆表避免了对相同子问题的重复计算,并利用最优性原理进行有效剪枝。
🌐 其他优化器实现
除了经典的动态规划和Cascades,业界还有许多其他优秀的优化器实现。
以下是几个值得关注的现代优化器框架:
- Pivotal Orca:一个独立的、基于Cascades的优化器服务。它不绑定特定数据库,通过XML文件接收元数据和查询。其特点是支持多线程并行搜索,并拥有先进的远程调试和成本模型准确性测试框架(TACO)。
- Apache Calcite:一个流行的、用Java编写的开源查询优化框架。它提供了完整的SQL解析、优化和查询生成能力。许多大数据系统(如Hive, Flink, Phoenix)都采用或集成了Calcite作为其优化器。
- CockroachDB 优化器:这个分布式数据库的优化器采用分层策略,但有一个独特步骤:在生成分布式执行计划后,会将计划转换回带注释的SQL语句,下发给各个存储节点。每个节点再本地重新优化其负责的片段,从而更好地利用本地数据分布信息。
这些实现展示了查询优化器在不同架构(单机/分布式、嵌入/独立)和不同需求下的灵活设计。
⚠️ 优化器的共同挑战与依赖
无论采用动态规划还是Cascades,或是其他高级框架,优化器的有效性都极度依赖于一个准确的成本模型。成本模型负责估算每个操作符的CPU、I/O和内存开销。如果基数估计(对中间结果大小的预测)或成本计算公式不准确,优化器很可能选择次优甚至性能很差的执行计划。
下一讲我们将深入探讨成本模型中最为棘手的问题之一:连接操作的基数估计。
📝 总结


本节课我们一起深入学习了查询优化器的核心实现技术。我们首先回顾了逻辑查询优化的几种基本转换方法。然后,我们重点剖析了Cascades优化器框架,理解了其按需生成、自顶向下搜索的核心机制,以及组、多表达式、规则和记忆表等关键概念。最后,我们概览了Orca、Calcite等现代优化器实现,并强调了准确成本模型对于任何优化器的基础性作用。优化器是数据库系统的“大脑”,其设计需要在搜索效率、计划质量和灵活性之间做出精妙的权衡。
21:查询优化器实现 3 🛠️

在本节课中,我们将要学习查询优化器的第三部分内容,重点探讨自适应查询优化技术。我们将了解传统优化器在生成计划后即开始执行的局限性,并学习如何通过运行时信息来动态调整和优化查询计划,以获得更好的性能。
概述
传统查询优化器在查询执行前生成一个固定的计划。然而,由于数据库状态(如数据分布、索引、统计信息)可能发生变化,预先生成的计划可能并非最优。自适应查询优化技术允许数据库系统在执行过程中,根据实际观察到的数据特性来修改查询计划,从而提升性能。
自适应查询优化简介
上一节我们介绍了传统优化器的工作流程。本节中我们来看看如何让优化过程更具适应性。
自适应查询优化(Adaptive Query Optimization, AQO),有时在文献中也称为自适应查询处理(Adaptive Query Processing),其核心思想是允许数据库系统修改查询计划,以更好地适应底层数据的真实情况。修改方式可以是生成一个全新的计划,也可以是在查询计划中引入可切换的子计划。
这种方法的关键在于,它不仅仅依赖于优化前基于统计模型的估算,而是尝试利用查询实际执行时收集到的数据来帮助我们为当前查询做出更正确的计划决策。收集到的数据既可用于改善当前查询,也可以合并回系统的全局目录中,供未来查询使用。
自适应优化的三大类别
以下是自适应查询优化的三种主要实现思路:
- 优化未来查询:在执行当前查询时收集信息,用于改进未来相同或类似查询的计划。
- 优化当前查询(重规划):在发现当前执行计划不佳时,中止或暂停执行,返回优化器重新生成(部分)新计划。
- 优化当前查询(计划切换点):在查询计划中预先嵌入多个备选子计划,并在执行时根据实时数据动态选择最优路径。
接下来,我们将逐一探讨这些类别。
1. 优化未来查询
这种方法的目的是利用本次执行获得的知识,让下一次执行变得更好。其最简单的形式是基于回归的计划修正。
基于回归的计划修正
其思路是:每次执行查询时,都记录下生成的计划、成本估算值以及实际的运行时指标(如输出的元组数、CPU/内存使用量)。数据库系统内部维护一个所有查询的历史信息库。
当同一个查询(例如一个预编译语句)被反复调用时,系统会使用缓存的查询计划。如果数据库的物理设计或统计信息发生变化,系统可能会为该查询重新生成一个新计划。但如果新计划的实际性能比旧计划更差(即出现性能“回归”),系统就会回退到已知性能更好的旧计划。
示例:
假设一个四表连接查询的原始计划使用哈希连接和顺序扫描,估算成本为1000,实际成本也为1000。系统将此记录在历史中。
随后,管理员在B表和D表上创建了索引。当再次执行相同查询时,优化器可能会生成一个使用索引嵌套循环连接和索引扫描的新计划,估算成本为800。
然而,实际执行时,由于某些原因(如估算错误),新计划的实际成本为1200,比旧计划更差。
此时,系统会记录新计划的糟糕表现。下次调用该查询时,它将选择回退到性能更优的原始计划。
计划缝合
基于回归的修正是比较粗粒度的“全有或全无”策略。更精细的方法是“计划缝合”,它允许从不同查询计划中“借用”逻辑上等效的、性能更优的子计划片段,组合成一个新的、“缝合”而成的计划。
核心思想:即使整个新计划无效或性能不佳,其中的某些子计划片段可能仍然是高效且可用的。我们可以将这些片段与当前可用的其他片段组合,形成一个总体成本更低的计划。
实现步骤:
- 识别逻辑等效的子计划:利用关系代数的交换律、结合律等规则,判断不同查询中的子计划是否输出相同的结果集。这是一个具有启发性的过程。
- 构建搜索空间:引入一个特殊的
OR运算符(仅用于搜索,不用于执行),表示其下的子计划是逻辑等效的,可以任选其一。这样就将所有可能的子计划组合编码成了一棵搜索树。 - 执行动态规划搜索:采用自底向上的动态规划方法,从叶节点开始,为每一层计算并保留到达该点的最优子计划及其成本,最终在根节点得到全局最优的“缝合”计划。
亚马逊 Redshift 的代码生成引擎也采用了类似思想,它在编译后的代码片段级别进行缓存和复用,跨查询甚至跨用户共享高效的执行代码片段。
2. 优化当前查询(重规划)
这类技术旨在修复正在运行的查询。如果发现查询计划的实际观测行为与优化器的估算行为严重偏离,系统可以决定中途停止,并重新进行规划。
重新规划当前调用
基本思路是:当执行过程中发现基数估算等严重错误时,权衡“继续执行现有糟糕计划”和“抛弃已做工作、重新开始”的成本。如果重新规划并执行的预期收益更高,则触发重优化。
关键决策:
- 是否抛弃所有已处理的中间结果,完全从头开始?
- 是否可以保留部分已完成的、代价高昂的操作结果(例如一个大的连接),只对查询的剩余部分重新规划?
前瞻信息传递
这是一个来自 Apache Quickstep 系统的有趣技术,特别适用于星型模式查询。
工作流程:
- 在执行事实表与维度表的连接前,先扫描维度表并构建布隆过滤器。
- 将布隆过滤器传递给事实表扫描端。
- 在扫描事实表时,利用布隆过滤器进行采样,估算不同维度表连接的实际选择性。
- 根据采样得到的实际选择性,动态调整连接顺序,将选择性更高的连接(能过滤掉更多元组)提前执行。
这种方法在真正开始主要的连接操作前,利用少量预处理工作获得了关键的数据分布信息,从而做出更优的决策。
3. 优化当前查询(计划切换点)
这类方法不在执行时返回优化器,而是在初始优化阶段就为可能产生重大性能差异的操作点生成多个备选子计划,并在执行时通过一个“开关”运算符动态选择。
参数化优化
这是 Volcano 项目在 1980 年代末提出的早期技术。其思想是:对于查询中的每个流水线阶段,如果不同执行策略的性能差异很大,就为其生成不同的子计划。
在查询计划中插入一个 ChoosePlan 运算符。该运算符包含一个条件判断,例如:
IF (input_cardinality < threshold) THEN
USE Plan_A (e.g., Nested Loop Join)
ELSE
USE Plan_B (e.g., Hash Join)
这样,执行时会根据上游操作符输出的实际数据量(基数)实时决定采用哪种连接算法。
主动重优化
这是更近期的、更复杂的技术,它结合了“重规划”和“计划切换”的思想。
工作流程:
- 初始优化:生成带有多个可切换子计划的查询计划,并为每个决策点设定一个“边界框”,用于量化估算的不确定性。
- 执行与监控:开始执行查询,并收集实时统计信息。
- 动态切换:如果数据落在某个边界框内,则根据条件在预置的子计划间切换。
- 触发重优化:如果观测数据严重超出所有边界框(即估算完全错误),则触发一次“中途重优化”,返回优化器重新生成(部分)计划,并决定是否保留已完成的中间结果。
总结
本节课中我们一起学习了自适应查询优化的各种技术。我们了解到,传统的“优化后执行”模式存在局限,而通过引入运行时反馈机制,可以显著提升查询计划的健壮性和性能。主要技术包括:
- 利用历史信息优化未来查询(回归修正、计划缝合)。
- 通过中途重规划来优化当前查询。
- 在计划中预设切换点,实现动态算法选择。
这些技术的有效实现需要优化器与执行引擎紧密协作,形成共生关系。目前,主流商业数据库系统已广泛采用自适应优化技术,而许多开源系统在此方面仍有发展空间。


下一节课,我们将深入探讨成本模型,了解为什么成本估算如此困难,以及它为何是优化器面临的核心挑战。
22:查询优化器成本模型 📊


在本节课中,我们将学习查询优化器成本模型的核心概念。成本模型是优化器的关键组件,用于估算执行特定查询计划所需的“代价”。这个代价是数据库系统内部的度量,用于比较不同查询计划的优劣,而不是一个跨系统可比较的绝对数值。
成本模型的构成
上一节我们介绍了查询优化器的整体架构,本节中我们来看看成本模型的具体构成。成本估算可以基于以下几种方式:
以下是三种主要的成本估算方法:
- 物理成本:估算硬件实际执行的操作,例如CPU周期数、缓存未命中次数、磁盘I/O量。这种方法高度依赖于具体的硬件配置,实现起来较为复杂。
- 逻辑成本:基于查询计划中逻辑操作符的行为进行估算,例如读取的元组数、连接操作输出的元组数。这种方法独立于具体的物理算法(如哈希连接 vs. 嵌套循环连接)。
- 算法复杂度:考虑操作符的渐进时间复杂度,为不同算法(如索引扫描 vs. 全表扫描)分配权重。
对于内存数据库,通常结合使用逻辑成本和算法复杂度进行估算。
磁盘与内存数据库的成本考量
对于基于磁盘的数据库系统,磁盘I/O通常是成本中最关键的部分。系统可以完全控制其缓冲池管理,因此可以在成本模型中精确计算顺序I/O与随机I/O的影响。
在内存数据库中,磁盘I/O不再是主要考量(除了日志写入)。成本模型主要关注每个操作符处理的元组数量,并结合一些基本权重(例如,认为哈希连接优于嵌套循环连接)。由于CPU缓存由硬件管理,数据库难以精确控制,因此通常不将其纳入精细的成本模型。
实际系统的成本模型示例
让我们看看一些实际数据库系统是如何实现成本模型的。
PostgreSQL 结合了CPU和I/O成本,并通过一些“魔法常数”因子进行加权。例如,默认配置可能设定内存访问比磁盘顺序I/O快400倍。管理员可以调整这些权重,但通常不建议。
IBM DB2 的成本模型更为复杂和成熟。它会:
- 收集表、列和索引的统计信息。
- 在系统启动时运行微基准测试,以确定特定硬件的性能权重。
- 考虑并发查询对资源(如内存)的竞争影响。
选择性估算:成本模型的核心
成本模型中最关键、也最具挑战性的部分是估算操作符的选择性。选择性决定了输入操作符的元组中有多少比例会作为输出。
以下是估算选择性的主要技术:
- 域约束:利用已知的属性值范围(如枚举类型)。
- 预计算统计信息:例如区块内的区域地图(Zone Maps),记录数据块内列的最小/最大值。
- 直方图:通过
ANALYZE操作生成,是传统教科书方法。 - 草图:使用近似数据结构(如各种 Sketch)来估计数据分布,近年来被认为可能比直方图更有效。
- 采样:在查询优化时或后台对数据子集运行查询片段,以估算选择性。
基数估算的假设与问题
传统的基数估算方法将选择性建模为概率,并通常做出三个假设,但这些假设在实际中常常不成立:
- 均匀分布假设:假设属性值均匀分布。现实中数据通常是倾斜的(例如,更多人在纽约市)。
- 谓词独立性假设:假设不同过滤条件是独立的,可以简单相乘得到总选择性。现实中属性常常相关(例如,车型“Accord”必然对应制造商“Honda”)。
- 包含性假设:假设连接键总是存在于连接关系中。在外连接中这不成立。
这些假设的违背会导致基数估算错误,并且错误会在查询计划树中自底向上被放大。
估算错误的影响与应对策略
研究(如Hyper团队发表的论文)表明,随着查询中连接表数量的增加,所有数据库系统的基数估算误差都会显著增大,通常表现为低估。这会导致优化器做出错误决策,例如:
- 选择嵌套循环连接而非哈希连接。
- 哈希表尺寸分配不当,导致性能下降。
因此,现代系统的设计原则是:
- 承认估算总会存在错误。
- 让查询执行操作符具备自适应性,例如能够动态调整哈希表大小、重新排序谓词。
- 将研发重点放在提高基数估算的准确性上(更好的统计信息、草图),而非过度追求硬件层面的微调。
学习型优化器
一个早期的创新想法是“学习型优化器”(如IBM的LEO)。其核心思想是:执行查询后,将实际观察到的基数与优化器的估算值进行比较,并将这些反馈信息用于优化后续查询的估算。尽管概念很有吸引力,但由于工程实现上的挑战,这类系统在实践中并未被广泛采纳。不过,这为后来利用机器学习进行数据库自动调优的研究奠定了基础。


本节课中我们一起学习了查询优化器成本模型的构建方法。我们了解到,对于内存数据库,估算操作符处理的元组数量是成本模型的核心。然而,由于数据分布的复杂性和关联性,准确的基数估算极具挑战性。常见的直方图等方法在简单情况下有效,但在复杂查询中误差会放大。因此,现代设计更倾向于使用草图、采样等高级技术来提高估算精度,并让执行引擎具备自适应性以容忍估算错误。理解成本模型的原理与局限,对于设计高性能的数据库系统至关重要。
23:超内存数据库体系架构 🗄️

在本节课中,我们将学习如何让一个内存数据库系统能够存储和访问超出其可用内存容量的数据。核心目标是引入磁盘等非易失性存储设备,同时避免将系统架构退回到传统的、较慢的磁盘导向型设计。
概述
我们整个学期都在讨论内存数据库,其所有算法和架构决策都基于一个核心假设:整个数据库都驻留在主内存中。这带来了极高的效率,因为我们无需考虑磁盘I/O。然而,DRAM价格昂贵且功耗高,而SSD和机械硬盘在性价比上仍有优势。因此,我们希望能在内存数据库系统中引入磁盘存储,用于存放“冷数据”(不常访问的数据),同时保持“热数据”(频繁访问的数据)在内存中,从而在不牺牲太多性能的前提下支持更大的数据库。
上一节我们介绍了内存数据库的优势,本节中我们来看看如何扩展它以支持超内存数据库。
背景与目标
内存数据库的访问是面向元组和字节可寻址的。相比之下,磁盘存储是面向块/页的,这意味着即使我们只需要一个字节,也必须读取整个数据块(例如4KB)。我们的设计挑战在于,如何在引入磁盘存储时,避免重新引入复杂的缓冲区管理器和相应的算法重写。
我们将主要关注OLTP(在线事务处理)工作负载。对于OLAP(在线分析处理)查询,由于其通常涉及大规模扫描,我们无法通过内存数据库架构获得特别的优势。OLTP工作负载的特点是存在明显的“热数据”和“冷数据”模式。例如,在社交媒体应用中,最近发布的帖子(热数据)被频繁访问和更新,而数月前的旧帖子(冷数据)则很少被触及。我们的目标是将冷数据移出内存,存放到磁盘上。
核心设计问题与决策
要实现超内存数据库,我们需要解决一系列设计问题。以下是关键决策点:
1. 冷热数据识别 ❄️🔥
首先,系统需要能够识别哪些数据是“冷”的,适合被移出内存。
- 在线识别:在查询/事务运行时,直接在元组或页面中维护访问元数据(例如,在元组头中存储一个指向最近访问链表的指针)。这种方法开销较大,因为每个元组都需要额外的存储空间来记录访问信息。
- 离线识别:系统在后台记录所有数据访问日志。一个独立的后台线程定期分析这些日志,计算访问频率直方图,从而识别冷数据。这种方法运行时开销较低。
2. 触发驱逐的时机 ⏰
系统需要知道何时开始将数据驱逐到磁盘。
- 管理员定义阈值:当数据库内存使用量达到预设阈值(例如80%)时,触发驱逐策略。
- 按需驱逐:当需要从磁盘读入新数据但内存已满时,运行替换算法(如LRU或其近似算法)来腾出空间。
3. 驱逐后的元数据管理 🗺️
数据被驱逐到磁盘后,系统必须在内存中保留一些信息,以避免“假阴性”(即查询找不到实际存在于磁盘上的数据)。
- 墓碑:在内存中原元组的位置放置一个特殊的“墓碑”元组。它不包含实际数据,但存储了该元组在磁盘上的位置信息(块ID和偏移量)。所有索引需要更新,以指向这个墓碑元组。
- 布隆过滤器:从内存索引中移除被驱逐元组的键。同时,为每个索引维护一个布隆过滤器。当内存索引查找失败时,查询布隆过滤器。如果布隆过滤器说“不存在”,则数据确实不存在;如果它说“可能存在”,则需查询一个磁盘上的二级索引来定位数据。
- 数据库系统管理的页表 / OS虚拟内存:在页级别跟踪哪些页在内存中,哪些已被换出到磁盘。这通常涉及更粗粒度的管理。
4. 数据读回策略 🔄
当查询需要访问已驱逐到磁盘的数据时,我们需要决定如何将其读回内存。
- 块粒度合并:将整个磁盘块读入内存,并将其中的所有元组合并回主表堆,并更新所有相关索引。缺点是可能读入大量不需要的“冷”元组,导致索引更新开销大,且这些元组可能很快又被驱逐,造成“乒乓效应”。
- 元组粒度合并:只读回查询真正需要的那些元组。这避免了不必要的索引更新,但需要在磁盘块中记录“空洞”,并可能需要后台进程进行压缩合并。
- 合并阈值:可以设置策略,例如仅在数据被更新时才将其合并回内存;或者根据磁盘块的访问频率决定是否将其提升回内存。
5. 查询执行策略 ⚙️
当事务或查询试图访问不在内存中的数据时,系统如何响应?
- 中止并重启:中止当前事务,由后台线程获取所需数据,待数据就绪后重启事务。这对于支持快照隔离等强隔离级别的事务比较棘手。
- 同步获取(更常见):暂停或阻塞查询,同步地从磁盘获取所需数据到内存,然后让查询继续执行。可以优化为预先收集查询所需的所有磁盘数据,然后批量获取,以减少停顿次数。
实际系统实现案例
接下来,我们看看几种研究或商业系统中实现超内存支持的具体方法。
1. 基于元组的早期系统
这些系统采用我们前面讨论的细粒度元组管理方法。
- H-Store / VoltDB (Anti-Caching):使用在线识别(LRU链)、管理员阈值、墓碑元组、中止并重启事务以及块粒度合并。实现复杂,开销较大。
- Microsoft Hekaton (Project Siberia):使用离线识别、管理员阈值、布隆过滤器、同步获取和元组粒度合并。该项目最终未投入生产。
- EPFL 方法 (使用
mlock):将表堆分为热区和冷区。热区页面用mlock锁定在内存中。冷区页面交给操作系统通过虚拟内存管理换出。使用离线识别和同步获取。 - Apache Geode:基于 H-Store 思路,但数据存储在 HDFS 上。它只在数据被更新时才将其合并回内存,因为 HDFS 是追加写的。
2. 基于页面的现代方法
这些方法在页面级别进行管理,更为统一和高效。
-
LeanStore:这是一个开创性的研究原型。其核心思想是:
- 页面层次结构:将数据(包括表和索引)组织成树形结构,确保每个子页面只有一个父页面引用它。
- 指针交换:利用指针的高位来标记该指针是指向内存地址(
0)还是指向磁盘上的页面ID(1)。当页面在内存时,指针是“交换过”的内存地址;当页面被驱逐,指针被“反交换”为磁盘页面ID。 - 随机化驱逐:随机选择一些页面进入“冷却”阶段(仍在内存,但指针已反交换)。通过一个哈希表跟踪这些“冷却”页面的访问情况。当需要空间时,驱逐那些在冷却期间未被访问的页面。
- 这种方法避免了为所有数据维护访问元数据的开销,且能统一处理表和索引。
-
Umbra:可以看作是 Hyper 的下一代,采用了 LeanStore 的思想,但引入了关键创新:
- 可变大小页面:像
jemalloc这样的 slab 分配器一样,分配不同大小类别(如 64KB, 128KB, ...)的内存块。这特别有利于存储大文本字段或压缩字典等变长数据。 - 它同样使用页面层次结构和指针交换,但指针中除了页面ID,还编码了大小类别信息。
- 其设计哲学是:构建一个复杂但高效的缓冲区管理器,可以简化系统其他部分的设计。
- 可变大小页面:像
-
MemSQL (SingleStore):其最新架构统一了行存和列存,声称可以在列存上支持事务。它可能使用基本的 LRU/Clock 算法管理页面换出,采用同步获取和完全合并策略。
总结
本节课中我们一起学习了如何为内存数据库系统添加支持超内存数据库的能力。我们探讨了核心的设计挑战,包括冷热数据识别、驱逐时机、元数据管理、数据读回和查询执行策略。我们回顾了从早期基于元组的系统(如 H-Store、Project Siberia)到现代基于页面的系统(如 LeanStore、Umbra)的演进。
现代的研究表明,像 LeanStore 和 Umbra 这样基于页面层次结构、指针交换和智能驱逐策略的方法,很可能是构建高效超内存数据库系统的正确方向。它们以统一的、开销较低的方式管理内存和磁盘数据,避免了早期细粒度方法带来的复杂性和性能开销。


最后需要指出,随着字节可寻址的非易失性内存(如 Intel Optane PMem)技术的成熟,未来我们可能不再需要如此复杂的内存-磁盘分层管理,数据可以持久地驻留在类似内存的介质中,届时今天讨论的许多技术可能会变得过时。
24:服务器端逻辑执行 🚀

在本节课中,我们将要学习服务器端逻辑执行,特别是如何优化用户定义函数的性能。我们将探讨两种主要方法:微软提出的内联方法和将UDF转换为公共表表达式的新方法。这些技术旨在让UDF运行得更快,同时保持数据库系统的可扩展性。
背景介绍
到目前为止,我们假设应用程序栈和数据库系统之间存在清晰的划分。数据库系统只看到通过JDBC或ODBC等对话式API发送的查询。这种方法的缺点是,它是一个“啰嗦”的API:发送查询、获取结果、处理、再发送下一个查询。
应用程序服务器上的程序逻辑与数据库查询执行交替进行,导致网络往返频繁。在事务中,这可能导致数据库服务器在等待下一个命令时持有锁,造成资源空闲。
服务器端逻辑执行的目标是将部分应用程序逻辑移入数据库系统内部,避免这些网络往返,将多个操作合并为一次数据库调用。
服务器端逻辑的类型
有多种方式可以将逻辑嵌入数据库系统:
- 存储过程:将事务逻辑封装成可调用的过程。
- 用户定义函数:附加在查询上的函数,是最常见的类型。
- 触发器:在特定事件发生时自动触发的函数。
- 用户定义类型:扩展数据库内部类型系统。
- 用户定义聚合:定义更复杂的聚合函数。
本节课我们将重点讨论用户定义函数。
用户定义函数简介
UDF是应用程序开发者编写的函数,用于扩展系统功能。它接受标量值作为输入,执行计算(可能包含循环、条件判断等),并返回标量值或关系。
以下是一个T-SQL的UDF示例,它根据客户的总消费金额计算服务等级:
CREATE FUNCTION customer_level(@ck int)
RETURNS CHAR(10)
AS
BEGIN
DECLARE @level CHAR(10)
DECLARE @total NUMERIC(12,2)
SELECT @total = SUM(o_totalprice)
FROM orders
WHERE o_custkey = @ck
IF @total > 1000000
SET @level = ‘Platinum’
ELSE
SET @level = ‘Regular’
RETURN @level
END
在查询中,可以这样调用它:
SELECT c_name, customer_level(c_custkey)
FROM customer
UDF的优势
- 模块化与重用:复杂逻辑可以集中定义,多处使用。
- 减少网络往返:逻辑在数据库内部执行,降低了延迟。
- 表达更灵活:某些逻辑用UDF比用纯SQL更易于编写。
UDF的性能问题
尽管UDF很有用,但它们会带来严重的性能问题:
- 查询优化器将其视为黑盒:优化器不知道UDF内部做了什么,因此无法估计谓词的选择性或基数,通常只能采用最坏情况的假设。
- 顺序执行,难以并行化:UDF是命令式代码,必须逐行执行,限制了并行优化。
- 可能包含动态SQL:UDF可以在运行时构建并执行SQL字符串,使得优化器无法提前准备。
- 行式调用:对于复杂的UDF,数据库系统通常需要为每一行数据单独调用一次UDF,无法进行批处理或向量化执行。这被称为 “Row-by-Agonizing-Row” 执行模式。
- 缺乏跨语句优化:UDF内部的多个SQL语句无法被整体优化。
一个简单的UDF可能导致查询从800毫秒暴增至13小时。
解决方案概述
为了解决UDF的性能瓶颈,研究人员提出了两种主要思路:
- FRODO(内联方法):将命令式UDF代码转换为关系代数表达式,然后内联到查询计划中。
- CTE转换方法:将UDF(特别是包含循环的UDF)转换为递归公共表表达式。
接下来,我们将详细探讨这两种方法。
方法一:FRODO - 内联优化 🧠
FRODO是微软提出的一种技术,用于将命令式UDF代码转换为可内联的关系代数表达式,从而使查询优化器能够理解并优化它。
FRODO的工作原理
FRODO的转换过程分为五个步骤:
- 将T-SQL语句转换为SQL查询:将UDF中的命令式语句(如变量赋值、IF条件)映射成等价的SQL查询片段。
- 将UDF划分为区域并构建关系表达式:根据控制流将UDF代码划分为不同区域,并为每个区域构建一个关系表达式(或SQL查询),使用合成表来存储变量值。
- 合并区域表达式:使用
CROSS APPLY(横向连接)操作符,将各个区域的查询合并成一个单一的SQL语句。CROSS APPLY允许内部查询引用外部查询的列,这对于组合顺序执行的逻辑至关重要。 - 内联到调用查询中:将上一步生成的庞大SQL语句替换掉原始查询中对UDF的调用。
- 交由查询优化器处理:现在,这个包含了内联逻辑的完整SQL语句可以被标准的查询优化器处理,进行子查询扁平化、选择连接顺序等优化。
优化示例
经过FRODO转换和内联后,之前那个计算客户等级的UDF,可能被优化器重写为一个先按客户分组聚合,再进行左外连接的高效计划。UDF不再是黑盒,可以并行执行,并且优化器可以准确估算成本。
获得的编译器式优化
通过将UDF转换为关系代数并利用现有优化器,FRODO间接获得了传统编译器的优化能力,例如:
- 动态切片:根据已知输入值,消除不可能执行到的代码路径。
- 常量传播与折叠:将常量表达式在编译时计算出来。
- 死代码消除:移除永远不会被执行的代码。
FRODO的支持范围与效果
截至SQL Server 2019,FRODO支持大多数T-SQL结构,但不支持循环、动态查询和异常处理。对真实世界数据库的分析表明,约60%的标量UDF可以被FRODO内联。性能提升非常显著,在某些案例中可达800倍的加速,且无需修改任何应用程序代码。
上一节我们介绍了通过内联来优化UDF的FRODO方法,本节中我们来看看另一种不同的思路,它特别擅长处理包含循环的UDF。
方法二:转换为公共表表达式 🔄
这种方法来自一篇较新的论文,核心思想是将UDF(特别是包含迭代控制流的UDF)转换为递归的公共表表达式。
CTE转换方法的步骤
该方法也包含五个关键步骤:
- 转换为SSA形式:将UDF转换为静态单赋值形式,这是一种编译器中间表示,每个变量只被赋值一次,并用goto语句表示控制流。
- 转换为管理范式:将SSA形式的程序进一步转换为一种“相互尾递归”的形式,即递归调用只发生在函数块的尾部。
- 转换为直接尾递归:将上一步的相互递归合并为单一函数的直接尾递归。
- 转换为递归CTE:利用SQL的
WITH RECURSIVE语法,将尾递归函数逻辑转换成一个递归CTE查询。递归CTE能够模拟循环行为。 - 交由查询优化器处理:将生成的递归CTE查询交给数据库优化器执行。
示例:计算幂函数
考虑一个计算幂的UDF(例如 power(x, n) 计算 x^n)。它内部包含一个while循环。通过上述转换过程,这个循环逻辑可以被表达为一个递归CTE。虽然生成的SQL语句看起来复杂,但它允许数据库引擎更高效地执行原本需要逐行迭代的逻辑。
优势与特点
- 支持循环:这是相比早期FRODO的一个主要优势。
- 可作为中间件实现:该转换层可以不紧密耦合到数据库内核,理论上可以作为中间件为多种数据库(如PostgreSQL、Oracle)提供UDF优化。
- 性能提升:在PostgreSQL上的初步实验显示,对于包含迭代的UDF,采用CTE方法能获得约40-50%的性能提升,主要避免了原生UDF中每条指令都被当作独立查询执行的巨大开销。
总结与展望 🎯
本节课中我们一起学习了服务器端逻辑执行,特别是优化用户定义函数性能的两种先进技术:
- FRODO(内联):通过将UDF转换为可内联的关系代数表达式,使优化器能充分发挥作用,适用于大量标量UDF,能带来数百倍的性能提升。
- CTE转换:通过将UDF(尤其是含循环的UDF)转换为递归公共表表达式,在数据库层面实现迭代逻辑,为FRODO无法处理的UDF提供了优化途径。
这两种方法都无需修改应用程序代码,体现了数据库系统在提供扩展性的同时追求极致性能的设计思想。未来的研究方向可能是混合方法,根据UDF的特性动态选择内联、编译(如LLVM编译)或其他执行策略,以达到最佳性能。


服务器端逻辑执行的优化,是让数据库系统更智能、更强大,从而更好地服务于现代复杂应用的关键一步。
25:新硬件上的数据库 🚀

概述
在本节课中,我们将探讨数据库系统如何运行在新兴或非传统硬件上,特别是持久内存和GPU。我们将了解这些硬件如何改变数据库系统的设计,并讨论相关的优化技术和挑战。
课程安排与反馈 📅
上一节我们介绍了课程的整体结构,本节中我们来看看本学期的剩余安排和课程反馈的重要性。
本学期剩余安排如下:
- 周三将有来自亚马逊的客座讲师,介绍Redshift相关工作。
- 5月4日将发布第二次代码评审提交的详细信息。
- 5月5日将进行最终项目演示。
- 5月13日是上周发布的期末考试截止日期。
- 5月16日将举行额外的黑客马拉松活动。
课程反馈对于改进课程至关重要。请通过指定URL填写课程评估。反馈是匿名的,有助于调整项目、阅读作业和课程节奏。
硬件加速数据库的历史回顾 📜
上一节我们了解了课程安排,本节中我们来看看数据库硬件加速的历史背景。
自数据库诞生之初,人们就一直在寻求使用专用或新硬件来加速数据系统。
- 1980年代:数据库机器:使用定制硬件(ASIC)来高效执行数据库操作(如哈希连接)。由于摩尔定律,通用CPU性能快速提升,导致此方案回报递减。
- 1990年代:商品硬件:大多数数据库运行在商品硬件上。
- 2000年代:FPGA与设备数据库:早期尝试使用FPGA构建数据库(如Teaser)。出现了设备数据库(在调优过的硬件上运行数据库软件)。云计算的兴起使得在亚马逊等云服务上购买商品硬件更具成本效益。
- 2010年代:GPU数据库:由于机器学习对GPU计算的兴趣,人们开始利用GPU进行数据库操作。
在当前十年,硬件领域可能出现新的变革,持久内存等新技术可能被纳入数据库系统。
持久内存介绍 💾
上一节我们回顾了硬件加速的历史,本节中我们重点探讨持久内存。
传统数据库设计需要区分易失性(DRAM)和非易失性(磁盘/SSD)存储。持久内存旨在提供接近DRAM的速度和字节可寻址的访问接口,同时能在断电后保持数据。
持久内存、非易失性内存和存储级内存通常指代相同概念。
底层技术
持久内存的实现基于几种技术:
- 相变内存:通过施加不同脉冲改变材料的电阻状态来表示0或1。Intel Optane DC持久内存即采用此技术。
- 忆阻器/电阻式RAM:基于Leon Chua在1971年假设的第四种电路元件。HP实验室在2000年代初期偶然制造出来。其特点是利用二氧化钛层间电阻变化存储数据,具有高密度、低能耗潜力,甚至支持“内存内计算”。
- 磁阻RAM/自旋电子学:利用磁性改变电子自旋来存储数据。具有能耗低、尺寸小、速度接近CPU缓存的潜力。
持久内存成为现实的原因
以下是持久内存如今值得关注的原因:
- 行业标准:JEDEC等联盟制定了持久内存的技术规范和外形标准。
- 操作系统支持:自2017/2018年起,Linux和Windows内核通过DAX(直接访问扩展)支持持久内存。
- 指令集支持:Intel更新了Xeon指令集,添加了显式的缓存行刷写到持久内存的指令(如
CLFLUSHOPT,CLWB)。 - 产品上市:Intel已推出Optane DC持久内存模组,外形类似DRAM,但具备非易失性。
持久内存的使用模式 ⚙️
上一节我们介绍了持久内存的技术背景,本节中我们来看看数据库系统如何使用它。
从数据库视角,主要有两种使用模式:
- 内存模式:DRAM作为持久内存的硬件管理缓存。数据库系统无需感知持久内存,将其视为更大、更便宜的DRAM。性能较好,但未利用其持久性。
- 应用直接模式:应用程序(数据库系统)明确知晓并管理DRAM和持久内存的边界。可以将关键数据结构和日志放在持久内存区域,并通过特定指令确保数据持久化。这要求重新设计数据库系统以利用字节可寻址的持久化特性。
预测:当持久内存普及时,内存数据库可能更容易适配,因为它们已假设可对内存进行快速随机访问。而基于磁盘的系统可能最初仅将持久内存用作更大容量的内存池。
面向持久内存的存储与恢复 🛠️
上一节我们了解了持久内存的使用模式,本节中我们探讨如何为持久内存重新设计数据库存储和恢复机制。
我们假设一个只有持久内存(无DRAM)的环境,并分析三种经典存储架构的改造:
1. 就地更新引擎(如B+树)
- 传统问题:一次逻辑更新(如更新元组)可能产生多次物理写入(WAL日志、表堆、快照),导致写放大。
- 持久内存优化:利用持久内存字节可寻址和指针持久化的特性。可以只记录更改的元组指针到日志中,而非完整数据。恢复时,只需处理日志中的指针元数据,无需重做(Redo)操作,因为数据更改已持久化在表堆中。
2. 写时复制引擎(如LMDB)
- 传统问题:即使只更新一个元组,也需要复制整个页,并更新多层目录指针,写放大严重。
- 持久内存优化:由于支持字节寻址,可以仅复制被修改的元组指针,而非整个页,从而大幅减少复制开销。
3. 日志结构引擎(如LevelDB/RocksDB)
- 传统问题:存在内存表(MemTable)和磁盘SSTable的转换,以及昂贵的压缩操作。
- 持久内存优化:可以消除MemTable和SSTable的区分,所有数据都以持久化形式存在。但压缩操作可能仍然需要。
写后日志:一种持久内存优化协议 📝
上一节我们讨论了存储引擎的优化,本节中我们介绍一种专为持久内存设计的日志协议——写后日志。
在同时具有DRAM和持久内存的混合系统中,写后日志旨在加速性能并实现快速恢复。
传统写前日志的瓶颈
WAL顺序写入是为了避免磁盘随机写。但在持久内存中,随机写入速度很快,顺序写优势减弱。
写后日志原理
写后日志结合了多版本并发控制:
- 数据布局:表堆主副本在DRAM中,其持久化副本在持久内存中。日志也放在持久内存。
- 写入流程:事务更新DRAM中的元组,同时将更改同步到持久内存中的副本,并在日志中记录被修改元组在持久内存中的指针及事务时间戳信息。
- 恢复机制:崩溃恢复时,无需重做。只需分析日志,确定崩溃时未提交事务的时间戳范围。系统恢复后,新事务或后台清理线程会忽略或回收属于该时间戳范围的元组版本,实现协同垃圾回收。
性能优势
- 恢复时间:相比WAL,写后日志的恢复时间快数千倍,因为它只需读取日志中的元数据范围。
- 运行时性能:在持久内存上,写后日志能获得约1.2倍的性能提升,因为它利用了持- 久内存快速的随机写入能力。但在SSD或HDD上,由于其随机写入性能差,写后日志性能会下降。
总结:写后日志与持久内存结合,能同时优化运行时性能和恢复时间。
GPU加速数据库 ⚡
上一节我们深入探讨了持久内存,本节中我们简要看看如何使用GPU加速数据库。
GPU包含数千个核心,适合对大数据流执行简单、重复、无复杂分支的操作。
适用与不适用场景
- 适用:顺序扫描、某些连接和聚合操作(有对应实现)。
- 不适用:事务处理、B+树遍历(因涉及分支决策)。
系统架构挑战
- 内存非一致性:GPU显存与CPU内存不缓存一致,数据需要显式拷贝。
- 带宽瓶颈:PCIe总线带宽(约16 GB/s)低于CPU内存带宽(约40 GB/s),可能成为瓶颈。NVLink技术可提供更高带宽,但通常限于PowerPC平台。
- 容量限制:GPU显存容量(目前最高约100GB)远小于CPU可支持的内存容量(TB级)。
数据组织模式
以下是使用GPU的三种方式:
- 全驻留模式:将整个数据库复制到GPU显存。受限于显存大小。
- 部分列驻留模式:仅将频繁查询的列复制到GPU。需要手动或自动选择列。
- 流处理模式:将数据分批流式传输到GPU处理,保持GPU持续忙碌。适用于超出显存大小的数据集。
目前已有多个GPU数据库产品(如Kinetica, BlazingSQL, OmniSci等),主要用于OLAP场景。
硬件事务内存 ⚙️
上一节我们讨论了GPU加速,本节中我们最后了解一下硬件事务内存。
硬件事务内存是CPU提供的一种机制,允许将代码块标记为事务,由硬件跟踪内存访问并自动检测冲突,类似于乐观并发控制。
工作原理
HTM利用缓存一致性协议来跟踪事务的读写集。如果发现冲突,则中止并回滚事务。读写集必须能放入L1缓存,因此不适合大型事务。
编程模型
- 硬件锁省略:首次以事务方式执行临界区。若冲突,则中止并重新以传统锁方式执行。
- 受限事务内存:事务中止后,跳转到程序员提供的备用代码路径执行,而非简单重试。
在数据库中的应用示例
例如,在B+树插入时,可以用HTM事务包裹从根节点到叶节点的遍历和加锁过程。如果成功,则仿佛自动获得了所有必要锁;如果因冲突中止,则回退到传统的锁遍历算法。
现状与挑战:Intel的TSX实现曾因安全漏洞被禁用多次。其稳定性和安全性尚存疑问,目前未在数据库中得到广泛应用。它可能更适用于简化并发编程,而非替代数据库中的完整事务管理。
总结与展望 🎯
本节课我们一起学习了在新硬件上运行数据库的关键知识。
核心要点总结
- 持久内存:是一种具有DRAM般速度、字节可寻址且非易失性的存储介质。它可能改变数据库架构,减少对复杂缓冲池和WAL机制的依赖。
- 存储优化:针对持久内存,可以重新设计存储引擎(就地更新、写时复制、日志结构),减少写放大和数据冗余。
- 写后日志:一种利用持久内存特性的日志协议,能极大加快恢复速度,并与多版本控制结合实现高效恢复。
- GPU加速:适用于大规模并行、无复杂分支的OLAP操作(如扫描)。面临内存一致性、带宽和容量挑战。
- 硬件事务内存:由CPU提供的事务支持,目前因稳定性和容量限制,在数据库中应用有限。
未来展望
- 持久内存的普及可能简化数据库内核设计,内存数据库可能率先受益。
- GPU和FPGA加速可能在特定领域(如OLAP、矩阵计算)继续发展。
- 未来可能出现更多专用加速器(如可配置空间加速器、TPU),数据库系统需要思考如何集成这些异构计算资源。


完成本课程后,你应能理解现代单节点数据库系统的核心原理,并具备评估新兴数据库技术声称的能力,区分真正的创新与市场宣传。

浙公网安备 33010602011771号