数据库的原理,一篇文章搞定(二)

客户端管理器

客户端管理器是处理客户端通信的。客户端可以是一个(网站)服务器或者一个最终用户或最终应用。客户端管理器通过一系列知名的API(JDBC, ODBC, OLE-DB …)提供不同的方式来访问数据库。

客户端管理器也提供专有的数据库访问API。

当你连接到数据库时:

管理器首先检查你的验证信息(用户名和密码),然后检查你是否有访问数据库的授权。这些权限由DBA分配。
然后,管理器检查是否有空闲进程(或线程)来处理你对查询。
管理器还会检查数据库是否负载很重。
管理器可能会等待一会儿来获取需要的资源。如果等待时间达到超时时间,它会关闭连接并给出一个可读的错误信息。
然后管理器会把你的查询送给查询管理器来处理。
因为查询处理进程不是『不全则无』的,一旦它从查询管理器得到数据,它会把部分结果保存到一个缓冲区并且开始给你发送。
如果遇到问题,管理器关闭连接,向你发送可读的解释信息,然后释放资源。

screenshot

客户端管理器是处理客户端通信的。客户端可以是一个(网站)服务器或者一个最终用户或最终应用。客户端管理器通过一系列知名的API(JDBC, ODBC, OLE-DB …)提供不同的方式来访问数据库。

客户端管理器也提供专有的数据库访问API。

当你连接到数据库时:

管理器首先检查你的验证信息(用户名和密码),然后检查你是否有访问数据库的授权。这些权限由DBA分配。
然后,管理器检查是否有空闲进程(或线程)来处理你对查询。
管理器还会检查数据库是否负载很重。
管理器可能会等待一会儿来获取需要的资源。如果等待时间达到超时时间,它会关闭连接并给出一个可读的错误信息。
然后管理器会把你的查询送给查询管理器来处理。
因为查询处理进程不是『不全则无』的,一旦它从查询管理器得到数据,它会把部分结果保存到一个缓冲区并且开始给你发送。
如果遇到问题,管理器关闭连接,向你发送可读的解释信息,然后释放资源。

查询管理器

screenshot
这部分是数据库的威力所在,在这部分里,一个写得糟糕的查询可以转换成一个快速执行的代码,代码执行的结果被送到客户端管理器。这个多步骤操作过程如下:

查询首先被解析并判断是否合法
然后被重写,去除了无用的操作并且加入预优化部分
接着被优化以便提升性能,并被转换为可执行代码和数据访问计划。
然后计划被编译
最后,被执行
这里我不会过多探讨最后两步,因为它们不太重要。

看完这部分后,如果你需要更深入的知识,我建议你阅读:

关于成本优化的初步研究论文(1979):关系型数据库系统存取路径选择。这个篇文章只有12页,而且具备计算机一般水平就能理解。
非常好、非常深入的 DB2 9.X 如何优化查询的介绍
非常好的PostgreSQL如何优化查询的介绍。这是一篇最通俗易懂的文档,因为它讲的是『我们来看看在这种情况下,PostgreSQL给出了什么样的查询计划』,而不是『我们来看看PostgreSQL用的什么算法』。
官方SQLite优化文档。『易于』阅读,因为SQLite用的是简单规则。再者,这是唯一真正解释SQLite如何工作的官方文档。
非常好的SQL Server 2005 如何优化查询的介绍
Oracle 12c 优化白皮书
2篇查询优化的教程,第一篇 第二篇。教程来自《数据库系统概念》的作者,很好的读物,集中讨论磁盘I/O,但是要求具有很好的计算机科学水平。
另一个原理教程,这篇教程我觉得更易懂,不过它仅关注联接运算符(join operators)和磁盘I/O。

查询解析器

每一条SQL语句都要送到解析器来检查语法,如果你的查询有错,解析器将拒绝该查询。比如,如果你写成”SLECT …” 而不是 “SELECT …”,那就没有下文了。
但这还不算完,解析器还会检查关键字是否使用正确的顺序,比如 WHERE 写在 SELECT 之前会被拒绝。

然后,解析器要分析查询中的表和字段,使用数据库元数据来检查:

  • 表是否存在
  • 表的字段是否存在
  • 对某类型字段的 运算 是否 可能(比如,你不能将整数和字符串进行比较,你不能对一个整数使用 substring() 函数)

