关系型数据库如何运行

首先:感谢Christophe Kalenzaga,他对数据库的不了解,而想去了解这个对不少程序员来讲的黑盒子,阅读了大量的文章,官方文档,研究资料,完成了本文。 本文的源地址在( How does a relational database work javascript

关系型数据库如何运行

当提到关系数据库,情不自禁地认为缺乏了些重要信息。这些数据库无所不在,发挥着他们的做用,他们包括了小巧的sqlite,强大的 Teradat。可是少有文章去说明数据的运行原理。你能够搜索 “how does a relational database work”【关系数据库运行原理】来了解这样的文章有多么少。若是你去搜索如今这些流行技术(大数据,Nosql和javascript),你会找到大量 深刻的文章在说明这些技术的运行原理。关系数据库太不流行太没意思,以致于出了大学课堂,就找不到书和研究资料去阐明它的运行原理?
main databaseshtml

做为一个开发者,我很是不喜欢用一些我不理解的技术/组件。即便数据库已经通过了40年运用的检验,可是我依然不喜欢。这些年,我花费了上百小时的时间去研究这些天天都用的奇怪的黑匣子。关系型数据库的有趣也由于他门构建在有效和重用的理念上。若是你要了解数据库,而有没有时间或者没有毅力去了解这个宽泛的话题,那么你应该阅读这篇文章。java

虽然文章标题已经足够明确,本文的目的不是让你学习怎么使用一个数据库.可是,你应该已经知道怎么写一个简单的链接查询和基本的增删改查的查询,不然,你就不能明白本文。这就是如今必需要知道,我将解释为何须要这些提早的知识。git

我将从时间复杂度开始开始这些计算机科学知识。固然固然,我晓得有些朋友不喜欢这些观点可是不了解这些,咱们就不明白数据库中使用的技巧。这是一个庞大的话题,我将聚焦于很是必要的知识上,数据库处理SQL查询的方法。我将只涉及数据库背后的基本观念,让你在本文结束的时候了解水面下发生了什么程序员

这是一篇又长又有技术性的文章,涉及了不少算法和数据结构,总之不怎么好理解,慢慢看吧同窗。其有一些观点确实不容易理解,你把它跳过去也能获得一 个比较全面的理解(译者注:这篇博文对于学习过《数据结构》的同窗,不算是很难,即便有一些难以理解的观点,要涉及技术的特性,这是使用这些许技的缘由, 对应可以明白使用技术要达成的结果)。github

本文大致分为3个部分,为了方便理解:算法

  • 底层技术和数据库模块
  • 查询优化技术
  • 事物和内存池管理

回归基础

好久之前(估计有银河系诞生那么久远...),开发人员不得不精通很是多的编程操做。由于他们不能浪费他们龟速电脑上哪怕一丁点儿的CPU和内存,他们必须将这些算法和相应的数据结构深深的记在内心。
在这个部分,我将带大家回忆一些这样的概念,由于它们对于理解数据库是很是必要的。我也将会介绍数据库索引这个概念。
sql

O(1)) vs O(n2)

如今,许多开发者再也不关心时间复杂度...他们是对的!
可是当大家正面临着一个大数据量(我谈论的并非几千这个级别的数据)的处理问题时或者正努力为毫秒级的性能提高拼命时,理解这个概念就很是的重要了。可 是大家猜怎么着?数据库不得不处理这两种极端状况!我不会占用大家太多时间,只须要将这个点子讲清楚就够了。这将会帮助咱们之后理解成本导向最优化的概念。
shell

基本概念

时间复杂度时用来衡量一个算法处理给定量的数据所消耗时间多少的。为了描述这个复琐事物,计算机科学家们用数学上的大写字母O符号.这个符号用来描述了在方法中一个算法须要多少次操做才能处理完给定的输入数据量。
例如,当我说”这个算法是在O(some_funtion())“时,这意味着这个算法为了处理肯定量的数据须要执行some_function(a_certain_amount_of_data)操做.
最重要的不是数据量,而是随着数据量的增长,操做步骤须要随之变化的方式。时间复杂度不是给出确切的操做数量而是一个概念。 TimeComplexity 数据库

在上图中,你能够看到不一样类型的复杂度演变的方式。我用了对数尺度来描绘。换句话讲,当数据的量从1到10亿,咱们能够看到:

  • O(1)即常数复杂度保持常数操做数(否则它就不叫常数复杂度了)。
  • O(log(n)) 即便是上亿级的数据仍保持较低操做数
  • 最差的复杂度是O(n2) ,它的操做数是爆炸式增加
  • 另外两种复杂度增加快速。

 

举例

当小数据量时,O(1)与O(n2)之间的差距是微乎其微的。例如,假设你须要处理2000条数据的算法。

  • O(1)算法须要1次操做
  • O(log(n))算法须要7次操做
  • O(n)算法须要2000次操做
  • O(n*log(n))算法须要14000次操做
  • O(n2)算法须要4000000次操做

O(1)与O(n2)之间的区别彷佛很是大(4百万倍),可是你实际上最多多消耗2毫秒,和你眨眼的时间几乎相同。的确,如今的处理器能处理每秒数以百万计指令。这就是为何在许多IT工程中性能和优化并非主要问题的缘由。


正如我所说,当面对海量数据时,了解这个概念仍是很是重要的。若是这时算法须要处理1000000条数据(对于数据库来讲,这还不算大):

  • O(1)算法须要1次操做
  • O(log(n))算法须要14次操做
  • O(n)算法须要1000000次操做
  • O(n*log(n))算法须要14000000次操做
  • O(n2)算法须要1000000000000次操做

我没有详细算过,可是我想若是采用O(n2)算法,你能够有时间来杯咖啡了(甚至再来一杯!)。若是你又将数据数量级提高一个0,你能够有时间去打个盹儿了。

继续深刻

给你一个概念:

  • 从一个哈希表中进行元素查找操做的复杂度是O(1)
  • 从一个平衡树中进行查找操做的复杂度时O(log(n))
  • 从数组中进行一次查找操做的复杂度O(n)
  • 最优的排序算法的复杂度是O(n*log(n))。
  • 差的排序算法的复杂度是O(n2)

注意:在以后的内容中,咱们将会看到这些算法和数据结构。

存在着多种种类的时间复杂度:

  • 平均状况
  • 最优状况
  • 以及最差状况

时间复杂度常常是最差状况。
我仅讨论时间复杂度,实际上复杂度还适用于:

  • 算法的内存消耗
  • 算法的磁盘I/O消耗

固然也有比n2还差的复杂度状况,例如:

  • n4:糟糕透了!我将会提到一些如此复杂度的算法。
  • 3n:不能再糟了! 咱们在本文中间部分将会看到这样复杂度的一个算法(并且在许多数据中,它确实在被使用着)。
  • n的阶乘 : 即便是很小数量级的数据,你也将永远得不到你想要的结果。
  • nn: 若是你最终的结果是这个算法复杂度,你应该好好问问本身究竟是不是作IT的…

注意:我并无给你O符号的真正定义,而只是抛出这个概念。若是你想找到真正的定义,你能够阅读这篇WikiPedia材料

归并排序

若是你须要排序一个集合,你会怎么作?什么?你会调用sort()函数... 好吧,真是个好答案...可是对于数据库来讲,你必须懂得sort()函数是如何工做的。

由于有太多好的排序算法,因此我将专一于最重要的一个:归并排序。此时此刻你可能不是很明白为何数据排序会有用,可是当完成这个部分的查询优化后,你确定会懂得。进一步来讲,掌握归并排序将有助于后续咱们对通常数据库中合并链接操做的理解。

合并

如同许多有用的算法,归并排序是基础的技巧:合并2个长度为N/2的有序数组为一个有N个元素的有序数组仅消耗N次操做。这个操做称为一次合并。

让咱们经过一个简单例子来看看其含义: merge_sort

你能从图中看到最终排序好8个元素的数组的结构,你仅须要重复访问一次2个4元素数组。由于这2个4元素数组已经排序好了:

  • 1) 你须要比较两个数组当前的元素(第一次的时候current=first)
  • 2) 接下来将最小的那个放进8元素数组中
  • 3) 将你提取最小元素的那个数组指向下一个元素
  • 重复1,2,3步骤,直到你到达任何一个数组的最后一个元素.
  • 接下来,你须要将另一个数组的剩余元素放进8元素数组中。

这个算法之因此生效是由于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;

归并排序将问题拆分为更小的问题,再求解这些小问题结果,从而得到最初的问题结果(注意:这类算法叫作分治法)。若是你不懂这个算法,不用担忧;我最初看这个算法时也是不懂。我将这个算法看做两个阶段算法,但愿对大家有所帮助:

  • 将一个数组才分为更小的数组称为分解阶段
  • 将小的数组组合在一块儿(使用合并)组成更大的数组称为排序阶段。

 

分解阶段

Division phase
在分解阶段中,数组被拆分为单一的数组用了3步。步骤数量的表达式为log(N)(因为 N=8,log(N) = 3)。

我是怎么知道的呢?

我是个天才!总之:数学。每一步的核心是将最初的数组长度对半拆分。步骤数量就是你能二分原始数组的次数。这就是对数的定义(以2为底)。

排序阶段

Sorting phase

在排序阶段中,你将从单一数组开始。在每一步中,你使用多重聚集。总共须要N=8次操做:

  • 第一步,你将作4次合并,每次须要2步操做
  • 第二步,你将进行2次合并,每次须要4步操做
  • 第三步,你将作1次合并,每次须要8步操做

由于总共有log(N)个步骤,总共须要N * log(N)次操做

归并排序的威力

为何这个算法有如此威力?

原因以下:

  • 你能够将它改造为低内存占用型,经过再也不创建一个新的数组而是直接修改输入数组。 注意:这种算法称为原地算法
  • 你能够将它改造为使用磁盘空间和更小的内存占用的同时,避免大量的磁盘I/O消耗。这个算法的理念是每次只加载当前处理的部分数据进入内存。当你只有100M内存空间却须要对数G大小的表进行排序时,这个算法将十分重要。 注意:这种算法称为外部排序
  • 你能够将这个算法改造为运行在多处理器/线程/服务器。 例如,分布式归并排序就是Hadoop的核心模块(一种大数据框架)。
  • 这个算法能点石成金(真的!)。

这个排序算法应用于大多数(好吧,若是不是所有)数据库,可是它不是惟一的。若是你想了解更多,你能够阅读这个研究材料,这里面讨论了数据库中所使用到的通用排序算法的优缺点。

数组,树以及哈希表

