文章大概分为这三个部分、
在硬件的发展还没像今天这么迅速时,开发人员必须知道他们正在编码的运算的确切数量。他们熟记自己的算法和数据结构,因为他们负担不起浪费CPU和内存的代价。在这一部分中,将阐述其中的一些概念,因为它们是理解数据库所必需的。我还将介绍数据库索引的概念。
现在,许多开发人员不关心时间复杂度……他们是对的!
但是,当您处理大量数据(不是几千行数据)或正在争取毫秒级的时间时,理解这个概念就变得非常重要了。你猜怎么着,数据库必须处理这两种情况!我不会让你厌烦很长时间,只是时间得到的想法。这将帮助我们以后理解基于成本的优化的概念。
**时间复杂度用于查看算法处理给定数量的数据需要多长时间。**为了描述这种复杂性,计算机科学家使用数学大O符号。这个符号与一个函数一起使用,该函数描述了一个算法需要对给定数量的输入数据进行多少操作。
重要的不是数据量,而是当数据量增加时操作数增加的方式。时间复杂度并不能给出操作的确切数量,但这是个比较好的办法。
在这个图中,我们可以看到不同时间复杂度的算法随着数据数量的增加,操作次数的演变。数据的数量正在迅速从10亿增长到10亿。我们可以看到:
如果数据量少,O(1)和O(n2)之间的差异可以忽略不计。例如,假设我们有一个需要处理2000个元素的算法。
O(1)和O(n2)之间的差异似乎很大(400万),但你最多会损失2毫秒,只是眨眼的时间。实际上,当前的处理器每秒可以处理数亿次操作。这就是为什么性能和优化在许多项目中不是一个问题。
就像我们前面说的一样,在面对大量数据时,了解这个概念仍然很重要。如果这一次算法需要处理1000000个元素(对于数据库来说不算大):
我没有做精确的计算,但我要说的是,对于O(n2)算法,我们有时间下楼拿个外卖了,如果我们在数据量上再加一个0,就有时间打个午觉了。
时间复杂度有多种类型:
复杂性通常是最坏的情况。
我们只讲了时间复杂度但是复杂度也适用于:
当然还有比n2更复杂的情况,比如:
当需要对集合排序时,应该怎么做?一般我们可以调用sort()函数。好的,回答得很好。但是对于数据库,我们必须了解sort()函数是如何工作的。
有几个很好的排序算法,所以我将重点介绍最重要的一个:归并排序。我们现在可能还不理解为什么对数据进行排序是很有用的,但是在学习查询优化部分之后,我们应该了解为什么排序数据是有用的。此外,理解合并排序将帮助我们以后理解一个称为**合并连接(merge join)**的公共数据库连接操作。
像许多有用的算法一样,归并排序基于一个技巧:将大小为N/2的两个排序数组合并到一个N元素排序数组只需要N个操作。这个操作称为归并。
让我们用一个简单的例子来归并排序怎么实现的:
从图中可以看出,为了构造最终的8个元素的排序数组,您只需要在2个4元素数组中迭代一次。因为这两个4元数组已经排序:
这是可行的,因为两个4元素数组都是排序的,因此您不需要在这些数组中“返回”。
现在我们已经知晓了归并排序如何实现的,下面是归并排序的伪代码。
array mergeSort(array a)
if(length(a)==1)
return a[0];
end if
//recursive calls
[left_array right_array] := split_into_2_equally_sized_arrays(a);
array new_left_array := mergeSort(left_array);
array new_right_array := mergeSort(right_array);
//merging the 2 small ordered arrays into a big one
result := merge(new_left_array,new_right_array);
return result;
归并排序将问题分解成更小的问题,然后找到更小问题的结果,从而得到初始问题的结果(注意:这种算法称为分治算法)。我认为这个算法是分为两个阶段的算法:
在划分阶段,使用3步将数组划分为单元数组。正式的步骤数是log(N)(因为N=8,所以log(N) = 3)。
In the sorting phase, you start with the unitary arrays. During each step, you apply multiple merges and the overall cost is N=8 operations:
在排序阶段,从划分的最小数组开始。在每一步,我们应用多个归并的成本是N=8次操作:
因为有log(N)个步骤,所以总的时间复杂度是N*log(N)个操作。
既然我们已经理解了时间复杂度和排序背后的思想,我必须告诉你3种数据结构。这很重要,因为它们是现代数据库的支柱。之后我还将介绍数据库索引的概念。
二维数组是最简单的数据结构。表可以看作一个数组。例如:
这个二维数组是一个包含行和列的表:
每一行代表一个主题,列是描述主题的特性。每个列存储特定类型的数据(整数、字符串、日期……)。虽然存储和可视化数据很好,但是当你需要查找特定的值时,它就很糟糕了。
例如,如果你想找到所有在英国工作的人,你必须查看每一行来确定这一行是否属于UK。这将花费你N个操作(N是行数),这并不坏,但是有更快的方法吗?这就是tree发挥作用的地方。
注意:大多数现代数据库提供高级数组来有效地存储表,如堆组织的表或索引组织的表。但这并没有改变在一组列上快速搜索特定条件的问题。
这棵树有N=15个元素。假设我在找208:
现在假设我们在找40:
就像上面的过程一样,我们最终找到了40的节点。然后提取节点内的行id(它不在图中),并查看表中给定的行id。知道行id让我知道数据在表中的确切位置,因此我可以立即获得它。最后,查找节点的算法复杂度是O(logN),至少比O(N)强了很多。 : )
但是这个东西很抽象,所以我们回到我们的问题,我们要找的不是整数,而是表示上表中某个国家的字符串。假设你有一个包含“国家”列表格的树:
如果您想知道谁在英国工作,您可以查看树以获得“UK节点”中代表英国的节点,您将找到英国工人的行位置。刚才我们说的树就是索引。
你可以建立一个树索引对于任何列(可以使字符串,也可以是整数,也可以是整数和一个字符串或日期…),只要你有一个函数比较这些键(即作为索引的列),这样你就可以给这些键创建顺序(这是任何基本类型在数据库的情况)。
尽管这个树可以很好地获得一个特定的值,但是当你需要在两个值之间获取多个元素时,就会出现一个大问题。它将花费O(N),因为你必须查看树中的每个节点,并检查它是否位于这两个值之间(例如,使用树的顺序遍历)。此外,这个操作对磁盘I/O不友好,因为必须读取整个树。我们需要找到一种有效地进行范围查询的方法。为了解决这个问题,现代数据库使用以前的树的一个修改版本,称为B+树。
B+树:
可以看到,节点更多(多了两倍)。实际上,还有其他节点,即“决策节点”,它将帮助您找到正确的节点(在关联的表中存储行位置)。但是搜索复杂度仍然是O(log(N))。最大的区别是,最低的节点与它们的后续节点相连。
在这个B+树中,如果你想找40到100之间的值:
假设有M个结点,树有N个结点。对特定节点的搜索花费与前一棵树一样的log(N)。但是,一旦有了这个节点,在M操作中就会有M个后续操作,它们都有与其后续操作的链接。这个搜索只需要花费M + log(N)操作与前一个树的N个操作。此外,您不需要读取整个树(只需要M + log(N)个节点),这意味着更少的磁盘使用。如果M很低(比如200行)而N很大(1000000行),则会有很大的不同。
但新的问题又出现了。如果在数据库中添加或删除一行(在相关的B+树索引中如何操作?):
换句话说,B+树需要是自排序和自平衡的。幸运的是,通过智能删除和插入操作,这是可行的。但是这是有代价的:在B+树中的插入和删除是在O(log(N))的时间复杂度。这就是为什么有些人听说使用太多索引不是一个好主意。实际上,由于数据库需要对每个索引执行代价高昂的O(log(N))操作来更新表的索引,所以会减慢表中某一行的快速插入/更新/删除操作。此外,添加索引意味着事务管理器有更多的工作负载(后面我们会讲)
最后一个重要的数据结构是哈希表。当我们希望快速查找值时,它非常有用。此外,理解哈希表将有助于我们以后理解称为哈希连接的公共数据库连接操作。数据库也使用此数据结构来存储一些内部内容(如锁表或缓冲池,稍后我们将看到这两个概念)
哈希表是一种数据结构,它可以快速找到具有其键值的元素。要建立一个哈希表,你需要定义:
这个哈希表有10个bucket。因为我很懒,我只画了5个桶但是我知道你很聪明,所以我让你想象其他5个。我使用的哈希函数是键的模10。换句话说,我只保留一个元素的键的最后一位来找到它的桶:
比较函数就是简单地比较两个整数。
假设你想要得到78号元素:
现在,假设你想要得到元素59:
很明显两次搜索的时间复杂度相差比较大,因为我们看到有很多bucket没有元素或很少,有的却很多,这就影响了我们的平均查找的时间复杂度。由此引出如何构建一个好的哈希函数来解决以上的问题。
如果我现在用键的1000000的模(即取最后6位数字)来改变哈希函数,那么第二次搜索只需要一个操作,因为在bucket 000059中没有元素。真正的挑战是找到一个好的散列函数,它将创建包含非常少的元素的bucket。
在我的例子中,找到一个好的哈希函数是很容易的。但这是一个简单的例子,当我们的键是以下时,找到一个好的哈希函数很困难:
使用好的哈希函数,在哈希表的查找的时间复杂度是O(1)。
哈希表可以在内存中加载一半,而其他bucket可以留在磁盘上。对于数组,必须使用内存中的连续空间。如果要加载一个大的表,就很难有足够的连续空间。使用哈希表,你也可以选择所需的键(例如国家和一个人的姓)。
我们已经看到了数据库中的基本组件。我们现在需要退一步来看看数据库总体是什么样子。
数据库是一组可以方便地访问和修改的信息。但是一堆简单的文件也可以做到这一点。事实上,像SQLite这样最简单的数据库只不过是一堆文件。但是SQLite是一组精心制作的文件,因为它允许:
一般而言,数据库可以如下图所示:
网上有很多数据库的结构图,因此,不要过多地关注我如何组织这个数据库或如何命名这些过程,因为我做了一些选择来适应本文的计划。重要的是不同的组成部分;总体思想是将数据库划分为多个相互交互的组件。
核心组件:
工具:
查询管理器:
数据管理器:
在本文的其余部分,我将重点介绍数据库如何通过以下过程管理SQL查询:
客户端管理器是处理与客户端通信的部分。客户机可以是(web)服务器,也可以是终端用户/终端应用程序。客户端管理器提供了通过一组API访问数据库,如JDBC、ODBC等。
当你连接到一个数据库::
查询管理器就是数据库的强大所在。在这一部分中,一个写得不好的查询被转换成一个快速的可执行代码。然后执行代码并将结果返回给客户端管理器。这是一个多步骤的操作:
在这一部分中,我不会过多地讨论最后两点,因为它们不太重要。
每个SQL语句都被发送到解析器,在那里检查语法是否正确。如果您在查询中出错,解析器将拒绝该查询。例如,如果你写的是“SLECT…”而不是“SELECT…”,那故事到此结束。不仅仅如此,它还检查关键字是否按正确的顺序使用。例如,WHERE在SELECT前的话就会被拒绝。
然后,分析查询中的表和字段。解析器使用数据库的元数据来检查:
然后,它检查您是否具有读取(或写入)查询中的表的授权。同样,这些表上的访问权限是由DBA设置的。在此解析过程中,SQL查询被转换为内部表示(通常是树)。如果一切正常,则将内部表示发送给查询重写程序。
在这一步,我们有一个查询的内部表示。改写的目的是:
重写器对查询语句执行一个已知规则列表。如果查询符合规则的模式,则应用规则并重写查询。以下是一个非详尽的(可选)规则列表:
举个栗子
SELECT PERSON.*
FROM PERSON
WHERE PERSON.person_key IN
(SELECT MAILS.person_key
FROM MAILS
WHERE MAILS.mail LIKE 'christophe%');
Will be replaced by
SELECT PERSON.*
FROM PERSON, MAILS
WHERE PERSON.person_key = MAILS.person_key
and MAILS.mail LIKE 'christophe%';
在我们看到数据库如何优化查询之前,我们需要讨论一下统计数据,因为没有统计数据,数据库是愚蠢的。如果你不告诉数据库去分析它自己的数据,它就不会去做,并且会做出很糟糕的假设。
但是数据库需要什么样的信息呢?
我必须简要地谈谈数据库和操作系统如何存储数据。他们使用一个最小的单元,称为一个页面或一个块(默认情况下是4或8kb)。这意味着如果你只需要1kb,但它还是要花费你一页。如果页面占用8kb,那么将浪费7kb。
回到数据上来!当你要求数据库收集统计数据时,它会计算这样的值:
这些统计数据将帮助优化器估计查询的磁盘I/O、CPU和内存使用量。
每一列的统计数据非常重要。例如,如果需要在两个列上联接表PERSON: LAST_NAME、FIRST_NAME。通过统计数据,数据库知道FIRST_NAME上只有1000个不同的值,LAST_NAME上只有1000个不同的值。因此,数据库将联接LAST_NAME、FIRST_NAME而不是FIRST_NAME、LAST_NAME上的数据,因为它产生的比较要少得多,因为LAST_NAME不太可能是相同的,所以大多数情况下,对LAST_NAME的第2(或3)个字符进行比较就足够了。
但这些都是基本的统计数据。您可以要求数据库计算称为直方图的高级统计数据。直方图是关于列内值分布的统计信息。例如:
这些额外的统计信息将帮助数据库找到更好的查询计划。特别是对于相等谓词(例如:AGE = 18)或范围谓词(例如:AGE > 10和AGE <40),因为数据库可以更好地了解这些谓词所涉及的行数。
统计数据存储在数据库的元数据中。例如,你可以看到(非分区)表的统计:
统计数字必须是最新的。没有什么比数据库认为一个表只有500行而它却有100000行更糟糕的了。统计数据的唯一缺点是计算它们需要时间。这就是大多数数据库默认情况下不会自动计算它们的原因。要计算数百万的数据是很困难的。在这种情况下,可以选择只计算基本统计信息,也可以选择计算数据库样本上的统计信息。
所有现代数据库都使用基于成本的优化(CBO)来优化查询。其思想是为每个操作设置一个成本,并通过使用开销最小的操作组合来获得结果,从而找到降低查询成本的最佳方法。
为了理解成本优化器是如何工作的,我认为最好有一个例子来“感受”这个任务背后的复杂性。在本部分中,我将向您介绍连接两个表的3种常见方法,我们很快就会发现,即使是一个简单的连接查询也很难优化。之后,我们将看到真正的优化器是如何完成这项工作的。
对于这些连接操作,我们将关注它们的时间复杂性,但是数据库优化器会计算它们的CPU成本、磁盘I/O成本和内存需求。时间复杂度和CPU成本之间的区别是,时间成本非常接近。对于CPU成本,我应该计算每一个操作,比如加法、“if语句”、乘法、迭代……
每个高级代码操作都有特定数量的低级CPU操作。CPU操作的成本是不一样的(就CPU周期而言),无论你使用的是Intel Core i7、Intel Pentium 4还是AMD Opteron……换句话说,它取决于CPU架构。
利用时间复杂性更容易,我们仍然可以得到CBO的概念。我有时会讨论磁盘I/O,因为它是一个重要的概念。请记住,瓶颈大部分时间是磁盘I/O,而不是CPU使用。
我们在讲B+树的时候讲过索引。只要记住这些索引已经排序了。另外,还有其他类型的索引,比如位图索引。它们在CPU、磁盘I/O和内存方面提供的成本与B+树索引不同。此外,如果可以提高执行计划的成本,许多现代数据库可以动态地为当前查询创建临时索引。
在使用连接操作符之前,首先需要获取数据。下面是获取数据的方法。
注意:由于所有访问路径的真正问题是磁盘I/O,所以我们不会过多地讨论时间复杂性。
全扫描(Full scan)
如果你曾经看过什么是执行计划,那么一定见过单词full scan(或只是scan)。完全扫描就是数据库完全读取一个表或一个索引。就磁盘I/O而言,表全扫描显然比索引全扫描更昂贵。
范围扫描(Range Scan)
还有其他类型的扫描,如索引范围扫描。例如,当使用谓词“WHERE AGE > 20 AND AGE <40”时,可以使用它。当然,您需要在字段AGE上有一个索引来使用这个索引范围扫描。
我们在第一部分中已经看到,范围查询的时间成本类似于log(N) +M,其中N是这个索引中的数据数量,M是这个范围内的行数的估计。由于统计数据,N和M值都是已知的(注意:M是谓词AGE >20和AGE<40的选择性)。此外,对于范围扫描,您不需要读取完整索引,因此就磁盘I/O而言,它比完整扫描开销更小。
唯一扫描(Unique scan)
如果只需要索引中的一个值,可以使用惟一扫描。
按行id访问
大多数情况下,如果数据库使用索引,它必须查找与索引关联的行。为此,它将使用一个按行id进行的访问。
举个栗子,看下面这个查询语句
SELECT LASTNAME, FIRSTNAME from PERSON WHERE AGE = 28
如果你在age列上有一个索引,优化器将使用索引来查找所有age = 28的person,再找到表中的行(索引节点会存储行id信息等),因为索引中的行只有年龄,但我们要查询他的lastname和firstname。
但是如果你执行下面的查询语句
SELECT TYPE_PERSON.CATEGORY from PERSON ,TYPE_PERSON
WHERE PERSON.AGE = TYPE_PERSON.AGE
PERSON上的索引将用于与TYPE_PERSON连接,但是表PERSON不会被行id访问,因为你没有查询这个表上的信息。虽然它对于一些访问非常有效,但是这个操作的真正问题是磁盘I/O。如果需要通过行id进行太多访问,数据库可能会选择全扫描。
我将介绍3个常见的连接操作符:合并连接、散列连接和嵌套循环连接。但在此之前,我需要介绍一些新的词汇:inner relation和outer relation。Relation 可以是:
当连接两个关系时,join算法以不同的方式管理这两个关系。在本文的其余部分,我将假设:
例如,A连接B是A和B之间的连接,其中A是outer relation,B是inner relation。
大多数情况下,A JOIN B的成本与B JOIN A的成本是不一样的。
在这一部分中,我还将假设外部关系有N个元素,内部关系有M个元素。请记住,一个真正的优化器通过统计信息知道N和M的值。
嵌套循环连接是最简单的。
实现原理:
伪代码:
nested_loop_join(array outer, array inner)
for each row a in outer
for each row b in inner
if (match_join_condition(a,b))
write_result_in_output(a,b)
end if
end for
end for
由于是两次循环,时间复杂度为O(N*M)
对于磁盘I/O,对于外部关系中的每N行,内部循环需要从内部关系中读取M行。这个算法需要从磁盘读取N + N*M行。但是,如果内部关系足够小,你可以把这个关系放在内存中,只需要读取M +N次。通过这种修改,内部关系必须是最小的那个数据集,因为它有更多的机会适应内存。
就时间复杂性而言,这没有什么区别,但就磁盘I/O而言,最好只读取一次这两个关系。当然,内部关系可以用索引代替,这对于磁盘I/O更好。
由于这个算法非常简单,所以如果内部关系太大而无法装入内存,这里有另一个版本,它对磁盘I/O更友好。这个想法是这样的:
// improved version to reduce the disk I/O.
nested_loop_join_v2(file outer, file inner)
for each bunch ba in outer
// ba is now in memory
for each bunch bb in inner
// bb is now in memory
for each row a in ba
for each row b in bb
if (match_join_condition(a,b))
write_result_in_output(a,b)
end if
end for
end for
end for
end for
在这个版本中,时间复杂度保持不变,但是磁盘访问的次数减少了:
哈希连接比较复杂,但在很多情况下,它的成本比嵌套循环连接要小。
哈希连接的思想是:
在时间复杂性方面,我需要做一些假设来简化问题。
时间复杂度为(M/X)N + cost_to_create_hash_table(M) + cost_of_hash_functionN
如果哈希函数创建了足够多的小尺寸桶,那么时间复杂度为O(M+N)
这里是另一个版本的哈希连接,它对内存更友好,但对磁盘I/O不那么友好。这一次是这样的:
归并连接是唯一能产生排序结果的连接。
注意:在这个简化的合并连接中,没有内表和外表,它们的作用都是一样的。但真正的实现会有所区别,例如,在处理重复表时。
归并连接可以分为两个步骤。
分类
我们已经讲到了归并排序,在这种情况下,归并排序是一个很好的算法(但如果内存不是问题的话,不是最好的)。
但是有时候数据集已经排序了,例如:
归并连接
这一部分与我们看到的合并排序的归并操作非常相似。但这一次,我们不是从两个关系中挑选每一个元素,而是只从两个关系中挑选等价的元素。思路是这样的,
这个算法是一个简化的版本,因为它没有处理同一数据在两个数组中多次出现的情况(换句话说就是多次匹配)。对于这种情况,真实版本的算法更复杂,这就是我选择简化版本的原因。
如果两个关系都已经排序了,那么时间复杂度是O(N+M)
如果两个关系都需要排序,那么时间复杂度就是对两个关系进行排序的成本。O(NLog(N) + MLog(M))
下面是多次匹配的情况的实现代码:
mergeJoin(relation a, relation b)
relation output
integer a_key:=0;
integer b_key:=0;
while (a[a_key]!=null or b[b_key]!=null)
if (a[a_key] < b[b_key])
a_key++;
else if (a[a_key] > b[b_key])
b_key++;
else //Join predicate satisfied
//i.e. a[a_key] == b[b_key]
//count the number of duplicates in relation a
integer nb_dup_in_a = 1:
while (a[a_key]==a[a_key+nb_dup_in_a])
nb_dup_in_a++;
//count the number of duplicates in relation b
integer dup_in_b = 1:
while (b[b_key]==b[b_key+nb_dup_in_b])
nb_dup_in_b++;
//write the duplicates in output
for (int i = 0 ; i< nb_dup_in_a ; i++)
for (int j = 0 ; i< nb_dup_in_b ; i++)
write_result_in_output(a[a_key+i],b[b_key+j])
a_key=a_key + nb_dup_in_a-1;
b_key=b_key + nb_dup_in_b-1;
end if
end while
哪一种连接算法最好呢?
如果有一个最好的连接算法,我们就不会介绍这么多连接算法。这个问题是非常难抉择的,因为很多因素都会影响到,比如:
我们刚刚介绍了三种类型的连接操作。
现在我们需要连接5个表,才能有一个人的完整视图。一个Person可以有:
翻译成SQL语句就是
SELECT * from PERSON, MOBILES, MAILS,ADRESSES, BANK_ACCOUNTS
WHERE
PERSON.PERSON_ID = MOBILES.PERSON_ID
AND PERSON.PERSON_ID = MAILS.PERSON_ID
AND PERSON.PERSON_ID = ADRESSES.PERSON_ID
AND PERSON.PERSON_ID = BANK_ACCOUNTS.PERSON_ID
要优化这个查询语句,我必须找到最好的处理数据的方法。但是有2个问题。
我有3种可能的Join(Hash Join、Merge Join、Nested Join),可以使用0,1或2个索引(更何况有不同类型的索引)
例如,下图显示了4个表上用3个连接的不同可能方案
那么,下面是我的几种可能。
我用暴力的方法
利用数据库的统计,我计算出每一个可能的方案的成本,选择了最佳方案。但是,有很多可能性。对于一个给定的加入顺序,每个加入有3种可能。HashJoin,MergeJoin,NestedJoin。所以,对于给定的连接顺序,有34种可能性。连接顺序是一个二进制树上的排列组合问题,并且有(24)!/(4+1)! 可能的顺序。对于这个非常简化的问题,我最终得到34(2*4)!/(4+1)! 的可能性。
用非专业术语来说,这意味着有27216种可能的计划。如果我现在再加上归并连接的可能性,即取0,1或2个B+Tree索引,可能的计划数就变成了210 000。我是不是忘了提一下,这个查询是非常简单的?
我哭着退出这个工作
很有诱惑力,但你不会得到你的结果,我需要钱来付账。
我只试了几个方案,就选成本最低的那个
因为我不是超人,我无法计算出每个计划的成本。相反,我可以从所有可能的计划中任意选择一个子集,计算出它们的成本,然后给出这个子集中最好的计划。
我应用智能规则来减少可能的计划数量
有2种类型的规则。
我可以使用 "逻辑 "规则,这些规则会删除无用的可能性,但它们不会过滤掉很多可能的计划。比如说 “嵌套循环连接的内部关系必须是最小的数据集”
我接受没有找到最好的解决方案,并应用更激进的规则来减少很多可能性。例如,“如果一个关系很小,就使用嵌套循环连接,千万不要使用合并连接或哈希连接”
在这个简单的例子中,我最终得到了很多可能性。但真正的查询可以有其他的关系运算符,如OUTER JOIN、CROSS JOIN、GROUP BY、ORDER BY、PROJECTION、UNION、INTERSECT、DISTINCT…这意味着更多的可能性。
那么,数据库是怎么做的呢?
动态规划、贪婪算法和启发式算法
一个关系型数据库尝试了我刚才说的多种方法。优化器的真正工作是在有限的时间内找到一个好的解决方案。
大多数时候,优化器并没有找到最好的解决方案,而是找到一个 "好的 "解决方案。
对于小的查询,做一个暴力的方法是可以的。但是有一种方法可以避免不必要的计算,这样即使是中等体量的查询也可以使用暴力方法。这就是所谓的动态编程。
它们共享同一个(A JOIN B)子树。所以,我们可以不在每次计划中计算这个子树的成本,而是计算一次,将计算出的成本保存下来,当我们再次看到这个子树的时候,再重复使用。更正式的说,我们面临的是一个重叠问题。为了避免部分结果的额外计算,我们使用类似备忘录技术。
使用这种技术,我们不再有(2*N)!/(N+1)!的时间复杂度,而是只需要3N。在我们之前的例子中,我们用4个连接,这意味着从336个排序传递到81个。如果你拿一个更大的查询,用8次接续(这并不大),这意味着从57 657 600传递到6561。
procedure findbestplan(S)
if (bestplan[S].cost infinite)
return bestplan[S]
// else bestplan[S] has not been computed earlier, compute it now
if (S contains only 1 relation)
set bestplan[S].plan and bestplan[S].cost based on the best way
of accessing S /* Using selections on S and indices on S */
else for each non-empty subset S1 of S such that S1 != S
P1= findbestplan(S1)
P2= findbestplan(S - S1)
A = best algorithm for joining results of P1 and P2
cost = P1.cost + P2.cost + cost of A
if cost < bestplan[S].cost
bestplan[S].cost = cost
bestplan[S].plan = “execute P1.plan; execute P2.plan;
join results of P1 and P2 using A”
return bestplan[S]
对于较大的查询,你仍然可以采用动态规划的方法,但要用额外的规则(或启发式算法)来消除可能性。
但是对于一个非常大的查询或有一个很快要得到的答案(但不是一个非常快的查询),使用另一种类型的算法,贪婪算法。
其思想是遵循规则(或启发)以增量方式构建查询计划。有了这个规则,贪婪算法就可以一步一步地找到问题的最佳解决方案。算法以一个连接开始查询计划。然后,在每个步骤中,算法使用相同的规则向查询计划添加一个新的连接。
让我们举一个简单的例子。假设我们有一个查询,它包含5个表(A、B、C、D和E)上的4个连接。让我们使用“使用成本最低的join”规则
因为我们任意地从A开始,我们可以对B应用相同的算法,然后是C,然后是D,然后是e,然后我们保持这个计划的最低成本。顺便说一下,这个算法有个名字,叫做最近邻算法。
我不会讲得太细,但是有了一个好的模型和一个Nlog(N)的排序,这个问题就很容易解决了。这个算法的代价是O(Nlog(N)) vs O(3N)对于完整的动态规划版本。如果有一个包含20个连接的大查询,这意味着26 vs 3 486 784 401,一个很大的差异!
这个算法的问题是,我们假设如果我们保持这个连接并添加一个新连接,那么在两个表之间找到最佳连接将会给我们带来最佳成本。但是:即使A JOIN B给出了A、B和C之间的最佳成本,(A JOIN C) JOIN B可能比(A JOIN B) JOIN C的结果更好。为了改善结果,可以使用不同的规则运行多个贪婪算法,并保持最佳计划。
如何找到最佳的可行方案是许多计算机科学研究者的一个活跃的研究课题。他们经常试图为更精确的问题/模式找到更好的解决方案。例如:
还研究了其他算法来代替动态规划的大量查询。贪婪算法属于一个更大的家族,称为启发式算法。贪心算法遵循一个规则(或启发式),保留它在前一步找到的解决方案,并“附加”它来找到当前步骤的解决方案。有些算法遵循一个规则,并以循序渐进的方式应用它,但并不总是保持在前一步找到的最佳解决方案。它们都被称为启发式算法。
例如,遗传算法遵循一个规则,但最后一步的最佳解决方案往往不保持:
适者生存法则。
但是,所有这些都是理论性的。因为我是开发人员而不是研究人员,所以我喜欢具体的例子。
让我们看看SQLite优化器是如何工作的。这是一个轻量级的数据库,所以它使用了一个简单的优化,基于一个附加规则的贪心算法来限制可能方案的数量:
自3.8.0版(2015年发布)以来,SQLite在搜索最佳查询计划时使用了“N近邻”贪婪算法。
让我们看看另一个优化器是如何工作的。IBM DB2就像所有的企业数据库一样,但是我将重点介绍这个数据库。如果我们查看官方文档,我们会了解到DB2优化器允许您使用7种不同级别的优化:
我们可以看到DB2使用贪婪算法和动态规划。当然,由于查询优化器是数据库的主要功能,所以它们不会共享它们所使用的启发方法。
仅供参考,默认级别是5。默认情况下,优化器使用以下特征:
默认情况下,DB2在连接排序中使用受启发法限制的动态规划。其他条件(GROUP BY、DISTINCT…)由简单的规则处理。
由于创建计划需要时间,大多数数据库将计划存储在查询计划缓存中,以避免对同一个查询计划进行无用的重新计算。这是一个很大的主题,因为数据库需要知道何时更新过时的计划。其思想是设置一个阈值,如果表的统计数据在这个阈值之上发生了变化,那么将从缓存中清除涉及该表的查询计划。
在这个阶段,我们有一个优化的执行计划。这个计划被编译成一个可执行的代码。然后,如果有足够的资源(内存、CPU),则由查询执行程序执行。计划中的操作符(JOIN、SORT BY…)可以按顺序或并行方式执行;由执行器决定。为了获取和写入数据,查询执行器与数据管理器进行交互,这是本文的下一部分。
在这个步骤中,查询管理器执行查询并需要来自表和索引的数据。它要求数据管理器获取数据,但是有两个问题:
在本部分中,我们将了解关系数据库如何处理这两个问题。我不会讨论数据管理器获取数据的方式,因为这不是最重要的。
正如我已经说过的,数据库的主要瓶颈是磁盘I/O。为了提高性能,现代数据库使用缓存管理器。
查询执行程序不是直接从文件系统获取数据,而是向缓存管理器请求数据。缓存管理器有一个称为缓冲池的内存缓存。从内存中获取数据极大地提高了数据库的速度。很难给出一个数量级,因为这取决于你需要做的操作:
以及数据库使用的磁盘类型:
但是我认为内存比磁盘快100到100000倍。
但是,这会导致另一个问题(与数据库一样……)。缓存管理器需要在查询执行器使用数据前之前获取内存中的相应的数据;否则,查询管理器必须等待来自慢速磁盘的数据。
这个问题称为预取。查询执行器知道它需要的数据,因为它知道查询的完整流程,并且知道磁盘上的数据和统计信息。这个想法是这样的:
CM将所有这些数据存储在其缓冲池中。为了知道数据是否仍然需要,缓存管理器添加了关于缓存数据的额外信息(称为锁存器(Latch))。
有时查询执行器不知道它需要什么数据,有些数据库不提供此功能。相反,它们使用推测性预取(例如:如果查询执行器请求数据1、3、5,它可能在不久的将来请求数据7、9、11)或顺序预取(在本例中,CM只是在请求的数据之后从磁盘加载下一个连续数据)。
为了监视预取的工作情况,现代数据库提供了一个称为缓冲区/缓存命中率的指标。命中率显示了在不需要访问磁盘的情况下,在缓冲区缓存中找到请求数据的频率。缓存命中率低并不总是意味着缓存不能正常工作。
但是,缓冲区是有限的内存。因此,它需要删除一些数据,以便能够加载新的数据。从磁盘和网络I/O的角度来看,加载和清除缓存是有代价的。如果你有一个经常执行的查询,那么总是加载然后清除此查询使用的数据将不会是有效的。为了处理这个问题,现代数据库使用缓冲区替换策略。
大多数现代数据库(至少SQL Server、MySQL、Oracle和DB2)使用LRU算法。
LRU是“最近最少使用”的意思。此算法背后的思想是将最近使用过的数据保存在缓存中,因此更有可能再次使用这些数据。
下面是个可视化的例子:
为了便于理解,我假设缓冲区中的数据没有被锁存器锁定(因此可以删除)。在这个简单的例子中,缓冲区可以存储3个元素:
该算法运行良好,但存在一些局限性。如果在一个大表上有一个完整的扫描呢?换句话说,当表/索引的大小超过缓冲区的大小时,会发生什么情况?使用此算法将删除缓存中的所有以前的值,而来自完整扫描的数据可能只使用一次。
为了防止这种情况发生,一些数据库添加了特定的规则。例如,根据Oracle文档:
“For very large tables, the database typically uses a direct path read, which loads blocks directly […], to avoid populating the buffer cache. For medium size tables, the database may use a direct read or a cache read. If it decides to use a cache read, then the database places the blocks at the end of the LRU list to prevent the scan from effectively cleaning out the buffer cache.”
还有其他的可能性,比如使用LRU的高级版本LRU-K。例如,SQL Server使用LRU-K,设K =2。
这个算法背后的想法是考虑到更多的历史。对于简单的LRU(也就是K=1时的LRU-K),算法只考虑数据最后一次使用的时间。LRU-K:
权重的计算非常昂贵,这就是SQL Server只使用K=2的原因。对于可接受的开销,这个值执行得很好。
当然,还有其他的算法来管理缓存:
我们只讨论了在使用数据之前读取数据的缓冲区。但是在数据库中,还可以使用写缓冲区来存储数据,并通过成批地将数据刷新到磁盘上,而不是逐个地写入数据并产生许多单独的磁盘访问。
请记住,缓冲区存储的是页面(最小的数据单元),而不是行(这是一种逻辑/人为的数据查看方式)。如果页已被修改且未写入磁盘,则缓冲池中的页是脏的。有多种算法可以决定在磁盘上写入脏页面的最佳时间,但是它与事务的概念高度相关,这是本文的下一部分。
这一部分是关于事务管理器的。我们将看到这个过程如何确保每个查询在自己的事务中执行。但是在此之前,我们需要理解ACID事务的概念。
ACID事务是一个工作单元,它确保4件事情:
在同一个事务期间,可以运行多个SQL查询来读取、创建、更新和删除数据。当两个事务使用相同的数据时,混乱就开始了。经典的例子是账户A向账户B转账。假设你有两笔交易:
我们要的事物要符合ACID:
许多现代数据库并没有将纯隔离作为默认行为,因为它带来了巨大的性能开销。SQL规范定义了4个隔离级别:
大多数数据库都添加了自己的自定义隔离级别(比如PostgreSQL、Oracle和SQL Server使用的快照隔离)。而且,大多数数据库并没有实现SQL规范的所有级别(尤其是read uncommitted级别)。
在连接开始时,用户/开发人员可以覆盖默认的隔离级别(只需添加非常简单的一行代码)。
确保隔离性、一致性和原子性的真正问题是对同一数据的写操作(添加、更新和删除):
这个问题称为并发控制。
解决这个问题最简单的方法是一个一个地运行每个事务(即顺序地)。但这是不可扩展的,而且只有一个内核在多处理器/核心服务器上工作,效率不是很高……
解决这个问题的理想方法是,每次创建或取消一个事务时:
更正式地说,这是一个日程安排冲突的日程安排问题。更具体地说,这是一个非常困难和CPU昂贵的优化问题。企业数据库承受不起为每个新事务事件寻找最佳调度而等待数小时的代价。因此,他们使用不太理想的方法,导致在冲突的事务之间浪费更多的时间。
为了处理这个问题,大多数数据库都使用锁和/或数据版本控制。因为这是一个很大的主题,所以我将重点讨论锁的部分,然后再谈一谈数据版本控制。
锁背后的想法是:
这称为独占锁。
但是,为只需要读取数据的事务使用排他锁是非常昂贵的,因为它会迫使只想读取相同数据的其他事务等待。这就是为什么有另一种类型的锁,共享锁。
共享锁:
但是,如果数据是一个独占锁,那么只需要读取数据的事务就必须等待独占锁的结束,以便在数据上放置一个共享锁。
锁管理器是给出和释放锁的进程。在内部,它将锁存储在一个哈希表中(其中的键是要锁的数据),并知道每个数据:
但是使用锁会导致两个事务永远等待一个数据:
在这个图:
这称为死锁。
在死锁期间,锁管理器选择取消哪个事务(回滚),以消除死锁。这个决定并不容易:
但是在做出这个选择之前,它需要检查是否存在死锁。
可以将哈希表视为一个图(如前面的图所示)。如果图中有一个循环,就会出现死锁。由于检查周期的开销很大(因为包含所有锁的图非常大),所以通常使用一种更简单的方法:使用超时。如果在此超时内没有给出锁,事务将进入死锁状态。
锁管理器还可以在提供锁之前检查这个锁是否会导致死锁。但同样地,完美地完成它需要很高的计算代价。因此,这些预先检查通常是一组基本规则。
确保纯粹隔离的最简单方法是在事务开始时获取锁,并在事务结束时释放锁。这意味着事务必须等待所有的锁才能启动,事务持有的锁在事务结束时释放。它可以工作,但是会浪费很多时间来等待所有的锁。
更快的方法是两阶段锁定协议(DB2和SQL Server使用),其中事务分为两个阶段:
这两个简单规则背后的想法是:
此协议可以正常工作,除非修改数据并释放关联锁的事务被取消(回滚)。你可能会遇到这样的情况:另一个事务读取修改后的值,而这个值将被回滚。为了避免这个问题,所有的独占锁必须在事务结束时释放。
当然,真正的数据库使用更复杂的系统,包括更多类型的锁(如意图锁)和更多粒度(行、页、分区、表、表空间上的锁),但原理是一样的。
我只介绍了纯粹的基于锁的方法。数据版本控制是处理这个问题的另一种方法。
版本背后的想法是:
它提高了性能,因为:
一切都比锁好,除非两个事务写相同的数据。此外,你会得到一个巨大的磁盘空间开销。
数据版本控制和锁是两种不同的观点:乐观锁和悲观锁。它们都有优点和缺点;这实际上取决于用例(更多的读和更多的写)。对于一个关于数据版本控制的演示,我推荐这个关于PostgreSQL如何实现多版本并发控制(MVCC)的非常好的演示。
有些数据库,如DB2(直到DB2 9.7)和SQL Server(快照隔离除外)只使用锁。其他像PostgreSQL, MySQL和Oracle使用了一种混合的方法,包括锁和数据版本控制。
如果你在不同的隔离级别上阅读这一部分,那么当你增加隔离级别时,你就增加了锁的数量,因此也就增加了事务等待它们的锁所浪费的时间。这就是大多数数据库在默认情况下不使用最高隔离级别(Serializable)的原因。
我们已经看到,为了提高性能,数据库将数据存储在内存缓冲区中。但是,如果在提交事务时服务器崩溃,你将在崩溃期间丢失仍然在内存中的数据,这会破坏事务的持久性。
我们可以在磁盘上写任何东西,但是如果服务器崩溃,最终会在磁盘上只写了一半的数据,这会破坏事务的原子性。
由事务执行的任何修改都必须撤消或完成。
解决这个问题有两种方法:
当在涉及许多事务的大型数据库上使用影子复制/页面时,会产生巨大的磁盘开销。这就是现代数据库使用事务日志的原因。事务日志必须存储在稳定的存储中。我不会深入讨论存储技术,但是使用(至少)RAID磁盘是防止磁盘故障的必要手段。
大多数数据库(至少Oracle、SQL Server、DB2、PostgreSQL、MySQL和SQLite)使用Write-Ahead日志记录协议(WAL)处理事务日志。WAL协议是由3条规则组成的:
这项工作由日志管理器完成。查看它的一个简单方法是,在缓存管理器和数据访问管理器(在磁盘上写入数据)之间,日志管理器在事务日志上写入每个update/delete/create/commit/rollback的操作,然后再将它们写到磁盘上。容易,对吧?
**显然不对!**在经历了这些之后,我们应该知道与数据库相关的所有内容都受到“数据库效应”的影响。更严重的问题是,如何在保持良好性能的同时编写日志。如果事务日志上的写操作太慢,则会降低所有操作的速度。
1992年,IBM的研究人员"发明"了一种增强版的WAL,称为ARIES。大多数现代数据库或多或少都在使用ARIES。逻辑可能不一样,但是ARIES背后的概念却无处不在。我之所以在发明后面加上引号,是因为根据MIT的这门课程,IBM的研究人员除了编写事务恢复的良好实践之外,什么也没做。
ARIES stands for Algorithms for Recovery and Isolation Exploiting Semantics.
这项技术的目的是双重的:
数据库必须回滚事务的原因有很多:
有时(例如,在网络故障的情况下),数据库可以恢复事务。那这是怎么实现的呢?要回答这个问题,我们需要理解存储在日志记录中的信息。
事务期间的每个操作(添加/删除/修改)都会生成一个日志。该日志记录由以下部分组成:
而且,磁盘上的每个页面(存储数据,而不是日志)都有最后一个修改数据的操作的日志记录(LSN)的id。LSN的给定方式更加复杂,因为它与日志的存储方式相关联。但想法是一样的。
ARIES只使用逻辑UNDO,因为处理物理UNDO实在是一团糟。
注意:据我所知,只有PostgreSQL没有使用撤销。它使用一个垃圾收集器守护进程来删除旧版本的数据。这与PostgreSQL中数据版本控制的实现相关联。
每个日志都有一个唯一的LSN。被链接的日志属于同一个事务。日志按时间顺序链接(链表的最后一个日志是最后一个操作的日志)。
为了避免日志写入成为主要瓶颈,需要使用日志缓冲区。
当查询执行器要求修改时:
当提交事务时,它意味着对事务中的每个操作执行步骤1、2、3、4、5。写入事务日志是快速的,因为它只是“在事务日志的某个地方添加了一个日志”,而在磁盘上写入数据则更为复杂,因为它是“以一种快速读取数据的方式写入数据”。
STEAL策略和FORCE策略
出于性能原因,第5步可能在提交之后执行,因为在发生崩溃的情况下,仍然可以使用REDO日志恢复事务。这被称为NO-FORCE。
数据库可以选择一个FORCE策略(即必须在提交之前执行步骤5)来降低恢复期间的工作负载。
另一个问题是选择数据是在磁盘上一步一步地写入(STEAL policy),还是缓冲区管理器需要等到提交命令才立即写入所有内容(NO-STEAL)。在“STEAL”和“NO STEAL”之间进行选择取决于你想要什么:使用UNDO日志进行长时间恢复的快速写入还是快速恢复?
以下是这些策略对Recovery的影响的总结:
Recovery
现在,我们有了很好的日志,让我们使用它们!假设新实习生破坏了数据库(规则1:这总是实习生的错)。重新启动数据库,恢复过程就开始了。
ARIES从崩溃中恢复过来需要三个步骤:
对于每个日志,恢复过程读取磁盘上包含要修改的数据的页面的LSN。如果LSN(page_on_disk)>=LSN(log_record),这意味着在崩溃之前已经在磁盘上写入了数据(但是该值被在日志之后和崩溃之前发生的操作所覆盖),所以什么也不做。
如果LSN(page_on_disk) 即使对于将要回滚的事务,也会进行redo,因为它简化了恢复过程(但我相信现代数据库不会这样做)。 在恢复期间,必须警告事务日志有关恢复过程所做的操作,以便磁盘上写入的数据与事务日志中写入的数据同步。解决方案可能是删除正在撤消的事务的日志记录,但这非常困难。相反,ARIES在事务日志中写入补偿日志,逻辑上删除被删除事务的日志记录。 当一个事务被“手动”取消,或者被锁管理器取消(以停止死锁),或者仅仅是因为网络故障,那么分析这一步就不需要了。事实上,关于REDO和UNDO操作的信息可以在2个内存表中找到: 缓存管理器和事务管理器为每个新事务事件更新这些表。因为它们位于内存中,所以当数据库崩溃时它们就会被销毁。 分析阶段的工作是使用事务日志中的信息在崩溃后重新创建这两个表。为了加速分析过程,ARIES提供了检查点(CheckPoint)的概念。其思想是不时地在磁盘上写入事务表和脏页表的内容以及写入时的最后一个LSN,以便在分析过程中,只分析这个LSN之后的日志。 但是数据库包含更多的智能。例如,我没有谈论一些敏感的问题,比如:
总结