接着,解析器检查在查询中你是否有权限来读取(或写入)表。再强调一次:这些权限由DBA分配。

在解析过程中,SQL 查询被转换为内部表示(通常是一个树)。

如果一切正常,内部表示被送到查询重写器。

查询重写器

在这一步,我们已经有了查询的内部表示,重写器的目标是:

  • 预优化查询
  • 避免不必要的运算
  • 帮助优化器找到合理的最佳解决方案

重写器按照一系列已知的规则对查询执行检测。如果查询匹配一种模式的规则,查询就会按照这条规则来重写。下面是(可选)规则的非详尽的列表:

  • 视图合并:如果你在查询中使用视图,视图就会转换为它的 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,都会被消除。
  • 常数计算赋值:如果你的查询需要计算,那么在重写过程中计算会执行一次。比如 WHERE AGE > 10+2 会转换为 WHERE AGE > 12 , TODATE(“日期字符串”) 会转换为 datetime 格式的日期值。
  • (高级)分区裁剪(Partition Pruning):如果你用了分区表,重写器能够找到需要使用的分区。
  • (高级)物化视图重写(Materialized view rewrite):如果你有个物化视图匹配查询谓词的一个子集,重写器将检查视图是否最新并修改查询,令查询使用物化视图而不是原始表。
  • (高级)自定义规则:如果你有自定义规则来修改查询(就像 Oracle policy),重写器就会执行这些规则。
  • (高级)OLAP转换:分析/加窗 函数,星形联接,ROLLUP 函数……都会发生转换(但我不确定这是由重写器还是优化器来完成,因为两个进程联系很紧,必须看是什么数据库)。 【译者注: 物化视图 。谓词,predicate,条件表达式的求值返回真或假的过程】

重写后的查询接着送到优化器,这时候好玩的就开始了。
统计

研究数据库如何优化查询之前我们需要谈谈统计,因为没有统计的数据库是愚蠢的。除非你明确指示,数据库是不会分析自己的数据的。没有分析会导致数据库做出(非常)糟糕的假设。

但是,数据库需要什么类型的信息呢?

我必须(简要地)谈谈数据库和操作系统如何保存数据。两者使用的最小单位叫做页或块(默认 4 或 8 KB)。这就是说如果你仅需要 1KB,也会占用一个页。要是页的大小为 8KB,你就浪费了 7KB。

回来继续讲统计! 当你要求数据库收集统计信息,数据库会计算下列值:

表中行和页的数量
表中每个列中的:
唯一值
数据长度(最小,最大,平均)
数据范围(最小,最大,平均)
表的索引信息
这些统计信息会帮助优化器估计查询所需的磁盘 I/O、CPU、和内存使用

对每个列的统计非常重要。
比如,如果一个表 PERSON 需要联接 2 个列: LAST_NAME, FIRST_NAME。
根据统计信息,数据库知道FIRST_NAME只有 1,000 个不同的值,LAST_NAME 有 1,000,000 个不同的值。
因此,数据库就会按照 LAST_NAME, FIRST_NAME 联接。
因为 LAST_NAME 不大可能重复,多数情况下比较 LAST_NAME 的头 2 、 3 个字符就够了,这将大大减少比较的次数。

不过,这些只是基本的统计。你可以让数据库做一种高级统计,叫直方图。直方图是列值分布情况的统计信息。例如:

出现最频繁的值
分位数 【译者注:http://baike.baidu.com/view/1323572.htm】

这些额外的统计会帮助数据库找到更佳的查询计划,尤其是对于等式谓词(例如: WHERE AGE = 18 )或范围谓词(例如: WHERE AGE > 10 and AGE < 40),因为数据库可以更好的了解这些谓词相关的数字类型数据行(注:这个概念的技术名称叫选择率)。

统计信息保存在数据库元数据内,例如(非分区)表的统计信息位置:

Oracle: USER / ALL / DBA_TABLES 和 USER / ALL / DBA_TAB_COLUMNS
DB2: SYSCAT.TABLES 和 SYSCAT.COLUMNS
统计信息必须及时更新。如果一个表有 1,000,000 行而数据库认为它只有 500 行,没有比这更糟糕的了。统计唯一的不利之处是需要时间来计算,这就是为什么数据库大多默认情况下不会自动计算统计信息。数据达到百万级时统计会变得困难,这时候,你可以选择仅做基本统计或者在一个数据库样本上执行统计。