咱们已经了解时间复杂度和排序背后的机理,我必须给你讲3种数据结构。它们十分重要,由于它们是现代数据库的支柱。同时我也会介绍数据库索引

数组

二维数组是最简单的数据结构。表格也能当作是一个数组。以下: Array
二维数组就是一个行列表:

  • 每行表示一个对象
  • 列表示描述对象的特性
  • 每列存储一种特定类型的数据(整型,字符串,日期 ...)。

尽管这样存储和数据可视化都很是好,可是当你面对特殊数据时,这个就很糟了。

例如,若是你想找到全部工做在英国的人,你将不得不查看每一行看这一行是否属于英国。这将消耗你N步操做(N是行数),这并不算太坏,可是否又有更好的方式呢?这就是为何要引入tree。

注意:大多数现代数据库提供了加强型数组来高效的存储表单,例如:堆组织表或索引组织表。可是他并无解决特殊值在列集合中的快速查找问题。

树和数据库索引

二叉搜索树时一种带有特殊属性的二叉树,每一个节点的键值必须知足:

  • 大于全部左子树的键值
  • 小于全部右子树的键值

让咱们直观的看看上面的含义

概念 Binary Search Tree

这棵树有 N=15 个节点构成。假设咱们搜索208:

  • 我将从根节点的键值136开始,由于136<208,因此咱们查找136节点的右子树。
  • 398>208 因此,咱们查找398节点的左子树
  • 250>208 因此,咱们查找250节点的左子树
  • 200<208 因此,咱们查找200节点的右子树。可是200节点没有右子树,这个值不存在(若是它存在,那么它一定在200节点的右子树中)

接下来假设咱们查找40

  • 我将从根节点的键值136开始,由于136>40,因此咱们查找136节点的左子树。
  • 80>40 因此,咱们查找80节点的右子树
  • 40=40,节点存在。提取出节点的行号(这个没在图上),而后根据行号查询数据表。
  • 得到了行号,咱们就能够知道数据在表上的精确位置,所以咱们就能当即获取到数据。

最后,这两个查询都消耗树的层数次操做。若是你仔细地阅读了归并排序部分,那么就应该知道这里是log(N)层级。因此知道搜索算法的时间复杂度是log(N),不错!

回到咱们的问题上

可是这些东西仍是比较抽象,咱们回到咱们具体的问题中。取代了前一张表中呆滞的整型,假想用字符串来表示某人的国籍。假设你又一棵包含了表格中“国籍”列的树:

  • 若是你想知道谁在英国工做
  • 你查找这棵树来获取表明英国的节点
  • 在“英国节点”中,你将找到英国工人的行地址。

这个查找仅须要log(N)次操做而不是像使用数组同样须要N次操做。大家刚才猜测的就是数据库索引

只要你有比较键值(例如 列组)的方法来创建键值顺序(这对于数据库中的任何一个基本类型都很重要),你能够创建任意列组的树形索引(字符串,整型,2个字符串,一个整型和一个字符串,日期...)。

B+树索引

尽管树形对于获取特殊值表现良好,可是当你须要获取在两个值范围之间的多条数据时仍是存在着一个大问题。由于你必须查询树种的每一个节点看其是否在两值范围之间(例如,顺序遍历整棵树)。更糟的是这种操做非常占用磁盘I/O,由于你将不得不读取整棵树。咱们须要找到一种有效的方式来作范围查询。为了解决这个问题,现代数据库用了一个以前树形结构的变形,叫作B+树。在B+树中:

  • 仅只有最底层的节点(叶子节点)存储信息(相关表的行坐标)
  • 其余节点仅在搜索过程当中起导向到对应节点的做用。

B+ Tree

如你所见,这将引入更多的节点(两倍多)。的确,你须要更多额外的节点,这些“决策节点”来帮助你找到目标节点(存储了相关表的行坐标信息的节点)。可是搜索的复杂度仍然是O(log(N))(仅仅是多了一层)。最大的区别在于最底层的节点指向了目标

假设你使用B+树来搜索40到100之间的值:

  • 你必须像上一树形结构中同样查找40的值(或者若是40不存在,就找最接近40的值)。
  • 40的结果集合直接连接了直到100的结果。

假设你找到了M个结果,而且整棵树有N个节点。如同上一树形结构同样查找特殊节点须要log(N)步操做。可是一旦你找到这个节点,你就能够经过指向他们结果集的M步操做来获取M个结果。这样的查找方式仅须要M + log(N)步操做相对于上一棵树形结构中的N步操做。更好的是你没必要讲整棵树读取进来(只须要读取M + log(N) 个节点),这意味着更少的磁盘消耗。若是M足够小(好比200行)而N足够大(1 000 000行),这会产生巨大的差异。

可是又会产生新的问题(又来了!)。若是你在数据库中增长或删除一行(与此同时在相关的B+树索引中也要进行相应操做):

  • 你必须保证B+树中的节点顺序,不然你不能在混乱中找到目标节点。
  • 你必须保证B+树中尽量最小的层数,不然时间复杂度会从O(log(N))变为O(N)。

换句话说,B+树须要是自生顺序的和自平衡的。幸亏使用智能删除和插入操做,这些都是可行的。可是这就引入了一个消耗:在一个B+树中的插入操做和删除操做的复杂度都是O(log(N))。这就是为何大家有些人据说的使用太多的索引并非一个好办法的缘由。确实,你下降了在表中行的快速插入/更新/删除,觉得数据库要为每一个索引更新数据表的索引集都须要消耗O(log(N))次操做。更糟的是,添加索引意味着事务管理(咱们将在本文最后看到这个管理)更多的工做量。

更多详情,能够查看维基百科B+树资料。若是你想知道数据库中B+树的实现细节,请查看来自MySQL核心开发者的博文博文。这两个材料都是聚焦于innoDB(MySQL数据库引擎)如何处理索引。

注意:由于我被一个读者告知,因为低级优化,B+树须要彻底平衡。

哈希表

咱们最后一个重要的数据结构是哈希表。当你想快速查找值的时候这会很是有用。更好的是了解哈希表有助于掌握数据库通用链接操做中的哈希链接。这个数据结构也用于数据库存储一些中间量(如咱们稍后会提到的锁表缓冲池概念)。

哈希表是一种利用其键值快速查找元素的数据结构。为了创建哈希表,你须要定义:

  • 为元素创建的
  • 为键创建的哈希方法。为键算出的哈希值能够定位元素(称为哈希桶)。
  • 键之间的比较方法。一旦你找到了目标桶,你必须使用这个比较方法来查找桶内的元素。


一个简单例子

让咱们来看看图形示例: Hash Map

这个哈希表有10个哈希桶。换句话说,我只使用元素的最后一个数字来查找它的哈希桶:

  • 若是元素的最后一个数字是0,那么就存在于0号哈希桶中,
  • 若是元素的最后一个数字是1,那么就存在于1号哈希桶中,
  • 若是元素的最后一个数字是2,那么就存在于2号哈希桶中,
  • ...

我所使用的比较方法仅仅是简单的比较2个整型是否相等。
假设你想查找一个78的元素:

  • 哈希表首先计算出78的哈希值是8。
  • 接下来它查看8号哈希桶,并找到第一个元素就是78。
  • 它就返回给你78的元素
  • 此次查找仅须要2步操做(1步是计算哈希值而另外一步则是查找哈希桶里面的元素)。

接下来,假设你想找到59的元素:

  • 哈希表首先计算59的哈希值为9。
  • 它查找9号哈希桶,第一个找到的元素是99。由于99!=59,99元素不是目标元素。
  • 使用相同的逻辑,它找到第二个元素(9),第三个(79),...,直到最后一个(29)。
  • 该元素不存在。


优秀的哈希方法

如你所见,根据你查找的值不一样,消耗也是不一样的!

若是如今我改用键值除以1 000 000的哈希方法(也就是取最后6位数字),第二个查找方法仅须要1步操做,由于不存在000059号的哈希桶。真正的挑战是找到一个建立能容纳足够小元素的哈希桶的哈希方法

在个人例子中,找到一个好的哈希方法是很是容易的。可是因为这是个简单的例子,当面对以下键时,找到哈希方法就很是困难了:

  • 字符串(例如人的姓氏)
  • 2个字符串(例如人的姓氏和名字)
  • 2个字符串和一个日期(例如人的姓氏,名字以及生日)
  • ...

若是有一好的哈希方法,在哈希表中查找的复杂度将是O(1)

数组与哈希表的比较

为何不使用数组?
嗯, 你问了一个好问题。

  • 哈希表能一半在内存中加载,而其他的哈希桶保存在磁盘上。
  • 若是使用数组,你必须使用内存中的连续内存。若是你正在加载一张较大的表,系统是很难分配出足够大的连续空间的
  • 若是使用哈希表,你能够任意选择你想要的键(例如:国家 AND 姓氏)。

想要更多的信息,你能够阅读个人博文一个高效哈希表的实现Java HashMap;你能够读懂这个文章内容而没必要掌握Java。

整体结构

咱们已经理解了数据库使用的基本组件,咱们须要回头看看这个整体结构图。
数据库就是一个文件集合,而这里信息能够被方便读写和修改。经过一些文件,也能够达成相同的目的(便于读写和修改)。事实上,一些简单的数据库好比SQLite就仅仅使用了一些文件。可是SQLite是一些通过了良好设计的文件,由于它提供了如下功能:

  • 使用事务可以保证数据安全和一致性
  • 即便处理百万计的数据也能高效处理

通常而言,数据库的结构以下图所示:
database architecture
在开始写以前,我曾经看过不少的书和论文,而这些资料都从本身的方式来讲明数据库。因此,清不要太过关注我怎么组织数据库的结构和对这些过程的命名,由于我选择了这些来配置文章的规划。无论这些不一样模块有多不同,可是他们整体的观点是数据库被划分为多个相互交互的模块

核心模块:

  • 进程管理器: 不少的数据库都有进程/线程池须要管理,另外,为了达到纳秒级(切换),一些现代的数据库使用本身实现线程而不是系统线程。
  • 网络管理器: 网络IO是一个大问题,尤为是分布式数据库。这就是一些数据库本身实现管理器的缘由。
  • 文件系统管理器: 磁盘IO是数据库的第一性能瓶颈。文件系统管理器过重要了,他要去完美使用OS文件系统,甚至本身取而代之。
  • 内存管理器: 为了不磁盘IO带来是惩罚,咱们须要很大的内存。可是为了有效的使用这些内存,你须要一个有效率的内存管理器。尤为在多个耗内存的查询操做同时进行的时候。
  • 安全管理器: 为了管理用户的验证和受权。
  • 客户端管理器: 为了管理客户端链接..
  • .......

