一、ACID特性

持久性,我们就不讲了,易懂。

原子性

在同一个事务内部的一组操作必须全部执行成功(或者全部失败)。

为了保证事务操作的原子性,必须实现基于日志的REDO/UNDO机制:将所有对数据的更新操作都写入日志,如果一个事务中的一部分操作已经成功,但以后的操作,由于断电/系统崩溃/其它的软硬件错误而无法继续,则通过回溯日志,将已经执行成功的操作撤销,从而达到“全部操作失败”的目的。

最常见的场景是,数据库系统崩溃后重启,此时数据库处于不一致的状态,必须先执行一个crash recovery的过程:读取日志进行REDO(重演将所有已经执行成功但尚未写入到磁盘的操作,保证持久性),再对所有到崩溃时尚未成功提交的事务进行UNDO(撤销所有执行了一部分但尚未提交的操作,保证原子性)。crash recovery结束后,数据库恢复到一致性状态,可以继续被使用。

某个应用在执行转帐的数据库操作时,必须在同一个事务内部调用对帐户A和帐户B的操作,才能保证数据的一致性。

但是,原子性并不能完全保证一致性。在多个事务并行进行的情况下,即使保证了每一个事务的原子性,仍然可能导致数据不一致的结果。

例如,事务1需要将100元转入帐号A:先读取帐号A的值,然后在这个值上加上100。但是,在这两个操作之间,另一个事务2修改了帐号A的值,为它增加了100元。那么最后的结果应该是A增加了200元。但事实上,事务1最终完成后,帐号A只增加了100元,因为事务2的修改结果被事务1覆盖掉了。

简而言之,就是:原子性仅能够保证单个事务的一致性。就像redis一样,也只能保证单操作的线程安全,并不能保证多操作下的线程安全。

一致性

按照我个人的理解,在事务处理的ACID属性中,一致性是最基本的属性,其它的三个属性都为了保证一致性而存在的。

我们举个反例来理解下一致性概念。例如:从帐户A转一笔钱到帐户B上,如果帐户A上的钱减少了,而帐户B上的钱却没有增加,那么我们认为此时数据处于不一致的状态。

为了保证并发情况下的一致性,引入了隔离性,即保证每一个事务能够看到的数据总是一致的,就好象其它并发事务并不存在一样。

隔离性

数据库四种隔离级别,以及常见的几种读异常,大家应该都是耳熟能详的,但数据库底层是怎么实现隔离性的呢?都采用了哪些技术呢?

主要有两个技术:MVCC(多版本并发控制)和锁。

MVCC(多版本并发控制)

多版本并发控制,顾名思义,在并发访问的时候,数据存在版本的概念,可以有效地提升数据库并发能力,常见的数据库如MySQL、MS SQL Server、IBM DB2、Hbase、MongoDB等等都在使用。

简单讲,如果没有MVCC,当想要读取的数据被其他事务用排它锁锁住时,只能互斥等待;而这时MVCC可以通过提供历史版本从而实现读取被锁的数据的历史版本,从而避免了互斥等待。

InnoDB采用的MVCC实现方式是:在需要时,通过undo日志构造出历史版本。

Shared Locks(共享锁/S锁)

若事务T对数据对象A加上S锁,则事务T只能读A;其他事务只能再对A加S锁,而不能加X锁,直到T释放A上的S锁。这就保证了其他事务可以读A,但在T释放A上的S锁之前不能对A做任何修改。

Exclusive Locks(排它锁/X锁)

若事务T对数据对象A加上X锁,则只允许T读取和修改A,其它任何事务都不能再对A加任何类型的锁,直到T释放A上的锁。它防止任何其它事务获取资源上的锁,直到在事务的末尾将资源上的原始锁释放为止。在更新操作(INSERT、UPDATE 或 DELETE)过程中始终应用排它锁。

注意:排他锁会阻止其它事务再对其锁定的数据加读或写的锁,但是不加锁的就没办法控制了。

Record Locks(行锁)

行锁,顾名思义,是加在索引行(对!是索引行!不是数据行!)上的锁。比如select * from user where id=1 and id=10 for update,就会在id=1和id=10的索引行上加Record Lock。

Gap Locks(间隙锁)