举个例子,我参与的一个项目需要处理每表上亿条数据的库,我选择只统计10%,结果造成了巨大的时间消耗。本例证明这是个糟糕的决定,因为有时候 Oracle 10G 从特定表的特定列中选出的 10% 跟全部 100% 有很大不同(对于拥有一亿行数据的表,这种情况极少发生)。这次错误的统计导致了一个本应 30 秒完成的查询最后执行了 8 个小时,查找这个现象根源的过程简直是个噩梦。这个例子显示了统计的重要性。

注:当然了,每个数据库还有其特定的更高级的统计。如果你想了解更多信息,读读数据库的文档。话虽然这么说,我已经尽力理解统计是如何使用的了,而且我找到的最好的官方文档来自PostgreSQL。

查询优化器

所有的现代数据库都在用基于成本的优化(即CBO)来优化查询。道理是针对每个运算设置一个成本,通过应用成本最低廉的一系列运算,来找到最佳的降低查询成本的方法。

为了理解成本优化器的原理,我觉得最好用个例子来『感受』一下这个任务背后的复杂性。这里我将给出联接 2 个表的 3 个方法,我们很快就能看到即便一个简单的联接查询对于优化器来说都是个噩梦。之后,我们会了解真正的优化器是怎么做的。

对于这些联接操作,我会专注于它们的时间复杂度,但是,数据库优化器计算的是它们的 CPU 成本、磁盘 I/O 成本、和内存需求。时间复杂度和 CPU 成本的区别是,时间成本是个近似值(给我这样的懒家伙准备的)。而 CPU 成本,我这里包括了所有的运算,比如:加法、条件判断、乘法、迭代……还有呢:

每一个高级代码运算都要特定数量的低级 CPU 运算。
对于 Intel Core i7、Intel Pentium 4、AMD Opteron…等,(就 CPU 周期而言)CPU 的运算成本是不同的,也就是说它取决于 CPU 的架构。
使用时间复杂度就容易多了(至少对我来说),用它我也能了解到 CBO 的概念。由于磁盘 I/O 是个重要的概念,我偶尔也会提到它。请牢记,大多数时候瓶颈在于磁盘 I/O 而不是 CPU 使用。

索引

在研究 B+树的时候我们谈到了索引,要记住一点,索引都是已经排了序的。

仅供参考:还有其他类型的索引,比如位图索引,在 CPU、磁盘I/O、和内存方面与B+树索引的成本并不相同。

另外,很多现代数据库为了改善执行计划的成本,可以仅为当前查询动态地生成临时索引。

存取路径

在应用联接运算符(join operators)之前,你首先需要获得数据。以下就是获得数据的方法。

注:由于所有存取路径的真正问题是磁盘 I/O,我不会过多探讨时间复杂度。

【译者注:四种类型的Oracle索引扫描介绍 】

全扫描

如果你读过执行计划,一定看到过『全扫描』(或只是『扫描』)一词。简单的说全扫描就是数据库完整的读一个表或索引。就磁盘 I/O 而言,很明显全表扫描的成本比索引全扫描要高昂。

范围扫描

其他类型的扫描有索引范围扫描,比如当你使用谓词 ” WHERE AGE > 20 AND AGE < 40 ” 的时候它就会发生。

当然,你需要在 AGE 字段上有索引才能用到索引范围扫描。

在第一部分我们已经知道,范围查询的时间成本大约是 log(N)+M,这里 N 是索引的数据量,M 是范围内估测的行数。多亏有了统计我们才能知道 N 和 M 的值(注: M 是谓词 “ AGE > 20 AND AGE < 40 ” 的选择率)。另外范围扫描时,你不需要读取整个索引,因此在磁盘 I/O 方面没有全扫描那么昂贵。

唯一扫描

如果你只需要从索引中取一个值你可以用唯一扫描。

根据 ROW ID 存取

多数情况下,如果数据库使用索引,它就必须查找与索引相关的行,这样就会用到根据 ROW ID 存取的方式。