工具类:

  • 备份工具: 保存和恢复一个数据库。
  • 恢复工具: 使据据库在崩溃重启以后,从新达到一致性的状态。
  • 监控工具: 记录数据库全部的行为,须要提供一个监控工具去监控数据库。
  • 管理工具: 保存元数据(好比表的结构和名字),并提供工具去管理数据库,模式,表空间等等。
  • ......

查询管理器:

  • 查询解析器: 确认查询是否合法
  • 查询重写器: 优化查询的预处理
  • 查询优化器: 优化查询语句
  • 查询执行器: 编译执行一个查询
  • ......

数据管理器:

  • 事务管理器: 管理事务
  • 缓存管理器: 在使用数据或者修改数据以前,将数据载入到内存,
  • 数据访问: 访问磁盘上数据

本文剩下部分,我将关注于数据库如何处理SQL查询的过程:

  • 客户端管理器
  • 查询管理器
  • 数据管理器(我也将在这里介绍恢复管理工具)

 

客户端管理器


Client manager
客户端管理器是处理和客户端交互的部分。一个客户端多是(网页)服务器或者终端用户或者终端程序。客户端管理器提供不一样的方法(广为人知的API: JDBC, ODBC, OLE-DB)来访问数据库。 固然它也提供数据库特有的数据库APIs。

当咱们链接数据库:

  • 管理器首先验证咱们的身份(经过用户名和密码)接着确认咱们是否有使用数据库的受权,这些访问受权是大家的DBA设置的。
  • 接着,管理器确认是否有空闲的进程(或者线程)来处理你的此次请求。
  • 管理器也要确认数据库是否过载。
  • 管理器在获得请求的资源(进程/线程)的时候,当等待超时,他就关闭这个链接,并返回一个易读的出错信息。
  • 获得进程/线程以后,就把这个请求传递给查询管理器,此次请求处理继续进行。
  • 查询过程不是一个all or nothing的过程,当从查询管理器获取数据以后,就马上将这些不彻底的结果存到内存中,并开始传送数据

  • 当遇到失败,他就中断链接,返回给你一个易读的说明,并释放使用到的资源。

 

查询管理器

Query manager
这部分是数据库的重点所在。在本节中,一个写的不怎么好的查询请求将转化成一个飞快执行指令代码。接着执行这个指令代码,并返回结果给客户端管理器。这是一个多步骤的操做。

  • 查询语句将被解析,看它是否有效。
  • 接着在它之上去除无用的操做语句,并添加与处理语句,重写出来。
  • 为了优化这个查询,提供查询性能,将它转化成一个可执行的数据访问计划。
  • 编译这个计划。
  • 最后,执行它。
    这部分,我不打算就爱那个不少在最后两点上,由于他们不是那么重要。

阅读完这部分以后,你将容易理解我推荐你读的这些材料:

  • 最初的基于成本优化的研究论文: Access Path Selection in a Relational Database Management System. 这篇文章只有12页,在计算机科学领域是一片相对易懂的论文。

  • 针对DB2 9.X查询优化的很是好,很是深深刻的文档here

  • 针对PostgreSQL查询优化的很是好的文档here。这是很是容易理解的文档,它更展现的是“PostgreSQL在不一样场景下,使用相应的查询计划”,而不是“PostgreSQL使用的算法”。

  • SQLite关于优化的官方SQLite documentation 文档。很是容易阅读,由于SQLite使用的很是简单的规则。此外,这是为惟一一个真正解释如何使用优化规则的文档。

  • 针对SQL Server 2005查询优化的很是好的文档here

  • Oracle 12c 优化白皮书 here

  • “DATABASE SYSTEM CONCEPTS”做者写的两个关于查询优化的2个理论课程here and here. 关注于磁盘I/O一个很好的读物,可是须要必定的计算机科学功底。

  • 另外一个很是易于理解的,关注于联合操做符,磁盘IO的 理论课

 

查询解析器

解析器会将每一条SQL语句检验,查看语法正确与否。若是你在SQL语句中犯了一些错误,解析器将阻止这个查询。好比你将"SELECT...."写成了"SLECT ....",此次查询就到此为止了。
说的深一点,他会检查关键字使用先后位置是否正确。好比阻止WHERE 在SELECT以前的查询语句。
以后,查询语句中的表名,字段名要被解析。解析器就要使用数据库的元数据来验证:

  • 是否存在
  • 表中字段是否存在
  • 根据字段的类型,对字段的操做能够(好比你不能将数字和字符串进行比较,你不能针对数字使用substring()函数)

以后确认你是否有权限去读/写这些表。再次说明,DBA设置这些读写权限。 在解析过程当中,SQL查询语句将被转换成一个数据库的一种内部表示(通常是树 译者注:ast) 若是一切进行顺利,以后这种表示将会传递给查询重写器

查询重写器

在这一步,咱们已经获得了这个查询内部的表示。重写器的目的在:

  • 预先优化查询
  • 去除没必要要的操做
  • 帮助优化器找到最佳的可行方案


重写器执行一系列广为人知的查询规则。若是这个查询匹配了规则的模型,这个规则就要生效,同时重写这个查询。下列有几个(可选的)规则:

  • 视图合并:若是你在查询仲使用了一个视图,这个视图将会被翻译成视图的SQL代码。
  • 子查询整理:若是查询仲有子查询很是难以优化,冲洗器可能会去除这个查询的子查询。

例子以下:

SELECT PERSON.*  
FROM PERSON  
WHERE PERSON.person_key IN  
(SELECT MAILS.person_key  
FROM MAILS  
WHERE MAILS.mail LIKE 'christophe%');

将会改写成:

SELECT PERSON.*  
FROM PERSON, MAILS  
WHERE PERSON.person_key = MAILS.person_key  
and MAILS.mail LIKE 'christophe%';
  • 去除非必须操做符: 好比若是你想让数据惟一,而使用DISTINCT的与此同时还使用一个UNIQUE约束。这样DISTINCT关键字就会被去除。
  • 消除重复链接:若是查询中有两个同样的join条件,无效的join条件将被移除掉。形成两个同样join的缘由是一次join的条件隐含在(view)视图中,也多是由于传递性。
  • 肯定的数值计算: 若是你写的查询须要一些计算,那么这些计算将在重写过程。去个例子"WHERE AGE > 10 + 2"将会转换成 "WHERE AGE > 12",TODATE("some date")将转化成datetime格式的日期。
  • "(高端功能)分区选择:" 若是你正在使用一个分过去的表,冲洗器会找到你要使用哪个分区。
  • "(高端功能)实体化视图:"若是你的查询语句实体化视图

这时候,重写的查询传递给查询优化器。 好戏开场了。

统计

在看优化查询以前,咱们必需要说一下统计,由于统计是数据库的智慧之源。若是你不告诉数据如何分析数据库本身的数据,它将不能完成或者进行很是坏的推测。
数据库须要什么样的信息?
我必须简要的谈一下,数据库和操做系统如何存储数据。他们使用一个称为page或者block(一般4K或者8K字节)的最小存储单元。这意味着若是你须要1K字节(须要存储),将要使用一个page。若是一个页大小为8K,你会浪费其余的7K。 注:

计算机内存使用的存储单元为page,文件系统的存储单元成为block
K -> 1024
4K -> 4096
8K -> 8192

继续咱们的统计话题!你须要数据库去收集统计信息,他将会计算这些信息:

  • table中,行/page的数量
  • table中,列信息:
    • 数据值distinct值
    • 数据值的长度(最小,最大,平均值)
    • 数据范围信息(最小,最大,平均值)
  • table的索引(indexes)信息

这些统计将帮助优化器去计算磁盘IO,CPU和查询使用的内存量
这些每一列的统计是很是重要的,好比:若是一个表 PERSON须要链接(join)两个列:LAST_ANME,RIRST_NAME。有这些统计信息,数据库就会知道RIRST_NAME只有1000 个不一样的值,LAST_NAME不一样的值将会超过100000个。所以,数据库将会链接(join)数据使用LAST_ANME,RIRST_NAME而 不是FIREST_NAME,LAST_NAME,由于LAST_NAME更少的重复,通常比较2-3个字符已经足够区别了。这样就会更少的比较。


这只是基本的统计,你能让数据库计算直方图这种更高级的统计。直方图可以统计列中数据的分布状况。好比:

  • 最多见的值
  • 分布状况
  • .....
    这些额外的统计将能帮助数据库找到最优的查询计划。特别对等式查询计算(例:WHERE AGE = 18)或者范围查询计算(例:WEHRE AGE > 10 and ARG < 40)由于数据更明白这些查询计算涉及的行数(注:科技界把这种思路叫作选择性)。

    这些统计数据存在数据的元数据。好比你能这些统计数据在这些(没有分区的)表中

  • Oracle的表USER/ALL/DBA_TABLES 和 USER/ALL/DBA_TAB_COLUMNS

  • DB2的表SYSCAT.TABLES 和 SYSCAT.COLUMNS


这些统计信息必须时时更新。若是出现数据库的表中有1000 000行数据而数据库只认为有500行,那就太糟糕了。统计这些数据有一个缺陷就是:要耗费时间去计算。这就是大多数数据库没有默认自动进行统计计算的缘由。当有数以百万计的数据存在,确实很难进行计算。在这种状况下,你能够选择进行基本统计或者数据中抽样统计一些状态。
好比:我正在进行一个计算表的行数达到亿级的统计工程,即便我只计算其中10%的数据,这也要耗费大量的时间。例子,这不是一个好的决定,由于有时候 Oracle 10G在特定表特定列选择的这10%的数据统计的数据和所有100%统计的数据差异极大(一个表中有一亿行数据是很罕见的)。这就是一个错误的统计将会导 致本来30s的查询却要耗费8个小时;找到致使的缘由也是一个噩梦。这个例子战士了统计是多么的重要。

注:固然每种数据库都有他本身更高级的统计。若是你想知道更多请好好阅读这些数据库的文档。值得一提的是,我之前尝试去了解这些统计是如何用的,我发现了这个最好的官方文档 one from PostgreSQL

查询优化器


CBO
全部的现代数据库都使用基于成本优化(CBO)的优化技术去优化查询。这个方法认为每个操做都有成本,经过最少成本的操做链获得结果的方式,找到最优的方法去减小每一个查询的成本。

