ARIES(基于语义的恢复与隔离算法)是现代数据库理论的基础。提供了解决ACID中A、I、D重要的解决思路。
基础知识
这里先复习一下关于ACID的含义以及数据库隔离级别:
ACID的含义
原子性(Atomicity): 一个事务中被视为不可分割的最小单元,整个事务中所有的操作要么全部提交成功,要么全部失败,对于一个事务来说,不可能只执行其中的一部分操作,这就是事务的原子性。
一致性(Consistency): 数据库总是从一个一致的数据状态转换成另外一个一致的状态。(比如银行转账前后,总金额是不变的。)
隔离性(Isolation): 通常来说一个事务所做的改变对其他事务来说是不可见的。
持久性(Durability): 一旦事务提交成功,事务所做的修改将会永久被保存在数据库中。
数据库隔离级别
读未提交(READ UNCOMMITED):在此级别,事务中的修改,即使没有提交,也能被其他事务可见。(事务可以读取未提交的数据,称为脏读 Dirty Read)
读已提交(READ COMMITED): 事务开始时,只能“看见”已经提交事务所做的修改。也叫不可重复读,因为有时可能两次查询的结果可能不一样(后面解释原因)。
可重复读(REPEATABLE READ): 该级别保证了再同一个事务中多次读取同样的记录结果是一样的。但是不能避免幻读,幻读是指在某个事务在读取一个范围内的记录时,另外一个事务又在该范围新增了一条记录,之前的事务再次读取该范围记录时,会产生幻行。InnoDB通过多版本控制MVCC解决了幻读的问题。可重复读是MYSQL默认的隔离级别。
串行化(SERIALIZABLE): 串行化是最高的隔离级别,它强制事务通过串行执行,避免前面的所有问题。
实现原子性和持久性
Commit Loging(Redo Log也称为重做日志)
数据需要写入磁盘等存储介质才能真正的持久化,但是写入磁盘这个操作不是原子性的。在内存中的数据一旦崩溃,数据就会存在丢失,也存在“正在写”、“写入”、“未写入”等状态。所以实现原子性持久性的最大困难在于写入磁盘。由于写入中间状态和崩溃无法避免,所以为了保证原子性和持久性,就只能采取崩溃后的补救恢复措施。这种被称为“崩溃恢复(Crash Recovery)”。那么如何补救呢?答案就是记录修改的日志信息。有了日志,修改数据就不能像之前一样直接修改,而是要先记录修改这个数据的全部信息,包括修改前的值,修改后的值,数据处于哪个内存页和磁盘块中等等,在日志记录全部成功落盘之后,写入提交记录,数据库在“看见”磁盘根据提交成功的“提交记录(Commited Record)”,才会根据日志信息对真正的数据进行修改,修改完之后,再提交一条结束修改的记录(End Record)表示数据已经完成持久化。这种实现方式被称为“提交日志(Commit Loging)”。
Commit Loging 原理
假设日志写入到Commited Record,即表示事务是成功,即使在此阶段崩溃,数据库重启之后,也会根据Commit Loging中的记录对数据进行恢复,继续持久化。假设日志没有写入到Commited Record,数据库重启之后,会认为此事务是没有成功的,直接将Commit Loging中的这部分的日志信息标记为回滚就行,数据也进行了恢复。(阿里的OceanBase就是基于这个原理实现的)
Commit Loging 缺陷
所有数据都必须在Commited Record记录成功之后,才会真正的进行修改。试想一想,这个地方是不是天然被加上了“锁”,在记录Commited Record之前,不管系统资源多么空闲,不会对任何数据进行修改。这样就形成了资源的浪费,性能的降低。
Write-Ahead Loging
为了优化Commit Loging锁带来的缺陷,ARIES提出提前写日志(Write-Ahead Loging)改进方案。就是允许在事务提交之前写入变动数据。
FORCE:在事务提交之后,要求变动的数据必须同时完成写入则称为FORCE。不强制变动数据写入则称为NO-FORCE。如何理解呢,由Commit Loging的写入过程可知,由于是先记录Commit Loging的日志信息,在写入Commited Record,最后再根据Commit Loging对数据进行真正的写入。FORCE就是在执行写入Commited Record之后,立马(同时)对数据进行持久化更改。NO-FORCE则是不限制数据真正修改的时间,可以批量操作等等。从磁盘IO优化的角度来说,NO-FORCE肯定更优一点。所以现在绝大多数数据库实现策略都是采用的NO-FORCE策略。
STEAL:在事务提交前,允许数据提前写入,则称为STEAL。不允许则称为NO_STEAL。提前写入有利于空闲IO的利用,也有利于节省数据库缓存区内存。但是Steal有个缺点就是,提前写入没提交事务之前的数据,一旦事务提交失败,这部分数据是需要回滚的。
所以综上两个分类可以知道,Commit Loging的设计原则是NO-FORCE、NO-STEAL的。
Write-Ahead Loging的设计原则是,NO-FORCE、STEAL。而引入了Undo Log解决提前写入数据的回滚问题。
Undo Log(回滚日志):当变动的数据写入磁盘前,必须先记录Undo Log日志,注明修改了哪个位置的数据,从什么修改成了什么等。以便在事务回滚或者崩溃恢复的时候根据Undo Log对提前写入的数据进行回滚。
Write-Ahead Loging 崩溃恢复的流程
- 分析阶段: 该阶段从最后一次检查点(Checkpoint: 该检查点之前的所有持久化都已经安全落盘)开始扫描日志,找出没有End Record的事务集合
- 重做阶段(Redo): 该阶段从分析阶段查找的事务集合中筛选出Commit Record的事务,并根据Redo Log进行数据的恢复落盘,并写入End Record,移除已经处理的事务
- 回滚阶段(Undo): 该阶段处理重做阶段中剩下的没有Commit Record记录的事务,这些事务都是需要回滚的。程序会根据Undo log 将数据进行回滚回去。
重做阶段和回滚阶段都必须设计为幂等。各个策略排队组合性能和需要的日志的排列组合图如下:
实现隔离性
隔离性保证了各个事务的读写数据互不影响。其实现原理就是加锁。
锁相关概念
写锁(Write Lock):也可以叫排他锁,只有持有写锁的事务才能对数据进行写入操作,数据加持着写锁,其他事务不能写入数据也不能施加读锁。(和Java 的读写锁一样)
读锁(Read Lock):也叫共享锁,多个事务可以对一个数据同时加读锁,但是数据一旦被加上了读锁,就不能再加写锁(即其他事务不能对此数据进行写入操作),但是仍然可以读,如果此数据之一一个事务加读锁,该事务本身可以将数据升级为写锁,然后写入数据。
范围锁(Range Lock): 对某个范围直接加排他锁,在这个范围内的数据不能被写入。注意的是范围锁是对整个范围加锁,没有获得范围锁的事务不能在这个范围内不能新增修改删除数据。以下就是加范围锁的例子,区别于id <100 的记录每条数据都加单个的读锁,假设我的id = 10的记录在数据库中不存在,单个加读锁的情况,就可以进行id=10这条数据的插入,但是范围锁不允许这样的操作。
SELECT * FROM goods WHERE id < 100 FOR UPDATE; -- 这就是加范围锁
隔离级别实现的原理
可串行化:对所有事务的读写全部加上读写锁、范围锁,即可做到可串行化。所以可串行化也是性能最差的隔离级别
可重复读:对所有的事务的读写全部加读写锁,直到事务的完成,且不再加范围锁。对于可串行化来说,少了范围锁,那么他统计某个范围条数第一次读取和第二次读取记录的条数可能会不一样,即出现幻读。例如下面的SQL中事务1中第一次读到的数据总数和第二次读到的不一致。但是如果下面的是MySQL的InnoDB就能读到一致的数据,因为InnoDB 使用MVCC解决了幻读的问题。
SELECT count(1) FROM goods WHERE price < 100; -- 事务1
INSERT INTO goods (id,name,price) VALUES(1000,'商品1',99); -- 事务2
SELECT count(1) FROM goods WHERE price < 100; -- 事务1
读已提交:读已提交对所有事务涉及的写操作会加写锁,持续到事务完成。但是对事务涉及的读锁在查询操作完成之后就会立即释放。正因为如此,假设有两个事务,分别是事务1和事务2,事务1对数据进行查询,然后立马释放锁,事务2对相同的数进行更新操作并提交,事务1再去对相同的数据进行查询,事务1中两次查询的结果就不是一样的,这是由于读锁操作完立马释放造成的不可重复读
SELECT * FROM goods WHERE id =1; -- 事务1第一次查询,查询完就释放掉读锁
UPDATE goods SET price = 100 WHERE id=1; COMMIT; -- 事务2可以立马获取到id=1的写锁进行更新,并提交事务
SELECT * FROM goods WHERE id =1;COMMIT; -- 事务1第二次查询,查询结果就是更新之后的100,不再是之前的数据
读未提交:它只对事务所有涉及的写操作加写锁,并持续到事务完成,对读操作完全不加锁。这个就很好理解,因为读操作不需要获取锁,所以对于加了写锁的数据依旧能读,这就是所谓的脏读。读操作不需要任何锁,写锁的排他性就不起作用,所以会出现脏读。
SELECT * FROM goods WHERE id =1; -- 事务1第一次查询,不需要任何锁
UPDATE goods SET price = 100 WHERE id=1; -- 事务2可以立马获取到id=1的写锁进行更新,还没提交事务
SELECT * FROM goods WHERE id =1;COMMIT; -- 事务1第二次查询,查询结果就是更新之后的100,因为少了加写锁的步骤,写锁的排他性就利用不了,就可以直接读事务2中还没提交的数据
MVCC 多版本并发控制(数据库的无锁优化方案)
“无锁”是指读取是不需要加锁。它的基本思路就是同时存在新老版本数据共存,以此达到读取不需要加锁的目的,类似乐观锁。
MVCC是一个行锁的变种,我的理解是他就是一个行级的乐观锁。基于事务ID是全局严格递增的数字,针对每一行数据进行修改的时候,每行记录有两个隐藏的版本号字段,一个创建版本号CREATE_VERSION、一个删除版本DELETE_VERSION
- 新增数据时,CREATE_VERSION的版本号存当前事务的ID,DELETE_VERSION置空
- 删除数据时,CREATE_VERSION版本号置空,DELETE_VERSION版本号存当前事务的ID
- 修改数据时,可以认为是先删除在插入。所以先复制一份原来的数据,CREATE_VERSION版本号置为当前更新的事务ID,DELETE_VERSION版本号置空。再删除原来的数据,DELETE_VERSION版本号置为当前更新的事务ID,CREATE_VERSION置空。
这样做的好处在于,针对可重复读隔离界别的时候,查询的时候总是取CREATE_VERSION小于等于当前事务ID的记录,如果小于等于当前事务ID的数据有多个版本,就取小于等于中最新的记录即可。例如像可重复读中的举的幻读的例子:
SELECT count(1) FROM goods WHERE price < 100; -- 事务1
INSERT INTO goods (id,name,price) VALUES(1000,'商品1',99); -- 事务2
SELECT count(1) FROM goods WHERE price < 100; -- 事务1
当不存在MVCC的时候且事务隔离界别是可重复读,出现的幻读场景,代码如上所示。
第一行事务1统计的商品数量肯定是比事务1第二次统计的数量至少少1(因为还可能有其他事务对goods进行插入操作等等).原因在可重复读的实现原理中也进行了说明,因为没有加范围锁导致的。
但是如果我们现在已经有了MVCC的情况下我们来分析一下结果。
按照执行顺序,事务1在事务2之前,那我根据数据库事务ID的严格递增性,那我们假设事务1的事务ID=100,事务2的事务ID=101,那么根据上面MVCC所描述的版本号实现的数据数据应该是这样(假设没有修改和删除操作)
id | name | price | CREATE_VERSION | DELETE_VERSION |
1 | 衣服 | 90 | 12 | |
2 | 鞋子 | 80.5 | 34 | |
3 | 裤子 | 73.5 | 43 | |
4 | 帽子 | 99.5 | 69 |
第一行事务1的第一次统计就变成了(CREATE_VERSION <= 100 是MySQL帮我们自动加上的)
SELECT count(1) FROM goods WHERE price < 100 AND CREATE_VERASION <= 100; -- 事务1
事务2执行后的结果,数据表就变成了如下所示
id | name | price | CREATE_VERSION | DELETE_VERSION |
1 | 衣服 | 90 | 12 | |
2 | 鞋子 | 80.5 | 34 | |
3 | 裤子 | 73.5 | 43 | |
4 | 帽子 | 99.5 | 69 | |
1000 | 商品1 | 99 | 101 |
那么因为事务1的ID依旧是100,第一次执行SQL依旧是
SELECT count(1) FROM goods WHERE price < 100 AND CREATE_VERASION <= 100; -- 事务1
所以两次的查询结果就是一样的,这样MVCC就解决了幻读了。
参考文献《高性能MYSQL》《凤凰架构》