间隙锁,它会锁住两个索引之间的区域。比如select * from user where id>1 and id<10 for update,就会在id为(1,10)的索引区间上加Gap Lock。
###Next-Key Locks(间隙锁)###
也叫间隙锁,它是Record Lock + Gap Lock形成的一个闭区间锁。比如select * from user where id>=1 and id<=10 for update,就会在id为[1,10]的索引闭区间上加Next-Key Lock。

这样组合起来就有,行级共享锁,表级共享锁,行级排它锁,表级排它锁。

什么时候会加锁?

在数据库增删改查四种操作中,insert、delete和update都是会加排它锁(Exclusive Locks)的,而select只有显式声明才会加锁:
select: 即最常用的查询,是不加任何锁的
select ... lock in share mode: 会加共享锁(Shared Locks)
select ... for update: 会加排它锁

四种隔离级别

不同的隔离级别是在数据可靠性和并发性之间的均衡取舍,隔离级别越高,对应的并发性能越差,数据越安全可靠。

READ UNCOMMITTED

顾名思义,事务之间可以读取彼此未提交的数据。机智如你会记得,在前文有说到所有写操作都会加排它锁,那还怎么读未提交呢?

机智如你,前面我们介绍排它锁的时候,有这种说明:

排他锁会阻止其它事务再对其锁定的数据加读或写的锁,但是对不加锁的读就不起作用了。

READ UNCOMMITTED隔离级别下, 读不会加任何锁。而写会加排他锁,并到事务结束之后释放。

实例1:

查-写:查并没有阻止写,表明查肯定并没有加锁,要不写肯定就阻塞了。写很明显,会加排它锁的。

实例2:

写-写:阻塞,表明,写会加排它锁。

READ COMMITTED

顾名思义,事务之间可以读取彼此已提交的数据。

InnoDB在该隔离级别(READ COMMITTED)写数据时,使用排它锁, 读取数据不加锁而是使用了MVCC机制。

因此,在读已提交的级别下,都会通过MVCC获取当前数据的最新快照,不加任何锁,也无视任何锁(因为历史数据是构造出来的,身上不可能有锁)。

但是,该级别下还是遗留了不可重复读和幻读问题:

MVCC版本的生成时机: 是每次select时。这就意味着,如果我们在事务A中执行多次的select,在每次select之间有其他事务更新了我们读取的数据并提交了,那就出现了不可重复读,即:重复读时,会出现数据不一致问题,后面我们会讲解超支现象,就是这种引起的。

REPEATABLE READ

READ COMMITTED级别不同的是MVCC版本的生成时机,即:一次事务中只在第一次select时生成版本,后续的查询都是在这个版本上进行,从而实现了可重复读。

但是因为MVCC的快照只对读操作有效,对写操作无效,举例说明会更清晰一点:

事务A依次执行如下3条sql,事务B在语句1和2之间,插入10条age=20的记录,事务A就幻读了。

  1. select count(1) from user where age=20;

-- return 0: 当前没有age=20的

  1. update user set name=test where age=20;

-- Affects 10 rows: 因为事务B刚写入10条age=20的记录,而写操作是不受MVCC影响,能看到最新数据的,所以更新成功,而一旦操作成功,这些被操作的数据就会对当前事务可见

  1. select count(1) from user where age=20;

-- return 10: 出现幻读

REPEATABLE READ级别,可以防止大部分的幻读,但像前边举例读-写-读的情况,使用不加锁的select依然会幻读。

SERIALISABLE

大杀器,该级别下,会自动将所有普通select转化为select ... lock in share mode执行,即针对同一数据的所有读写都变成互斥的了,可靠性大大提高,并发性大大降低。

读-写,写-写均互斥。

总结:几类读异常

读-写-读,引起的异常

脏读:读取了脏数据(不存在的数据)。
​ 事务一读
​ 事务二写(未提交)
​ 事务二读(脏数据)
​ 事务二回滚

不可重复读:既可以读取修改的数据,也可以读取新增的数据(幻读)。
​ 事务一读
​ 事务二写(更新已提交)
​ 事务二读(数据不一致,不可重复读)

幻读:仅可以读取新增的数据,但是无法读取修改的数据;
​ 事务一读
​ 事务二写(新增已提交)
​ 事务二读(数据不一致,幻读)
附命令
查看表的加锁情况:select * from information_schema.INNODB_LOCKS;

事务状态 select * from information_schema.INNODB_TRX;