为了明白成本优化器的工做,最好的例子是"感觉"一个任务背后的复杂性。这个部分我将展现3个经常使用方法去链接(join)两个表。咱们会快速明白一个简单链接查询是多么的那一优化。以后,咱们将会看到真正的优化器是如何工做的。
我将关注这些链接查询的时间复杂度而不是数据库优化器计算他们CPU成本,磁盘IO成本和内存使用。时间复杂度和CPU成本区别是,时间复杂度是估算的(这是想我这样懒人的工具)。对于CPU成本,我还要累加每个操做一个加法、一个if语句,一个乘法,一个迭代... 此外:

  • 一个高等级代码操做表明着一系列低等CPU操做。
  • 一个CPU操做的成本不是同样的(CPU周期)。无论咱们使用i7,P4,amd的Operon。一言以蔽之,这个取决于CPU架构。

使用时间复杂度太简单(起码对我来讲)。使用它咱们能轻易明白CBO的思路。咱们须要讨论一下磁盘IO,这个也是一个重要的概念。记住:一般状况,性能瓶颈在磁盘IO而不是CPU使用

索引


咱们讨论的索引就是咱们看到的B+树。记得吗?索引都是有序的。说明一下,也有一些其余索引好比bitmap 索引,他们须要更少的成本在CPU,磁盘IO和内存,相对于B+树索引。 此外,不少现代数据库当前查询动态建立临时索引,若是这个技术可以为优化查询计划成本。

访问路径


在执行join以前,你必须获得你的数据。这里就是你如何获得数据的方法。 注:全部访问路径的问题都是磁盘IO,我将不介绍太多时间复杂度的东西。

全扫描
若是已经看个一个执行计划,你必定看过一个词full scan(或者just scan)。全扫描简单的说就是数据库读整个表或者这个的索引。对磁盘IO来讲,整表扫描但是性能耗费的要比整个索引扫描多得多

范围扫描
还有其余的扫描方式好比索引范围扫描。举一个它使用的例子,咱们使用一些像"WHERE AGE > 20 AND AGE <40"计算的时候,范围就会使用。
固然咱们在字段AGE上有索引,就会使用索引范围扫描
咱们已经在第一章节看到这个范围查询的时间复杂度就是Log(N)+M,这个N就是索引数据。M就是一个范围内行的数目的估算。由于统计N和M都是已知(注:M就是范围计算 AGE >20 AND AGE<40的选择性)。 此外,对一个范围查询来讲,你不须要读取整个索引,因此在磁盘IO上,有比全扫描有更好的性能

惟一扫描
你只须要索引中获得一个值,咱们称之为惟一扫描

经过rowid访问
在大部分时间里,数据库使用索引,数据库会查找关联到索引的行。经过rowid访问能够达到相同的目的。
举个例子,若是你执行

SELECT LASTNAME, FIRSTNAME from PERSON WHERE AGE = 28

若是你有一个索引在列age上,优化器将会使用索引为你找到全部年龄在28岁的人,数据库会查找关联的行。由于索引只有age信息,而咱们想知道lastname和firstname。
可是,若是你要作这个查询

SELECT TYPE_PERSON.CATEGORY from PERSON ,TYPE_PERSON WHERE PERSON.AGE = TYPE_PERSON.AGE


PERSON上的索引会用来链接TYPE_PERSON。可是PERSON将不会经过rowid进行访问。由于咱们没有获取这个表的信息。
即便这个查询在某些访问可以工做的很好,这个查询真真正的问题是磁盘IO。若是你须要经过rowid访问太多的行,数据库可能会选择全扫描。

其余方法
我不能列举全部的访问方法。若是你须要知道的更多,你能够去看[Oracle documentation]()。名字可能和其余数据库不同,可是背后的机制是同样的。

链接操做符
咱们知道如何获取咱们的数据,咱们链接他们!
我列举3个常见的链接操做:归并链接,哈希链接和嵌套循环链接。再次以前,我须要介绍几个新名词:内部关系和外部关系。一个关系(应用在):

  • 一张表
  • 一个索引
  • 一个中间结果经过明确的操做(好比一个明确链接结果)

当你链接两个关系,join运算不一样的方式管理两种关系。在剩下的文章里边,我假设:

  • 外部关系是左侧数据集合
  • 内部关系是右侧数据集合

举例, A join B 就是一个A-B链接查询,A是外部关系,B是内部关系。
一般,A join B的成本和B join A的成本是不同的
在这部分,我假设外部关系有N个元素,内部关系有M个元素。记住,一个真正的优化器经过统计知道N和M的值。
注:N和M都是关系的基数。

嵌套循环链接
嵌套循环链接是最简单的。
Nested Loop Join
这是思路:

  • 找外部关系中的每一个元素
  • 你将查找内部关系的全部行,确认有没有行是匹配的。

这是伪代码

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)
从磁盘IO来讲,外部关系的N行数据每个行,内部循环须要读取M行数据。这个算法须要读N+N*M行数据从磁盘上。可是,若是内部关系足够小,你就能把这个关系放在内存中这样就只有M+N 次读取数据。经过这个修改,内部关系必须是最小的那个,由于这样这个算法,才能有最大的机会在内存操做。
从时间复杂度来讲,它没有任何区别,可是在磁盘IO上,这个是更好的读取方法对于二者。
固然,内部关系将会使用索引,这样对磁盘IO将会更好。

由于这个算法是很是简单,这也是对磁盘IO更好的版本,若是内部关系可以彻底存放在内存中。这就是思路:

  • 不用读取一行一行的读取数据。
  • 你批量的读取数据,保持两块数据(两种关系)在内存中。
  • 你比较块中的每行数据,记录匹配的行
  • 从磁盘读取新块,并比较数据
  • 持续执行,直到数据执行完。

这是可行的算法:

// 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


这个版本,时间复杂度是同样的,磁盘访问数据下降

  • 前一个版本,这个算法须要N + N*M 次访问(一次读一行)
  • 新版本中,磁盘访问次数成了number_of_bunches_for(outer)+ number_of_ bunches_for(outer)* number_of_ bunches_for(inner)。
  • 若是你增长每一个块的数量,就减小了磁盘访问次数。

注:比起前一个算法,一个数据库访问收集越多的数据。若是是顺序访问还不重要。(机械磁盘的真正问题是第一次获取数据的时间。)

哈希链接
相对于嵌套循环链接,哈希链接更加复杂,可是有更好的性能,在不少状况下。 Hash Join
哈希链接的思路为:

  • 1)获取全部的内部关系的全部元素。
  • 2)建立一个内存hash表
  • 3)一个一个的获取全部的外部关系元素。
  • 4)针对每一个内部关系元素计算每一个元素的哈希值(经过哈希函数)找到关联域。
  • 5)找到外部表格元素匹配的关联域的元素。
    从时间复杂度来讲,我必须先作一些假设来简化问题:
  • 内部关系元素被分割到X个域中。
  • 哈希方法分布的哈希范围对于两个关系是一致的。换而言之,域的大小是同样的。
  • 匹配外部关系的一个元素和域中全部元素的成本为域中元素的个数。


时间复杂度是(M/X)*N +cost_to_create_hash_table(M) + cost_of_hash_function*N
若是哈希函数建立足够小的域,这个复杂度为时间复杂度为O(M+N)

这就是另外一个版本的哈希链接,它更多的内存,和更少的磁盘IO。

  • 1)你计算出内部关系哈希表和外部关系的哈希表
  • 2)而后把他们放在磁盘上。
  • 3)而后你就能够一个一个比较两个哈希表的域(一个彻底载入内存,一个是一行一行的读)。


归并链接
归并链接是惟一产生有序结果的链接
注:在这个简化的归并链接,没有内部表和外部表的区别。他们是一样的角色。可是实际实现中又一些区别。好比:处理赋值的时候。
归并链接能够分为两个步骤:

  1. (可选项)排序操做:两个输入项都是在链接键上已经排好序。
  2. 归并链接操做:将排序好序的两个输入项合并在一块儿。

排序
咱们已经说过了归并排序,从这里来讲,归并排序是一个好的算法(若是有足够内存,还有性能更好的算法)。
可是有时数据集已是排好序的。好比:

  • 若是表是天然排序的,好比一个在链接键上使用了索引的表。
  • 若是关系就是链接条件的索引
  • 链接操做要使用查询过程当中的一个已经排好序的中间结果。

 

归并链接
merge join
这一部分比起归并排序简单多了。可是此次,不须要挑选每个元素,我只须要挑选二者相等的元素。思路以下:

  • 1)若是你比较当前的两个关系的元素(第一次比较,当前元素就是第一个元素)。
  • 2)若是他们相等,你就把两个元素放入结果集中,而后获取两个关系的下一个元素。
  • 3)若是不相等,你就获取较小元素的关系下一个元素(由于下一个元素较大,他们可能会相等)。
  • 4)重复 1,2,3步。一直到已经其中一个关系已经比较了所有的元素。

这样执行是由于两个关系都是排好序的,你不须要回头找元素。
这个算法是简化以后的算法。由于它没有处理两个关系中都会出现多个相同值的状况。实际的版本就是在这个状况上变得复杂了。这也是我选了一个简化的版本。

在两个关系都是排好序的状况下,时间复杂度为O(M+N) 两个关系都须要排序的状况下时间复杂度加上排序的消耗 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 and 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
            write_result_in_output(a[a_key],b[b_key])
            //We need to be careful when we increase the pointers
            integer a_key_temp:=a_key;
            integer b_key_temp:=b_key;
            if (a[a_key+1] != b[b_key])
                b_key_temp:= b_key + 1;
            end if
            if (b[b_key+1] != a[a_key])
                a_key_temp:= a_key + 1;
            end if
            if (b[b_key+1] == a[a_key] && b[b_key] == a[a_key+1])
                a_key_temp:= a_key + 1;
                b_key_temp:= b_key + 1;
            end if
            a_key:= a_key_temp;
            b_key:= b_key_temp;
        end if
    end while


