数据库原理概览

合并排序

这个算法在大多数数据库中使用,但并不是唯一算法。

【本质】将 2 个大小为 N/2 的已排序序列合并为一个 N 元素已排序序列仅需要 N 次操作。

【分治思想】把问题拆分为小问题,通过解决小问题来解决原问题。

【成本分析】拆分阶段,共拆分logN次,最底层只有一个元素。合并阶段,每层合并涉及到N个元素,共有logN层,合并次数NlogN。

所以最终时间复杂度是NlogN。

【特点分析】

可以改为原址排序,以节省内存——方法是不创建新的空间,而是直接修改输入序列。

可以改为外部排序——只向内存加载当前处理的部分,同时使用磁盘空间和少量内存而避免巨量磁盘IO。在仅仅100M的内存缓冲区内排序一个几GB的表时,是很重要的技巧。

可以改为多处理器、多线程、多服务器上运行——譬如,分布式合并排序是hadoop的关键组件之一。

 

screenshot

screenshot

screenshot

阵列

二维阵列是最简单的数据结构。

每个行代表一个主体,列用来描述主体的特征,每个列保存某一种类型对数据(整数、字符串、日期……)
【缺点】虽然用这个方法保存和视觉化数据很棒,但是当你要查找特定的值它就很糟糕了。 举个例子,如果你要找到所有在 UK 工作的人,你必须查看每一行以判断该行是否属于 UK 。这会造成 N 次运算的成本(N 等于行数)

二叉查找树

【特点】每个节点的关键字必须:比保存在左子树的任何键值都要大;比保存在右子树的任何键值都要小。

【查询成本】最多查询次数是树的层数logN。

【应用举例】

想象一下前表中代表某人的国家的字符串。假设你有个树包含表中的列『country』:如果你想知道谁在 UK 工作,你在树中查找代表 UK 的节点,在『UK 节点』你会找到 UK 员工那些行的位置。
这次搜索只需 log(N) 次运算,而如果你直接使用阵列则需要 N 次运算。你刚刚想象的就是一个数据库索引。

【缺点】查找一个特定值这个树挺好用,但是当你需要查找两个值之间的多个元素时,就会有大麻烦了。你的成本将是 O(N),因为你必须查找树的每一个节点,以判断它是否处于那 2 个值之间(例如,对树使用中序遍历)。而且这个操作不是磁盘I/O有利的,因为你必须读取整个树。

screenshot

 

B+树

在大规模的数据存储中,要实现索引查询。树节点存储的元素数量是有限的(因为元素数量过多的话,查找就退化成节点内部的线性查找了),这样导致二叉查找树结构由于树的深度过大,造成磁盘IO读写过于频繁,进而导致查询效率低下。

因为磁盘查找存取次数往往由树的高度决定,所以多路查找树可以通过减少树的高度来减少磁盘IO次数。

现代数据库使用了一种修订版的树,叫做B+树。

在一个B+树里:

  • 只有最底层的节点(叶子节点)才保存信息(相关表的行位置)
  • 其它节点只是在搜索中用来指引到正确节点的。
  • 最底层的节点是相连的

【成本分析】

假设要找到19,在第一层,判断比13大,查找子树2. 第2层判断比23小,查找子树1.第3层逐个比较。

查找次数为树高+每个节点的数据数。假设每个节点有m个数据点,树总共有N个节点。则搜索成本是logN + M。如果N很大,M很小,就接近于logN。

【为什么过多索引不好】

如果在数据库中增加或删除一行,需要同步索引。除了必须在B+树中的节点之间保持顺序,还要尽可能降低B+树的高度。

在b+树中,插入和删除操作是O(logN)复杂度。增加索引意味着给事务管理器带来更多的工作负荷,减慢了插入、更新、删除表中一行的操作速度。

哈希表

【特点】键-值。

【构建哈希表】需要定义关键字,包括关键字的哈希函数hashCode();关键字的比较函数equals()。

【查找成本】第一次哈希运算,查找存放元素的桶。第二次通过比较运算,在链表中查找到确切的元素。真正的挑战是找到一个好的哈希函数,这样可以尽可能把元素分散到各个桶中,减少链表长度,查找成本就接近于O(1)。

screenshot

阵列和哈希表

一个哈希表可以只装载一半到内存,剩下的哈希桶可以留在硬盘上。
用阵列的话,你需要一个连续内存空间。如果你加载一个大表,很难分配足够的连续内存空间。
用哈希表的话,你可以选择你要的关键字(比如,一个人的国家和姓氏)。

数据库组件

screenshot

客户端管理器 Client Manager

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

screenshot

当你连接到数据库时:

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

查询管理器 Query Manager

screenshot

查询首先被解析并判断是否合法
然后被重写,去除了无用的操作并且加入预优化部分
接着被优化以便提升性能,并被转换为可执行代码和数据访问计划。
然后计划被编译
最后,被执行