例如,假如你运行:

SELECT LASTNAME, FIRSTNAME from PERSON WHERE AGE = 28

如果 person 表的 age 列有索引,优化器会使用索引找到所有年龄为 28 的人,然后它会去表中读取相关的行,这是因为索引中只有 age 的信息而你要的是姓和名。

但是,假如你换个做法:

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

PERSON 表的索引会用来联接 TYPE_PERSON 表,但是 PERSON 表不会根据行ID 存取,因为你并没有要求这个表内的信息。

虽然这个方法在少量存取时表现很好,这个运算的真正问题其实是磁盘 I/O。假如需要大量的根据行ID存取,数据库也许会选择全扫描。

其它路径

我没有列举所有的存取路径,如果你感兴趣可以读一读 Oracle文档。其它数据库里也许叫法不同但背后的概念是一样的。

联接运算符

那么,我们知道如何获取数据了,那现在就把它们联接起来!

我要展现的是3个个常用联接运算符:合并联接(Merge join),哈希联接(Hash Join)和嵌套循环联接(Nested Loop Join)。但是在此之前,我需要引入新词汇了:内关系和外关系( inner relation and outer relation) 【译者注: “内关系和外关系” 这个说法来源不明,跟查询的“内联接(INNER JOIN) 、外联接(OUTER JOIN) ” 不是一个概念 。只查到百度百科词条:关系数据库 里提到“每个表格(有时被称为一个关系)……” 。 其他参考链接 “Merge Join” “Hash Join” “Nested Loop Join” 】 。 一个关系可以是:

  • 一个表
  • 一个索引
  • 上一个运算的中间结果(比如上一个联接运算的结果)
    当你联接两个关系时,联接算法对两个关系的处理是不同的。在本文剩余部分,我将假定:

  • 外关系是左侧数据集

  • 内关系是右侧数据集
    比如, A JOIN B 是 A 和 B 的联接,这里 A 是外关系,B 是内关系。

多数情况下, A JOIN B 的成本跟 B JOIN A 的成本是不同的。

在这一部分,我还将假定外关系有 N 个元素,内关系有 M 个元素。要记住,真实的优化器通过统计知道 N 和 M 的值。

注:N 和 M 是关系的基数。【译者注: 基数 】

嵌套循环联接

嵌套循环联接是最简单的。
screenshot

道理如下:

针对外关系的每一行
查看内关系里的所有行来寻找匹配的行
下面是伪代码:

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 次读取。这样修改之后,内关系必须是最小的,因为它有更大机会装入内存。

在CPU成本方面没有什么区别,但是在磁盘 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

使用这个版本,时间复杂度没有变化,但是磁盘访问降低了:

用前一个版本,算法需要 N + N*M 次访问(每次访问读取一行)。
用新版本,磁盘访问变为 外关系的数据簇数量 + 外关系的数据簇数量 * 内关系的数据簇数量。
增加数据簇的尺寸,可以降低磁盘访问。

哈希联接

哈希联接更复杂,不过在很多场合比嵌套循环联接成本低。
screenshot

哈希联接的道理是:

1) 读取内关系的所有元素
2) 在内存里建一个哈希表
3) 逐条读取外关系的所有元素
4) (用哈希表的哈希函数)计算每个元素的哈希值,来查找内关系里相关的哈希桶内
5) 是否与外关系的元素匹配。
在时间复杂度方面我需要做些假设来简化问题:

  • 内关系被划分成 X 个哈希桶
  • 哈希函数几乎均匀地分布每个关系内数据的哈希值,就是说哈希桶大小一致。
  • 外关系的元素与哈希桶内的所有元素的匹配,成本是哈希桶内元素的数量。

时间复杂度是 (M/X) * (N/X) + 创建哈希表的成本(M) + 哈希函数的成本 * N 。
如果哈希函数创建了足够小规模的哈希桶,那么复杂度就是 O(M+N)。

还有个哈希联接的版本,对内存有利但是对磁盘 I/O 不够有利。 这回是这样的:

1) 计算内关系和外关系双方的哈希表
2) 保存哈希表到磁盘
3) 然后逐个哈希桶比较(其中一个读入内存,另一个逐行读取)。

客户端管理器

