背景
事务
事务是数据库执行过程中的一个逻辑单位,由一个有限的数据库操作序列构成。事务的出现主要有两个目的,一是提供数据库操作失败时的恢复方法,而是当多个应用程序同时访问数据库时,对其进行隔离,以防止相互干扰。事务具有原子性、一致性、隔离性、持久性四种特性,也就是所谓的ACID特性。在多个应用程序同时访问数据库时,如果不做并发控制,则可能出现脏读、不可重复读、幻读等异常情况。因此,为了各数据库厂商能够更容易地设计并实现数据库,ANSI/ISO SQL 标准规定了四种隔离级别,即READ UNCOMMITTED、READ COMMITTED、REPEATABLE READS以及SERIALIZABLE,明确规定了各个隔离级别下可能出现的异常,见表1。
脏读 | 不可重复读 | 幻读 | |
---|---|---|---|
READ UNCOMMITTED | 是 | 是 | 是 |
READ COMMITTED | 否 | 是 | 是 |
REPEATABLE READS | 否 | 否 | 是 |
SERIALIZABLE | 否 | 否 | 否 |
基于锁的并发控制
ANSI/ISO SQL 标准在规定了隔离级别的同时,也提出了基于锁实现并发控制的一种方法,其基本思想是将锁分为三种,即读锁、写锁以及范围锁,读锁和写锁我们都很清楚,那么什么是范围锁?所谓范围锁其实是读锁的一种,但范围锁不是对一条记录加锁,而是对符合条件的一个范围加锁。当使用带WHERE子句的SELECT查询时,为了防止幻读的产生,需要使用范围锁。表2为不同隔离级别下对上述三种锁的持用情况,“是”表示在该隔离级别下需要得到对应的锁,并且直到事务结束才会释放。
下面我们详细分析一下使用锁了进行并发控制的流程,对读操作和写操作分别进行讨论。
写锁 | 读锁 | 范围锁 | |
---|---|---|---|
READ UNCOMMITTED | 是 | 否 | 否 |
READ COMMITTED | 是 | 否 | 否 |
REPEATABLE READS | 是 | 是 | 否 |
SERIALIZABLE | 是 | 是 | 是 |
写操作:
在所有的四种隔离级别下,写事务进行操作的流程都是一样的。如果它要对某条记录进行操作,则需要获得该记录的写锁,与此同时,保存该记录的旧值,然后对该记录进行写操作。当然,根据读写锁的相容性矩阵,只有该记录没有被加任何锁的时候才能获得写锁。对于每条记录来说,同一时刻最多只有一个事务能得到写锁,所以最多只会有两个不同的值,一个是旧值,即写入该值的事务已经成功提交,另外一个是新值,写入该值的事务目前处于未提交状态。如果写入该记录的事务成功提交,就删除该记录的旧值,否则则需要恢复该记录的旧值,最后释放该记录的写锁。
读操作:
在READ UMCOMMITED这个隔离级别下,读事务不需要获得所读记录的读锁,因此进行读事务永远不会阻塞,它会读取该记录最新的值(写入该值的事务可能处于未提交状态)。
在READ COMMITED这个隔离级别下,读事务需要获得所读记录的读锁,并且与READ UMCOMMITED这个级别不一样,此时读事务会读取该记录最新的已经提交的值。但是需要特别注意的一点是,一旦事务的读操作完成,就会将读锁释放掉,读事务并不会一直持有该读锁。
在REPEATABLE READS这个隔离级别下,读事务需要获得所需记录的读锁。如果该记录已经被上了写锁,则读事务会阻塞,直到持有该记录写锁的事务提交为止;否则(即该记录未被上锁或者被上了读锁)直接读取该记录(此时该记录只会有一个值),并对该条记录上读锁。读事务会一直持有读锁,直到该事务提交或者回滚时,才会释放读锁。
在SEREIALIZABLE这个隔离级别下,读事务也需要获得所需记录的读锁。对于一般的读操作,同REPEATABLE READS一样进行处理,即如果该记录已经被上了写锁,则读事务会阻塞,直到持有该记录写锁的事务提交为止;否则直接读取该记录,并对该条记录上读锁。但是对于范围SELECT查询,在这个隔离级别下,需要对符合SELECT条件的记录加范围锁,以保证不会出现幻读。读事务会一直持有读锁或者范围锁,直到该事务提交或者回滚时,才会释放读锁。
MVCC
MVCC的精要用一句话来概括即是“读不阻塞写,写不阻塞读”。由于MVCC有多种实现方式,此处就不一一列举,MVCC的主要实现机制是保存每个记录的多个版本。由于是对MySQL进行测试,所以详细分析一下在MySQL进行并发控制时MVCC的作用。
MVCC本质上并不是由MySQL实现的,MySQL将上层与存储分离,提出了一个插件式的架构,底层可以使用不同的存储引擎。在这些存储引擎中,最著名的应该是Innodb存储引擎,其中很重要的一个原因是Innodb是少数几个支持事务的存储引擎之一。
Innodb使用锁与MVCC相结合的方式实现并发控制,它只在READ COMMITTED以及REPEATABLE READS这两个隔离级别下使用MVCC机制。Innodb把读分为了半一致性读和需要加锁的读,当事务的隔离级别低于SERIALIZABLE时,读都是半一致性读,而当进行半一致性读时,Innodb并不需要对记录行加读锁,Innodb会读取使用MVCC机制保存的旧版本。因此在事务对某条记录进行半一致性读时,其他事务仍然可以进行写操作,与之对应,如果某条记录已经被加了写锁,那么此时其他事务仍然可以进行半一致性读。
测试方法
本次测试环境要求比较简单,最基本的要求当然是MySQL数据库,并且安装了Innodb存储引擎(一般默认在安装MySQL时已经安装完毕)。测试过程较为简单,下面主要描述测试过程中需要注意的一些关键问题。
在创建测试表test_pk时后注意一定要使用Innodb作为存储引擎,使用SHOW CREATE TABLE test_pk这个命令可以查看test_pk的存储引擎,因为有时Innodb没有安装成功,会导致创建的表使用默认的MyISAM存储引擎。
CREATE TABLE test_pk
(
id
int(11) NOT NULL,
PRIMARY KEY (id
),
UNIQUE KEY id
(id
)
) ENGINE=InnoDB;
设置会话的隔离级别使用SET SESSION TRANSACTION ISOLATION LEVEL XXX命令,注意其中SESSION不可省略,这样隔离级别才会对整个会话生效,否则的话设置仅对下一个事务起作用。
测试时必须使用BEGIN或者START TRANSACTION命令开始事务,这样就不会受到Mysql AUTOCOMMIT变量的影响,注意不要使用单行事务(autocommit=1时,Innodb对单行SELECT做了优化,不管在什么隔离级别,都不会加读锁)。
用例与分析
测试结果
表1是测试一(先读后写)的测试结果,表中的项“是”表示写事务阻塞,“否”则表示写事务没有被阻塞。
表1
READ UNCOMMITED | READ COMMITTED | REPEATABLE READS | SERIALIZABLE | |
---|---|---|---|---|
READ UNCOMMITED | 否 | 否 | 否 | 否 |
READ COMMITTED | 否 | 否 | 否 | 否 |
REPEATABLE READS | 否 | 否 | 否 | 否 |
SERIALIZABLE | 是 | 是 | 是 | 是 |
表2是测试二(先写后读)的测试结果,表中的项“是”表示写事务阻塞,“否”则表示写事务没有被阻塞。
表2
READ UNCOMMITED | READ COMMITTED | REPEATABLE READS | SERIALIZABLE | |
---|---|---|---|---|
READ UNCOMMITED | 否 | 否 | 否 | 是 |
READ COMMITTED | 否 | 否 | 否 | 是 |
REPEATABLE READS | 否 | 否 | 否 | 是 |
SERIALIZABLE | 否 | 否 | 否 | 是 |
用例分析
由于Innodb在READ COMMITTED和REPEATABLE READS这两个隔离级别下使用了MVCC机制,下面我们首先分析其他两个隔离级别下的测试结果
READ UNCOMMITTED/SERIALIZABLE(测试一):会话一的隔离级别为READ UNCOMMITTED,会话二的隔离级别为SERIALIZABLE。会话一在进行SELECT操作的时候不需要获得读锁,并没有对相应的记录加锁,因此当会话二进行UPDATE操作时,可以成功地获得该记录的写锁,并不会阻塞。
SERIALIZABLE/READ UNCOMMITTED(测试一):会话一的隔离级别为SERIALIZABLE,会话二的隔离级别为READ UNCOMMITTED。会话一在进行SELECT操作的时候需要获得读锁,因此当会话二进行UPDATE操作时,无法获得该记录的写锁,UPDATE操作被阻塞。
READ UNCOMMITTED/SERIALIZABLE(测试二):会话一的隔离级别为READ UNCOMMITTED,会话二的隔离级别为SERIALIZABLE。会话一在进行UPDATE操作的时候首先获得该记录的写锁,当会话二进行SELECT操作时,由于其隔离级别为SERIALIZABLE,需要获得该记录的读锁,因此SELECT操作被阻塞。
SERIALIZABLE/READ UNCOMMITTED(测试二):会话一的隔离级别为SERIALIZABLE,会话二的隔离级别为READ UNCOMMITTED。会话一在进行SELECT操作的时候需要获得读锁,因此当会话二进行UPDATE操作时,无法获得该记录的写锁,UPDATE操作被阻塞。
根据上一小节关于纯粹的基于锁的并发控制原理的描述,如果使用纯粹锁的并发控制,测试一的预期结果应 该如表3所示,测试二的预期结果应该如表4所示。
表 3
READ UNCOMMITED | READ COMMITTED | REPEATABLE READS | SERIALIZABLE | |
---|---|---|---|---|
READ UNCOMMITED | 否 | 否 | 否 | 否 |
READ COMMITTED | 否 | 否 | 否 | 否 |
REPEATABLE READS | 是 | 是 | 是 | 是 |
SERIALIZABLE | 是 | 是 | 是 | 是 |
对于测试一,对比表1与表3我们可以看到,预期结果与测试结果在会话一的隔离级别为REPEATABLE READS时不一致,这主要是因为在REPEATABLE READS这个隔离级别下,Innodb使用了MVCC机制,根据关于MVCC的描述,Innodb此时进行的读操作是半一致性读,并不需要对记录加读锁,测试二与此类似,不再赘述。
READ UNCOMMITED | READ COMMITTED | REPEATABLE READS | SERIALIZABLE | |
---|---|---|---|---|
READ UNCOMMITED | 否 | 否 | 是 | 是 |
READ COMMITTED | 否 | 否 | 是 | 是 |
REPEATABLE READS | 否 | 否 | 是 | 是 |
SERIALIZABLE | 否 | 否 | 是 | 是 |
总结
前面我们说过,实现并发控制大致有三种方式,即锁、时间戳以及MVCC。锁应该是最早被用来进行并发控制的机制,时间戳和MVCC是随后出现的,它们各有自己的优点与缺点。在当今的数据库中,很少有单纯使用某一种机制来进行并发控制,大多数都杂合了其中的两种或者三种,各取所长,MySQL的Innodb就是其中的一个。Innodb采用了锁与MVCC结合的方式实现并发控制,在实际运行中取得了不错的效果。