数据库原理:数据库并发控制与封锁
事务与锁是实现数据库管理系统中数据一致性与并发性的保障。事务可以是一条语句,也可以是由多个SQL语句共同组成的一个逻辑单元,以完成较为复杂的数据操作。当多个用户的事务同时并发操作数据库时,会出现相互干扰,使数据库发生错误。因此,数据库系统需要通过适当的并发控制技术来保证数据的一致性。在MySQL数据库中,事务是数据库应用程序的基本逻辑操作单元,封锁机制是用于实现并发控制的主要技术。
本章学习目标:理解事务的概念、事务的ACID特性和事务的隔离级别;明确事务并发操作会导致的数据不一致现象,并能够区别4种不一致现象;了解锁的类型,理解封锁机制与封锁协议,并能够将封锁协议与4种数据不一致性问题对应。
一、数据库并发性的含义
数据库最大的特点是数据共享,允许同一时间多个用户根据自己的需要来操作数据库。每个用户在存取数据库中的数据时,可能是串行执行,即每个时刻只有一个用户程序运行,也可能是多个用户并行地存取数据库。串行执行意味着一个用户在运行程序时,其他用户程序必须等到这个用户程序结束才能对数据库进行存取,这样如果一个用户程序涉及大量数据的输人输出操作,那么数据库系统的大部分时间将处于闲置状态。为了充分提高系统的执行效率,最大限度地利用数据库,多个用户并行执行更具有价值,这就是数据库的并发性。
并发性提高了数据库的运行效率,但也带来了很多意想不到的后果,比如某个用户在修改一个数据,此时另一个用户也有可能正在删除这一数据,这就造成了数据的不一致性。为了解决此类问题,数据库系统提供了并发控制机制。数据库的并发性及并发控制机制是衡量数据库系统性能的重要标准。
二、事务及其性质
2.1、事务的概念
事务是实现数据库中数据一致性的重要技术。数据库事务由一系列数据库访问、更新操作组成,这些操作要么全部执行,要么全部不执行,是一个不可分割的逻辑工作单元。例如在银行转账业务的处理过程中,客户A1要向客户A2转账,那么客户A1的账户account1转出金额,客户A2的账户account2转入金额,这两个操作需要被当作一个整体,要么都执行,要么都不执行,否则银行数据库中的数据不一致将会给客户带来损失。数据库事务是构成单一逻辑工作单元的操作集合,对其概念的理解需要注意以下几点。
(1)事务中包含的操作可以是一个,也可以是多个,但这些操作必须构成一个逻辑上的整体。
(2)构成事务的所有操作,要么全都对数据库产生影响,要么全都不产生影响,即不管事务是否执行成功,用户看到的数据总能保持一致性。
(3)事务执行的结果是使数据库从一种一致性状态转变到另一种一致性状态。
(4)以上所述在数据库出现故障或并发事务存在的情况下仍然成立。
MySQL支持4种事务模式:自动提交事务、显式事务、隐式事务和适合多服务器系统的分布式事务。其中显式事务和隐式事务属于用户定义的事务。在MySQL中,对事务的管理操作包括启动、结束和回滚等,常用的语法格式如下。
1 | |
其中,START TRANSACTION表示事务启动;COMMIT语句提交所执行的所有操作,标志一个事务的结束;ROLLBACK语句是回滚语句,当事务运行过程中发生故障时,事务不能继续执行,此时回滚事务所做的修改,并结束当前这个事务。
2.2、事务的性质
构成一个逻辑工作单元的一系列操作称为事务,但并非任意的对数据库的操作序列都是数据库事务。如果操作序列被称为事务,那么其必须具备4个属性,即原子性、一致性、隔离性和持久性,这4个属性通常称为事务的ACID特性。
2.2.1、原子性
原子性(Amicity)意指事务中的所有操作作为一个整体,像原子一样不可分割。事务中的所有语句必须全部成功执行才可认为整个事务执行成功。如果事务失败,那么它执行过的部分也要取消,数据库将返回到该事务开始执行前的状态。即事务的操作如果成功就必须完全应用到数据库,如果操作失败则不能对数据库产生任何影响。
2.2.2、一致性
一致性(Consistency)指数据库始终保持一致性,事务的执行结果必须使数据库从一个一致性状态到另一致性状态。例如,在银行转账事务中,客户A1和客户A2在转账之前的总金额为2000元,那么无论是A1向A2转账,还是A2向A1转账,在转账结束之后,客户A1和客户A2的总金额仍应该为2000元,这就是事务的一致性。
2.2.3、隔离性
隔离性(Isolation)指并发执行的事务之间不会相互影响。比如多个用户同时向一个账户转账,那么最后的结果应该和他们按先后次序转账的结果一样。即一个事务内部的操作及使用的数据对并发的其他事务是隔离的。并发控制就是为了保证事务间的隔离性。
2.2.4、持久性
持久性(Dunbility)指一个事务一旦被提交了,那么对数据库中数据的改变就是永久性的,即便是在数据库系统遇到故障的情况下也不会丢失提交事务的操作。
事务的ACID特性保障了数据库的一致性。以客户A1向客户A2转账事务来具体分析这4个性质,银行转账需要以下6个操作:
(1)读取账户account1的余额;
(2)从账户account1中转出相应金额;
(3)把结果写account1中;
(4)读取账account2的余额;
(5)向账户account2中转入相应金额;
(6)把结果写回account2中。
事务的原子性保证上述6个操作要么都执行,要么都不执行,一旦在执行某一操作的过程中发生故障,就需要执行回滚操作,撤销前述操作,以回滚到执行事务之前的状态。
事务的一致性保证了在执行事务之前和执行之后,account1和account2的总金额保持一致,同时还能保证账余额不会变成负数等。
事务的隔离性保证了在账过程中,只要事务还没有提交(COMMIT),那么在查询account1和account2账户的时候,两个账户里的金额都不会发生变化。同时也可以保证,如果存在另一个事务执行了账户account3向account2转账的操作,那么当两个事务都执行结束的时候,结果应与两个事务分别执行的结果一致,即账户account2中转入的金额应该是account1转给account2的金额加account3转给account2的金额。
持久性保证了一旦转账成功,那么两个账户里的金额就会写入数据库做持久化保存。
在MySQL环境中,将上面的例用SQL语句来表达,账户account1向账户account2转账,转账金额为R。
1 | |
从START TRANSACTION开启事务到COMMIT提交,中间的系列操作集合称为事务。
三、并发控制与数据的不一致
多个用户并发地访问同一个数据资源时,即同一个数据库系统中有多个事务并发运行,如果不加以适当控制,可能会存储不正确的数据,产生数据的不一致性问题。数据库管理系统的并发控制就是为了合理调度并发事务,避免并发事务之间的互相干扰造成数据的不一致性。
【例9-1】并发取款操作。假设存款余额R=1000(单元:元),事务T1取走存款100元,事务T2取走存款200元。如果正常操作,即事务T1执行完毕再执行事务T2,存款余额更新后应该是700元。但是如果按照以下顺序操作,则会有不同的结果,如图9-1所示。
(1)事务T1读取存款余额R=1000。
(2)事务T2读取存款余额R=1000。
(3)事务T1取走存款100元,修改存款余额R=R-100=900,把R=900写回数据库。
(4)事务T2取走存款200元,修改存款余额R=R-200=800,把R=800写回数据库。
两个事务共取走存款300元,但结果是数据库中的存款却只少了200元。产生这种错误结果的原因是事务T1和事务T2并发操作。数据库的并发操作导致的数据不一致性主要有丢失更新、读取脏数据、不可重复读和幻象读。
3.1、丢失更新
例9-1中,事务T1和T2读入同一数据,并发执行修改操作时,T2把T1对数据的已修改结果覆盖,导致这些修改好像丢失了一样,从而造成了数据的不一致,这种并发性问题称为丢失更新(Lost Update)。在图9-1中,数据库中R的初值是1000。事务T1包含3个操作:读入R的初值(SELECT R),计算存款余额(R=R-100),更新R (UPDATE R)。事务T2也包含3个操作:读入R,计算存款余额(R=R-200),更新R。由于T1和T2按图9-1所示并发执行,R的值是800,R本应该为700却得到了800,并发操作不加控制得到了错误的结果,原因在于在t4时刻丢失了T1对数据库的更新操作。
3.2、读取脏数据
读取脏数据(Dirty Read)是指一个事务读取了另一个事务未提交的数据。如一个事务正在多次修改某个数据,在这个修改过程中,事务还未提交修改,这时一个并发的事务来访问该数据,就会造成两个事务得到的数据不一致,称为读取脏数据,也称为“脏读”。下面以表9-1中的例子进分析。
| 时间 | 事务T1 | 数据库中的R值 | 事务T2 |
|---|---|---|---|
| t0 | 1000 | ||
| t1 | SELECT R | ||
| t2 | R=R-100 | ||
| t3 | UPDATE R | ||
| t4 | 900 | SELECT R | |
| t5 | ROLLBACK | ||
| t6 | 1000 |
事务T1在t3时刻对数据库中的R进行了修改,但尚未提交(COMMIT),在t4时刻,事务T2读取了T1尚未提交的数据900,而之后事务T1回滚(ROLLBACK)操作后,数据库中R的值恢复为1000,此时事务T2依然在使用读出的900,和数据库中R的值不一致。造成不一致的原因在于,事务T2在t4时刻读取了事务T1尚未执行提交操作的结果,事务T2读取的是“脏数据”。
3.3、不可重复读
不可重复读(Unrepeatable Read)是指一个事务对同一数据的读取结果前后不一致,这是由于在两次查询期间该数据被另一个事务修改并提交了。当其中一个事务需要校验或再确认数据时,出现再读数据与之前读的数据值不相同,这种情况就称为不可重复读。下面以表9-2中的例子进行分析。
| 时间 | 事务T1 | 数据库中R的值 | 事务T2 |
|---|---|---|---|
| t0 | 1000 | ||
| t1 | SELECT R | ||
| t2 | SELECT R | ||
| t3 | R=R-100 | ||
| t4 | UPDATE R | ||
| t5 | 900 | ||
| t6 | R=R-200 | ||
| t7 | UPDATE R | ||
| t8 | 800 | ||
| t9 | SELECT R |
事务T1在t1时刻读取了R的值,并在t3时刻对其进行了修改,修改后的值为900,但当其再次读取(t9时刻)R的值进行核验时,R的值已被事务T2修改,变成了800。事务T1在对R的两次读取间隔中,事务T2修改了R并且提交,导致两次读取的值不相同,即发生了不可重复读。
读取脏数据和不可重复读的区别在于,前者读取的是事务未提交的脏数据,而后者读取的是事务已经提交的数据。
3.4、幻象读
幻象读(Phantom Read)指当用相同的条件查询记录时,记录个数忽多忽少,给人一种“幻象”的感觉。原因在于在两次查询间隔中,有并发的事务在对相同的表做插入或删除操作。下面以表9中的例子进行分析。
| 时间 | 事务T1 | 数据表的记录数 | 事务T2 |
|---|---|---|---|
| t0 | 3 | ||
| t1 | SELECT(R<1000) | ||
| t2 | WRITE(R=800) | ||
| t3 | COMMIT | ||
| t4 | 4 | ||
| t5 | SELECT(R<1000) |
数据表中存储了3条R值小于1000的记录,事务T1第一次根据R<1000的条件查询时,得到了3条记录,之后事务T2插入了一条R=800的记录,当事务T1再次根据R<1000的条件查询时,却得到了4条记录。事务T1在时刻t4和t5以相同的条件查询,得到的结果不相同,由于事务T2的插入操作,事务T1的第二次读显示有一条记录不存在于原始读中,这种现象即为幻象读。
幻象读和不可重复读都是读取了另一个事务已经提交的数据,这点与读取脏数据不同。但二者的区别在于,前者是针对不确定的多行数据而言的,而后者是针对确定的某一行数据而言的,因而幻象读通常出现在带有查询条件的范围查询中。
四、事务的隔离级别
产生上述4种数据不一致性问题的主要原因是并发的事务操作破坏了事务的隔离性。为了防止数据库的并发操作导致丢失更新、读取脏数据、不可重复读和幻象读等问题,SQL标准定义了4种隔离级别:读取未提交的数据(READ UNCOMMITTED)、读取提交的数据(READ COMMITTED)、可重复读(REPEATABLE RETAD)及串行化(SERIALIZABLE)。4种隔离级别从低至高。事务的隔离级别越低,可能出现的并发异常越多。MySQL数据库支持所有的隔离级别,查询当前事务隔离级别的语句如下。
1 | |
设置当前事务的隔离级别有以下两种方式。
1 | |
其中,GLOBAL表示设置的隔离级别适用于所有的用户;SESSION表示设置的隔离级别只适用于当前运行的会话和连接。
4.1、读取未提交的数据
读取未提交的数据(READ UNCOMMITTED)是最低事务隔离级别。该级别下的事务可以读取另一个未提交事务的数据,该级别很少用于实际应用。设置该隔离级别的语法格式如下。
1 | |
【例9-2】设置事务T1的隔离级别为读取未提交的数据。事务T2向余额增加存款100元,在T2尚未提交结果时,事务T1读取余额。
事务T1
1 | |
第一次查询结果如表9-4所示。
| id | R |
|---|---|
| account3 | 900 |
| account1 | 500 |
| account2 | 900 |
第二次查询结果如表9-5所示。
| id | R |
|---|---|
| account1 | 600 |
| account2 | 900 |
| account3 | 900 |
事务T2
1 | |
读取未提交的数据(READ UNCOMMITTED)的隔离级别最低,无法避免读取脏数据、不可重复读和幻象读。由于该隔离级别允许事务读取其他事务尚未提交的数据进行计算等,所以如果那些未提交的数据被回滚,那么将导致混乱的数据变化。
4.2、读取提交的数据
读取提交的数据(READ COMMITTED)比上一级别稍高,该级别下的事务只能读取其他事务已经提交的数据,满足了隔离性的简单定义,但不可避免不可重复读问题的出现。设置该级别的语法格式如下。
1 | |
【例9-3】设置事务T1的隔离级别为读取提交的数据READ COMMITTED。事务T1和事务T2同时读取账户余额;紧接着事务T2向账户存入100元不提交,然后事务T1再次读取账户余额,发现账户未发生改变,不读取未提交的数据;之后事务T2提交,最后事务T1第三次读取账户余额,发现账户多了100元,于是读取事务T,提交的数据。
事务T1:
1 | |
第一次查询结果如表9-6所示。
| id | R |
|---|---|
| account1 | 500 |
| account2 | 900 |
| account3 | 900 |
第二次查询结果如表9-7所示。
| id | R |
|---|---|
| account1 | 500 |
| account2 | 900 |
| account3 | 900 |
第三次查询结果如表9-8所示。
| id | R |
|---|---|
| account1 | 600 |
| account2 | 900 |
| account3 | 900 |
事务T2:
1 | |
当把事务的隔离级别设置为READ COMMITTED时,可以避免读取脏数据,但不可避免不可重复读和幻象读。
4.3、可重复读
可重复读(REPEATABLE READ)是MySQL默认的隔离级别,该级别可确保同一事务内执行相同的查询语句时,读取的结果是一致的。设置该隔离级别的语法格式如下。
1 | |
【例9-4】设置事务T1的隔离级别为可重复读。事务T1读取表中的数据,之后事务T2更新account并提交,然后T1读取表中的数据。
事务T:
1 | |
第一次查询结果如表9-9所示。
| id | R |
|---|---|
| account1 | 500 |
| account2 | 900 |
| account3 | 900 |
第二次查询结果如表9-10所示。
| id | R |
|---|---|
| account1 | 500 |
| account2 | 900 |
| account3 | 900 |
事务T2:
1 | |
事务T2读取的结果如表9-11所示。
| id | R |
|---|---|
| account1 | 600 |
| account2 | 900 |
| account3 | 900 |
由于设置了事务T1的隔离级别为可重复读,所以在事务T2修改数据后,事务T2查询到的结果是金额已修改为了600,但在事务T1中两次查询到的金额均为500,结果一致。可重复读隔离级别有效避免了不可重复读的问题,但仅是针对同行数据而言的,如果事务T2对多行数据进行增加,那么将会出现幻象读的问题。
4.4、串行化
串行化(SERIALIZABLE)的隔离级别最高,其通过强制事务排序,使事务之间不可能相互冲突。设置该隔离级别的语法格式如下。
1 | |
该隔离级别下,用户之间一个接一个顺序地执行当前事务,从而解决幻象读问题,但是可能导致大量的等待现象(具体引发的问题将在9.5.4小节介绍)。
4.5、小结
隔离级别与能够避免的事务并发异常问题如表9-12所示。
隔离级别的选择对每个应用程序来说是没有标准答案的,需要基于不同的事务选择不同的级别。事务隔离性的实现通常依赖于并发控制技术,按照其对可能重读的操作采取的不同策略可以分为乐观并发控制和悲观并发控制两大类。
(1)乐观并发控制:对于并发执行可能冲突的操作,假定其不会真的冲突,允许并发执行,直到真正发生冲突时才去解决冲突,比如让事务回滚。
(2)悲观并发控制:对于并发执行可能冲突的操作,假定其必定发生冲突,通过让事务等待(锁)或者中止(时间戳排序)的方式让并行的操作串行执行。
本书着重对锁进行介绍。
五、封锁及封锁协议
当用户对数据库并发访问时,为了确保事务完整性、数据库一致性,需要对其进行锁定(封锁)。封锁可以防止用户读取正在由其他用户修改的数据,并可以防止多个用户同时更改相同数据。封锁是一种用来防止多个事务同时访问数据而产生问题的机制。事务T在对某个数据对象(如表、记录等)操作之前,先向系统发出请求,对其加锁。加锁后事务T对该数据对象就有了一定的控制,在事务T释放它的锁之前,其他事务不能更新该数据对象。
5.1、封锁粒度
封锁的数据库对象的大小称为封锁粒度。对数据库对象的封锁需要消耗资源,锁的各种操作(包括获取锁、释放锁及检查锁状态)都会增加系统开销。因此,封锁时应尽量只锁定修改的那部分数据,而不是所有的资源。锁定的数据量越少,发生锁争用的可能性就越小,系统的并发程度就越高。实际使用时,需要综合考虑锁开销和并发程度,对系统的锁开销与并发程度进行权衡,选择合适的封锁粒度。
MySQL提供了两种封锁粒度:表级锁和行级锁。不同的存储引擎支持不同的封锁粒度,例如,MyISAM和MEMORY存储引擎采用的是表级锁(TABLE-LEVEL LOсKING); BDB存储引擎采用的是页面锁(PAGE-LEVEL LOCKING),也支持表级锁;InnoDB存储引擎既支持行级锁(ROW-LEVEL LOCKING),也支持表级锁,但在默认情况下采用行级锁。
(1)表级锁:整个表被锁定,其他事务不能向表中插入记录,甚至读取数据也受到限制。其特点是开销小,加锁快;不会出现死锁。缺点是封锁粒度大,发生锁冲突的概率最高,并发程度最低。
(2)行级锁:只有正在使用的行是锁定的,表中的其他行对于其他事务都是可用的。在多用户的环境中,行级锁降低了线程间的冲突,可以使多个用户同时从一个相同表读数据甚至写数据。其特点是开销大,加锁慢;会出现死锁。优点是封锁粒度最小,发生锁冲突的概率最低,并发程度也最高。
行级锁和表级锁在使用时要根据具体应用进行选择,无法笼统地说哪种更好。
5.2、封锁类型
封锁分为排它锁和共享锁两种。排它锁,简称X锁,又称独占锁或写锁;共享锁,简称S锁,又称读锁。对于X锁和S锁有以下两个规定。
(1)一个事务对数据对象A加了X锁,那么该事务可以对A进行读和写,但其加锁期间其他事务不能对A加任何锁,直到X锁释放。
(2)一个事务对数据对象A加了S锁,那么该事务可以对A进行读操作,但不能进行写操作,同时在其加锁期间其他事务能对A加S锁,但不能加X锁,直到S锁释放。
封锁和解锁的语法格式如下。
1 | |
【例9-5】事务T1获得对数据表account的排它锁权限,事务T2尝试读取数据表中的数据。
事务T1:
1 | |
事务T2:
1 | |
在T1为数据表accant加上排它锁之后,事务T2执行读取操作,此时并没有显示结果,而是等待T1释放锁权限。排它锁确保不会同时对同一资源进行多重更新。
【例9-6】在事务T1获得对数据表account的共享锁权限的情况下:事务T2尝试读取、修改数据表中的数据;事务T2尝试对数据表继续添加共享锁;事务T2尝试对数据表继续添加排它锁。
事务1:
1 | |
查询结果如表9-13所示。
| id | R |
|---|---|
| account1 | 500 |
| account2 | 900 |
| account3 | 900 |
事务T2:
1 | |
查询结果如表9-14所示。
| id | R |
|---|---|
| account1 | 500 |
| account2 | 900 |
| account3 | 900 |
事务T1为数据表account添加共享锁之后,事务T1可以对表account进行读取操作,但不能进行写操作;同时事务T2可以对数据表account继续添加共享锁和进行读取操作,但不能对其进行写操作,更不能添加排它锁。
共享锁允许并发事务读取同一个资源。资源上存在共享锁时,任何其他事务都不能修改数据。除非将事务隔离级别设置为可重复读或更高级别,或者在事务生存周期内用锁定提示保留共享锁,这样一旦读取数据,资源上的共享锁便被立即释放。
5.3、封锁协议
封锁可以保证合理地进行并发控制,保证数据的一致性。在封锁时,人们还需要约定一些规则,例如何时申请封锁、申请何种锁、持锁时间、何时释放等,这些规则被称为封锁协议(Locking Protocol)。对封锁方式规定不同的规则,就形成了各种不同的封锁协议。9.3节所讲述过的并发操作会引发的丢失更新、脏读、不可重复读和幻象读等数据不一致性问题,可以通过封锁协议进行解决。
5.3.1、一级封锁协议
事务T在修改数据A时必须先对其加X锁,直到事务结束才能释放锁。
利用一级封锁协议可以解决图9-1所示的丢失更新问题,如图9-2所示,事务T1在对R进行修改之前先对R加X锁,当T2再请求对R加X锁时被拒绝,T2只能等待T1释放R上的X锁后再对R加X 锁,这时它读到的R已经是T1更新过的值900。这样就避免了丢失T1的更新。
一级封锁协议有以下特点。
(1)在修改数据时对其加了X锁,不允许其他事务同时对数据进行修改,所以可以解决丢失更新问题。
(2)事务结束包括正常结束(COMMIT)和非正常结束(ROLLBACK),所以一级封锁协议还能保证事务T是可恢复的。
(3)在一级封锁协议中,如果仅仅是读数据不对其进行修改,是不需要加锁的,所以不能保证可重复读和不读脏数据。
5.3.2、二级封锁协议
二级封锁协议是在一级封锁协议的基础上,另外加上事务T在读取数据R之前必须先对其加S锁,读完后释放S锁。利用二级封锁协议可以解决表9-1中读取脏数据的问题,如表9-15所示。事务T1在对R进行修改之前,先对R加X锁,修改其值后写回磁盘。这时T2请求在R上加S锁,因为T1已在R上加了X锁,所以T2只能等待。T1因某种原因被撤销,R恢复为原值1000, T1释放R上的X锁后,T2对R加S锁,读R=1000。这就避免了T2读脏数据。
| 时间 | 事务T1 | 数据库中R的值 | 事务T2 |
|---|---|---|---|
| t0 | XLOCK R | 1000 | |
| t1 | FIND R | ||
| t2 | R=R-100 | ||
| t3 | UPDATE R | ||
| t4 | 900 | SLOCK R | |
| t5 | ROLLBACK | WAIT | |
| t6 | UNLOCK | 1000 | SLOCK R |
| t7 | FIND R | ||
| t8 | UNLOCK S |
二级封锁协议具有以下特点。
(1)防止了丢失更新,还可以进一步防止读脏数据。
(2)由于读完数据后即可释放S锁,所以不能保证可重复读。
5.3.3、三级封锁协议
三级封锁协议是在一级封锁协议的基础上,加上事务T在读取数据R之前必须先对其加S锁,读完后并不释放S锁,而直到事务T结束才释放。利用三级封锁协议可以解决表9-2和表9-3中的不可重复读及幻象读问题,如表9-16所示。事务T1在读R之前,先对R加S锁,这样其他事务只能再对R加S锁,而不能加X锁,即其他事务只能读R,而不能修改它们。所以当T2为修改R而申请对R加X锁时被拒绝,只能等待T1释放R上的锁。T1为验算再读R,这时读出的R仍然是原来读出的数值,即可重复读。T1结束才释放R上的S锁,这时T2才能对R加X锁。
| 时间 | 事务T1 | 数据库中R的值 | 事务T2 |
|---|---|---|---|
| t0 | 1000 | ||
| t1 | SLOCK R | ||
| t2 | FIND R | ||
| t3 | XLOCK R | ||
| t4 | COMMIT | WAIT | |
| t5 | UNLOCK S | WAIT | |
| t6 | XLOCK R | ||
| t7 | FIND R | ||
| t8 | R=R-200 | ||
| t9 | UPDATE R | ||
| t10 | 800 | UNLOCK X |
通过三级封锁协议可以防止丢失更新和不读脏数据,还可以进一步防止不可重复读。
不同级别的封锁协议的主要区别在于什么操作需要申请封锁,以及何时释放。封锁协议有效解决了并发操作导致的数据不一致性问题,但若封锁机制使用不当,则可能引发新的问题,即死锁与活锁。
5.4、死锁与活锁
5.4.1、死锁
死锁(Dead Lock)是指两个或更多的事务同时处于等待状态,每个事务都在等待其中另一个事务解除封锁,它才能继续执行下去,结果造成任何一个事务都无法继续执行。例如事务T1和T2 都需要更改学生成绩,T1首先封锁了学生表,接下来需要封锁成绩表;而此时事务T2首先封锁了成绩表,其需要封锁学生表,二者在互相等待中无法前进,即造成了死锁,如表9-17所示。
| 时间 | 事务T1 | 事务T2 |
|---|---|---|
| t0 | 封锁学生表 | |
| t1 | 封锁成绩表 | |
| t2 | 要求封锁成绩表,等待 | |
| t3 | 要求封锁学生表,等待 | |
| t4 | 等待 | 等待 |
(1)产生死锁的必要条件
死锁产生的必要条件包括以下4个。
①互斥条件:事务在某一时间内独占资源,其他事务无法对资源进行操作。
②请求与保持条件:事务因请求资源而阻塞时,对已获得的资源保持不放,导致该事务与其他事务都无法继续执行。
③不剥夺条件:事务在获得资源后,如若事务没有解锁,则其他事务不能强行剥夺。
④循环等待条件:多个事务之间形成一种头尾相接的循环等待资源关系,互相牵制。
(2)避免死锁的常用方法
①不同的事务同时并发存取多个表时,应尽量约定以相同的顺序访问各表,这样可以降低产生死锁的概率。通常称这种方法为顺序加锁法。
②事务如需要更新记录,应该直接申请足够级别的锁,即排它锁,而不应先申请共享锁。因为当事务申请排它锁时,如果数据已经被其他事务加上共享锁,那么可能造成锁冲突,甚至死锁。
③同一事务的执行如果需要多个数据对象,可以对这些数据对象一次性全部加锁,避免出现“请求与保持”,通常称这种方法为一次加锁法,一次加锁法程序流程图如图9-3所示。
表9-17所示的死锁例子,如采用一次加锁法,事务T1在开始执行时,可以一次性对成绩表和学生表加锁,待事务T1执行完毕释放锁之后,事务T2方可继续对成绩表与学生表进行更新操作。表9-17所示的死锁,如采用顺序加锁法,约定成绩表和学生表同属某一事务操作的对象时,需先对成绩表添加锁,再对学生表加锁,这样就可以避免“请求与保持”现象的出现。
(3)死锁的诊断与解除
死锁造成了多个事务互相等待无法继续执行。判断死锁通常采用超时法和等待图法。
当两个事务互相等待的时间超过设置的某一阈值时,则判断形成了死锁。此时对其中一个事务进行回滚,则另一个等待的事务就能继续进行。超时机制简单,方便操作,但如果回滚的事务操作相对较多,且比较重要,那么采用这种方法就不太合适了,因为回滚此事务相对于其他事务所占的时间可能会更多。
相比超时法,等待图法是一种更为主动的死锁检测方法。在等待图中,如果事务T1等待事务T2所占用的资源,那么则由T1向T2画一箭头:若事务T2等待事务T1所占用的资源,那么则由T2向T1画一箭头,此时事务T1、T2互相等待造成了回路,由此可判断发生了死锁,如图9-4所示。该方法中,在每个事务请求锁并发生等待时都需要判断是否存在回路,如果存在则有死锁,选择回滚操作最少的事务来破坏回路,从而解除死锁。等待图法不仅适用于两个事务之间发生死锁,对于多个事务之间发生死锁也适用。
5.4.2、活锁
活锁(Live Lock)是指由于其他事务的封锁操作使某个事务永远处于等待状态,得不到继续操作的机会。如图9-5所示,事务T2对数据加锁之后,事务T1请求封锁,于是T1等待。在事务T2释放锁之后,T3对数据封锁,于是T2继续等待。接着事务T3释放锁,此时事务T4对数据加锁,事务T2继续等待……从而产生了活锁。
避免活锁的方法就是采用先来先服务的策略,按照请求封锁的次序让事务进行排队。
六、小结
本章讲述了事务、并发控制、封锁与封锁协议的基本概念,介绍了事务并发操作会导致的4种数据不一致性问题,说明了避免数据不一致可以采取的封锁类型及封锁协议。
事务是指一系列操作的集合,具有原子性、一致性、隔离性和持久性的特点。
事务并发操作如果不加控制,则会导致丢失更新、读取脏数据、不可重复读和幻象读等问题出现,这些问题的出现从根本上说是违背了事务的隔离性。事务的隔离级别由低到高分别为读取未提交的数据、读取提交的数据、可重复读和串行化。
为了确保数据的一致性,需要对数据进行封锁,封锁的粒度可以是行,也可以是表。封锁类型分为共享锁和排它锁。何时封锁、何时解锁等规则的不同形成了三级封锁协议。