客户端管理器是处理客户端通信的。客户端可以是一个(网站)服务器或者一个最终用户或最终应用。客户端管理器通过一系列知名的API(JDBC, ODBC, OLE-DB …)提供不同的方式来访问数据库。

客户端管理器也提供专有的数据库访问API。

当你连接到数据库时:

管理器首先检查你的验证信息(用户名和密码),然后检查你是否有访问数据库的授权。这些权限由DBA分配。
然后,管理器检查是否有空闲进程(或线程)来处理你对查询。
管理器还会检查数据库是否负载很重。
管理器可能会等待一会儿来获取需要的资源。如果等待时间达到超时时间,它会关闭连接并给出一个可读的错误信息。
然后管理器会把你的查询送给查询管理器来处理。
因为查询处理进程不是『不全则无』的,一旦它从查询管理器得到数据,它会把部分结果保存到一个缓冲区并且开始给你发送。
如果遇到问题,管理器关闭连接,向你发送可读的解释信息,然后释放资源。

screenshot

客户端管理器是处理客户端通信的。客户端可以是一个(网站)服务器或者一个最终用户或最终应用。客户端管理器通过一系列知名的API(JDBC, ODBC, OLE-DB …)提供不同的方式来访问数据库。

客户端管理器也提供专有的数据库访问API。

当你连接到数据库时:

管理器首先检查你的验证信息(用户名和密码),然后检查你是否有访问数据库的授权。这些权限由DBA分配。
然后,管理器检查是否有空闲进程(或线程)来处理你对查询。
管理器还会检查数据库是否负载很重。
管理器可能会等待一会儿来获取需要的资源。如果等待时间达到超时时间,它会关闭连接并给出一个可读的错误信息。
然后管理器会把你的查询送给查询管理器来处理。
因为查询处理进程不是『不全则无』的,一旦它从查询管理器得到数据,它会把部分结果保存到一个缓冲区并且开始给你发送。
如果遇到问题,管理器关闭连接,向你发送可读的解释信息,然后释放资源。

查询管理器

screenshot
这部分是数据库的威力所在,在这部分里,一个写得糟糕的查询可以转换成一个快速执行的代码,代码执行的结果被送到客户端管理器。这个多步骤操作过程如下:

查询首先被解析并判断是否合法
然后被重写,去除了无用的操作并且加入预优化部分
接着被优化以便提升性能,并被转换为可执行代码和数据访问计划。
然后计划被编译
最后,被执行
这里我不会过多探讨最后两步,因为它们不太重要。

看完这部分后,如果你需要更深入的知识,我建议你阅读:

关于成本优化的初步研究论文(1979):关系型数据库系统存取路径选择。这个篇文章只有12页,而且具备计算机一般水平就能理解。
非常好、非常深入的 DB2 9.X 如何优化查询的介绍
非常好的PostgreSQL如何优化查询的介绍。这是一篇最通俗易懂的文档,因为它讲的是『我们来看看在这种情况下,PostgreSQL给出了什么样的查询计划』,而不是『我们来看看PostgreSQL用的什么算法』。
官方SQLite优化文档。『易于』阅读,因为SQLite用的是简单规则。再者,这是唯一真正解释SQLite如何工作的官方文档。
非常好的SQL Server 2005 如何优化查询的介绍
Oracle 12c 优化白皮书
2篇查询优化的教程,第一篇 第二篇。教程来自《数据库系统概念》的作者,很好的读物,集中讨论磁盘I/O,但是要求具有很好的计算机科学水平。
另一个原理教程,这篇教程我觉得更易懂,不过它仅关注联接运算符(join operators)和磁盘I/O。

查询解析器

每一条SQL语句都要送到解析器来检查语法,如果你的查询有错,解析器将拒绝该查询。比如,如果你写成”SLECT …” 而不是 “SELECT …”,那就没有下文了。
但这还不算完,解析器还会检查关键字是否使用正确的顺序,比如 WHERE 写在 SELECT 之前会被拒绝。

然后,解析器要分析查询中的表和字段,使用数据库元数据来检查:

  • 表是否存在
  • 表的字段是否存在
  • 对某类型字段的 运算 是否 可能(比如,你不能将整数和字符串进行比较,你不能对一个整数使用 substring() 函数)

