本文转载自如果有人问你数据库的原理,叫他看这篇文章(下)
动态规划,贪心算法和启发式算法
我们已经研究了3种类型的联接操作。现在,比如说我们要联接5个表,来获得一个人的全部信息。一个人可以有:多个手机号(MOBILES),多个邮箱(MAILS),多个地址(ADRESSES),多个银行账号(BANK_ACCOUNTS)。换句话说,我们需要用下面的查询快速得到答案:
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种可选(哈希、合并、嵌套),同时可能用到0,1或2个索引(不必说还有多种类型的索引),按什么顺序执行联接?比如,下图显示了针对 4 个表仅仅 3 次联接,可能采用的执行计划:
多数时候,优化器找到的不是最佳的方案,而是一个不错的。对于小规模的查询,采取粗暴的方式是有可能的。但是为了让中等规模的查询也能采取粗暴的方式,我们有办法避免不必要的计算,这就是动态规划。这几个字背后的理念是,很多执行计划是非常相似的。看看下图这几种计划:
它们都有相同的子树(A JOIN B),所以,不必在每个计划中计算这个子树的成本,计算一次,保存结果,当再遇到这个子树时重用。用更正规的说法,我们面对的是个重叠问题。为了避免对部分结果的重复计算,我们使用记忆法。针对大规模查询,你也可以用动态规划方法,但是要附加额外的规则(或者称为启发式算法)来减少可能性。如果我们仅分析一个特定类型的计划(例如左深树left-deep tree),我们得到n*2^n而不是3^n。
如果我们加上逻辑规则来避免一些模式的计划(像如果一个表有针对指定谓词的索引,就不要对表尝试合并联接,要对索引),就会在不给最佳方案造成过多伤害的前提下,减少可能性的数量。如果我们在流程里增加规则(像联接运算先于其他所有的关系运算),也能减少大量的可能性。但是,优化器面对一个非常大的查询,或者为了尽快找到答案(然而查询速度就快不起来了),会应用另一种算法,叫贪心算法。原理是按照一个规则(或启发)以渐进的方式制定查询计划。在这个规则下,贪心算法逐步寻找最佳算法,先处理一条JOIN,接着每一步按照同样规则加一条新的JOIN。我们来看个简单的例子。比如一个针对5张表(A,B,C,D,E)4次JOIN的查询,为了简化我们把嵌套JOIN作为可能的联接方式,按照使用最低成本的联接规则:直接从5个表里选一个开始(比如 A);计算每一个与A的联接(A作为内关系或外关系);发现A JOIN B成本最低;计算每一个与A JOIN B的结果联接的成本(A JOIN B作为内关系或外关系);发现(A JOIN B)JOIN C成本最低;计算每一个与(A JOIN B) JOIN C的结果联接的成本……最后确定执行计划( ( (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个联接的大型查询,这意味着26vs3486784401,天壤之别!这个算法的问题是,我们做的假设是:找到2个表的最佳联接方法,保留这个联接结果,再联接下一个表,就能得到最低的成本。但是:即使在A,B,C之间A JOIN B可得最低成本,但是(A JOIN C) JOIN B也许比 (A JOIN B) JOIN C更好。为了改善这一状况,你可以多次使用基于不同规则的贪心算法,并保留最佳的执行计划。
(4)查询执行器
在这个阶段,我们有了一个优化的执行计划,再编译为可执行代码。然后,如果有足够资源(内存,CPU),查询执行器就会执行它。计划中的操作符可以顺序或并行执行,这取决于执行器。为了获得和写入数据,查询执行器与数据管理器交互,本文下一部分来讨论数据管理器。
3.数据管理器
在这一步,查询管理器执行了查询,需要从表和索引获取数据,于是向数据管理器提出请求。但是有2个问题:关系型数据库使用事务模型,所以当其他人在同一时刻使用或修改数据时,你无法得到这部分数据;数据提取是数据库中速度最慢的操作,所以数据管理器需要足够聪明地获得数据并保存在内存缓冲区内。我不会讲数据管理器是怎么获得数据的,因为这不是最重要的。
(1)缓存管理器
我已经说过,数据库的主要瓶颈是磁盘I/O。为了提高性能,现代数据库使用缓存管理器。
查询执行器不会直接从文件系统拿数据,而是向缓存管理器要。缓存管理器有一个内存缓存区,叫做缓冲池,从内存读取数据显著地提升数据库性能。对此很难给出一个数量级,因为这取决于你需要的是哪种操作以及数据库使用的磁盘类型。要我说,内存比磁盘要快100到10万倍。然而,这导致了另一个问题,缓存管理器需要在查询执行器使用数据之前得到数据,否则查询管理器不得不等待数据从缓慢的磁盘中读出来。
预读
这个问题叫预读。查询执行器知道它将需要什么数据,因为它了解整个查询流,而且通过统计也了解磁盘上的数据。道理是这样的:当查询执行器处理它的第一批数据时,会告诉缓存管理器预先装载第二批数据;当开始处理第二批数据时,告诉缓存管理器预先装载第三批数据,并且告诉缓存管理器第一批可以从缓存里清掉了……缓存管理器在缓冲池里保存所有的这些数据。为了确定一条数据是否有用,缓存管理器给缓存的数据添加了额外的信息(叫闩锁)。有时查询执行器不知道它需要什么数据,有的数据库也不提供这个功能。相反,它们使用一种推测预读法(比如:如果查询执行器想要数据1、3、5,它不久后很可能会要 7、9、11),或者顺序预读法(这时候缓存管理器只是读取一批数据后简单地从磁盘加载下一批连续数据)。为了监控预读的工作状况,现代数据库引入了一个度量叫缓存命中率,用来显示请求的数据在缓存中找到而不是从磁盘读取的频率。缓冲只是容量有限的内存空间,因此,为了加载新的数据,它需要移除一些数据。加载和清除缓存需要一些磁盘和网络I/O的成本。如果你有个经常执行的查询,那么每次都把查询结果加载然后清除,效率就太低了。现代数据库用缓冲区置换策略来解决这个问题。
缓冲区置换策略
多数现代数据库使用LRU算法。LRU代表最近最少使用(Least Recently Used)算法,背后的原理是:在缓存里保留的数据是最近使用的,所以更有可能再次使用。
为了更好的理解,我假设缓冲区里的数据没有被闩锁锁住(就是说是可以被移除的)。在这个简单的例子里,缓冲区可以保存3个元素:
1:缓存管理器(简称CM)使用数据1,把它放入空的缓冲区;
2:CM使用数据4,把它放入半载的缓冲区;
3:CM使用数据3,把它放入半载的缓冲区;
4:CM使用数据9,缓冲区满了,所以数据1被清除,因为它是最后一个最近使用的,数据9加入到缓冲区;
5:CM使用数据4,数据4已经在缓冲区了,所以它再次成为第一个最近使用的;
6:CM使用数据1,缓冲区满了,所以数据9被清除,因为它是最后一个最近使用的,数据1加入到缓冲区;
……
这个算法效果很好,但是有些限制。如果对一个大表执行全表扫描怎么办?换句话说,当表/索引的大小超出缓冲区会发生什么?使用这个算法会清除之前缓存内所有的数据,而且全扫描的数据很可能只使用一次。为了防止这个现象,有些数据库增加了特殊的规则,比如Oracle文档中的描述:『对非常大的表来说,数据库通常使用直接路径来读取,即直接加载区块[……],来避免填满缓冲区。对于中等大小的表,数据库可以使用直接读取或缓存读取。如果选择缓存读取,数据库把区块置于LRU的尾部,防止清空当前缓冲区。』还有一些可能,比如使用高级版本的LRU,叫做LRU-K。例如,SQL Server使用LRU-2。这个算法的原理是把更多的历史记录考虑进来。简单LRU(也就是 LRU-1),只考虑最后一次使用的数据。LRU-K考虑数据最后第K次使用的情况,数据使用的次数加进了权重。一批新数据加载进入缓存,旧的但是经常使用的数据不会被清除(因为权重更高)。但是这个算法不会保留缓存中不再使用的数据,所以数据如果不再使用,权重值随着时间推移而降低。计算权重是需要成本的,所以SQL Server只是使用K=2,这个值性能不错而且额外开销可以接受。当然还有其他管理缓存的算法,比如:2Q(类LRU-K算法)、CLOCK(类LRU-K算法)、MRU(最新使用的算法,用LRU同样的逻辑但不同的规则)、LRFU(Least Recently and Frequently Used,最近最少使用最近最不常用)……
写缓冲区
我只探讨了读缓存——在使用之前预先加载数据。用来保存数据、成批刷入磁盘,而不是逐条写入数据从而造成很多单次磁盘访问。要记住,缓冲区保存的是页(最小的数据单位)而不是行(逻辑上/人类习惯的观察数据的方式)。缓冲池内的页如果被修改了但还没有写入磁盘,就是脏页。有很多算法来决定写入脏页的最佳时机,但这个问题与事务的概念高度关联,下面我们就谈谈事务。
(2)事务管理器
最后但同样重要的,是事务管理器,我们将看到这个进程是如何保证每个查询在自己的事务内执行的。但开始之前,我们需要理解ACID事务的概念。
I’m on acid
一个ACID事务是一个工作单元,它要保证4个属性:
原子性(Atomicity):事务要么全部完成,要么全部取消,即使它持续运行10个小时。如果事务崩溃,状态回到事务之前(事务回滚)。
隔离性(Isolation):如果2个事务A和B同时运行,事务A和B最终的结果是相同的,不管A是结束于B之前/之后/运行期间。
持久性(Durability):一旦事务提交(也就是成功执行),不管发生什么(崩溃或者出错),数据要保存在数据库中。
一致性(Consistency):只有合法的数据(依照关系约束和函数约束)能写入数据库,一致性与原子性和隔离性有关。
在同一个事务内,你可以运行多个SQL查询来读取、创建、更新和删除数据。当两个事务使用相同的数据,麻烦就来了。经典的例子是从账户A到账户B的汇款。假设有2个事务:事务1(T1)从账户A取出100美元给账户,事务2(T2)从账户A取出50美元给账户B。我们回来看看ACID属性:原子性确保不管T1期间发生什么(服务器崩溃、网络中断…),你不能出现账户A取走了100美元但没有给账户B的现象(这就是数据不一致状态);隔离性确保如果T1和T2同时发生,最终A将减少150美元,B将得到150美元,而不是其他结果,比如因为T2部分抹除了T1的行为,A减少150美元而B只得到50美元(这也是不一致状态);持久性确保如果T1刚刚提交,数据库就发生崩溃,T1不会消失得无影无踪;一致性确保钱不会在系统内生成或灭失。现代数据库不会使用纯粹的隔离作为默认模式,因为它会带来巨大的性能消耗。SQL一般定义4个隔离级别:
(1)串行化(Serializable,SQLite默认模式):最高级别的隔离。两个同时发生的事务100%隔离,每个事务有自己的世界。
(2)可重复读(Repeatable read,MYSQL默认模式):每个事务有自己的世界,除了一种情况。如果一个事务成功执行并且添加了新数据,这些数据对其他正在执行的事务是可见的。但是如果事务成功修改了一条数据,修改结果对正在运行的事务不可见。所以,事务之间只是在新数据方面突破了隔离,对已存在的数据仍旧隔离。举个例子,如果事务A运行SELECT count(1) from TABLE_X ,然后事务B在TABLE_X加入一条新数据并提交,当事务A再运行一次 count(1)结果不会是一样的。这叫幻读(phantom read)。
(3)读取已提交(Read committed,Oracle、PostgreSQL、SQL Server默认模式):可重复读+新的隔离突破。如果事务A读取了数据D,然后数据D被事务B修改(或删除)并提交,事务A再次读取数据D时数据的变化(或删除)是可见的。这叫不可重复读(non-repeatable read)。
(4)读取未提交(Read uncommitted):最低级别的隔离,是读取已提交+新的隔离突破。如果事务A读取了数据D,然后数据D被事务B修改(但并未提交,事务B仍在运行中),事务A再次读取数据D时,数据修改是可见的。如果事务B回滚,那么事务A第二次读取的数据D是无意义的,因为那是事务B所做的从未发生的修改(已经回滚了嘛)。这叫脏读(dirty read)。
多数数据库添加了自定义的隔离级别(比如 PostgreSQL、Oracle、SQL Server的快照隔离),而且并没有实现SQL规范里的所有级别(尤其是读取未提交级别)。默认的隔离级别可以由用户/开发者在建立连接时覆盖(只需要增加很简单的一行代码)。
并发控制
确保隔离性、一致性和原子性的真正问题是对相同数据的写操作(增、更、删):如果所有事务只是读取数据,它们可以同时工作,不会更改另一个事务的行为;如果(至少)有一个事务在修改其他事务读取的数据,数据库需要找个办法对其它事务隐藏这种修改。而且,它还需要确保这个修改操作不会被另一个看不到这些数据修改的事务擦除。这个问题叫并发控制。最简单的解决办法是依次执行每个事务(即顺序执行),但这样就完全没有伸缩性了,在一个多处理器/多核服务器上只有一个核心在工作,效率很低。理想的办法是,每次一个事务创建或取消时,监控所有事务的所有操作,检查是否2个(或更多)事务的部分操作因为读取/修改相同的数据而存在冲突,重新编排冲突事务中的操作来减少冲突的部分,按照一定的顺序执行冲突的部分(同时非冲突事务仍然在并发运行)。用更正规的说法,这是对冲突的调度问题。更具体点儿说,这是个非常困难而且CPU开销很大的优化问题。企业级数据库无法承担等待几个小时,来寻找每个新事务活动最好的调度,因此就使用不那么理想的方式以避免更多的时间浪费在解决冲突上。为了解决这个问题,多数数据库使用锁和/或数据版本控制。这是个很大的话题,我会集中探讨锁,和一点点数据版本控制。如果一个事务需要一条数据它就把数据锁住,如果另一个事务也需要这条数据它就必须要等第一个事务释放。这条数据这个锁叫排他锁。但是对一个仅仅读取数据的事务使用排他锁非常昂贵,因为这会迫使其它只需要读取相同数据的事务等待。因此就有了另一种锁,共享锁。如果一个事务只需要读取数据A它会给数据A加上共享锁并读取。如果第二个事务也需要仅仅读取数据A它会给数据A加上共享锁并读取。如果第三个事务需要修改数据A它会给数据A加上排他锁,但是必须等待另外两个事务释放它们的共享锁。同样的,如果一块数据被加上排他锁,一个只需要读取该数据的事务必须等待排他锁释放才能给该数据加上共享锁。
锁管理器是添加和释放锁的进程,在内部用一个哈希表保存锁信息(关键字是被锁的数据),并且了解每一块数据是被哪个事务加的锁以及哪个事务在等待数据解锁。但是使用锁会导致一种情况,2个事务永远在等待一块数据:
在本图中事务A给数据1加上排他锁并且等待获取数据2;事务B给数据2加上排他锁并且等待获取数据1,这叫死锁。在死锁发生时,锁管理器要选择取消(回滚)一个事务,以便消除死锁。这可是个艰难的决定:杀死数据修改量最少的事务(这样能减少回滚的成本)?杀死持续时间最短的事务,因为其它事务的用户等的时间更长?杀死能用更少时间结束的事务(避免可能的资源饥荒)?一旦发生回滚,有多少事务会受到回滚的影响?在作出选择之前,锁管理器需要检查是否有死锁存在。哈希表可以看作是个图表(见上文图),图中出现循环就说明有死锁。由于检查循环是昂贵的(所有锁组成的图表是很庞大的),经常会通过简单的途径解决:使用超时设定。如果一个锁在超时时间内没有加上,那事务就进入死锁状态。锁管理器也可以在加锁之前检查该锁会不会变成死锁,但是想要完美的做到这一点还是很昂贵的。因此这些预检经常设置一些基本规则。实现纯粹的隔离最简单的方法是:事务开始时获取锁,结束时释放锁。就是说,事务开始前必须等待确保自己能加上所有的锁,当事务结束时释放自己持有的锁。这是行得通的,但是为了等待所有的锁,大量的时间被浪费了。更快的方法是两段锁协议(Two-Phase Locking Protocol,由DB2和SQL Server使用),在这里,事务分为两个阶段:成长阶段事务可以获得锁,但不能释放锁;收缩阶段事务可以释放锁(对于已经处理完而且不会再次处理的数据),但不能获得新锁。
这两条简单规则背后的原理是:释放不再使用的锁,来降低其它事务的等待时间;防止事务最初获得的数据,在事务开始后被修改,当事务重新读取该数据时发生不一致。这个规则可以很好地工作,但有个例外:如果修改了一条数据、释放了关联的锁后,事务被取消(回滚),而另一个事务读到了修改后的值,但最后这个值却被回滚。为了避免这个问题,所有独占锁必须在事务结束时释放。当然了,真实的数据库使用更复杂的系统,涉及到更多类型的锁(比如意向锁,intention locks)和更多的粒度(行级锁、页级锁、分区锁、表锁、表空间锁),但是道理是相同的。我只探讨纯粹基于锁的方法,数据版本控制是解决这个问题的另一个方法。版本控制是这样的:每个事务可以在相同时刻修改相同的数据;每个事务有自己的数据拷贝(或者叫版本);如果2个事务修改相同的数据,只接受一个修改,另一个将被拒绝,相关的事务回滚(或重新运行)。这将提高性能,因为读事务不会阻塞写事务;写事务不会阻塞读事务;没有臃肿缓慢的锁管理器带来的额外开销。除了两个事务写相同数据的时候,数据版本控制各个方面都比锁表现得更好。只不过,你很快就会发现磁盘空间消耗巨大。数据版本控制和锁机制是两种不同的见解:乐观锁和悲观锁。(悲观锁(Pessimistic Lock)每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁;乐观锁(Optimistic Lock)每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据)两者各有利弊,完全取决于使用场景(读多还是写多)。一些数据库,比如DB2(直到版本9.7)和SQL Server(不含快照隔离)仅使用锁机制。其他的像MySQL和Oracle使用锁和数据版本控制混合机制。版本控制对索引的影响挺有趣的:有时唯一索引会出现重复,索引的条目会多于表行数,等等。如果你读过不同级别的隔离那部分内容,你会知道,提高隔离级别就会增加锁的数量和事务等待加锁的时间。这就是为什么多数数据库默认不会使用最高级别的隔离(即串行化)。
日志管理器
我们已经知道,为了提升性能,数据库把数据保存在内存缓冲区内。但如果当事务提交时服务器崩溃,崩溃时还在内存里的数据会丢失,这破坏了事务的持久性。你可以把所有数据都写在磁盘上,但是如果服务器崩溃,最终数据可能只有部分写入磁盘,这破坏了事务的原子性。事务作出的任何修改必须是或者撤销,或者完成。有2个办法解决这个问题:影子副本/页(Shadow copies/pages):每个事务创建自己的数据库副本(或部分数据库的副本),并基于这个副本来工作,一旦出错这个副本就被移除;一旦成功数据库就把副本替换到数据中,然后删掉旧数据;事务日志(Transaction log):事务日志是一个存储空间,在每次写盘之前,数据库在事务日志中写入一些信息,这样当事务崩溃或回滚,数据库知道如何移除或完成尚未完成的事务。影子副本/页在运行较多事务的大型数据库时制造了大量磁盘开销,所以现代数据库使用事务日志。事务日志必须保存在稳定的存储上,我不会深挖存储技术,但至少RAID磁盘是必须的,以防磁盘故障。多数数据库使用预写日志协议(Write-Ahead Logging protocol)来处理事务日志。WAL协议有3个规则:
(1)每个对数据库的修改都产生一条日志记录,在数据写入磁盘之前日志记录必须写入事务日志。
(2)日志记录必须按顺序写入;记录A发生在记录B之前,则A必须写在B之前。
(3)当一个事务提交时,在事务成功之前,提交顺序必须写入到事务日志。
这个工作由日志管理器完成。简单的理解就是,日志管理器处于缓存管理器(cache manager)和数据访问管理器(data access manager)之间,每个update/delete/create/ commit/rollback操作在写入磁盘之前先写入事务日志。简单,对吧?回答错误!问题在于,如何找到写日志的同时保持良好的性能的方法。如果事务日志写得太慢,整体都会慢下来。1992年,IBM 研究人员发明了WAL的增强版,叫ARIES。ARIES或多或少地在现代数据库中使用,逻辑未必相同,但AIRES背后的概念无处不在。ARIES代表数据库恢复原型算法(Algorithms for Recovery and Isolation Exploiting Semantics)。这个技术要达到一个双重目标:写日志的同时保持良好性能;快速和可靠的数据恢复。有多个原因让数据库不得不回滚事务:因为用户取消;因为服务器或网络故障;因为事务破坏了数据库完整性(比如一个列有唯一性约束而事务添加了重复值);因为死锁……有时候(比如网络出现故障),数据库可以恢复事务。这怎么可能呢?为了回答这个问题,我们需要了解日志里保存的信息。事务的每一个操作(增/删/改)产生一条日志,由如下内容组成:
LSN:一个唯一的日志序列号(Log Sequence Number)。LSN是按时间顺序分配的,这意味着如果操作A先于操作B,log A的LSN要比log B的LSN小。
TransID:产生操作的事务ID。
PageID:被修改的数据在磁盘上的位置。磁盘数据的最小单位是页,所以数据的位置就是它所处页的位置。
PrevLSN:同一个事务产生的上一条日志记录的链接。
UNDO:取消本次操作的方法。比如,如果操作是一次更新,UNDO将或者保存元素更新前的值/状态(物理UNDO),或者回到原来状态的反向操作(逻辑UNDO)。
REDO:重复本次操作的方法。 同样的,有2种方法,或者保存操作后的元素值/状态,或者保存操作本身以便重复。
…:(供您参考,一个 ARIES 日志还有2个字段:UndoNxtLSN和Type)。
进一步说,磁盘上每个页(保存数据的,不是保存日志的)都记录着最后一个修改该数据操作的LSN。LSN的分配其实更复杂,因为它关系到日志存储的方式。但道理是相同的。
ARIES只使用逻辑UNDO,因为处理物理UNDO太过混乱了。据我所知,只有PostgreSQL没有使用UNDO,而是用一个垃圾回收服务来删除旧版本的数据,这个跟PostgreSQL对数据版本控制的实现有关。为了更好的说明这一点,这有一个简单的日志记录演示图,是由查询“UPDATE FROM PERSON SET AGE=18”产生的,我们假设这个查询是事务18执行的。【SQL语句原文如此,应该是作者笔误】
每条日志都有一个唯一的LSN,链接在一起的日志属于同一个事务。日志按照时间顺序链接(链接列表的最后一条日志是最后一个操作产生的)。为了防止写日志成为主要的瓶颈,数据库使用了日志缓冲区。
当查询执行器要求做一次修改:
(1)缓存管理器将修改存入自己的缓冲区;
(2)日志管理器将相关的日志存入自己的缓冲区;
(3)到了这一步,查询执行器认为操作完成了(因此可以请求做另一次修改);
(4)接着(不久以后)日志管理器把日志写入事务日志,什么时候写日志由某算法来决定。
(5)接着(不久以后)缓存管理器把修改写入磁盘,什么时候写盘由某算法来决定。
当事务提交,意味着事务每一个操作的1,2,3,4,5步骤都完成了。写事务日志是很快的,因为它只是在事务日志某处增加一条日志。而数据写盘就更复杂了,因为要用能够快速读取的方式写入数据。出于性能方面的原因,第5步有可能在提交之后完成,因为一旦发生崩溃,还有可能用REDO日志恢复事务。这叫做NO-FORCE策略。数据库可以选择FORCE策略(比如第5步在提交之前必须完成)来降低恢复时的负载。另一个问题是,要选择数据是一步步的写入(STEAL策略),还是缓冲管理器需要等待提交命令来一次性全部写入(NO-STEAL策略)。选择STEAL还是NO-STEAL取决于你想要什么:快速写入但是从UNDO日志恢复缓慢,还是快速恢复。
总结一下这些策略对恢复的影响:
STEAL/NO-FORCE需要UNDO和REDO,性能高,但是日志和恢复过程更复杂(比如 ARIES)。多数数据库选择这个策略。
STEAL/FORCE只需要UNDO。
NO-STEAL/NO-FORCE只需要REDO。
NO-STEAL/FORCE什么也不需要,性能最差,而且需要巨大的内存。
Ok,有了不错的日志,我们来用用它们!假设新来的实习生让数据库崩溃了(首要规矩:永远是实习生的错),你重启了数据库,恢复过程开始了。ARIES从崩溃中恢复有三个阶段:
(1)分析阶段:恢复进程读取全部事务日志,来重建崩溃过程中所发生事情的时间线,决定哪个事务要回滚(所有未提交的事务都要回滚),崩溃时哪些数据需要写盘。
(2)Redo阶段:这一关从分析中选中的一条日志记录开始,使用REDO来将数据库恢复到崩溃之前的状态。在REDO阶段,REDO日志按照时间顺序处理(使用LSN)。对每一条日志,恢复进程需要读取包含数据的磁盘页LSN。如果LSN(磁盘页)>=LSN(日志记录),说明数据已经在崩溃前写到磁盘(但是值已经被日志之后崩溃之前的某个操作覆盖),所以不需要做什么。如果LSN(磁盘页)<LSN(日志记录),那么磁盘上的页将被更新。即使将被回滚的事务,REDO也是要做的,因为这样简化了恢复过程(但是我相信现代数据库不会这么做的)。
(3)Undo阶段:这一阶段回滚所有崩溃时未完成的事务。回滚从每个事务的最后一条日志开始,并且按照时间倒序处理UNDO日志(使用日志记录的PrevLSN)。恢复过程中,事务日志必须留意恢复过程的操作,以便写入磁盘的数据与事务日志相一致。一个解决办法是移除被取消的事务产生的日志记录,但是这个太困难了。相反,ARIES在事务日志中记录补偿日志,来逻辑上删除被取消的事务的日志记录。当事务被手工取消,或者被锁管理器取消(为了消除死锁),或仅仅因为网络故障而取消,那么分析阶段就不需要了。对于哪些需要REDO哪些需要UNDO的信息在2个内存表中:事务表(保存当前所有事务的状态)和脏页表(保存哪些数据需要写入磁盘)。当新的事务产生时,这两个表由缓存管理器和事务管理器更新。因为是在内存中,当数据库崩溃时它们也被破坏掉了。分析阶段的任务就是在崩溃之后,用事务日志中的信息重建上述的两个表。为了加快分析阶段,ARIES提出了一个概念:检查点(check point),就是不时地把事务表和脏页表的内容,还有此时最后一条LSN写入磁盘。那么在分析阶段当中,只需要分析这个LSN之后的日志即可。