哪个是最好的链接算法
若是有一个最好的链接算法,那么它不会有这么多种链接算法。选择出一个最好的链接算法,太困难。他有那么评判标准:

  • 内存消耗:没有足够内存,你必定肯定以及确定用不了强大的哈希链接(起码也是全内存的哈希链接)。
  • 两个数据集合的大小。若是你有一个很大的表和一个很小的表作链接查询,嵌套循环链接要比哈希链接还要快。由于哈希链接在创建哈希表的时候,消耗太大。若是你有两个很大的表,嵌套循环链接就会耗死你的CPU。
  • 索引的存在。有了两个B+树的索引,归并链接就是显而易见的选择。
  • 要求结果排序;若是你要链接两个无序的数据集,你想使用一个很是耗费性能的归并链接由于你须要结果有序,以后,你就能够拿着这个结果和其余的(表)进行归并联合。(或者是查询任务要求一个有序结果经过order by / group by / distinct操做符)
  • 两个排好序的关系:归并排序,归并排序,归并排序。
  • 你使用的链接的种类:是相等链接(例子: tableA.col1 = tableB.col2)?内链接?外链接?笛卡尔乘积?自链接?在某些状况下,链接也是无效的。
  • 你想让多进程/多线程来执行链接操做。

    更多内容,请看DB2,ORACLE,SQL Server的文档。

    例子
    咱们已经见过了3种链接操做。
    如今若是你须要看到一我的的所有信息,要链接5张表。一我的可能有: 多个手机电话 多个邮箱 多个地址 多个银行帐户
    总而言之,这么多的信息,须要一个这样的查询:

    SELECT * from PERSON, MOBILES, MAILS,ADRESSES, BANK_ACCOUNTS
    WHERE
    SPERSON.PERSON_ID = MOBILES.PERSON_ID
    SAND PERSON.PERSON_ID = MAILS.PERSON_ID
    SAND PERSON.PERSON_ID = ADRESSES.PERSON_ID
    SAND PERSON.PERSON_ID = BANK_ACCOUNTS.PERSON_ID


    若是一个查询优化器,我得找到最好的方法处理这些数据。这里就有一个问题:

  • 我该选择那种链接查询?
    我有三种备选的链接查询(哈希,归并,嵌套循环),由于他们可以使用0,1,2个索引(先不提有不一样的索引)。

  • 咱们选择表来作链接查询的顺序?
    举个例子,下图展现了4个表上的3次链接操做的可行的执行计划:
    Join Ordering Problem
    我可能会这么作:
  • 1)我用了暴力破解的方法
    经过数据库的统计,我能够计算每个执行计划的成本以后,选择那个最优解。可是有太多的可行方法了。就给定的链接查询的顺序而言,每一次链接有三种选择,哈希链接,归并链接,嵌套链接。因此对于肯定顺序的链接就有3的4次方的方法。链接的顺序是一个二叉树置换问题,它有(2*4)!/(4+1)!种可行方法。在这个问题上,咱们有34*(2*4)!/(4+1)!种方法。
    更直观的数字是,27216个方法。若是我把使用了0,1,2个索引的可能性增长到这个问题上,这个数字了21000种。看到这个简单查询,傻眼不?
  • 2)把我搞哭了,不干这个事儿了。
    这个提议很吸引人。可是你得不到结果,我还期望它挣钱呢。
  • 3)我就找几个执行计划试试,用其中最好性能的那个。
    我不是超人,我可算不出来每个执行计划的成本。因而,我就从全部可能的执行计划中随意选了一些,计算他们的成本,给你其中性能最好的哪一个。
  • 4)我使用了更聪明的规则减小了可行的执行计划


这里就有2种规则:
逻辑:我能够删除没有用的可能,可是不能过滤不少的可能。 好比:使用嵌套循环链接的内部关系必定是最小的数据集。
我能够接受不是最优解。使用更加有约束性的条件,减小更多的可行方法。好比:若是一个关系很小,使用嵌套循环查询,而不是归并、哈希查询。

在这个简单例子中,我获得了那么多的可行方法。可是一个现实的查询还有其余的关系操做符,像 OUTER JOIN, CROSS JOIN, GROUP BY, ORDER BY, PROJECTION, UNION, INTERSECT, DISTINCT …这意味着更多更多的可行方法。
这个数据库是怎么作的呢?

动态规划,贪婪算法和启发式算法


我已经提到一个数据库要尝试不少种方法。真正的优化就是在必定时间内找到一个好的解。
大多数状况下,优化器找到的是一个次优解,找不到最优解
小一点的查询,暴力破解的方式也是可行的。可是有一种方法避免了不少的重复计算。这个算法就是动态规划。
动态规划
动态规划的着眼点是有不一样的执行计划的有些步骤是同样的。若是你看这些下边的这些执行计划:
overlapping trees
他们使用了相同的子树(A JOIN B),因此每个执行计划都会计算这个操做。在这儿,咱们计算一次,保存这个结果,等到从新计算它的时候,就能够直接用这个结果。更加正式的说,咱们遇到一些有部分重复计算的问题,为了不额外的计算,咱们用内存保存重复计算的值。
使用这个技术,咱们仅仅有了3^N的时间复杂度,而不是(2*N)!/(N+1)!。在上个例子中的4个链接操做,经过使用动态规划,备选计划从336减小到81。若是咱们使用一个更大的嗯 8链接的查询,就会从57657个选择减小到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]


对于更大的查询,咱们不但使用动态规划,还要更多的规则(或者启发式算法)去减小无用解:

  • 好比咱们在分析一个执行计划(下例:left-deep tree)咱们就从3^n减小到了 N* 2^n
    left-deep-tree
  • 咱们增长一些逻辑条件,减小某些状况下下的计划。(好比:在给定一个表有给定条件须要的索引,就再也不尝试归并链接,直接使用索引)他也能减小不少的状况,而损害最后获得最好的结果。
  • 若是咱们在过程当中增长一些条件(好比:执行链接操做以前,执行其余的关系查询)他也能减小不少的可能状况。
  • .....

贪婪算法
对于一个很是大规模的请求可是须要极其快速得到答案(这个查询并非很快速的查询),要使用的就是另外一种类型的算法,贪婪算法。
这个思路是根据一个准则(或者说是启发式)逐步的去建立执行计划。经过这个准则,贪婪算法一次只获得一个步骤的最优解。
贪婪算法从一个JOIN来开始一次执行计划,找到这个JOIN查询的最优解。以后,找到每一个步骤JOIN的最优解,而后增长到执行计划。

让咱们开始这个简单的例子。好比:咱们有一个查询,有5张表的4次join操做(A, B, C, D, E)。为了简化这个问题,咱们使用嵌套循环链接。咱们使用这个准则:使用最低成本的JOIN

  • 随意选择一张表(选择A)
  • 咱们计算每个表JOIN A的成本。(A是外链接的内部关系)
  • 咱们获得了 A JOIN B是性能最好的。(A JOIN B结果为AB)
  • 咱们计算每一张表 JOIN AB的成本。(AB在这个计算中做为外链接的内关系)
  • 咱们获得 AB JOIN C 是性能最好的。(AB JOIN C 结果为ABC)
  • 咱们计算剩下的每张表 JOIN ABC的成本.....
  • .......
  • 最后,咱们找到了这个结果的存续为(((A JOIN B) JOIN C) JOIN D ) JOIN E

由于我是随意的从A开始,固然咱们也能够指定从B,或者C,D,E 开始咱们的算法。咱们也是经过这个过程获得性能最好的执行计划。
这个算法有一个名字,叫作最邻近节点算法
我不深刻的讲解算法的细节了。在一个良好的设计模型状况下,达到N*log(N)时间复杂度,这个问题将会被很好的解决。这个算法的时间复杂度是O(N*log(N)),对于所有动态计算的算法为O(3^N)。若是你有一个达到20个join的查询,这就意味着26 vs 3 486 784 401,这是一个天上地下的差异。
这个算法的问题在于咱们假设咱们在两张表中已经使用了最好的链接方法的选择,经过这个链接方式,已经给咱们最好的链接成本。可是:

  • 若是A JOIN B拥有最好的效率在A, B ,C的链接的第一个步骤中。
  • (A JOIN C) JOIN B这个链接可能拥有比(A JOIN B)JOIN C更好的性能。

为了优化性能,你能够运行多个贪婪算法使用不一样的规则,最后选择最好的执行计划。
其余算法
[若是你已经充分了解了这些算法,你就能够跳跃过下一部分。我想说的是:它不会影响剩下的文章的阅读]
对于不少计算机研究学者而言,得到最好的执行计划是一个很是活跃的研究领域。他们常常尝试在更加实用的场景和问题上,找到更好的解决方案。好比:

  • 若是是一个star join(一种特定类型的多链接查询),数据库将会使用一个特定的算法。
  • 若是是一个并行的查询,一些数据库会使用特定的算法。
  • ....

注: star join不太肯定是什么链接查询。
这些算法在大规模的查询的状况下,能够用来取代动态规划。贪婪算法属于启发式算法这一大类。贪婪算法是听从一个规则(思路),在找到当前步骤的解决方法,并将以前步骤的解决方法结合在一块儿。一些算法听从这个规则,一步一步的执行,但并不必定使用以前步骤的最优解。这个叫作启发式算法。
好比,遗传算法听从这样的规则--最后一步的最优解一般是不保留的:

  • 一个解决方案是可能的执行计划的所有步骤
  • 每一步保存P个方案(或计划),而不是一个方案(或 计划)
  • 0) P个计划是随机建立的
  • 1) 只有最优的计划才能被保留
  • 2) 混合计算这些计划,而后产生P个新的计划
  • 3) P个计划中的一些会被随机的修改
  • 4) 而后将步骤1,2,3重复执行T次。
  • 5) 在最后一次循环中从P个计划中,保留最好计划。


越多的循环次数,会获得更好的执行计划的。
这是魔术吗?不,这是天然的规则:只有最适合的才会存在。
另外,PostgreSQL已经实现了遗传算法,可是我还不了解是否默认使用了这个算法。
数据库使用了其余的启发式算法,好比Annealing, Iterative Improvement, Two-Phase Optimization… 可是我不了解这些算法是否用在企业数据库中,或者还在处于数据库研究的状态上。
实际的优化器
[能够跳过这一章,这一章对于本文不重要,也不影响阅读]
我已经说了这么多,并且这么理论,可是我是一个开发人员,不是一个研究人员。我更喜欢实实在在的例子
让我看一下 SQLite 优化器是如何工做的。SQLite是一个轻量级的数据库,它使用了很是简单优化方法,具体是基于贪婪算法,添加额外的约束,以减小可选解数量。

  • SQLite在CROSS JOIN时候,不对表进行从新排序。
  • 实现joins都是嵌套循环链接
  • outer joins 一般是按照顺序计算
  • .....
  • 在3.8.0版本以前,SQLite计算最优的查询计划时候,使用"最近邻节点"贪婪算法
    等一下....咱们已经知道这个算法那了!很巧合啊。
  • 从3.8.0版本之后(2015年发布),sqlite 在查找最优解的时候使用了N Nearest Neighbors+贪婪算法