接着,解析器检查在查询中你是否有权限来读取(或写入)表。再强调一次:这些权限由DBA分配。

在解析过程中,SQL 查询被转换为内部表示(通常是一个树)。

如果一切正常,内部表示被送到查询重写器。

查询重写器

在这一步,我们已经有了查询的内部表示,重写器的目标是:

  • 预优化查询
  • 避免不必要的运算
  • 帮助优化器找到合理的最佳解决方案

重写器按照一系列已知的规则对查询执行检测。如果查询匹配一种模式的规则,查询就会按照这条规则来重写。下面是(可选)规则的非详尽的列表:

  • 视图合并:如果你在查询中使用视图,视图就会转换为它的 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,都会被消除。
  • 常数计算赋值:如果你的查询需要计算,那么在重写过程中计算会执行一次。比如 WHERE AGE > 10+2 会转换为 WHERE AGE > 12 , TODATE(“日期字符串”) 会转换为 datetime 格式的日期值。
  • (高级)分区裁剪(Partition Pruning):如果你用了分区表,重写器能够找到需要使用的分区。
  • (高级)物化视图重写(Materialized view rewrite):如果你有个物化视图匹配查询谓词的一个子集,重写器将检查视图是否最新并修改查询,令查询使用物化视图而不是原始表。
  • (高级)自定义规则:如果你有自定义规则来修改查询(就像 Oracle policy),重写器就会执行这些规则。
  • (高级)OLAP转换:分析/加窗 函数,星形联接,ROLLUP 函数……都会发生转换(但我不确定这是由重写器还是优化器来完成,因为两个进程联系很紧,必须看是什么数据库)。 【译者注: 物化视图 。谓词,predicate,条件表达式的求值返回真或假的过程】

重写后的查询接着送到优化器,这时候好玩的就开始了。
统计

研究数据库如何优化查询之前我们需要谈谈统计,因为没有统计的数据库是愚蠢的。除非你明确指示,数据库是不会分析自己的数据的。没有分析会导致数据库做出(非常)糟糕的假设。

但是,数据库需要什么类型的信息呢?

我必须(简要地)谈谈数据库和操作系统如何保存数据。两者使用的最小单位叫做页或块(默认 4 或 8 KB)。这就是说如果你仅需要 1KB,也会占用一个页。要是页的大小为 8KB,你就浪费了 7KB。

回来继续讲统计! 当你要求数据库收集统计信息,数据库会计算下列值:

表中行和页的数量
表中每个列中的:
唯一值
数据长度(最小,最大,平均)
数据范围(最小,最大,平均)
表的索引信息
这些统计信息会帮助优化器估计查询所需的磁盘 I/O、CPU、和内存使用

对每个列的统计非常重要。
比如,如果一个表 PERSON 需要联接 2 个列: LAST_NAME, FIRST_NAME。
根据统计信息,数据库知道FIRST_NAME只有 1,000 个不同的值,LAST_NAME 有 1,000,000 个不同的值。
因此,数据库就会按照 LAST_NAME, FIRST_NAME 联接。
因为 LAST_NAME 不大可能重复,多数情况下比较 LAST_NAME 的头 2 、 3 个字符就够了,这将大大减少比较的次数。

不过,这些只是基本的统计。你可以让数据库做一种高级统计,叫直方图。直方图是列值分布情况的统计信息。例如:

出现最频繁的值
分位数 【译者注:http://baike.baidu.com/view/1323572.htm】

这些额外的统计会帮助数据库找到更佳的查询计划,尤其是对于等式谓词(例如: WHERE AGE = 18 )或范围谓词(例如: WHERE AGE > 10 and AGE < 40),因为数据库可以更好的了解这些谓词相关的数字类型数据行(注:这个概念的技术名称叫选择率)。

统计信息保存在数据库元数据内,例如(非分区)表的统计信息位置:

Oracle: USER / ALL / DBA_TABLES 和 USER / ALL / DBA_TAB_COLUMNS
DB2: SYSCAT.TABLES 和 SYSCAT.COLUMNS
统计信息必须及时更新。如果一个表有 1,000,000 行而数据库认为它只有 500 行,没有比这更糟糕的了。统计唯一的不利之处是需要时间来计算,这就是为什么数据库大多默认情况下不会自动计算统计信息。数据达到百万级时统计会变得困难,这时候,你可以选择仅做基本统计或者在一个数据库样本上执行统计。

