# 数据管理器
[![](https://box.kancloud.cn/2016-04-26_571f7002c5c37.png)](http://files.devbean.net/images/2016/04/data_manager.png)
在这一步,查询管理器已经执行了查询,需要从表和索引获取数据。查询管理器请求数据管理器读取数据,但是这里有两个问题:
* 关系数据库使用的是事务模型。所以,当其他人正在使用或修改数据时,你不能同时获取数据。
* **数据获取是数据库中最慢的操作**,因此,数据管理器需要足够聪明,以便将数据缓存在内存缓冲区中。
在这部分,我们将了解关系数据库是如何处理这两个问题的。我不会详细介绍数据管理器是如何得到数据的,这并不是最重要的(并且这篇文章也不足够那么长)。
## 缓存管理器
正如我说的那样,数据库的主要瓶颈是磁盘 I/O。为了改进性能,现代数据库都使用了缓存管理器。
[![](https://box.kancloud.cn/2016-04-26_571f7002db213.png)](http://files.devbean.net/images/2016/04/cache_manager.png)
查询执行器并不会直接从文件系统取得数据,而是向缓存管理器请求数据。缓存管理器有一个内存中缓存,称为**缓冲池**。**从内存获取数据能够显著提升数据库性能。**但是,明确给出一个量级也是非常困难的,因为这取决于你需要的操作:
* 顺序访问(例如,全表扫描)或随机访问(例如,通过 row id 访问)
* 读或写
以及数据库使用的磁盘类型:
* 7.2k/10k/15k RPM HDD
* SSD
* RAID 1/5/…
但是,我可以说,**内存要比磁盘快 100 到 10万倍。**
但是,这导致了另外的问题(一直伴随着数据库)。缓存管理器需要在查询执行器使用数据*之前*就将数据放在内存,否则,查询管理器不得不等待从缓慢的磁盘读取数据。
### 预读取
这个问题称为预读取。查询执行器知道它需要的数据,因为它知道查询的整个流程以及通过统计获得的有关磁盘上数据的结构知识。下面就是思路:
* 当查询执行器正在处理它的第一批数据时
* 它请求缓存管理器将第二批数据预先加载到内存
* 当它开始处理第二批数据时
* 它请求缓存管理器将第三批数据预先加载到内存,并且告诉缓存管理器,第一批数据可以从缓存清除了
* …
缓存管理器将所有数据都存入其缓冲池。为了知道数据是否还需要,缓存管理器为那些已缓存的数据添加了额外的信息(称为**栓,latch**)。
有时,查询执行器并不知道它需要什么数据,某些数据库并不提供这种功能。相反,它们使用一种推断式预读取(例如,如果查询执行器请求数据 1,3,5,那么,它很有可能请求数据7,9,11)或者串行预读取(在这种情况下,缓存管理器从磁盘加载当前数据之后下一批连续数据)。
为了监控预读取是不是工作良好,现代数据库提供了称为**缓冲/缓存命中率(buffer/cache hit ratio)**的度量。这个命中率显示了请求的数据在缓冲区被找到,而不是请求磁盘访问的频率。
注意,不好的缓冲命中率并不意味着缓存不能好好工作。更多信息,可以阅读 [Oracle 文档](http://docs.oracle.com/database/121/TGDBA/tune_buffer_cache.htm)。
但是,缓冲受到内存总数的**限制**。因此,为了加载新的数据,缓冲需要移除旧的数据。加载和移除缓存数据需要消耗一定的磁盘和网络 I/O。如果你的查询需要频繁执行,将该查询使用的数据加载然后移除就不是那么高效了。为解决这一问题,现代数据库使用了一种缓冲替代策略。
### 缓冲替换策略
大多数现代数据库(包括 SQL Server、MySQL、Oracle 和 DB2)都使用了 LRU 算法。
#### LRU
**LRU** 意思是 **L**east **R**ecently **U**sed(最近最少使用)。算法思想是,将最近使用的数据放在缓冲区,最近使用的数据更有可能再次使用。
下面是一个例子:
[![](https://box.kancloud.cn/2016-04-26_571f7002eeb6b.png)](http://files.devbean.net/images/2016/04/LRU.png)
为了便于理解,假设缓冲区的数据没有被栓锁住(因此是可以被移除的)。在这个例子中,缓冲区可以储存 3 个元素:
* 1: 缓存管理器使用数据 1,将数据放入空的缓冲区
* 2: 缓存管理器使用数据 4,将数据放入有了加载的缓冲区
* 3: 缓存管理器使用数据 3,将数据放入有了加载的缓冲区
* 4: 缓存管理器使用数据 9。缓冲区已经满了,**因为数据 1 是最早使用的数据,因此数据 1 被移除**。数据 9 添加入缓冲区
* 5: 缓存管理器使用数据 4。**数据 4 已经在缓冲区了,因此它又变成了最近被使用的数据**
* 6: 缓存管理器使用数据 1。缓冲区已经满了,**因为数据 9 是最早使用的数据,因此数据 9 被移除**。数据 1 添加入缓冲器
* …
这个算法工作得很好,但是有一些限制。如果是一个大表的全表扫描该怎么办?换句话说,表或索引的大小超过了缓冲区的大小,会发生什么?使用这个算法,尽管全表扫描获取的那些数据只使用一次,但是却导致缓存中保存的所有数据都会被清除。
#### 改进
为避免这种问题,某些数据库添加了额外的规则。例如,根据 [Oracle 文档](http://docs.oracle.com/database/121/CNCPT/memory.htm#i10221):
> “对于非常大的表,数据库通常使用一次重定向路径读取,直接加载块 […],避免从缓冲区获取数据。对于中等大小的表,数据库可能使用直接读取,也可能使用缓存读取。如果数据库决定使用缓存读取,那么,数据库会替换 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):[The LRU-K page replacement algorithm for database disk buffering](http://www.cs.cmu.edu/~christos/courses/721-resources/p297-o_neil.pdf)。
#### 其它算法
当然,管理缓存还有其它的算法,比如:
* 2Q(一种类似于 LRU-K 的算法)
* CLOCK(一种类似于 LRU-K 的算法)
* MRU(最近最多使用,使用类似 LRU 的逻辑,但是另外的规则)
* LRFU(最少最近频繁使用)
* …
有些数据库允许使用除默认算法外的另外的算法。
### 写缓存
我只讨论了读缓存,也就是在使用之前加载数据。但是,数据库还有写缓存,即保存数据然后将数据一次性写入磁盘,而不是每次写入数据,造成大量单次磁盘访问。
记住,缓冲存储**页(page,最小的数据单位)**,不是行(行是逻辑/人可读的数据)。当缓冲池中的页被修改但是没有写入磁盘时,这个页就是**脏(dirty)**的。有很多算法确定什么是将脏页写入磁盘的最好时刻,但是这部分内容高度依赖于事务的术语,我们将在下一章节详细介绍。
## 事务管理器
最后但并不是不重要的,这一部分关于事务管理器。我们将看到如何保证每个查询都在自己的事务中进行。但是,在此之前,我们需要了解 ACID 事务的概念。
### I’m on acid
一个 ACID 事务是一个**工作单元**,保证 4 件事:
* **原子性(Atomicity)**:事务“要么全做,要么全不做”,即便要持续 10 个小时。如果事务崩溃,状态将回到事务开始之前(此时事务**回滚**)。
* **隔离性(Isolation)**:同时执行两个事务 A 和 B,事务 A 和 B 的结果必须是一致的,不管 A 是在 B 之前/之后/之间完成。
* **持久性(Durability)**:一定事务**提交**(也就是成功结束),数据会保存到数据库,不管发生了什么(崩溃或者错误)。
* **一致性(Consistency)**:只有有效数据(按照关系约束和函数约束)可以被写入数据库。一致性与原子性和隔离性相关。
[![](https://box.kancloud.cn/2016-04-26_571f700309643.jpg)](http://files.devbean.net/images/2016/04/dollar_low.jpg)
在同一个事务中,你可以运行多条 SQL 查询去读取、创建、更新和删除数据。当两个事务使用相同数据时,混乱就开始了。经典的例子是,将钱从 A 账户转移到 B 账户。假设你有两个事务:
* 事务 1 从 A 账户取出 100 美元,将它们给 B 账户
* 事务 2 从 A 账户获得 50 美元,将它们给 B 账户
如果我们回到 **ACID** 属性:
* **原子性**保证不管在 T1 执行期间发生了什么(服务器宕机、网络异常…),你不能处于 A 账户少了 100 美元而 B 账户什么都没得到的状态(这种情况是不一致的状态)。
* **隔离性**保证 T1 和 T2 同时发生,最终,A 将减少 150 美元,B 获得 150 美元,而不是,比如因为 T2 部分抵消了 T1 的某些行为,A 少了 150 美元而 B 只获得 50 美元(这种情况也是不一致的状态)。
* **持久性**保证在 T1 刚刚提交之后,数据库崩溃,也不会导致 T1 的操作消失不见。
* **一致性**保证系统中的钱既没有被创造也没有被销毁。
[你可以略过下面跳到下一部分,下面我所讲的对于本文其余部分不是那么重要]
很多现代数据库不会将纯粹的隔离性作为默认行为,因为这会导致很大的性能问题。SQL 标准定义了 4 种不同级别的隔离性:
* **可串行读**(SQLite 的默认行为):最高级别的隔离性。同时发生的两个事务 100% 隔离。每一个事务都有它自己的“世界”。
* **可重复读**(MySQL 的默认行为):每个事务都有它自己的“世界”,不过有一点例外。如果一个事务成功结束,并且添加的新的数据,这些数据对另外的正在执行的事务是可见的。但是,如果 A 修改了数据并且成功结束,这些修改对正在执行的事务是不可见的。所以,事务之间的隔离性对于新的数据而言是被打破的,对于已有数据则没有。
例如,事务 A 执行的是 “SELECT count(1) from TABLE_X”,事务 B 则向 TABLE_X 加入了新的数据并且提交,那么,事务 A 再次计算 count(1) 所获得的结果是不一致的。
这种情况被称为**幻读**。
* **已提交读**(Oracle、PostgreSQL 和 SQL Server 的默认行为):这是可重复读 + 一个新的隔离性打破。如果事务 A 读取数据 D,然后该数据被事务 B 修改(或删除)并且提交,A 再次读取 D 将会看到由 B 所做的修改(或删除)
这被称为**不可重复读**。
* **未提交读**:最低级别的隔离。这是已提交读 + 一个新的隔离性打破。如果事务 A 读取数据 D,然后数据 D 被事务 B 修改(并未提交,继续运行),如果 A 再次读取 D,那么它将看到被修改的数据。如果事务 B 回滚,那么事务 A 第二次读出的数据 D 是没有意义的,因为事务 B 所做的修改已经被撤消了(事务 B 已经回滚)。
这被称为**脏读**。
大多数数据库添加了自定义的隔离级别(例如 PostgreSQL、Oracle 和 SQL Server 的快照隔离)。另外,大多数数据库并没有实现 SQL 标准的全部级别(尤其是未提交读级别)。
隔离性的默认级别可以由用户/开发者在开始连接时指定(只需要添加很简单的一行代码)。
### 并发控制
保证隔离性、一致性和原子性的真正问题是**在同一数据上的写操作**(增加、修改和删除):
* 如果所有事务都是读数据,它们可以同时进行,不需要修改另外事务的行为
* 如果这些事务中(至少)一个是修改数据,其余事务读取数据,那么,数据库需要找到一种方式向其它事务隐藏修改。并且,数据库还需要确保这种修改不会被不能看到修改的其它事务覆盖掉。
这个问题称为**并发控制**。
解决这个问题的最简单方法是,一个一个执行事务(串行)。但是这没有一点可扩展性,并且在多核处理器的服务器上只能有一个核在工作,非常浪费资源…
解决这一问题的理想方法是,每次一个事务被创建或取消时:
* 监控所有事务的所有操作
* 检查两个(或多个)事务的某些部分是不是因为读/写相同数据导致冲突
* 在发生了冲突的数据内部进行操作的重排序,减小发生冲突的部分
* 以特定顺序执行冲突部分(与此同时,不冲突的事务继续并发执行)
* 考虑是不是有事务可以被取消
更正式的说,这是一个带有冲突计划的调度问题。进一步说,这是一个非常困难、消耗大量 CPU 资源的优化问题。企业数据库不可能等待几个小时去为每一个新的事务事件找到最好的调度。因此,数据库使用不那么理想的实现,通过浪费一些时间来解决冲突的事务。
### 锁管理器
为了解决这一问题,大多数数据库使用**锁(lock)**和/或**数据版本管理(data versioning)**。这是一个很大的话题,我主要关注于锁的部分,之后将简单介绍数据版本。
#### 悲观锁
锁背后的思路是:
* 如果事务需要数据
* 锁住数据
* 如果另外的事务也需要这个数据
* 让它等待,直到第一个事务释放了数据
这种锁被称为**互斥锁**。
但是,对于那些只需要读数据的事务而言,互斥锁非常昂贵,因为它**强制只需要读取相同数据的事务等待**。这就是为什么有另外一种类型的锁:**共享锁**。
使用共享锁:
* 如果事务需要读取数据 A
* 它使用共享锁锁住数据,读取数据
* 第二个事务只需要读取数据 A
* 它用共享锁锁住数据,读取数据
* 第三个事务想要改变数据 A
* 它想要用互斥锁锁住数据,但是它必须等待另外两个事务释放其共享锁,才能够用自己的互斥锁锁住数据 A
但是,如果数据有互斥锁,即便事务只需要读取数据,它还是得等待互斥锁释放掉,才能够用共享锁锁住数据。
[![](https://box.kancloud.cn/2016-04-26_571f70034a064.png)](http://files.devbean.net/images/2016/04/lock_manager.png)
锁管理器就是加锁和解锁的进程。它将锁存储在哈希表(其键值就是需要加锁的数据)中,知道每一个数据的信息:
* 哪些事务正在锁住数据
* 哪些事务正在等待数据
#### 死锁
但是,锁的使用可能会导致这样一种情况:两个事务永远等待数据的锁:
[![](https://box.kancloud.cn/2016-04-26_571f70035f2bd.png)](http://files.devbean.net/images/2016/04/deadlock.png)
在这个图中:
* 事务 A 为 data1 加了互斥锁,等待获得 data2
* 事务 B 为 data2 加了互斥锁,等待获得 data1
这种情况被称为**死锁**。
在死锁中,锁管理器决定哪一个事务被取消(回滚),以便移除死锁。这种决定并不那么简单:
* 取消修改了最少数据的那个事务更好一些吗(这样就回滚的代价是最小的)?
* 取消执行时间最短的那个事务更好一些吗(因为其它事务都执行了更长的时间)?
* 取消最快执行完毕的那个事务更好一些吗(避免出现饥饿)?
* 对于那个回滚,究竟有多少事务会收回滚的影响?
在做出选择之前,数据库需要首先检查是不是真的存在死锁。
哈希表可以看做一个图(与上面的图类似)。如果图中存在环,说明存在死锁。因为检查环是很昂贵的(包含所有锁的图是非常大的),所以通常会使用一种简单的实现:使用**超时**。如果锁没有在规定时间内获得,事务就进入了死锁状态。
锁管理器在加锁之前也可以检查这个锁会不会导致死锁。但是一个完美的实现同样代价昂贵。因此,所做的先期检查只是基于一些基本的规则。
#### 两阶段加锁
保证纯粹隔离性的**最简单方法**是,在事务开始时获得锁,在事务结束时释放锁。这意味着事务必须在开始之前等待它的所有的锁,在其结束时等待其持有的锁都释放掉。这当然可以工作,但是等待所有的锁会**产生大量时间浪费**。
更多的方法是**两阶段加锁协议**(DB2 和 SQL Server 使用)。在这个协议中,事务分为两个阶段:
* **生长阶段(growing phase)**,事务可以获得锁,但是不能释放锁
* **收缩阶段(shrinking phase)**,事务可以释放锁(对于那些已经处理过、不再需要的数据),但是不能获得新锁
[![](https://box.kancloud.cn/2016-04-26_571f7003714b0.png)](http://files.devbean.net/images/2016/04/two-phase-locking.png)
这两个简单规则背后的思路是:
* 释放掉不再使用的锁,减少其它事务等待这些锁的等待时间
* 避免事务开始之后获取到被修改的数据的情况,这种情况不能保证事务请求的第一个数据的一致性
这个协议工作得很好,除了一个修改了数据又释放掉相关锁的事务取消(回滚)。在这种情况下,另外的事务读到的是被修改的数据值,而这个值马上就要被回滚。为避免这一问题,**所有的互斥锁必须在事务最后释放**。
#### 额外的话
当然,实际的数据库使用了更复杂的系统,引入了更多的锁类型(例如意向锁)和更细致的粒度(在行、页、分区、表和表空间加锁),但是思路是一致的。
我只表述了纯粹的基于锁的实现。**数据版本管理是解决这一问题的另外的方法**。
数据版本管理背后的思想是:
* 每一个事务可以在相同时间修改相同数据
* 每一个事务都保存这个数据的自己的备份(或称版本)
* 如果两个事务修改相同数据,只有一个修改会被接受,另外一个则会被拒绝,其相关事务会被回滚(可能会重新运行)。
基于以下原因,数据版本管理可以提高性能:
* **读事务不需要锁住写事务**
* **写事务不需要阻塞读事务**
* 没有来自“又大又慢的”锁管理器的开销
一切都比锁要好,除了两个事务写同样的数据。另外,你可以很快达到巨大的磁盘空间消耗。
数据版本控制和锁是两种不同视角:**乐观锁**和**悲观锁**。二者都有优缺点,取决于使用用例(读更多还是写更多)。对于数据版本的详细信息,我推荐阅读有关 PostgreSQL 如何实现多版本并发控制的[这篇非常好的论文](http://momjian.us/main/writings/pgsql/mvcc.pdf)。
某些数据库,例如 DB2(DB2 9.7 之前)和 SQL Server (不包括快照隔离)只支持锁。另外的数据库,比如 PostgreSQL、MySQL 和 Oracle 混合使用锁和数据版本。我还没有发现只使用数据版本的数据库(如果你发现了纯粹基于数据版本的数据库,请告诉我)。
[2015 年 8 月 20 日更新] 有位读者告诉我:
> Firebird 和 Interbase 使用了版本,没有使用记录锁。
> 对于索引,版本有一种非常有趣的影响:有时唯一索引可能包含重复数据;索引中的记录数可能比表行数更多等。
如果你阅读了有关隔离性的不同级别,当提高隔离级别时,也就增加了锁的数量,因此,事务将要花费更多时间等待锁。这也就是为什么数据库不默认使用最高级别的隔离(可串行读)。
与往常一样,你可以自行查阅主流数据库的相关文档(例如 [MySQL](http://dev.mysql.com/doc/refman/5.7/en/innodb-transaction-model.html)、[PostgreSQL](http://www.postgresql.org/docs/9.4/static/mvcc.html) 和 [Oracle](http://docs.oracle.com/cd/B28359_01/server.111/b28318/consist.htm#i5337))。
### 日志管理器
我们已经认识到,为了提高性能,数据库将数据存储在内存缓冲区中。但是,如果在事务提交时,服务器崩溃,你就会丢失所有内存中的数据,这将导致事务持久性问题。
你可以将所有数据写入磁盘,但是,如果服务器崩溃,你可能只写入一半数据,这会导致事务的原子性问题。
**事务所做的任何修改写入必须要全部撤销或全部写入。**
为解决这一问题,有两种解决方案:
* **影子拷贝/页**:每一个事务创建自己的数据库拷贝(或者是数据库的一部分),在这个拷贝上面工作。如果发生了错误,移除该拷贝;如果成功,数据库立刻将数据通过文件系统技巧从拷贝切换,并移除“旧的”数据。
* **事务日志**:事务日志是一段存储空间。在每个写入磁盘操作之前,数据库将信息写入事务日志,以便在事务崩溃/撤销时,数据库能够知道如何移除(或完成)未完成的事务。
#### WAL
当大型数据库需要很多事务时,影子拷贝/页会占用大量磁盘空间。这就是现代数据库使用**事务日志**的原因。事务日志必须存储在**稳定的存储**中。我不会深入介绍存储技术,只简单提一句,使用(至少)RAID 磁盘阵列能够有效防止磁盘失败。
很多数据库(包括 Oracle、[SQL Server](https://technet.microsoft.com/en-us/library/ms186259(v=sql.105).aspx)、[DB2](http://www.ibm.com/developerworks/data/library/techarticle/0301kline/0301kline.html)、[PostgreSQL](http://www.postgresql.org/docs/9.4/static/wal.html)、MySQL 和 [SQLite](https://www.sqlite.org/wal.html))使用**预写式日志(Write-Ahead Logging protocol,WAL)**。WAL 包括三个规则:
* 1) 数据库每一条修改都要产生一条日志记录,**这个日志记录必须在数据写入磁盘之前先写入事务日志**
* 2) 日志记录必须按照顺序写入:如果日志记录 A 发生在日志记录 B 之前,那么 A 必须在 B 之前写入
* 3) 当事务提交时,直到事务成功结束,提交顺序必须写入事务日志
[![](https://box.kancloud.cn/2016-04-26_571f700384fa8.png)](http://files.devbean.net/images/2016/04/log_manager.png)
这个工作是由日志管理器完成的。简单来说,日志管理器就是,在缓存管理器和数据访问管理器(数据访问管理器将数据写入磁盘)之间,在数据写入磁盘之前,将每一个更新/删除/创建/提交/回滚写入事务日志。很简单,是不是?
*不对!*经过这么多的学习,你应该知道,关于数据库的一切都可能引起“数据库效应”。严肃地说,日志管理器的问题是,找到一种既能写入日志,又能保持好性能的方式。如果写入事务日志太慢,它就会拖慢所有事情。
#### ARIES
1992 年,IBM 的研究者“发明了”一种改进版本的 WAL,称为 ARIES。ARIES 多多少少被大多数现代数据库使用。具体逻辑可能不一样,但是 ARIES 背后的概念已经被广泛使用。我给发明这个词加了引号,是因为根据 [MIT 课程](http://db.csail.mit.edu/6.830/lectures/lec15-notes.pdf),IBM 研究者“什么都没干,只是写了篇事务恢复的最佳实践”。因为 ARIES 论文发表的时候,我只有 5 岁,我不关心那些尖酸刻薄的研究者们的流言蜚语。事实上,我之所以在这里写出来这个,主要是为了在我们开始最后一个技术部分之前稍微休息一下。我阅读了大量[关于 ARIES 的研究论文](http://www.cs.berkeley.edu/~brewer/cs262/Aries.pdf),发现它的确很有趣!在这部分我只介绍 ARIES 的大概,但是如果你想学习点真正的知识的话,我强烈推荐你去阅读这些论文。
ARIES 是 **A**lgorithms for **R**ecovery and **I**solation **E**xploiting **S**emantics(利用语义的恢复和隔离算法)的缩写。
这项技术的目的有两个:
* 1) 获得**写日志的良好性能**
* 2) 获得快速**可靠的恢复**
数据库之所以要回滚事务,其原因是多方面的:
* 因为用户取消了事务
* 因为服务器或网络失败
* 因为事务打破了数据库完整性(例如,你有一个带有 UNIQUE 约束的列,而事务视图插入重复值)
* 因为死锁
有时(比如网络失败)数据库能够从事务恢复。
这是怎么做到的?为回答这个问题,我们需要理解存储在日志记录中的信息。
##### 日志
每一个**事务中的操作(增加/删除/修改)都会产生一条日志**。这个日志记录包括:
* **LSN**:**L**og **S**equence **N**umber(日志顺序数)。LSN 按照顺序以此给出*。这意味着如果一个操作 A 发生在操作 B 之前,那么日志 A 的 LSN 就会比 日志 B 的 LSN 小。
* **TransID**:产生操作的事务 ID。
* **PageID**:被修改的数据在磁盘的位置。磁盘上数据的最小值是页,因此数据的位置也就是包含了数据的页的位置。
* **PrevLSN**:同一事务产生的前一个日志记录的链接。
* **UNDO**:移除该操作影响的方法。例如,如果操作是更新,UNDO 会保存元素被更新之前的值/状态(物理 UNDO)或者回到之前状态的撤销操作(逻辑 UNDO)**。
* **REDO**:重现操作的方法。类似地,有两种方法保存 REDO:保存操作之后元素的值/状态或者可以重复执行的操作本身。
* …:(顺便说一句,ARIES 日志还有另外两个字段:UndoNxtLSN 和 Type)。
另外,磁盘上每一个页(保存数据的页,不是保存日志的页)都保存有最后修改数据的那个操作的日志记录 ID(LSN)。
*这种给出 LSN 的方式更复杂一些,因为它与保存日志的方式相关。但思路是一致的。
**ARIES 只使用逻辑 UNDO,因为物理 UNDO 会有很大的消耗。
注意:就我所知,只有 PostgreSQL 没有使用 UNDO,而是使用了一个垃圾回收守护进程来移除旧的数据。这与 PostgreSQL 使用的数据版本实现相关。
为了更好理解,下面是日志记录的简单的图形化例子。这个日志记录来自于查询 “UPDATE FROM PERSON SET AGE = 18;”。假设该查询在事务 18 中执行。
[![](https://box.kancloud.cn/2016-04-26_571f70039937f.png)](http://files.devbean.net/images/2016/04/ARIES_logs.png)
每个日志都有唯一的 LSN。同一事务的日志被链接到一起。日志是顺序相连的(链表中的最后一个日志对应着最后一个操作)。
##### 日志缓冲
为避免写入日志带来的主要瓶颈,数据库会使用**日志缓冲**。
[![](https://box.kancloud.cn/2016-04-26_571f7003a960d.png)](http://files.devbean.net/images/2016/04/ARIES_log_writing.png)
当查询执行器请求修改时:
* 1) 缓存管理器在其缓冲区保存修改
* 2) 日志管理器在其缓冲区保存相关日志
* 3) 在这一步,查询执行器认为操作已经完成(因此开始请求另外的修改)
* 4) 然后(之后),日志管理器在事务日志写入日志。何时写入日志是由算法决定的。
* 5) 然后(之后),缓存管理器将修改写入磁盘。何时写入数据是由算法决定的。
**事务提交,意味着事务中的每一个操作,上述 1,2,3,4,5 步已经完成。**写入事务日志是很快的,因为这仅仅是“在事务日志某个地方加入一条记录”;但是,将数据写入磁盘就复杂得多,因为“需要按照能够快速读取的方式写入数据”。
##### STEAL 和 FORCE 策略
由于性能原因,**第 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 不需要任何东西:**最坏性能**,需要大量冗余。
##### 恢复
好了,现在我们有了漂亮的日志,用用它们吧!
假设我们新来的临时工把数据库搞垮了(规则 No 1:总是临时工的错)。你重启数据库,开始恢复进程。
ARIES 从崩溃中恢复需要三个阶段:
* **1) 分析阶段**:恢复进程读取完整的事务日志*,重建时间线,找出崩溃期间发生了什么,决定回滚哪个事务(所有没有提交命令的事务都要被回滚),哪些数据需要在崩溃时写入磁盘。
* **2) 重做阶段**:这一阶段开始于分析决定的一条日志记录,使用 REDO 更新数据库,使其回到崩溃开始之前的状态。在重做期间,REDO 日志按照顺序处理(使用 LSN)。对于每一条日志,恢复进程读取包含有需要修改的数据的磁盘上的页的 LSN。如果 LSN(page_on_disk) >= LSN(log_record),意味着崩溃之前数据已经写入磁盘(但是值被日志之后、崩溃之前的操作覆盖掉了),什么都不做。如果 LSN(page_on_disk) < LSN(log_record),那么更新磁盘上的页。对于那些准备回滚的事务也需要进行重做,因为这会简化恢复进程(但是我肯定现代数据库不会这么干)。
* **3) 撤销阶段**:这一阶段回滚所有在崩溃时未完成的事务。回滚开始于每一个事务的最后一个日志,按照倒序执行 UNDO 日志(使用日志记录的 PrevLSN)。
在恢复期间,事务日志必须警惕恢复管理器执行的动作,以便保证写入磁盘的数据与事务日志中的是完全同步的。一个解决方案是,删除已经撤销完成事务日志记录,但这很困难。ARIES 则是在事务日志中写入补偿日志,删除那些逻辑删除的日志记录。
当事务“手动”取消或者由锁管理器取消(终止死锁)或仅仅是由于网络失败,分析阶段是不需要的。REDO 和 UNDO 所需信息保存在两个内存中的表:
* **事务表**(保存所有当前事务的状态)
* **脏页表**(保存哪些数据需要被写入磁盘)
这些表由缓存管理器和事务管理器为每一个新的事务事件进行更新。由于它们是在内存中的,数据库崩溃时就会被销毁。
分析阶段的工作是在崩溃之后使用事务日志重建这两个表*。为加速分析阶段,ARIES 提供了**检查点**标记,其思路是,将事务表和脏页表的内容不定时写入磁盘,并且写入此时最后一个 LSN,这样在分析阶段,只有在该 LSN 之后的日志才会被分析。