好吧,让我了解一下其余优化怎么来工做。IBM DB2像其余的企业级数据库同样,它是我关注大数据以前专一的最后一个数据库。
若是咱们查看了DB2的官方文档,咱们会了解到DB2的优化器有7个优化层次。

  • 在joins操做时候,使用贪婪算法
  • + 0 - 最少的优化,仅仅使用索引扫描和嵌套循环链接,避免查询重写。
  • + 1 - 低层次优化
  • + 2 - 全优化
  • 使用动态规划来计算链接方案
  • + 3 - 保守的优化和粗略的邻近估计
  • + 5 - 全优化,使用启发性算法的全部技术。
  • + 7 - 相似于第5个层次,不使用启发性算法
  • + 9 - 竭尽全力的最大的优化考虑全部的可能链接顺序,包括笛卡尔乘积


咱们能够了解到DB2使用了贪婪算法。固然,自从查询优化器成为数据库的一大动力的时候,他们再也不分享他们使用的启发式算法。
说一句,默认的优化层次是5。缺省状况下,优化器使用如下数据:

  • 全部有效的统计,包括使用经常使用值(frequent-value)和分位数统计信息
  • 实施全部查询重写规则(包括具体化查询表的路由选择),不过一些计算密集型的规则只有在不多的状况才会被使用。
  • 使用动态规划的链接列举,固然有一些限制使用的地方:
  • + 合成的内部关系
  • + 星型模式下笛卡尔乘积,包括了查找表。
  • 考虑不少的访问方法,包括列表预读,index anding(注:一个做用于indexes的特殊操做)和具体化的表的路由选择。


默认状况下, DB2 在选择链接顺序时候,使用启发算法约束的动态规划
其余的SQL选择条件能够使用简单的规则
查询计划缓存
建立一个执行计划是须要时间的,大多数的数据库都把这些查询计划存储在查询计划缓存中,减小从新计算这些相同的查询计划的消耗。只是一个很是大的课题,由于数据库必须知道何时替换掉已通过期无用的计划。这个方法是建立一个阀值,当统计信息中表结构产生了变化,高于这个阀值,数据库就必须将涉及这张表的查询计划删除,净化缓存。

查询执行器


辛辛苦苦到了这个环节,咱们已经得到优化过的执行计划。这个计划已是编译成了可执行代码。若是有了足够的资源(内存,CPU),查询执行器就会执行这个 计划。计划中的操做(JOIN, SORT BY....)能够顺序执行,也能够并行执行,全看执行器。为了获取和写入数据,执行器必须和数据管理器打交道,也就是下一节的内容。

数据管理器


数据管理器
到了这一步,查询管理器执行查询,须要从表,索引获取数据。它从数据管理器请求数据,有2个问题:

  • 关系型数据库使用事务模型。因此,咱们不能想何时获取,就能立刻获取数据滴。由于这个时候,其余人可能正在使用或者修改咱们要求的数据。
  • 获取数据是数据库最慢的操做,由于数据管理器须要足够的智慧去获取数据并在内存中保存数据。

在这个部分,咱们将会看到关系型数据库如何处理这2个问题。咱们不会讨论管理器如何获取数据,由于这个不是很是重要(本文如今也太长了)。

缓存管理器


我已经说过,数据库最大的瓶颈就是磁盘I/O。为了提升性能,现代数据库使用缓存管理器。
缓存管理器
相比于直接从文件系统获取数据,查询执行器从缓存管理器中请求数据。缓存管理器有一个常驻内存的缓存叫作内存池,直接从内存获取数据可以极大极大的提升数据库的性能。可是很难说明性能提高的量级,由于这个跟你的执行操做息息相关。

  • 顺序读取(好比:全扫描)和随机读取(好比:经过rowid访问)
  • 读和写
    也跟数据库服务器使用的磁盘类型关系很大
  • 7200转/1W转/1W5转 机械硬盘
  • SSD
  • RAID 1/5/....

可是内存要比磁盘操做快100到10W倍
这个速度的差别引出了另外一个问题(数据库也有这个问题)。缓存管理器须要在查询执行器使用以前加载数据到内存,否则查询管理器必须等待从慢腾腾磁盘上获取数据。

预加载


这个问题叫作预加载,查询执行器知道它须要那些数据。由于它已经知道了查询的整个流程,经过统计数据已经了解磁盘上的数据。这里有一个加载思路:

  • 当查询执行器处理它的第一组数据
  • 它通知缓存管理器预先加载第二组数据
  • 当他开始处理第二组数据
  • 它继续通知缓存管理器预先加载第三组数据,并通知缓存管理器从缓存中清除第一组数据。

缓存管理器在内存池中存储全部这些数据。为了肯定数据须要与否,管理器附加缓存中的数据额外的管理信息(称为latch)。 注:latch真的无法翻译了。
有时,查询执行器不知道他须要什么样的数据,由于数据库并不提供这项功能。相应的,数据使用推测性预加载(好比:若是查询执行器要求数据1,3,5,他们他极可能继续请求7,9,11)或者连续预加载(这个例子中,缓存管理器从磁盘上顺序加载数据,根据执行器的请求)
为了显示预加载的工做状况,现代数据提供一个衡量参数叫作buffer/cache hit ratio请求数据在缓存中几率。
注:很低的缓存命中率不意味着缓存工做的很差。更多的信息能够参考Oracle文档
若是缓存是一个很是少的内存。那么,他就须要清除一部分数据,才能加载新的数据。数据的加载和清除,都须要消耗成本在磁盘和网络I/O上。若是一个查询常常执行,频繁的加载/清除数据对于这个查询也不是很是有效率的。为了解决这个问题,现代数据库使用内存更新策略。
内存更新策略
如今数据库(SQL Server,MySQL,Oracle和DB2)使用LRU算法。
LUR
LRU全称是Least Recently Used,近期最少使用算法。算法思路是最近使用的数据应该驻留在缓存,由于他们是最有可能继续使用的数据。
这是可视化的例子:
LRU
综合考虑,咱们假设缓存中的数据没有被latches锁定(这样能够被清除)。这个简单的例子中,缓存能够存储3个元素:

  • 1:缓存管理器使用数据 1,将这个数据放入空的内存
  • 2:管理器使用数据 4,将这个数据放入半加载的内存
  • 3:管理器使用数据 3,把这个数据放入半加载的内存
  • 4:管理器使用数据 9,内存已经满了,数据 1就要被移除由于他是最久没用的数据。数据 9放入内存。
  • 5: 管理器使用数据 4,数据4已经在内存了,由于4从新成为最近使用的数据
  • 6: 管理器使用数据 1,内存已经满了,数据9被移除,由于它是最久没用的数据,数据1 放入内存。
  • ....

这个算法工做的很好,可是有一些限制。若是在一个大表上进行全扫描,怎么办?话句话说,若是表/索引的大小超过的内存的大小,怎么办?使用这个算法就会移除缓存中以前的全部数据,然而全扫描的数据只使用一次。
加强方法
为了不这个这个问题,一些数据增长了一些规则。好比 Oracle请看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的新版本,叫作LRU-K,在SQL-Server中使用了LRU-K,K=2.
算法的思路是记录更多的历史信息。对于简单的LRU(也能够认为是LRU-K,K=1),算法仅仅记录了最后一个数据使用的信息。对于LRU-K

  • 记录K次数据的使用信息
  • 权重是基于数据使用次数。
  • 若是一组新数据载入缓存,最经常使用的老数据不会被移除(由于它的权重更高)。
  • 可是算法不会保留再也不被使用的老数据。
  • 因此不使用数据的状况下,权重根据时间衰减

权重的计算是很是耗费成本的,因此SQL-Server仅仅使用了K=2.整体来看,使用这个值,运行状况也是能够接受的。
关于LRU-K,更多更深刻的信息信息,你能够阅读研究文档(1993):数据库缓存的LRU-K页面替换算法
其余算法
固然,管理缓存许多其余的算法好比:

  • 2Q(相似于LRU-K算法)
  • CLOCK(相似于LRU-K算法)
  • MRU(最近最多使用算法,使用和LRU相同逻辑,使用规则不一样)
  • LRFU(最近最少,最频繁使用算法)
  • .......

一些数据库提供了使用其余算法而不是默认算法的方法。
写缓存
我仅仅讨论了读缓存--使用数据以前,载入数据。可是数据库中,你还必须有写缓存,这样你能够一次批量的写入磁盘。而不是写一次数据就写一次磁盘,减小单次的磁盘访问。
记住缓存保存数据页(Pages,数据的最小单元)而不是行(这是人/逻辑看待数据的方式)。若是一个页被修改而没有写入磁盘,那么缓存池中的这个数据页是的。选择写入磁盘时间的算法有不少,可是他们都和事务的概念息息相关。这就是本文下一章要讲述的。

事务管理器


本文最后,也就是本章内容就是事务管理器。咱们将看到一个进程如何保证每个查询都是在本身的事务中执行的。可是在此以前,咱们必须了解事务 ACID的概念。
I‘m on acid
事务是关系型数据库的工做单元,它有四个性质:

  • 原子性(Atomicity):事务是要么全作,要么不作,即使它执行了10个小时。若是事务执行失败,数据库的状态将回到事务执行以前的状态(事务回滚)。
  • 隔离性(Isolation):若是事务A和B同时执行,那么无论事务A仍是事务B先完成,结果老是同样的。
  • 持久性(Durability):一旦事务已经提交(成功结束),数据将存在数据库中,无论任何事情发生(崩溃或者错误)。
  • 一致性(Consistency):只有有效的数据(依照关系约束和功能约束)写入数据库。一致性和原子性、隔离性有一些联系。

一美圆
在一个事务里边,你能够有不少SQL语句去对数据增删改查。混乱的开始:两个事务同时使用相同的数据。典型例子:一个转帐从帐户A到帐户B。想象一下,你有两个事务:

  • 事务1 从帐户A转走100美圆到帐户B
  • 事务2 从帐户A转走50美圆到帐户B

若是咱们回到事务的ACID性质:

  • 原子性确保无论在T1过程当中发生什么(系统崩溃,网络异常),你都不会形成A转走了100$,却没有给B。(这个就是不一致的状态)
  • 隔离性确保即便T1和T2同时执行,最终结果就是A将会转走150$,B获得150$。而不是其余的状态。好比:A转走了150$,B获得了只有50$由于T2抹去了一部分T1的行为。(这个状况也是不一致的状态)。
  • 持久性确保在T1提交以后,即便数据库崩溃,T1也不会凭空消失。
  • 一致性确保没有钱在系统中多了或者少了。
    [若是你想,你能够略过本章剩下的内容,它对本文并非很重要]
    不少现代的数据库默认并无使用彻底的隔离,由于他会带来巨大的性能问题。正常状况下,SQL规范定义了4个层次的隔离:
  • 串行化(SQLite的默认选择):最高级别的隔离。同时发生的两个事务是100%的隔离的。每个事务都有本身的"世界"。
  • 可重复读(MySQL的默认选择):每个事务都有本身的"世界",除了一种状况。若是一个事务正确结束, 那么它增长的新数据,对于其余正在运行的事务是可见的。若是更新数据的事务正常结束,这个修改将对其余正在运行的事务,是不可见的。和其余事务的的隔离的 不一样,在新增数据,而不是已经存在的数据(修改的数据)。