查询解析器 Query Parser

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

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

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

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

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

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

查询重写器 Query Rewriter

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

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

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

  • 视图合并:如果你在查询中使用视图,视图就会转换为它的 SQL 代码。
  • 子查询扁平化:子查询是很难优化的,因此重写器会尝试移除子查询
  • 去除不必要的运算符:比如,如果你用了 DISTINCT,而其实你有 UNIQUE 约束(这本身就防止了数据出现重复),那么 DISTINCT 关键字就被去掉了。
  • 排除冗余的联接:如果相同的 JOIN 条件出现两次,比如隐藏在视图中的 JOIN 条件,或者由于传递性产生的无用 JOIN,都会被消除。
  • 常数计算赋值:如果你的查询需要计算,那么在重写过程中计算会执行一次。比如 WHERE AGE > 10+2 会转换为 WHERE AGE > 12 , TODATE(“日期字符串”) 会转换为 datetime 格式的日期值。

重写后的查询接着送到优化器

查询优化器 Query Optimizer

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

索引

存取路径

全扫描

就磁盘 I/O 而言,很明显全表扫描的成本比索引全扫描要高昂。

范围扫描

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

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

唯一扫描

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

根据 ROW ID 存取

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

SELECT LASTNAME, FIRSTNAME from PERSON WHERE AGE = 28

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

联接运算符

查询计划缓存

由于创建查询计划是耗时的,大多数据库把计划保存在查询计划缓存,来避免重复计算。这个话题比较大,因为数据库需要知道什么时候更新过时的计划。办法是设置一个上限,如果一个表的统计变化超过了上限,关于该表的查询计划就从缓存中清除。

查询执行器

在这个阶段,我们有了一个优化的执行计划,再编译为可执行代码。然后,如果有足够资源(内存,CPU),查询执行器就会执行它。计划中的操作符 (JOIN, SORT BY …) 可以顺序或并行执行,这取决于执行器。为了获得和写入数据,查询执行器与数据管理器交互

数据管理器

screenshot

查询管理器执行了查询,需要从表和索引获取数据,于是向数据管理器提出请求。但是有 2 个问题:

关系型数据库使用事务模型,所以,当其他人在同一时刻使用或修改数据时,你无法得到这部分数据。
数据提取是数据库中速度最慢的操作,所以数据管理器需要足够聪明地获得数据并保存在内存缓冲区内。

缓存管理器

screenshot

数据库的主要瓶颈是磁盘 I/O。为了提高性能,现代数据库使用缓存管理器。

查询执行器不会直接从文件系统拿数据,而是向缓存管理器要。缓存管理器有一个内存缓存区,叫做缓冲池,从内存读取数据显著地提升数据库性能。

预读

查询执行器知道它将需要什么数据,因为它了解整个查询流,而且通过统计也了解磁盘上的数据。

当查询执行器处理它的第一批数据时
会告诉缓存管理器预先装载第二批数据
当开始处理第二批数据时
告诉缓存管理器预先装载第三批数据,并且告诉缓存管理器第一批可以从缓存里清掉了。

缓存管理器在缓冲池里保存所有的这些数据。为了确定一条数据是否有用,缓存管理器给缓存的数据添加了额外的信息(叫闩锁)。

有时查询执行器不知道它需要什么数据,有的数据库也不提供这个功能。相反,它们使用一种推测预读法(比如:如果查询执行器想要数据1、3、5,它不久后很可能会要 7、9、11),或者顺序预读法(这时候缓存管理器只是读取一批数据后简单地从磁盘加载下一批连续数据)。

为了监控预读的工作状况,现代数据库引入了一个度量叫缓冲/缓存命中率,用来显示请求的数据在缓存中找到而不是从磁盘读取的频率。

注:糟糕的缓存命中率不总是意味着缓存工作状态不佳。

缓冲只是容量有限的内存空间,因此,为了加载新的数据,它需要移除一些数据。加载和清除缓存需要一些磁盘和网络I/O的成本。如果你有个经常执行的查询,那么每次都把查询结果加载然后清除,效率就太低了。现代数据库用缓冲区置换策略来解决这个问题。

缓冲区转换策略

多数现代数据库(至少 SQL Server, MySQL, Oracle 和 DB2)使用 LRU 算法。

LRU代表最近最少使用(Least Recently Used)算法,背后的原理是:在缓存里保留的数据是最近使用的,所以更有可能再次使用。

事务管理器

并发控制

如果所有事务只是读取数据,它们可以同时工作,不会更改另一个事务的行为。
如果(至少)有一个事务在修改其他事务读取的数据,数据库需要找个办法对其它事务隐藏这种修改。而且,它还需要确保这个修改操作不会被另一个看不到这些数据修改的事务擦除。

最简单的解决办法是依次执行每个事务(即顺序执行),但这样就完全没有伸缩性了,在一个多处理器/多核服务器上只有一个核心在工作,效率很低。

