偶然之间读了Christophe写的How does a relational database work,感觉获益良多,所以在征得他本人书面同意之后,在此把这篇文章翻译成中文。英文水平和技术能力有限,有任何问题,欢迎指正。
当我们说起关系型数据库,我不得不想有些东西丢失了。它们在任何地方都被使用。有很多种数据库,从小巧有用的SQLite到功能强大的Teradata。但是只有一小部分文章介绍数据库是如何工作的。你可以去谷歌上搜索“关系数据库是如何工作的”会发现此类结果是多么的少。此外,这些文章也很短。现在,如果你搜索最新的趋势技术(大数据、NoSQL或者JavaScript),你会发现很多讲解深入的文章介绍他们是如何工作的。
难道是关系型数据库技术太老还是太令人厌倦以致于在大学外的课程、论文和书本之外很少有文章介绍它们。
作为一个开发人员,我很讨厌自己使用的技术是自己不懂的。并且,一种技术如果被使用了40年,肯定是有原因的。过去几年,我花了很多时间去认真的了解这些我每天使用的怪异的黑盒子。关系型数据库是非常有趣的,因为它们基于有用和可重用的概念。如果你有兴趣了解数据库但是你又没有时间或没有意愿深入这个广括的主题,你可能会喜欢这篇文章。
虽然文章的标题是直截了当的,本文的目的不是了解如何使用数据库。因此,你应该早就知道如何写简单的join查询和基本的CRUD查询;否则你可能不能读懂这篇文章。你只需要知道这些,其余的我会介绍。
我会从一些计算机科学的东西开始,例如时间复杂度。我知道你们中一些人憎恶这个概念,但是如果没有它,你不可能了解到一个数据库内部的巧妙设计。因为这也不是一个大的主题,我会关注于我认为我重要的部分:数据库处理SQL查询的方式。我只会展示数据库的基本概念所以在文末你会清楚地了解到在数据库内部发生了什么。
因为这是一篇长的技术文章,包含了很多算法和数据结构,请花费一些时间认真阅读。一些概念会非常难懂,你可以跳过,它们不会影响你了解整体的思想。
很久以前,开发人员需要准确的知道他们写的代码中有多少个操作。他们深知他们的算法和数据结构因为他们浪费不起他们慢计算机的CPU和内存。
在这部分,我会提醒你一些概念因为它们对于理解数据库非常重要。同时我也会介绍数据库索引的概念。
时下,许多开发人员不关心时间复杂度…他们也没错。
但是当你处理大量数据(我说的不是数千)或者如果你需要达到毫秒级,就迫切的需要了解这些概念。再或者是数据库需要同时处理这两种情况!我不会烦你很长时间,只是现在需要了解这个概念。后期会用到它来理解基于花费的优化。
时间复杂度被用来理解某个算法需要花费多长时间来处理给定量的数据。计算机科学家用数学上的大O符号来描述这种复杂度。这个符号和一个函数一起用来描述处理给定量的输入数据时某个算法需要多少次操作。
举个例子,当我说这个算法是O(some_function()),它指的是给定一定量的数据,这个算法需要some_function(a_certain_amount_of_data)操作来处理这些数据。
重要的不是数据量有多少,而是操作数增加的方式。时间复杂度不会给出精确的操作数值,但这也许是个好主意。
在上图中,你可以看到不同类型的时间复杂度的演变。我用对数标度绘制它。换句话说,数据量快速的从1增加到10亿。我们可以看出:
当只有少量的数据的时候,O(1)和O( n2 n 2 )的区别是微不足道的。例如,我们举个例子, 假如我们有一个算法需要处理2000个元素。
O(1)和O( n2 n 2 )算法看上去有40亿的差别,但是你最多只是慢了2ms, 只是一眨眼的时间。 事实上,目前的处理器每秒可以处理十亿级的数据。这就是为什么性能和优化不是很多IT项目问题的原因。
正如我之前说的,在面对大量数据的时候,了解这个概念还是很重要的。 如果现在算法需要处理1百万数据(对目前的数据库来说不是很大):
我没有实际算但是我可以说如果用O( n2 n 2 )算法,等待算法完成我们可以喝杯咖啡(甚至可以再喝一杯)。如果数据量再加个0,你将可以小睡一会儿。
给你一个概念:
我们有多种类型的时间复杂度:
* 普通的实例场景
* 最好的实例场景
* 最差的实例场景
时间复杂度一般是最差的实例场景。
我只讨论时间复杂度,但是复杂度也包含:内存消耗算法和I/O消耗算法。
当然有比n*n更差的复杂度,例如:
注意:我不会讲大O概念的定义,而仅是概念了解。你可以通过阅读Wikipedia来了解大O的定义。
当你排序一个集合的时候你需要做什么?你会调用sort()方法…好的,好答案…但是对于一个数据库来说你fsdv知道sort()函数是如何工作的。
有很多种好的排序算法,因此我会专注于最重要的一种:归并排序。你可能不能马上了解为什么排序数据是重要的,但是你需要在查询优化部分之后了解。此外,了解归并排序会帮助我们后期了解一个通用数据库join操作:归并join。
像很多有用的算法一样,归并排序基于一个技巧:合并2个有序的N/2长度的数组成一个N长度的有序数组仅花费N个操作。这个操作叫作合并。
让我们通过一个简单的例子来了解这是什么意思:
通过上图你可以发现,为了构造最终长度为8的有序数据,你只需要迭代一次这两个长度为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
array result := merge(new_left_array,new_right_array);
return result;
归并排序把问题分成小的问题然后找到小问题的结果之后再获取初始问题的结果。(注意:这种算法叫划分和征服)。如果你不了解这个算法,不要着急;我起初第一次看到它的时候也不了解。如果这样子可以帮你理解,我把这个算法看成两阶段算法:
划分阶段会把数组划分成更小的数组。
排序阶段会把小数组合并到一起来构建成一个更大的数组。
在划分阶段,数组通过3步被分成长度为1的数组。步骤数的正式算法是log(N)。 (因为N=8, log(N) = 3).
我是怎么知道的?
数学算出来的。 算法是每次划分数组大小除以2。步骤数就是你可以把初始数组一分为2的次数。这就是对数的精确定义(基于2)。
在排序阶段,我们从长度为1的数组开始。在每一步中,我们应用多次合并操作并且总共花费N=8次操作。
这种算法为什么会这个强大?
因为:
你可以修改算法来达到减少内存占用的目标,这种方式你不需要创建新的数组而仅仅是修改输入数组。注意:这种算法叫做in-place。
你可以修改算法来达到使用磁盘和少量内存同时不需要大量磁盘I/O。这种想法是仅加载当前处理中的部分数量到内存中。当你需要排序好几G大小表数据但只有100M内存缓存时,这就显得很我还要了。
注意:这种算法叫做external sorting。
你可以修改算法使它运行在多处理器/线程/服务器中。例如,分布式归并排序是Hadoop(大数据框架)的核心组件。
这种算法可以把铅变成黄金(真实存在!)。
这种排序算法用在大多数的数据库中,但不是唯一的一种。如果你想要了解更多,可以阅读这篇论文它是关于数据库通用排序算法正反两方面。
既然你已经了解了时间复杂度和排序的概念,我不得不告诉你三种数据结构。
这是重要的,因为它们是现代数据库的骨干。我还会介绍数据库索引的概念。
二维数组是最简单的数据结构。一个表可以看作是一个数组。例如:
这个二维数组是一个包含行和列的表:
虽然它用来存储和展示数据非常好,但是当你想要查询一个特定的值的时候,它就有点糟糕了。
例如,如果你想要找到所有在UK工作的人,你需要查询所有的行来判断这一行是否属于UK。这会花费N次操作(N是行数)这也不差但是是否可以有更快的方式?这就需要树来表演了。
注意:大部分现代数据库提供先进的数组来高效的存储表,例如堆-组织表或索引-组织表。但是它们不能改变在一组列上快速查询一个特定条件的问题。
二叉搜索树是一个二叉树有如特性,每个节点上的值必须:
1. 大于所有存储在左子树的值
2. 小于所有存储在右子树的值
让我们直观的看下这是什么意思。
这棵树有N=15个元素。假如我们要找208:
现在假如我们要找40:
知道行id可以让我知道数据精确地存放在表的哪里,因此我们可以马上获取数据。
最终,两次查询都花费了我们树层级数的操作次数。如果你仔细阅读了归并排序部分,你就应该知道我们有log(N)层。因此查询的花费是log(N),不差吧!
再回到我们的问题
这个东西非常抽象,因此让我们回到我们的问题。假设在之前的表上country的字段是string类型而不是integer。假设你有一颗树包含了表中的列country:
如果你想要知道谁在UK工作,你通过查找这颗树来找到代表UK的结点,在UK结点中你会找到这些行的数据存储位置。
这个搜索只会花费你long(N)次操作而不是N次操作(如果你直接使用数组)。你刚刚构想的就是一个数据库索引。
你可以为任意组合的列构建一个树的索引(一个string, 一个integer,2个strings, 一个integer和一个string,一个日期…)只要你有一个函数来比较这些健(也就是列的组合),这样你就可以在这些键中建立顺序。
B+Tree索引
虽然这种树可以很好的获取特定的值,但是当你想要查询两个值之间多个元素的时候会有一个大问题。它将会花费O(N)次操作,因为你需要查询树的每个元素,并且判断它是否在这两个元素之间(例如,顺序的遍历树)。此外,这个操作也不是I/O友好的,你需要读整个树。我们需要找到一种高效的方式来做范围查询。要解决这个问题,现代数据库使用一种之前树改版后的树叫B+树。在一颗B+树中:
就像你看到的,现在会有更多的结点(两倍多)。事实上,会有额外的结点,这些“决策结点”会帮助我们找到正确的结点(相关表中存储行数据位置信息)。但是查询复杂度还是O(log(N)) (只是多了一个层)。最大的区别是最底层的结点会链接到它们的后继者。
用B+Tree,假设我们在查询40到100之前的数据:
假如说我们查询M个后继者,然后树有N个结点。查询一个特定的结点花费log(N),但是,一旦你有了这个结点,你就可以用M次操作通过链接到后继者的链接获取M个后继者。这个查询只花费M+log(N)次操作而之前树的操作需要花费N次操作。而且,你不需要读所有的树,这意味着更少的磁盘使用。如果M(例如200行)比较小,N比较大(例如1 000 000行),这就会有重大的意义。
但是现在有新的问题。如果你增加或者移除一行数据库的数据(因此在相关的B+树索引上):
换而言这,B+树需要自排序和自平衡。谢天谢地,这可以通过巧妙的删除和查询操作来达到。但是这会有消耗:在B+树上插入和删除时间复杂度是O(log(N))。这就是为什么有些人听说过使用太多的索引不是一个好主意。实际上,这是在减慢快速的插入/更新/删除表中某一行数据,因为数据库需要更新索引它会花费O(long(N))次操作每个索引。此外,增加索引对于事务来说意味着更多的工作量(我们将会在文末了解到事务管理)。
如果需要了解更多的B+树详情,你可以查询Wikipedia B+ Tree的文章。如果你想要一个数据库中B+树实现的例子,可以看这篇和这篇MYSQL两位核心开发人员写的文章。他们都关注innoDB引擎如何处理索引。
注意:有个读者告诉我,由于底层优化,B+树需要被完全的平衡。
我们要介绍的最后一个重要的数据结构是哈希表。如果你想快速查询值它会非常有用。此外,了解哈希表可以在后面帮助我们了解一个能用的数据库操作叫哈希join。这种数据结构也被数据库用在存储一些内部组件(例如锁表和缓存池,我们会在后面的概念中看到它们)。
哈希表可以快速地通过键查询元素。如果要构建一个哈希表,你需要定义:
简单的例子
让我们来看一个例子:
这个哈希表有10个桶。因为我懒所以我只画了5个桶但是我知道你们很聪明,因此我让你想象其余5个。我使用的哈希函数是键模10。换而言之,我只保存元素的键的最后一位数字来找它们的桶:
如果最后一位数字是0,元素会在桶0,
如果最后一位数字是1,元素会在桶1,
如果最后一位数字是2,元素会在桶2,
…
我使用的比较函数是简单的两个Integer相等。
假如说我们想要找到元素78:
现在假设你要找元素59:
一个好的哈希函数
就像你所看到的,依赖于你所要找的值,花费是不一样的。
如果我现在改变哈希函数用键模1 000 000(用最后6们数字),第二个操作只花费一个操作,因为没有元素在桶000059。真正的挑战是要找一个好的哈希函数,会创建桶并且包含很少量的元素。
在我的例子中,找一个好的哈希函数是简单的。但是这是一个简单的例子,找一个好的哈希函数在下列情况下会更难当键是:
如果有一个好的哈希函数,查询一个哈希表的时间复杂度是O(1)。
数组与哈希表比较
为什么不使用数组?
嗯,这是一个好问题。
一个哈希表可以被部分加载到内存,其它桶可以在磁盘中。
使用数组你不得不使用连续的内存空间。如果你加载一个大的表,分配足够的连续空间会非常困难。使用哈希表,你可以选择你想使用的键(例如国家和人的姓)。
如果要查看更多的信息,你可以读我的文章 Java HashMap,这是一个非常高效的哈希表实现;你不需要了解Java来了解文中的概念。
我们刚刚看了数据库中的基本组件。我们现在需要回头看看整体情况。
一个数据库是信息的集合,它们可以简单的被访问和修改。但是一个简单的文件串也可以做这些事情。事实上,最简单的数据库像SQLite除了一串文件之外也没有其它东西了。但是SQLite是一个精心制作的文件串,因为它允许你:
通常情况下,数据库可以被认为是下面的图:
在写这部分之前,我读了很多书和论文,而且它们fjtb有自己的表式数据库的方式。因此,不要在我如何组织数据或者我怎么命名流程关注太多,因为我做了一些选择来配合文章的计划。真正我还要的是不同的组件;总的来说一个数据库被分成多个相互作用的组件。
核心组件:
工具:
查询管理:
数据管理:
文中的其它部分,我会关注在下面的步骤中数据库是如何管理SQL查询:
客户端管理是数据库处理客户端连接的组件。客户端可以是一个网络服务器或者是终端用户/终端程序。客户端管理提供不同的方式通过一系列众所周知的APIS:JDBC、ODBC、OLE-OB…来访问数据库。
它也可以提供专门的数据库访问APIS。
当客户端连接数据库:
这部分是正是数据库强大的基石。 在这部分,一条写的不好的查询被转换成一个段可热行的代码。代码之后会被执行,结果会返回给客户端。它是一个多步骤操作:
读完这部分,如果你想要更好的了解我推荐你阅读:
为了检查语法是否正确每条SQL语句都被发送到解析器。如果你的语句弄错了,解析器会拒绝这条语句。例如,如果你写“SLECT …”代替“SELECT …”,故事到此为止。
但是它的功能不仅是这些。它还可以检查关键字的顺序。例如当WHERE出现在SELECT之前就会拒绝。
然后,语句中的表和字段会被分析。解析器使用数据库的元数据来检查:
然后它检查你是否有读或写查询语句中表的权限,这些表的访问权限是DBA设置的。
在解析阶段,SQL语句转换成内部的表式方式(通常是一棵树)。
如果解析没问题,内部的表达式被发送到查询重写器。
在这个步骤中,我们有一个内部的语句表式。重写的目的是:
重写在语句上执行一系列即定的规则。如果查询匹配一条规则的模式,这条规则就会被应用,然后查询会被重写。这里有部分规则列表:
视图合并:如果你在语句中使用视图,视图会转换成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%';
*(高级)实体化视图重写:如果你有一个实体化的视图匹配语句中的一些谓词子集,重写器会检查视图是否最新的然后修改语句来用实体化的视图替换裸表。
*(高级)自定义的规则:如果你有自定义的规则要修改一条语句,然后重写时会执行这些规则。
*(高级)联机分析转换:分析或者窗口函数,star joins,rollup…也会转换(但不确定是在重写还是在优化的时候,因为两个进程非常相似,它们的实现依赖于数据库)。
之后重写语句会被发送到查询优化器,然后有趣的地方开始了。
在我们了解数据库如何优化一条语句之前,我们需要先说说统计,因为没有统计数据库是很蠢的。如果你不告诉数据库让它分析自己的数据,它不会这么做,然后它会做很差的假设。
但哪种信息是数据库需要的呢?
我需要简单的谈论数据库和操作系统是如何存储数据的。它们使用最小的单元叫页或者块(默认是4或者8kb)。这意味着如果你只需要1Kb,它还是会花费你一页内存。如果一页内存占用8Kbs,你将浪费7Kbs。
回到统计!当你让一个数据库获取统计信息,它会像这样计算值:
这些统计会帮助优化器来评估查询语句对磁盘I/O,CPU和内存的使用情况。
每列的统计很重要。例如PERSON表需要两列LAST_NAME, FIRST_NAME来连接。借助于统计,数据库可以知道FIRST_NAME有1000个同的值,LAST_NAME有1000000个不同值。因此,数据库会选择LAST_NAME, FIRST_NAME来连接,而不是FIRST_NAME,LAST_NAME,这样会产生更少的比较。因为LAST_NAME的取值不太可能会一样,因此大部分情况下比较2到3个字符就可以了。
但是这些是基本的统计信息。你可以让数据库计算更高级的统计叫柱状图。柱状图是统计信息会告知列中数据库的分布情况。例如:
* 最频繁的值
* 分位数
…
这些额外的统计信息可以帮助数据库找到更佳的查询计划。尤其是对于等式谓词(例如:WHERE AGE = 18)或范围谓词(例如:WHERE AGE > 10 and AGE <40)因为数据库会产生关于这些谓词行数的更好的想法。(统计中这些科技单词都是选择性的。)
统计信息存储在数据库的元数据中。例如你可以发现未分区表的统计信息:
这些统计信息必须是最新的。没有比数据库认为一个表只有500行数据然而实际上它有1000000行。统计唯一不好是它需要花费一些时间计算。这就是为什么在大多数数据库中它们不是自动计算的原因。在百万数据量中计算统计信息就变困难了。因此,你可以选择只计算基本信息或者在样本数据库中计算统计信息。
例如,当我在一个项目中处理每表十亿行数据时,我选择只计算其中10%数据量的统计信息,这样会省很多时间。这个例子可能最终变成一个坏的决定,因为偶尔的从10GORACLE某个表的某个列选择的10%数据可能会和总共100%非常不同(这种情况一般不太会在100M行的表中发生)。这些错误的统计信息会导致查询偶尔8小时而不是30秒;找到原因真是个恶梦。这个例子告诉我们统计是多么重要。
注意:当然每个数据库还有更多的特定统计。如果你想要知道更多信息,读数据库的文档。我尝试了解统计信息是如何使用的,然后我发现最好的官方文档来自PostgreSQL。
所有的现代数据库都使用基于消耗的优化(CBO)来优化查询。这个概念是把每步操作算一次消耗,然后找到最好的方式来减少查询的消耗,然后使用最便宜的操作链来获取结果。
要了解消耗优化是如何工作的,我认为先感受一下任务背后的复杂度是很好的。在这部分,我将向你展示3种通用的连接2张表的方式,我们马上就会发现即使是一条简单的连接查询优化赶来也是恶梦般的。然后,我们会了解实际的优化程序是如何做的。
对于这些连接,我会关注于它们的时间复杂度,但是一个数据库优化程序会计算它们的CPU消耗,磁盘I/O消耗和内存需求。时间复杂度和CPU消耗不同之处在于时间消耗是非常近似的。对于CPU消耗,我需要统计每一个操作例如一个加操作,一个if语句,一个乘法操作,一次迭代操作…此外:
使用时间复杂度是简单的,而且通过它我们也可以了解CBO的概念。我有时也会谈磁盘I/O,因为它是个重要的概念。记住数据库的瓶颈基本上是磁盘I/O而不是CPU。
我们看到了B+树然后来讨论索引。只要记住这些索引是有序的。
供参考,还有其它类型的索引像位图索引。它们不提供和B+树相同的CPU,磁盘I/O和内存开消。
此外,更多的当代数据加库可以动态的为当前的查询创建临时的索引,如果这样可以改进执行计划的消耗。
在应用你的连接操作之前,你首先需要获取你的数据。这里就是你如何获取你的数据。
注意:由于和所有访问路径相关的实际问题都是磁盘I/O,因此我不想过多的谈论时间复杂度。
全表扫描
如果你曾经读过一篇执行计划,你肯定看过full scan这个词。全表扫描简单理解就是数据库完整的读一张表或者一个索引。由于磁盘I/O,全表扫描明显地比全索引扫描消耗更多。
范围扫描
还有其它类型的扫描像索引范围查询。举例,当你使用谓词像“WHERE AGE > 20 AND AGE <40”的时候它会被用到。
当然,你需要在字段AGE上建一个索引来使用范围查询。
我们在第一部分就知道范围查询的时间消耗是log(N) +M,N是索引中的数据量,M是估算的在范围中的行数。幸亏有统计,N和M都是知道的。此外,范围查询你不需要读整个索引,因此磁盘I/O消耗比全索引查询要少。
唯一扫描
如果你只需要索引中的一个值你可以使用唯一扫描。
通过行号访问
通常情况下,如果数据库使用索引,它将不得不查询索引关联的行。这样做它会用行号来访问。
例如:如果你想要这么
SELECT LASTNAME, FIRSTNAME from PERSON WHERE AGE = 28如果你在PERSON表的age字段上有索引,优化器会使用索引来查询所有28岁的人,然后它会请求表中相关的行,因为索引只有年龄信息,然而你想要知道lastname和firstname。
但是,如果你现在想要这么做
SELECT TYPE_PERSON.CATEGORY from PERSON ,TYPE_PERSON
WHERE PERSON.AGE = TYPE_PERSON.AGE
PERSON上的索引会用来与表TYPE_PERSON连接,但是表PERSON不会被通过行号访问,因为你不会请求这张表上的信息。
它仅仅在少量访问的时候工作的很好,这个操作真正的问题是磁盘I/O。如果你需要太多次通过行号访问,数据库可能会选择全扫描。
其它的路径
我没有展示所有的访问路径。如果你想了解更多,你可以读Oracle文档。路径名称可能和其它数据库不一样,但背后的概念是一样的。
现在,我们知道如何获取我们的数据,我们来连接它们。
我将展示3种通用的连接操作:归并连接,哈希连接和嵌套循环连接。但这之样,我需要介绍新的词汇:内关联和外关联。一种关联可以是:
* 一个表
* 一个索引
* 之前操作的一个中间结果(例如一次之前连接操作的结果)
当你在连接两个关系的时候,连接算法用不同的方式管理两个关系。在本文的其它地方,我假设:
* 外部关系是左边的数据集
* 内部关系是右边的数据集
例如,A连接B是A和B之间的连接,A是外关系而B是内关系。大部分情况下,A连接B的消耗和B连接A的消耗是不一样的。
在这部分,我还会假设外关系有N个元素,内关系有M个元素。牢记真实的优化器可以通过统计知道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
通过这个版本,时间复杂度还是一样,但是磁盘访问减少了:
记住:每次磁盘访问都获取了比之前算法多的数据,但是这没有关系,因为它们是顺序访问的(机械磁盘的真实问题是找到第一条数据的时间)。
哈希连接
哈希连接是更复杂的,但是它在很多情况下会比嵌套循环连接有更少的花费。
哈希连接的算法是:
1)获取所有的内关系的数据
2)建立一个内存哈希表
3)逐条获取外关系的所有元素
4)计算每个元素的哈希值(用哈希表的哈希算法)来找到关联的内关系的桶
5)从桶内查找是否有和外元素相匹配的元素
根据时间复杂度,我需要做一些假设来简化问题:
内关系被分成X个桶。
哈希函数把哈希值几乎把两边的关系一致的散列。换句话说,桶的大小基本相同。
外关系元素和桶内所有元素的匹配都花费桶内元素数据的次数。
时间复杂度是(M/X) * N + cost_to_create_hash_table(M) + cost_of_hash_function*N。
如果哈希函数创建了足够多大小很小的桶,那么时间复杂度是O(M+N)。
这是另一个哈希连接的版本,它更内存友好但是磁盘I/O不太友好。这次:
1)计算两边的哈希表
2)然后把它们放到磁盘中
3)然后逐桶比较两个关系(把一个加载到内存中,另一个逐行读)
归并连接
归并连接是唯一产生有序结果的连接。
注意:在这个简化了的归并连接中,没有内或外的表;他们都扮演了相同的角色。但真实的实现会有不同,例如,当处理重复。
归并连接可以分成两个步骤:
排序
我早就说过归并排序,在这个场景中,归并排序是不错的算法(但不是最好的,如果内存没有问题的话)。
但有时候数据集已经排好序了,例如:
Merge join
这部分和归并排序中我们看到的合并操作很像。但这次,我们不会把两边关系中每个元素都捡赶来,我们只是把两边关系中相等的元素拿走。下面是想法:
1) 比较两边关系中的两个当前元素 (current=first for the first time)
2) 如果他们相等,然后你把两个元素放到结果中,然后再转到两个关系的下一个元素。
3) 如果不相等,转到小一点元素的下一个元素 (因为下一个元素可能匹配)
4) 然后重复1,2,3直到匹配关系的最后一个元素。
因为关系是有序的,因此它能工作并且你也不需要回头。
这个算法是简化了的版本,因为你不需要处理相同数据出现多次的场景(多重匹配)。实际的版本加上这样的案例会更复杂,因此我选择了一个简化版本的。
如果两个关系都是排过序的,时间复杂度是O(N+M)。
如果两边关系需要被排序,排序时间复杂度是O(N*Log(N) + M*Log(M))。
下面有一个可能可以处理多重匹配的算法(记住:我不是100%确认这个算法):
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
哪种更好?
如果有一种最好的join类型,那就不会有多种类型了。这个问题非常困难因为许多因素会发生,例如:
如果需要更多的信息:你可以读DB2,ORACLE,SQL Server的文档。
我们刚看了3种类型的例子。
假设我们需要连接5张表来查询一个人的全部视图。一个人可以有:
换语话说,我们需要快速知道下面查询的答案:
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
做为一个查询优化器,我不得不找到处理数据的最佳方式。但是这里有两个问题:
我有3个可选的连接(哈希连接,归并连接,内连接),可使用0,1或2个索引(更别说有不同种类的索引)。
例如,下面的图片展示了4张表3个连接操作的,可能的执行计划:
因此下面就是可能的场景:
1) 使用暴力方法
使用数据库统计,我计算每个可能计划的消耗,并且保存最好的一个。但是现在有很多可能性。对于一个有序的连接,每个连接有三种可能性,哈希连接,归并连接,内连接。因此,对于一个给定连接顺序的连接有$3^4$种可能性。连接顺序是二叉树的排列问题,有(2*4)!/(4+1)!种可能的顺序。对于这个非常精简的问题,最终有$3^4$*(2*4)!/(4+1)!种可能性。
非极客术语,它意味着有27 216可能的计划。如果我现在增加归并连接可能使用0,1或2B+树索引的可能性,可能的计划变成了210 000个。我是不是忘了说这个查询很简单?
2) 我哭了不干了
这非常诱人,但是你不能得到结果,而且我需要钱付账单
3) 我只尝试一些计划,然后用消耗最低的一个
因为我不是超人,我不能计算每个执行计划的消耗。代替的,我可以**任意选择所有可能计划的子集**,计算它们的消耗并且把子集最好的执行计划给出来。
4)我应用**聪明的规则来减少可能计划的数量**。
有两种类型的规则:
我可以使用逻辑的规则来移除无效的不可能的,但是它不能过滤很多可能的计划。例如:嵌套循环连接的内关联必须是最小的数据集。
我可以接受找不到最佳的解决方案,然后应用很多侵略性的规则来减少很多可能的数量。例如,如果关系很小,用嵌套循环连接,从不使用归并连接或者哈希连接。
在这个例子中,我最终会有很多可能性。但是真实的查询会有其它的操作像OUTER JOIN, CROSS JOIN, GROUP BY, ORDER BY, PROJECTION, UNION, INTERSECT, DISTINCT … 这意味着有更多的可能性。
那么,一个数据库会怎样选择连接方式?
关系数据库尝试多种我刚说的方式。优化器的工作是在有限的时间内找到一个好的解决方案。
大多数时间一个优化器不会找最好的但是会找一个好的。
对于小的查询,暴力方式也是可行的。但是这里有个方法可以避免没必要的计算,这样尽管中等的查询也可以使用暴力方法。这叫做动态编程。
动态编程
这两个词背后的思路是因为许多执行计划都非常相似。如果你看下而的计划:
重叠树优化动态编程
他们共享相同的 (A JOIN B)子树。因此,代替计算在每条计划中都计划这个子树的消耗,我可以计算一次,保存计算的结果然后当我再看到子树的时候重用它。更正式的,我们在面对重叠问题。为了避免额外的计算部分结果,我们使用备忘。
使用这个技术,代替(2*N)!/(N+1)! 时间复杂度,我们只需要 3N 3 N 。在我们之前例子中有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]
对于更大的查询你也可以做一次动态编程方法,结合额外的规则(启发式)来消除一些可能性:
* 如果我们分析某种类型的计划(例如:左-深树,如下图所示)最终会是$n*2^n$,而不是$3^n$。
* 如果我们增加逻辑规则来避免一些模式的计划(例如,谓词是在表中某个索引上,不要尝试在表上归并连接,而仅是在索引上)。它会减少可能性的次数而不会把最佳的可能方案也伤害到。
* 如果我们在流程上增加规则(例如,在所有其它关系操作之前执行连接操作)也会减少许多可能性。
* …
贪婪算法
但是对于非常大的查询,或者是为了非常快的答案(而不是非常快的查询),另一种类型的算法被使用,贪婪算法。
贪婪算法的思想是根据一种规则来增量式的创建执行计划。通过这个规则,贪婪算法在某时找到某个问题的最佳解决方案。算法从一个连接的查询计划开始,然后,每一个步骤中,算法用相同的规则给查询计划增加一个新的连接。
让我们来看一个简单的例子。假我们有一个查询有4个连接来关联5个表(A,B,C,D和E)。为了简化问题,我们仅把内联接当作可行的连接。我们用规则“使用最小的消耗的连接”。
由于我们随意的从A开始,我们可以把相同的算法应用到B上,然后是C,再是D,再是E。最终我们能找到最小消耗的计划。
顺便说一下,这个算法有个名称:叫做最近邻居算法。
我不会深入这个算法的细节,但是如果有好的模型并且通过N*log(N) 排序后,这个问题可以很简单的被解决。算法的时间复杂度是O(N*log(N)) 相对于 O( 3N 3 N )的全动态编程版本。如果你有一个大查询有20个连接,这意味着26对3 486 784 401,有很大的不同。
贪婪算法的问题是,我们假设找到2个表中最好的连接会给我们最小的消耗,如果我们保存这个连接,然后再增加一个连接。但是:
尽管A连接B会有最好的消耗,但是在A,B和C之间,(A JOIN C) JOIN B可能会比(A JOIN B) JOIN C有更小的消耗。为了提升结果,你可以使用不同规则跑多个贪婪算法,然后保存最好的计划。
其它算法
如果你讨厌算法,跳到下一个部分,我将说的对于文中的其它部分来说不是很重要。
对于很多计算机研究人来说,找到最好的可行计划是一个活跃的研究主题。他们经常试图为更多的精确问题的模式找到更好的解决方案。例如:
一些算法在替代动态编程方面对于大查询来说还是很蠢的。贪婪算法属于启发式算法中的一员。一个贪婪算法遵循一个规则,把之前步骤找到的结果保存下来,并且把当前步骤找到的结果追加到后面。一些算法遵循一个规则,并且通过逐步的方式应用上去,但是也不经常保存之前步骤最好的解决方案。它们通常被叫做启发式算法。
例如,遗传算法遵循一个规则,但是上一步的最佳解决方案经常不被保存:
* 一个解决方案代表一个可能的全查询计划。
* 在每步中保存P种方案而不仅是一个方案。
* 0) 随机的创建P个查询计划。
* 1) 消耗最小的计划被保留下来。
* 2) 这些最好的计划混合在一起产生P个新的计划。
* 3) 某些新的P个计划随机的被修改。
* 4) 步骤1,2,3被重复T次。
* 5) 然后你保留最后一次循环的最佳计划。
循环次数越多,计划会更好。
这是不是有魔力?这是大自然的规则,适者生存!
供参考,遗传算法在PostgreSQL中实现了,但是我不知道他们是否默认被使用。
还有其它的遗传算法数据库中使用,像模拟退火算法,迭代提高算法,两阶段优化…但是我不知道它们是否在企业级数据库被应用或者只是在研究型数据库中。
要想获取更多信息,你可以阅读下面的论文,它们展示更多可行的算法:数据库查询优化中对于连接排序问题的算法概览
你可以跳到下个部分,我将要说的不是很重要。
这些都是学术性的,作为一个开发人员而不是研究人员,我喜欢具体的例子。
让我们来看下SQLite优化器是如何工作的。它是一个轻量级的数据库,因此它使用简单的基于贪婪算法的优化–使用额外的规则来减少可能的次数。
等等…我们早就看过这个算法。好巧!
让我们来看另外的优化器是怎么做的。IBM DB2像其它所有的企业级数据库,但是我将专注于它,因为它是我在转换到大数据之前使用的最后一个关系数据库。
让我们来看看官方文档,我们了解到DB2优化器允许我们使用7种级别的优化:
使用贪婪算法来做连接
使用动态编程来做连接
我们可以发现DB2使用贪婪算法和动态编程。当然,他们不会共享它们使用的启发式,因为查询优化是数据库最强大的部分。
供参考,默认级别是5。默认优化器使用下面的特性:
默认情况下,DB2使用动态编程和有限制的启发式算法来做连接排序。
其它的条件(GROUP BY,DISTINCT…)通过简单的规则来处理。
由于创建查询计划花费时间,大部分数据库把计划存储进查询计划缓存来避免不必要的相同查询计划的计算。这是一个大的主题因为数据库需要知道何时更新过时的计划。想法是放一个临界值,如果表的统计信息变了超过了这个临界值,包含这个表的查询计划会从缓存中移除。
在目前阶段,我们有一个优化的执行计划。它被编译成可执行的代码。然后 ,如果有足够的资源(内存,CPU),它被查询执行器执行。计划中的操作(JOIN, SORT BY…)可以被串行或并行的方式执行;由执行器决定。查询执行器通过和数据管理器交互来获取和写数据,这是本文的下一个部分。
在这个阶段,查询管理器在执行查询,然后需要表和索引的数据。它请求数据管理器来获取数据,但这里有两个问题;
* 关系数据库使用事务模型。因此,佻不能在任何时候获取任何数据,因为有可能同一时间其它人在使用或修改数据。
* **数据获取是数据库最慢的操作**,因此数据管理器需要足够聪明在内存缓存中获取和保存数据。
在这部分,我们会发现关系数据库是如何处理这两个问题的。我不会讲关系数据库是如何获取数据的,因为这个不是最重要的,并且本文足够长了。
就像我之前说的,数据库最大的瓶颈是磁盘I/O。要提高性能,现代数据库使用缓存管理。
查询执行器先请求缓存管理器,而不是直接从文件系统获取数据。缓存管理器有一个内存缓存叫缓存池。从内存中获取数据显著地提升了数据库的速度。很难给出量级排序,因为它依赖于你的操作。
还有数据库使用的磁盘类型:
但是我想说内存比磁盘快100到100K倍。
但是,这会有其它问题。缓存管理需要在查询执行器使用它们之前先把数据放到内存中;否则查询管理器需要等待从低速磁盘来的数据。
问题是预先读取。一个查询执行器知道它将来需要的数据,因为它知道查询的所有流量,并且根据统计知道磁盘上的数据。下面是具体的做法:
缓存管理器把所有的数据存在缓存池中。为了知道数据是否还需要,缓存管理器在缓存数据上增加了额外的信息叫做latch。
有时候查询执行器不知道什么数据将被需要,有些数据库不提供这个功能。反而,它们使用推测预加载(例如:如果查询管理器请求数据1,3,5,看上去未来可能请求7,9,11) 或者一个顺序预加载(这种情况下,缓存管理器简单的加载请求过的下一个聆近的数据)。
为了监控预先加载工作的如何,现代数据库提供了一个指标叫做缓存命中率。它展示了请求数据在缓存中找到的概念。
注意:不好的缓存命中率不能通常表示缓存工作的不好。需要更多信息,你可以读Oracle的文档。
但是,缓存是有限的内存数量。因此,它需要移除一些数据来加载新的。加载和清除缓存会产生磁盘和网络I/O。如果你有经常要执行查询,经常加载和清除这部分查询需要的数据是不高效的。现代数据库使用缓存代替策略来处理这个问题。
大部分当代数据库(至少SQL Server, MySQL, Oracle and DB2)使用LRU算法。
LRU
LRU 是 L east R ecently U sed的缩写。这个算法背后的想法是把最近刚使用的数据放在缓存中,因为它们很可能再次使用。
这里有一个视觉化的例子:
为了方便理解,我们假设缓存池中的数据没有被锁住(因此可以被移除)。本例中,缓存池可以存储3个数据:
这个算法可以很好的工作,但是它有一些限制。如果对一张大表有全扫描怎么处理?换句话说,当表或索引的大小比缓存的大小大时会发生什么?用这种算法会移除所有之前缓存的数据,然而全扫描的数据有可能只使用一次。
改进
为了防止这个发生,一些数据库增加了一些特定的规则。例如根据Oracle的文档:
对于非常大的表,数据库使用直接路径读,它直接加载块,为了避免占据缓存池。对于中等大小的表,数据库会使用直接读或者缓存读。如果它决定使用缓存读,数据库把块数据库放在LRU的末尾来避免扫描的数据不能高效的从缓存池中清理出去。
还有其它可能性像使用改进版本的LRU叫LUR-K。例如SQL Serve使用LRU-K for K =2。
这种算法背后的思路是把更加重视历史数据。依据简单的LRU(which is also LRU-K for K=1,算法只是考虑数据使用的最后时间。当使用LRU-K:
* 它也会把数据被使用的次数,K次考虑进去。
* 权重根据数据的使用次数被放入。
* 如果一串新数据被加载到缓存中,老的经常使用的数据不会被移除(因为它们有高的权重)。
* 因此随着时间的过去,如果数据不被使用,它的权重会减少。
计算权重的代价很高,这就是为什么SQL Server只使用K=2。2的消耗还是可以接受的。
如果想要更深入了解LRU-K,你可以读原创论文:The LRU-K page replacement algorithm for database disk buffering。
其它算法
当然还有其它算法来管理缓存:
一些数据库允许使用除了默认算法外的另一种算法。
我只讨论了读缓存它在使用之前加载数据。但是在数据库中,你也需要写缓存,它存储数据,然后把数据一串串而不是一条条刷到磁盘,来减少许多单个的磁盘访问。
记住缓存存储页(最小的数据单位)而不是行(逻辑或者人查看数据的方式)。缓存池中的一页数据如果修改了却没有写到磁盘它就脏掉了。有多种算法来决定把脏页数据写到磁盘的最佳时间,比较推荐的是事务,它是本文的下一章。
最后但是非常重要的,这部分是关于事务管理的。我们会发现事务管理如何保证每条查询在它们自己的事务中执行。但是在这之前,我们需要了解ACID事务。
ACID事务是一连串的工作,它可以保证的件事情:
在同一个事务中,你可以运行多条SQL查询来读,创建,更新和删除数据。当两个事务使用相同的数据就会变得混乱。经典的例子是一个从账号A到账号B的转账。假设有两个事务:
如果我们回头看ACID属性:
[这部分不重要,可以先跳过]
很多现代数据库不会使用纯的隔离作为转为的操作,因为它会带来巨大的性能开销。数据库规范定义了4种隔离级别:
序列化(Serializable默认的行为):最高级别的隔离级别。两个同时发生的事务100%隔离。每个事务有自己的世界。
可重复读(MySQL默认的行为):每个事务有自己的世界除了一种情况。如果一个事务最终成功并且增加了新的数据,这些数据会在其它正在运行中的事务中出现。但是如果A修改了数据,最终成功了,这些修改不会出现正在运行的事务中。因此,破坏事务隔离仅会在新数据中发生,而不会在现存的记录中发生。例如,如果事务A做了一个SELECT count(1) from TABLE_X,然后新数据增加到库里,并且事务B提交成功,如果事务A重新做count(1),值会不一样。这叫幻读。
读已提交(Oracle, PostgreSQL and SQL Server的默认行为)它有可重复读+一种新破坏隔离级别。如果事务A读了数据D,并且数据被修改或者删除了,并且被事务B提交了,如果A重新读了数据D,它会发现被B修改(或者删除)后的数据。这叫不可重复读。
读未提交:最低级的隔离级别。它是读已提交+一种新的破坏隔离级别。如果事务A读了数据D,并且数据D被事务B修改了(未提交并且还在运行),如果事务A再读取数据D,它会发现修改后的值。如果事务B被回滚,这样事务A第二次读取的数据D就没有任何意义了。因为它已经被事务B回退了。这叫脏读。
大部分数据库增加了它们自宣言的隔离级别(像PostgreSQL,Oracle and SQL Server的快照隔离)。此外大部分数据库不实现所有的数据库规范的级别 (尤其是读未提交级别)。
默认的隔离级别可以在连接开始的时候被用户或者开发人员重写(代码很简单)。
要保证隔离性,一致性和原子性的真正问题是在相同数据上的写操作(增,更新和删除)。
这个问题就是并发控制。
最简单的解决这个问题的方法是每个事务顺序执行。但是这完全没有扩展性,只有一个核心在多处理器,多核服务器上执行,不是很高效…
解决这个问题的理想方法是,每次当事务创建或者取消:
更正式的说,它是一个有冲突的调度的问题。更具体的说,它是非常困难并且耗CPU的优化问题。企业级的数据库不可能等好几个时来找到最好的每个事务事件的调度。因此,他们使用不是最理想的方法来调度冲突的事务虽然这会导致更多的时间浪费。
要处理这个问题,大部分数据库使用锁和数据版本。由于它是一个很大的主题,因此我只会关注于锁的部分,然后会讲一点数据版本。
悲观锁
锁背后的概念是:
* 如果某个事务需要数据,
* 它锁住数据,
* 如果另一个事务也需要数据,
* 它会等待直到每一个事务释放数据。
这叫互斥锁。
但是一个事务使用互斥锁仅仅是需要读数据是非常昂贵的,因为这会让其它只是想读相同数据的事务等待。因此就有了其它类型的锁,共享锁。
有了共享锁:
锁管理就是添加和释放锁的过程。在内部,它把锁放在一个哈希表上(键就是要锁住的数据),然后每个数据都会知道。
死锁
但是使用锁可能导致一种情况,两个事务在永久地等待一个数据:
在图中:
事务A有data1的互斥锁,在等待获取事务Bdata2的互斥锁,事务B有data2的互斥锁在等待data1。这叫做死锁。
在死锁中,锁管理选择哪个事务来撤销以释放死锁。这个决定不是很简单:
但是在做这个决定之前,它需要检查是否有死锁。
哈希表可以被看成一张图(就像之前一张图)。如果图中有循环就有死锁。
因为检查循环消耗有点大,有一种简单的方法经常被使用:使用超时。如果一个锁如果超时之内没有释放,事务会进入死锁状态。
锁管理在加锁之前,也会检查是否会造成死锁。但是做好它的计算还是很昂贵的。因此,这些预检查通常是一些基本的规则。
两阶段锁
保证纯的隔离级别最简单的方式是如果锁在事务开始的时候可以获取,在事务结束的时候可以释放。这意味着一个事务在它开始的时候不得不等待它的所有的锁,事务持有的锁也会在事务结束的时候释放掉。它可以工作,但是它也会产生很多时间上的浪费当它在等待所有锁的时候。
一种更快的方式是两阶段锁协议(DB2和SQL Server)事务被分成两个阶段:
两阶段锁可以避免一个问题:
这两个简单规则之后的想法是:
这个协议工作的很好,除了当一个事务修改了数据并且释放了相关的锁(回滚)。当一个事务读修改过的数据,但是它将被回滚。为了避免这个问题,所有的互斥锁必须在事务结束之后释放。
三言两语
真实的数据库当然会使用更复杂的系统包含更多类型的锁,更多粒度的锁(行锁,页锁,分区锁,表锁,表空间锁),但是背后的想法还是一样的。
我只是呈现了纯基于锁的方式。数据版本是另一种处理这种问题的方式。
版本控制背后的想法是:
它增加了性能,因为:
除了2个事务写相同的数据,所有的事情都比锁好。此外,你可以快速巨大的磁盘空间消耗。
数据版本控制和锁是不同的:乐观锁和悲观锁。他们有利有敝;不过它还是取决于用途(读多还是写多)。PostgreSQL是非常好的数据版本控制展示,并实现了多版本控制。
一些数据库像DB2 (until DB2 9.7) and SQL Server仅使用锁。其它的像PostgreSQL, MySQL and Oracle使用混合的方法包括锁和数据版本控制。我不知道仅仅使用数据版本控制的数据库。
[更新于 08/20/2015] 一个读者告诉我说:
Firebird和Interbase仅仅使用了乐观锁。
版本控制对索引有一个有趣的影响:有时候一个唯一索引包括重复的数据,索引可以有超过行数的实体,等等。
如果你读了不同隔离级别的那部分,当你增加了隔离级别,你增加了锁的数量,因此时间会因为事务等待锁而浪费掉。这就是为什么数据库默认不使用最高级别隔离级别(Serializable)的原因。
像往常一样,你可以自己查看主要数据库的文档(例如MYSQL,PostgreSQL,Oracle)。
我们早就知道为了提高性能,数据库把数据存在内存缓存中。但是如果服务器在事务提交的时候挂了,你会丢失在内存中的数据,这将破坏事务的永久性。
你可以在磁盘写任务东西,但是如果服务器挂了,你最终会以数据只是在磁盘中写了一半,这会破坏事务的原子性。
任何事务的修改要么完成了,要么取消。
为了解决这个问题,有两种方法:
WAL
在大数据库中包含许多事务的时候浅拷贝会创建巨大的磁盘开销。这就是为什么现代数据库使用事务日志的原因。事务日志必须存储在可靠的存储中。我不会深入存储技术但是使用RAID磁盘会可靠的保护磁盘失败。
大部分数据库(至少Oracle, SQL Server, DB2, PostgreSQL, MySQL and SQLite)处理事务日志使用预写式日志协议。预写式日志包含三个规则的集合:
这是日志管理做的事情。一种简单的方法来看在缓存管理和数据访问管理之间,日志管理写每个update/delete/create/commit/rollback操作在事务日志中,在它们写入磁盘之前。很简单,是吗?
完全错误!你知道所有和数据库相关的都会被数据库影响所诅咒。更严重的是,问题是在写日志的时候还要保持好的性能。如果写事务日志很慢,它们会拖慢所有的操作。
ARIES
在1992年,IBM的研究人员发明了加强版的WAL叫做ARIES。ARIES多多少少被大部分现代数据库使用。逻辑可能不一样,但是ARIES前后的概念被在到处使用。我把引号放在发明上,因为根据MIT麻省理工学院的课程,IBM研究人员只是写了好的事务恢复的好实践。当我5岁的时候ARIES的论文发表了,我不关心从这些这些尖刻的研究人员的小道消息。事实上,我只是在开最后的技术部分之前,把这条信息放在这里来让你们休息一下。 我读了大量的ARIES的论文,然后我发现它非常有趣!在这部分,我只会告诉你ARIES的概诉,但是我强烈推荐你读论文,如果你想真正学到些知识。
ARIES是A lgorithms for R ecovery and **I**solation **E**xploiting **S**emantics的缩写。
这篇科技论文的目的有两个:
数据库不得不回滚事务有多种原因:
有时候(例如,网络失败),数据库可以恢复事务。
它为什么可以呢?要回答这个问题,我们需要了解存在日志记录里的信息。
日志
每个事务中操作(add/remove/modify)产生一条日志。
这条日志记录是由以下部分组成:
此外,磁盘上的每一页(存储数据的,不是日志)都有一个最后修改那条数据操作的日志记录ID(LSN)。
* LSN的生成是更复杂的,因为它会和日志存储连接在一起。但是背后的思想还是一样的。
** ARIES使用逻辑UNDO,因为它是处理物理UNDO实在是太混乱了。
记住:从我的微薄知识来看,只有PostgreSQL不使用UNDO。它使用垃圾收集守护进程来移除旧版本的数据。在PostgreSQL它会链接到数据版本控制的实现。
为了让你更好的了解,这里有一个可视化的简化了的产生日志记录的例子。查询语句是“UPDATE FROM PERSON SET AGE = 18;”。我们知道这个查询在事务18中执行。
每条日志都有一个唯一的LSN。链接在一起的日志属于相同的事务。日志根据时间顺序链接在一起(链接列表的最后一条日志是最后一次操作的日志)。
日志缓存
为了避免日志写入成为主要的瓶颈,日志缓存被使用。
当查询执行器请求一次修改:
当事务提交之后,它意味着事务中的每一个操作1,2,3,4,5都完成了。把日志写入事务日志快是因为它仅仅在事务日志的某个地方增加一条日志。而在磁盘中写数据更复杂因为写数据为了快速的读取它们。
STEAL and FORCE policies
为了性能的原因,步骤5可能会在提交之后做,因为如果数据库挂了,它还是可以通过REDO日志来恢复。这叫NON-FORCE策略。
数据库可以选择FORCE策略(例如,步骤5必须在事务提交之前做)来减少恢复阶段的工作量。
另一个问题是选择数据是否按步骤写入磁盘(STEAL policy)或者是否缓存管理需要等到提交顺序才一次性写入(NO-STEAL)。选择STEAL还是NO-STEAL取决于你想要:快速写入但是很长恢复使用UNDO来恢复日志还是快速恢复?
这里有一个关于这些策略会对恢复影响的综述:
恢复部分:
好的,我们有非常好的日志,让我们来使用它们!
假设新来的把数据库搞挂了。你重启数据库,恢复进程开始了。
AREIS从一次宕机之后恢复经过三个阶段,
1)分析阶段:恢复进程读一遍所有的事务日志来重新创建在宕机的时候发生的事情。它决定哪个事务要回滚(所有未提交的事务需要回滚),和哪个数据需要在当时写入磁盘。
2) Redo阶段:这个阶段从分析阶段决定日志记录开始,然后用REDO来更新数据库到宕机之前的状态。在REDO阶段,REDO日志以时间顺序被处理(通过LSN)。
对于每条日志,恢复进程读磁盘页上包含要修改的数据的LSN。
如果 LSN(pageondisk)>=LSN(logrecord) L S N ( p a g e o n d i s k ) >= L S N ( l o g r e c o r d ) ,这意味着数据在宕机之前已经写入了磁盘,因此啥也不用做。
If LSN(pageondisk)<LSN(logrecord) L S N ( p a g e o n d i s k ) < L S N ( l o g r e c o r d ) ,然后磁盘上的页会被更新。
redo尽管在事务要回滚的时候也做,因为它会简化恢复处理(但是我确定没有现代数据库这么做)。
3)Undo阶段:这个阶段回滚宕机时所有未完成的事务。回滚从每个事务最后一条日志开始,然后处理UNDO日志根据时间倒序处理(使用日志记录的PrevLSN)。
在恢复阶段,事务日志必须发出警告关于事务处理器所做的操作,这样写到磁盘的数据会和事务日志同步。一种解决方法可以是移除undo的事务日志记录,但是这非常困难。反而,ARIES在事务中写补偿日志。
当一个事务被手动撤销,或者被锁管理(停止死锁)或者仅仅因为网络错误,然后分析阶段不需要了。事实上,关于如何REDO和UNDO的信息在两个内存表中:
这些表被缓存管理和事务管理在每个新事务事件的时候更新。因此它们在内存中,当数据库宕机的时候会被销毁。
分析阶段的工作是在宕机之后使用事务日志重新创建这两张表。要加速分析阶段,ARIDS提供检查点的概念。思路是随时把事务表和脏页表的数据和本次写的时候的最后LSN的写入磁盘,这样在分析阶段,只有这个LSN之后的日志会被分析。
在写本文之前,我知道这个主题有多大,我知道需要花费时间写深入的文章。结果是我非常乐观,并且花费了比预期多两倍多的时间,但是我学了很多。
如果你想清楚的了解数据库,我建议读论文Architecture of a Database System。它是一本好的数据库入资料,仅此一次它对于非计算机专业的人也是易读的。这篇文章在本文写作计划中帮助我很多,然后它不是关注于数据结构和算法,而更多是架构概念。
如果你仔细读了这篇文章,你现在应该知道数据库是多么强大。虽然它是非常长的文章,让我提醒你我们之前看到过什么:
关于B+树索引的综述
但是数据库包含更多的聪明。例如,我没说一些敏感问题,像:
因此,仔细考虑当你不得不在有很多BUG的NOSQL数据库和绝对可靠的关系数据库之间选择。别被我误导了,一些NOSQL数据库是伟大的。但是他们还是很年轻,回答了一小部分程序关心的特定问题。
最后,如果有人问你数据库是如何工作的,你现在可以回答而不是逃跑:
否则,你可以把这篇文章给他。