好比,若是一个事务 A执行"select count(1) from TABEL_X",在这个时候,事务B向TABEL_X中,增长了一条新的数据,并正确提交,若是事务A 从新执行count(1),得到的值是不一样的。
这叫作幻读

  • 读已提交(Oracle,PostgreSQL,SQL Server的默认选择):这是这个是重复读+一个新的不一样的隔离。若是事务A 读数据D,而这个时候,数据D被事务B修改(或者删除),而且已经正确提交。若是A从新读数据D,那么他就能够看到在这个数据上的修改(或者删除)。
  • 读未提交:这个事最低层次的隔离。这个就是读已提交+新的不一样的隔离。若是事务A读了数据D,那么数据D被 事务B(正在运行还未提交)修改。若是这个时候A从新读数据D,他将会看到这个修改后的结果。若是事务B回滚,这个时候A第二次读数据D,被事务B修改的 数据就像没有被修改的同样,没有任何理由。(由于事务B回滚了)

这个叫作脏读

大部分数据使用它本身独特的隔离级别(就像Post供热SQL, Oracle,SQL Server使用的快照隔离)。而后,更多数据一般并非所有的SQL规范的四个隔离,尤为是读未提交。
默认的隔离级别能够在数据库链接开始的时候(仅仅须要添加很是简单的代码),被用户和开发者从新定义。

同步控制


隔离,一致性和原子性的真正问题是在相同数据上的写操做(增长,修改和删除):

  • 若是全部的事务都是只读数据,在没有其余事务修改数据的状况下,他们均可以同时运行。
  • 若是事务中的一个(起码),正在修改一个数据,这个数据要被其余的事务读取,数据库须要一种方法,对其余的事务,隐藏这个修改。另外,也须要保证这个修改不会被其余的事务消除,由于其余事务看不到这份修改的数据。


这个问题叫作并行控制
解决这个问题最简单的方式是一个接一个执行事务(好比:串行)。可是它不能进行扩展,即便运行在多核多处理的服务器上也只能使用一个核。很是没有效率...
解决这个问题的理想方式是:在每一时刻,事务均可以常见或者取消:

  • 监控全部事务的的全部操做。
  • 检测2个(或者多个)事务在读/修改相同数据的时候,是否冲突。
  • 修改冲突的事务的操做的执行顺序,减小冲突的部分的范围。
  • 记录能够被取消的事务。


更加正式的说,这是一个冲突调度时候的再调度问题。更加具体的说,这个是一个困难的,CPU密集型的优化问题。企业型数据库确定不能耗费数小时去给每一个事务去找到到最好的调度方式。所以,他们使用次于理想方式的途径,这些方式致使处理冲突的事务更多的时间浪费。

锁管理器


为了解决这个问题,大部分数据库使用而且/或者数据版本。这个是一个大的话题,我将关注于锁。以后我会讲解写数据版本。
悲观锁
锁机制的思路是:

  • 若是事务须要数据
  • 它锁定数据
  • 若是其余事务也须要这个数据
  • 它等待第一个事务释放这个数据的锁。

这就被称为独占锁
可是事务在只须要读数据的时候,却使用独占锁就太浪费了。由于它强制其余事务在读相同的数据的时候也必须等待。这就是为何须要另外一种锁,共享锁
共享锁的思路:

  • 若是事务只须要读数据A,
  • 它对数据A加“共享锁”,而后读数据
  • 若是第二个事务也只须要读数据A,
  • 它对数据A加”共享锁“,而后读数据
  • 若是第三个事务须要修改数据A,
  • 它对数据加”独占锁“,可是他必须等待,一直等到2个其余事物释放他们的共享锁,才能实施它的独占锁。

另外,若是数据被施加独占锁,一个事务只须要读这个数据,也必须等待独占锁的结束,而后对数据加共享锁。
锁管理器
锁管理器是加锁和解锁的过程。从实现上来讲,它在哈希表中存储着锁(键值是要锁的数据),以及对应的数据。

  • 那些事务正在锁定数据
  • 那些事务正在等待数据


死锁
可是锁的使用会致使一个问题,两个事务在永远的等待对方锁定的数据。
死锁
在这个例子中:

  • 事务A在数据data1上加了独占锁,并等待数据data2
  • 事务B在数据data2上加了独占锁,并等待数据data1


这就是死锁
在死锁中,锁管理器为了不死锁,会选择取消(回滚)其中一个事务。这个选择不容易的:

  • 取消修改最少数据的事务是否是更好(这样产生了最好性能的回滚)?
  • 取消已经执行时间最少的事务是否是更好,由于其余事务已经等了更久?
  • 取消整体执行时间最少的事务(可以避免可能的饥饿问题)。
  • 当出现回滚的时候,多少个事务别这个回滚影响?


在咱们作出选择以前,必须肯定是否存在死锁。
这个哈希表,能够被看作是图(像以前的例子)。若是在图中产生了一个循环,就有一个死锁。由于确认环太浪费性能(由于有环的图通常都很是大),这里有一个经常使用小技术:使用超时。若是一个锁没有在超时的时间内结束,这个事务就进入了死锁状态。

锁管理器在加锁以前也会检测这个锁会不会产生死锁。可是重复一下,作这个检测是很是耗费性能的。所以,这些提早的检测是基本的规则。
两段锁
营造纯净的隔离最简单的方式是在事务开始的时候加锁,在事务结束的时候解锁。这意味着事务开始必须等待全部的锁,在事务结束的时候必须解除它拥有的锁。这是能够工做的可是产生了巨大的时间浪费
一个更快速的方法是两段锁协议(DB2,SQL Server使用这项技术),在这项技术中,事务被划分红两个极端:

  • 扩展阶段,这个阶段,事务能够获取锁,可是不能解锁。
  • 收缩阶段,这个阶段,事务能够解锁(锁定的数据已经处理完成,不能再继续处理),可是不能获取新锁。


两段锁
两段锁的思路有两个简单的规则:

  • 解除再也不使用的锁,减小须要这些锁其余事务等待时间。
  • 避免在其余事务开始时候,修改数据,所以形成其余事务须要的数据不一致。


这个协议工做的很好,除了这个状况,即一个事务修改数据,在释放关联的锁被取消(回滚)。这个状况结束时候,会致使其余事务读取修改后的值,而这个值就要回滚。为了解决这个问题,全部的独占锁必须在事务完成的时候释放
一些话
固然真正的数据库是更加复杂、精细的系统,使用了更多类型锁(好比意向锁),更多的控制粒度(能够锁定一行,锁定一页,锁定一个分区,锁定一张表,锁定表分区)。可是基本思路是同样的。
我只讲述了段春基于锁的方法。数据版本是另外一个处理这个问题的方式
版本处理的思路是:

  • 每个事务在同一时间修改一样的数据。
  • 每个事务有他本身的数据拷贝(或者说版本)。
  • 若是两个事务修改一样的数据,只有一份修改会被接受,另外一份修改将会被拒接,相应的实惠就会回滚(或者从新执行)。


这个种方式提升了性能:

  • 读类型的事务不会阻塞写类型的事务
  • 写类型的事务不会阻塞读类型的事务
  • 没有了”繁重的、缓慢的“锁管理器的影响


万物皆美好啊,除了两个事务同时写一份数据。所以,你在结束时候,会有巨大磁盘空间浪费。
数据版本和锁是两个不一样的方式:乐观锁和悲观锁。他们都有正反两方面,根据你的使用状况(读的多仍是写的多)。对于数据版本的介绍,我推荐这个关于Post供热SQL实现多版本的并发控制是很是好的介绍
一些数据库好比DB2(一直到DB2 9.7),SQL Server(除了快照隔离)都只是使用了锁。其余的像PostgreSQL,MySQL和Oracle是使用了混合的方式包括锁,数据版本。我还不知道 有什么数据库只使用了数据版本(若是你知道那个数据库只是用了数据版本,请告诉我)。
[更新在2015/08/20],一个读者告诉我:

Firebird和 Interbase就是只使用了数据版本,没有使用记录锁,版本控制对于索引来讲也是有很是有意思的影响:有时,一个惟一索引包含了副本,索引的数目比数据的行更多


若是你读到关于不一样的隔离层次的时候,你能够增长隔离层次,你增长锁的数量,所以事务在等待锁时间的浪费。这就是不少数据库默认不使用最高级别隔离(串行化)的缘由。
固然,你能够本身查看这些主流数据库的文档(像 Mysql,PostgreSQL,Oracle)。

日志管理器


咱们已经看到为了增长性能,数据库在内存中存储数据。事务已经提交,可是一旦服务器崩溃,你但是可能丢失在内存中的数据,这就破坏了事务的持久性。
若是服务器崩溃的时候,你正在向磁盘写入数据。你会获得一部分写入磁盘的结果,这样就破坏了事务的原子性。
事务的任何修改写入都是要不不作要么作完
为了解决这个问题,有两个方法:

  • 影子拷贝/影子页:每个事务建立本身的一份数据库拷贝(或者数据库的一部分拷贝)并在这个拷贝上进行修改。一旦出现错误,这份拷贝就被删除。若是成功,数据库就当即切换到数据到这份拷贝上,经过文件系统的技巧,他就删除了老的数据。
  • 事务日志:事务日志是指就是存储空间。在每次写入磁盘以前,数据库已经写了一份信息在事务日志中,防止事务崩溃、取消的出现,数据库知道如何删除(或者完成)没有完成事务。

WAL
当大型数据库上许多事务都在运行,影子拷贝/影子页有一个巨大的磁盘使用过量。这就是现代数据库使用事务日志。事务日志必须存储在稳定的存储介质上,我不能更加深刻的挖掘存储技术可是使用(起码)RAID 磁盘是必须,避免磁盘损坏。
大部分现代数据库(起码Oracle,SQL Server, DB2, PostgreSQL,Mysql和SQLite)处理事务日志使用了*Write-Ahead Logging protocol *(WAL),这个WAL协议是一组规则:

  • 在数据库的每个修改都产生一条日志记录,这条日志记录必须在数据写入磁盘以前,写入事务日志。
  • 日志记录必须按顺序写,记录A先于记录B发生,那么必须在B以前写入。
  • 当一个事务已经提交,在事务胜利完成以前,提交单据必须已经写入事务日志。