理想的办法是,每次一个事务创建或取消时:

监控所有事务的所有操作
检查是否2个(或更多)事务的部分操作因为读取/修改相同的数据而存在冲突
重新编排冲突事务中的操作来减少冲突的部分
按照一定的顺序执行冲突的部分(同时非冲突事务仍然在并发运行)
考虑事务有可能被取消
用更正规的说法,这是对冲突的调度问题。更具体点儿说,这是个非常困难而且CPU开销很大的优化问题。企业级数据库无法承担等待几个小时,来寻找每个新事务活动最好的调度,因此就使用不那么理想的方式以避免更多的时间浪费在解决冲突上。

锁管理器

多数数据库使用锁和/或数据版本控制。

悲观锁

如果一个事务需要一条数据
它就把数据锁住
如果另一个事务也需要这条数据
它就必须要等第一个事务释放这条数据
这个锁叫排他锁。
但是对一个仅仅读取数据的事务使用排他锁非常昂贵,因为这会迫使其它只需要读取相同数据的事务等待。因此就有了另一种锁,共享锁。

如果一个事务只需要读取数据A
它会给数据A加上『共享锁』并读取
如果第二个事务也需要仅仅读取数据A
它会给数据A加上『共享锁』并读取
如果第三个事务需要修改数据A
它会给数据A加上『排他锁』,但是必须等待另外两个事务释放它们的共享锁。
同样的,如果一块数据被加上排他锁,一个只需要读取该数据的事务必须等待排他锁释放才能给该数据加上共享锁。

锁管理器是添加和释放锁的进程,在内部用一个哈希表保存锁信息(关键字是被锁的数据),并且了解每一块数据是:

被哪个事务加的锁
哪个事务在等待数据解锁

死锁

screenshot

在死锁发生时,锁管理器要选择取消(回滚)一个事务,以便消除死锁。这可是个艰难的决定:

  • 杀死数据修改量最少的事务(这样能减少回滚的成本)?
  • 杀死持续时间最短的事务,因为其它事务的用户等的时间更长?
  • 杀死能用更少时间结束的事务(避免可能的资源饥荒)?
  • 一旦发生回滚,有多少事务会受到回滚的影响?

在作出选择之前,锁管理器需要检查是否有死锁存在。

哈希表可以看作是个图表(见上文图),图中出现循环就说明有死锁。由于检查循环是昂贵的(所有锁组成的图表是很庞大的),经常会通过简单的途径解决:使用超时设定。如果一个锁在超时时间内没有加上,那事务就进入死锁状态。

锁管理器也可以在加锁之前检查该锁会不会变成死锁,但是想要完美的做到这一点还是很昂贵的。因此这些预检经常设置一些基本规则。

两段锁

实现纯粹的隔离最简单的方法是:事务开始时获取锁,结束时释放锁。就是说,事务开始前必须等待确保自己能加上所有的锁,当事务结束时释放自己持有的锁。这是行得通的,但是为了等待所有的锁,大量的时间被浪费了。

更快的方法是两段锁协议(Two-Phase Locking Protocol,由 DB2 和 SQL Server使用),在这里,事务分为两个阶段:

  • 成长阶段:事务可以获得锁,但不能释放锁。
  • 收缩阶段:事务可以释放锁(对于已经处理完而且不会再次处理的数据),但不能获得新锁。

这两条简单规则背后的原理是:

释放不再使用的锁,来降低其它事务的等待时间
防止发生这类情况:事务最初获得的数据,在事务开始后被修改,当事务重新读取该数据时发生不一致。
这个规则可以很好地工作,但有个例外:如果修改了一条数据、释放了关联的锁后,事务被取消(回滚),而另一个事务读到了修改后的值,但最后这个值却被回滚。为了避免这个问题,所有独占锁必须在事务结束时释放。

数据版本控制

一些数据库,比如DB2(直到版本 9.7)和 SQL Server(不含快照隔离)仅使用锁机制。其他的像PostgreSQL, MySQL 和 Oracle 使用锁和鼠标版本控制混合机制。

版本控制是这样的:

每个事务可以在相同时刻修改相同的数据
每个事务有自己的数据拷贝(或者叫版本)
如果2个事务修改相同的数据,只接受一个修改,另一个将被拒绝,相关的事务回滚(或重新运行)
这将提高性能,因为:

读事务不会阻塞写事务
写事务不会阻塞读
没有『臃肿缓慢』的锁管理器带来的额外开销
除了两个事务写相同数据的时候,数据版本控制各个方面都比锁表现得更好。只不过,你很快就会发现磁盘空间消耗巨大。

 

 

参考资料

伯乐在线

http://blog.csdn.net/v_july_v/article/details/6530142

posted @ 2016-08-18 17:02  流年素心  阅读(1106)  评论(0编辑  收藏  举报