举个例子,我参与的一个项目需要处理每表上亿条数据的库,我选择只统计10%,结果造成了巨大的时间消耗。本例证明这是个糟糕的决定,因为有时候 Oracle 10G 从特定表的特定列中选出的 10% 跟全部 100% 有很大不同(对于拥有一亿行数据的表,这种情况极少发生)。这次错误的统计导致了一个本应 30 秒完成的查询最后执行了 8 个小时,查找这个现象根源的过程简直是个噩梦。这个例子显示了统计的重要性。

注:当然了,每个数据库还有其特定的更高级的统计。如果你想了解更多信息,读读数据库的文档。话虽然这么说,我已经尽力理解统计是如何使用的了,而且我找到的最好的官方文档来自PostgreSQL。

查询优化器

所有的现代数据库都在用基于成本的优化(即CBO)来优化查询。道理是针对每个运算设置一个成本,通过应用成本最低廉的一系列运算,来找到最佳的降低查询成本的方法。

为了理解成本优化器的原理,我觉得最好用个例子来『感受』一下这个任务背后的复杂性。这里我将给出联接 2 个表的 3 个方法,我们很快就能看到即便一个简单的联接查询对于优化器来说都是个噩梦。之后,我们会了解真正的优化器是怎么做的。

对于这些联接操作,我会专注于它们的时间复杂度,但是,数据库优化器计算的是它们的 CPU 成本、磁盘 I/O 成本、和内存需求。时间复杂度和 CPU 成本的区别是,时间成本是个近似值(给我这样的懒家伙准备的)。而 CPU 成本,我这里包括了所有的运算,比如:加法、条件判断、乘法、迭代……还有呢:

每一个高级代码运算都要特定数量的低级 CPU 运算。
对于 Intel Core i7、Intel Pentium 4、AMD Opteron…等,(就 CPU 周期而言)CPU 的运算成本是不同的,也就是说它取决于 CPU 的架构。
使用时间复杂度就容易多了(至少对我来说),用它我也能了解到 CBO 的概念。由于磁盘 I/O 是个重要的概念,我偶尔也会提到它。请牢记,大多数时候瓶颈在于磁盘 I/O 而不是 CPU 使用。

索引

在研究 B+树的时候我们谈到了索引,要记住一点,索引都是已经排了序的。

仅供参考:还有其他类型的索引,比如位图索引,在 CPU、磁盘I/O、和内存方面与B+树索引的成本并不相同。

另外,很多现代数据库为了改善执行计划的成本,可以仅为当前查询动态地生成临时索引。

存取路径

在应用联接运算符(join operators)之前,你首先需要获得数据。以下就是获得数据的方法。

注:由于所有存取路径的真正问题是磁盘 I/O,我不会过多探讨时间复杂度。

【译者注:四种类型的Oracle索引扫描介绍 】

全扫描

如果你读过执行计划,一定看到过『全扫描』(或只是『扫描』)一词。简单的说全扫描就是数据库完整的读一个表或索引。就磁盘 I/O 而言,很明显全表扫描的成本比索引全扫描要高昂。

范围扫描

其他类型的扫描有索引范围扫描,比如当你使用谓词 ” WHERE AGE > 20 AND AGE < 40 ” 的时候它就会发生。

当然,你需要在 AGE 字段上有索引才能用到索引范围扫描。

在第一部分我们已经知道,范围查询的时间成本大约是 log(N)+M,这里 N 是索引的数据量,M 是范围内估测的行数。多亏有了统计我们才能知道 N 和 M 的值(注: M 是谓词 “ AGE > 20 AND AGE < 40 ” 的选择率)。另外范围扫描时,你不需要读取整个索引,因此在磁盘 I/O 方面没有全扫描那么昂贵。

唯一扫描

如果你只需要从索引中取一个值你可以用唯一扫描。

根据 ROW ID 存取

多数情况下,如果数据库使用索引,它就必须查找与索引相关的行,这样就会用到根据 ROW ID 存取的方式。

例如,假如你运行:

SELECT LASTNAME, FIRSTNAME from PERSON WHERE AGE = 28

如果 person 表的 age 列有索引,优化器会使用索引找到所有年龄为 28 的人,然后它会去表中读取相关的行,这是因为索引中只有 age 的信息而你要的是姓和名。

但是,假如你换个做法:

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

PERSON 表的索引会用来联接 TYPE_PERSON 表,但是 PERSON 表不会根据行ID 存取,因为你并没有要求这个表内的信息。

虽然这个方法在少量存取时表现很好,这个运算的真正问题其实是磁盘 I/O。假如需要大量的根据行ID存取,数据库也许会选择全扫描。

其它路径

我没有列举所有的存取路径,如果你感兴趣可以读一读 Oracle文档。其它数据库里也许叫法不同但背后的概念是一样的。

联接运算符

那么,我们知道如何获取数据了,那现在就把它们联接起来!

我要展现的是3个个常用联接运算符:合并联接(Merge join),哈希联接(Hash Join)和嵌套循环联接(Nested Loop Join)。但是在此之前,我需要引入新词汇了:内关系和外关系( inner relation and outer relation) 【译者注: “内关系和外关系” 这个说法来源不明,跟查询的“内联接(INNER JOIN) 、外联接(OUTER JOIN) ” 不是一个概念 。只查到百度百科词条:关系数据库 里提到“每个表格(有时被称为一个关系)……” 。 其他参考链接 “Merge Join” “Hash Join” “Nested Loop Join” 】 。 一个关系可以是:

  • 一个表
  • 一个索引
  • 上一个运算的中间结果(比如上一个联接运算的结果)
    当你联接两个关系时,联接算法对两个关系的处理是不同的。在本文剩余部分,我将假定:

  • 外关系是左侧数据集

  • 内关系是右侧数据集
    比如, A JOIN B 是 A 和 B 的联接,这里 A 是外关系,B 是内关系。

多数情况下, A JOIN B 的成本跟 B JOIN A 的成本是不同的。

在这一部分,我还将假定外关系有 N 个元素,内关系有 M 个元素。要记住,真实的优化器通过统计知道 N 和 M 的值。

注:N 和 M 是关系的基数。【译者注: 基数 】

嵌套循环联接

嵌套循环联接是最简单的。
screenshot

道理如下:

针对外关系的每一行
查看内关系里的所有行来寻找匹配的行
下面是伪代码:

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 次读取。这样修改之后,内关系必须是最小的,因为它有更大机会装入内存。

在CPU成本方面没有什么区别,但是在磁盘 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

使用这个版本,时间复杂度没有变化,但是磁盘访问降低了:

用前一个版本,算法需要 N + N*M 次访问(每次访问读取一行)。
用新版本,磁盘访问变为 外关系的数据簇数量 + 外关系的数据簇数量 * 内关系的数据簇数量。
增加数据簇的尺寸,可以降低磁盘访问。

哈希联接

哈希联接更复杂,不过在很多场合比嵌套循环联接成本低。
screenshot

哈希联接的道理是:

1) 读取内关系的所有元素
2) 在内存里建一个哈希表
3) 逐条读取外关系的所有元素
4) (用哈希表的哈希函数)计算每个元素的哈希值,来查找内关系里相关的哈希桶内
5) 是否与外关系的元素匹配。
在时间复杂度方面我需要做些假设来简化问题:

  • 内关系被划分成 X 个哈希桶
  • 哈希函数几乎均匀地分布每个关系内数据的哈希值,就是说哈希桶大小一致。
  • 外关系的元素与哈希桶内的所有元素的匹配,成本是哈希桶内元素的数量。

时间复杂度是 (M/X) * (N/X) + 创建哈希表的成本(M) + 哈希函数的成本 * N 。
如果哈希函数创建了足够小规模的哈希桶,那么复杂度就是 O(M+N)。

还有个哈希联接的版本,对内存有利但是对磁盘 I/O 不够有利。 这回是这样的:

1) 计算内关系和外关系双方的哈希表
2) 保存哈希表到磁盘
3) 然后逐个哈希桶比较(其中一个读入内存,另一个逐行读取)。

猜你喜欢

转载自blog.csdn.net/yyjava/article/details/77881298
今日推荐