日志管理器
这个是日志管理器的工做。在缓存管理器和数据访问管理器(它将数据吸入磁盘)中间,就能找到它的身影,日志管理器把每一个update/delete/create/commit/rollback,在写入磁盘以前,将相应的信息在事务日志上。很简单,不是吗?
错误的答案!毕竟咱们已经想过,我已经知道和数据库相关的一切事情都被”database effect“所诅咒。更加严重的问题是,找到具备很好性能的日志写入方法。若是吸入日志太慢,他们竟会拖慢全部的事情。
ARIES
在1992年,IBM的研究人员”发明“了一个WAL的加强版本叫作ARIES。ARIES差很少已经被全部的现代数据使用。虽然逻辑处理有一些差别,可是ARIES的思想都已经遍地开花了。跟着MIT的课程, 我也引用了这项发明的思想,”没有比写一个好的事务恢复更好的作法“。在我5岁的时候,ARIES论文已经发表,我不了解那些苦逼的研究人民的传言。我打 算在咱们开始最后的技术章节以前,将一些有意思的东西,放松一下。我阅读了ARIES很大篇幅的研究论文,我发现这个颇有意思。我想讲述一个ARIES的 总体的形态,可是若是你有一些真材实料,强烈推荐去阅读那篇论文。
ARIES全称是Algorithms for Recovery and Isolation Exploiting Semantics。
这项技术的两个目的:

  • 1)写日志性能
  • 2)快速和可靠的恢复

数据库回滚事务有多种缘由:

  • 用户取消了事务
  • 服务器运行失败或者网络失败
  • 事务破坏了数据库的完整性(好比:你在一个column上有UNIQUE的约束,可是事务增长了一个相同的值)
  • 由于死锁

有时候(好比,遇到网络失败),数据库能恢复事务。
可是这可能吗?为了回答这个问题,咱们必须明白信息就在日志记录里面。
日志
每个事务的每个操做(dadd/remove/modify)都要产生日志。日志记录包括:

  • LSN: 惟一的日志序列号(Log Sequence Number)。LSN是按照时间如遇的。只意味着操做A比操做B发生的早,操做A的LSN比操做B的LSN要小。
  • TransID:操做的事务ID
  • PageID: 修改数据的磁盘位置。磁盘数据的最小单位是块,因此数据的位置也就是存放数据磁盘块的位置。
  • PrevLSN:相同事务,上一条日志记录的LSN。
  • UNDO:消除这个操做影响的方法。

好比,一个Update操做,这个UNDO就要存放update以前,要update元素的元素值(物理UNDO)或者能够回归以前状态的逆运算(物理UNDO)。

  • REDO:继续操做的方法
    相同的事情是,完成这个操做是这两种方法。要么存放修改以后值的元素,要么记录继续操做的运算。
  • ....(ARIES还有两个字段:UndoNxtLSN和类型)


注:原文是Page,可是磁盘单位可是咱们更愿意用块。
此外,磁盘的每一个块(存储数据,不是日志)都有最有一个最后修改数据的操做日志记录的ID(LSN)
*这个方式LSN更复杂,由于由于他还要牵涉到日志存储。可是思路是同样的。
**ARIES只只用逻辑UNDO,由于处理物理UNDO才是个大麻烦。
注:从我这点浅薄的看法,只有PostgreSQL没有使用UNDO。它使用一个垃圾收集服务,有这个服务来清除老版本的数据。这个实现跟PostgreSQL的数据版本时间有关。
为了更清楚的理解,这个一个简化的图形,这个例子说明的是语句”UPDATE FROM PERSON SET AGE = 18;“产生的日志记录。这个语句是在ID18的事务中执行的。
简化ARIES日志
每个日志都有惟一的LSN。这些日志经过相同的事务关联在一块儿,经过时间顺序进行逻辑管理。(这个执行链的最后一条日志,也是以后一个操做的日志)
日志缓冲
为了不日志写入成为性能瓶颈,引入了日志缓冲
日志写入过程
若是查询执行器要求这样的修改:

  • 1)缓存管理器在它的缓冲区保存修改结果。
  • 2)日志管理器在它的缓冲区保存相应的日志。
  • 3)在这个步骤中,查询执行器关心这个操做是否完成(能够执行其余的修改)
  • 4)这个时候(或者稍后)日志管理器将日志写入事务日志。决定何时写入日志由这个算法决策。
  • 5)这个时候(或者稍后)缓存管理器将修改写入磁盘。决定何时写入磁盘由这个算法决策。


当一个事务已经提交,这意味着事务中的每个操做的1,2,3,4,5个步骤都已经完成。写入事务日志挺快的,由于它仅仅是”在事务日志中增长记录“,然而写入数据是很是复杂的,由于”要用方便、快速读的方式写入数据“。
STEAL和FORCE模式
性能缘由,5个步骤可能在提交以后才能完成,由于在崩溃的状况下,可能须要REDO日志恢复事务执行。这是就是NO-FORCE policy(非强制模式)
一个数据库能够选择强制模式(FORCE policy)(五个步骤必须在提交以前完成),这样能够减小恢复过程当中的负载。
另外一个问题是选择一步一步将数据写入磁盘(STEAL Policy),仍是缓存管理器等待,直到提交指令,一次将全部的修改一次性写入磁盘(NO-STEAL)。在STEAL和NO STEAL中进行选择,要看你的须要。快速写入可是使用UNDO日志数据恢复慢,仍是快速恢复。
不一样的模式对于数据恢复有不一样的影响,请看以下总结:

  • STEAL/NO-FORCE须要UNDO和REDO:最好的性能可是更富在的日志和恢复过程(像ARIES)。这是大部分数据库的选择。注:我已经在大力那个的研究资料和课程中了解到这个事情,可是并无在官方文档上找到这个描述(明确的)。
  • STEAL/FORCE只须要UNDO
  • NO-STEAL/NO-FORCE只须要REDO
  • NO-STEAL/FORCE不须要任何条件,性能最差和须要巨大的内存。

注: STEAL/NO STEAL描述对象是修改的数据
FORCE/NO FORCE说明的对象是写入日志的时间。
数据恢复
OK,咱们已经有了很是好的日志,让咱们来使用它。
假设数据由于内部错误而崩溃,你重启数据库,这个恢复程序就开始了。
ARIES从失败中经过三个方法进行恢复

  • 1)分析方式:恢复程序读取真个事务日志,从新建立在崩溃的时候,正在执行的操做。这可肯定了那些事务要被回滚(没有提交指令的事务都要被回滚),那些数据应该在崩溃的时候写入磁盘。
  • 2)redo方式:这个方法从分析过程已经肯定读取日志记录开始,使用REDO去更新数据库到在崩溃以前的状态。
    在redo过程当中,REDO的日志是按照时间循序处理的(使用LSN)。
    对于每一条日志,恢复程序读取须要修改数据所属的磁盘块的LSN。
    若是LSN(page_on_disk) >= LSN(log_record),这就是说数据在崩溃以前已经被写入磁盘(在崩溃以前,日志以后,LSN值已经被重写),因此已经完成。
    若是LSN(page_on_disk) < LSN(log_record),磁盘块上的数据须要更新。
    REDO对全部的事务都回滚,是能够简化恢复过程的(可是我肯定现代的数据不会这样作)
  • 3)UNDO方式:这个方式回滚全部在崩溃以前没有提交的事务。回滚从每一个事务的最后一条日志开始,一直执行UNDO日志,直到出现非时间表的顺序(这个就是用到了日志的PrevLSN)。


在恢复过程当中,事务日志必须提醒恢复程序,他们要作出的行动,由于数据写入磁盘和写入事务日志是同步的。一个解决方案能够删除未完成的事务的条目,可是至关困难。相应的,ARIES在事务日志中写入综合性的日志,能够逻辑删除那些已经移除的事务的日志条目。
当一个事务被”手动的“取消,或者被锁管理器(为了解决死锁),或者仅仅由于网络失败,这个时候分析方法就是不须要的。事实上,那些须要REDO,那些须要UNDO的信息是在两个内存中的表里边:

  • 事务表(存储全部当前的事务状态)
  • 脏页表(存储全部须要被写入磁盘的数据信息)


这些表被缓存管理器更新,在新事务建立时候,事务管理器更新。由于他们是在内存中的,当数据库崩溃,他们也要被销毁。
分析阶段的工做就是运用事务日志的信息,重建崩溃后的两张表。为了加快分析速度,ARIES提供了检查点(checkpoint)*的概念。这个思路就是将事务表,脏页表一次一次的写入磁盘,在写入磁盘的时候,保存的最后一个LSN也写入磁盘。在分析阶段,以后LSN以后的日志才会被分析。

结束语


在写这篇文章以前,我已经明白这是一个庞大的课题,须要花费大量时间才能写出深刻的文章。事实证实,我太乐观了,我比预计多花费了两倍的时间,可是我学到了不少。
若是你想对数据库有一个全面的了解,我推荐你阅读这个研究资料”数据库结构“。这个一个对于数据库一个很是好的入门介绍(有119页),同时对于非计算机学科的人也是很是易读的。这篇研究资料在本文的计划上帮了我不少。它想个人文章同样,没有太关注与数据结构和算法,更多的是,架构原理。
若是你已经仔细的阅读了本文,你就应该知道一个数据库系统是多么的强大。由于这是一个长而又长的文章,让我来帮你回顾一下,咱们看到了什么:

  • 概述了B+树索引
  • 全面描述了数据库系统
  • 概述了重点在join操做上,基于成本的数据库优化方法。
  • 概述了缓存池的管理
  • 概述了事务管理

可是数据库有更多的智能。好比,我也不能讨论的高深的话题:

  • 如何管理数据库集群和全局事务
  • 如何在数据库系统正在运行中,进行快照
  • 如何有效的存储(压缩)数据
  • 如何管理内存


请你再选择问题多多的NoSQL数据库和拥有坚实基础的关系型数据库时候多多考虑。固然别误导我,有一些NoSQL数据仍是很棒的。可是他们仍然是关注与特定的问题的新人,也是吸引了一些应用使用。
做为结束语,若是有问你一个数据库如何运行,除了临阵脱逃以外,你能够告诉他们
魔术
或者给他们看看这篇文章。
注: data set 翻译为数据集,指的是table。 index anding 这个操做,没明白是个什么操做,已经在pg的官方文档上寻找,没有找到。

cache和buffer:对于本文来讲,这二者并无什么太大的不一样,都是指的是内存。 而内存中一些区别,具体能够看操做系统drop cache的级别。 可是在本文中,并无严格的区分。

相关文章
相关标签/搜索