第五章 分布式数据库中的并发控制
1 比较的DB2 ORACLE MSSQL SYBASE INFORMIX并发控制机制
在关系数据库(DB2,Oracle,Sybase,Informix和SQL Server)最小的恢复和交易单位为一个事务(Transactions),事务具有ACID(原子性,一致性,隔离性和永久性)特征。关系数据库为了确保并发用户在存取同一数据库对象时的正确性(即无丢失更新、可重复读、不读"脏"数据,无"幻像"读),数据库中引入了并发(锁)机制。基本的锁类型有两种:排它锁(Exclusive locks记为X锁)和共享锁(Share locks记为S锁)。
排它锁:若事务T对数据D加X锁,则其它任何事务都不能再对D加任何类型的锁,直至T释放D上的X锁;一般要求在修改数据前要向该数据加排它锁,所以排它锁又称为写锁。
共享锁:若事务T对数据D加S锁,则其它事务只能对D加S锁,而不能加X锁,直至T释放D上的S锁;一般要求在读取数据前要向该数据加共享锁,所以共享锁又称为读锁。
1.1 DB2数据库
1.1.1 DB2数据库表锁的模式
IS、IX、SIX方式用于表一级并需要行锁配合,他们可以阻止其他应用程序对该表加上排它锁。
如果一个应用程序获得某表的IS锁,该应用程序可获得某一行上的S锁,用于只读操作,同时其他应用程序也可以读取该行,或是对表中的其他行进行更改。
如果一个应用程序获得某表的IX锁,该应用程序可获得某一行上的X锁,用于更改操作,同时其他应用程序可以读取或更改表中的其他行。
如果一个应用程序获得某表的SIX锁,该应用程序可以获得某一行上的X锁,用于更改操作,同时其他应用程序只能对表中其他行进行只读操作。
S、U、X和Z方式用于表一级,但并不需要行锁配合,是比较严格的表加锁策略。
如果一个应用程序得到某表的S锁。该应用程序可以读表中的任何数据。同时它允许其他应用程序获得该表上的只读请求锁。如果有应用程序需要更改读该表上的数据,必须等S锁被释放。
如果一个应用程序得到某表的U锁,该应用程序可以读表中的任何数据,并最终可以通过获得表上的X锁来得到对表中任何数据的修改权。其他应用程序只能读取该表中的数据。U锁与S锁的区别主要在于更改的意图上。U锁的设计主要是为了避免两个应用程序在拥有S锁的情况下同时申请X锁而造成死锁的。
如果一个应用程序得到某表上的X锁,该应用程序可以读或修改表中的任何数据。其他应用程序不能对该表进行读或者更改操作。
如果一个应用程序得到某表上的Z锁,该应用程序可以读或修改表中的任何数据。其他应用程序,包括未提交读程序都不能对该表进行读或者更改操作。
IN锁用于表上以允许未提交读这一概念。
1.1.2 DB2数据库行锁的模式
1.1.3 DB2数据库表锁的相容矩阵
1.1.4 DB2数据库行锁的相容矩阵
1.1.5 DB2中各SQL语句产生表锁的情况(假设缺省的隔离级别为CS)
1.1.6 DB2锁的升级
每个锁在内存中都需要一定的内存空间,为了减少锁需要的内存开销,DB2提供了锁升级的功能。锁升级是通过对表加上非意图性的表锁,同时释放行锁来减少锁的数目,从而达到减少锁需要的内存开销的目的。锁升级是由数据库管理器自动完成的,有两个数据库的配置参数直接影响锁升级的处理:
locklist--在一个数据库全局内存中用于锁存储的内存。单位为页(4K)。
maxlocks--一个应用程序允许得到的锁占用的内存所占locklist大小的百分比。
锁升级会在这两种情况下被触发:
某个应用程序请求的锁所占用的内存空间超出了maxlocks与locklist的乘积大小。这时,数据库管理器将试图通过为提出锁请求的应用程序申请表锁,并释放行锁来节省空间。
在一个数据库中已被加上的全部锁所占的内存空间超出了locklist定义的大小。这时,数据库管理器也将试图通过为提出锁请求的应用程序申请表锁,并释放行锁来节省空间。
锁升级虽然会降低OLTP应用程序的并发性能,但是锁升级后会释放锁占有内存并增大可用的锁的内存空间。
锁升级是有可能会失败的,比如,现在一个应用程序已经在一个表上加有IX锁,表中的某些行上加有X锁,另一个应用程序又来请求表上的IS锁,以及很多行上的S锁,由于申请的锁数目过多引起锁的升级。数据库管理器试图为该应用程序申请表上的S锁来减少所需要的锁的数目,但S锁与表上原有的IX锁冲突,锁升级不能成功。
如果锁升级失败,引起锁升级的应用程序将接到一个-912的SQLCODE。在锁升级失败后,DBA应该考虑增加locklist的大小或者增大maxlocks的百分比。同时对编程人员来说可以在程序里对发生锁升级后程序回滚后重新提交事务(例如:if sqlca.sqlcode=-912 then rollback and retry等)。
1.2 ORACLE数据库
1.2.1 Oracle 多粒度锁机制介绍
根据保护对象的不同,Oracle数据库锁可以分为以下几大类:
(1) DML lock(data locks,数据锁):用于保护数据的完整性;
(2) DDL lock(dictionary locks,字典锁):用于保护数据库对象的结构(例如表、视图、索引的结构定义);
(3) Internal locks 和latches(内部锁与闩):保护内部数据库结构;
(4) Distributed locks(分布式锁):用于OPS(并行服务器)中;
(5) PCM locks(并行高速缓存管理锁):用于OPS(并行服务器)中。
在Oracle中最主要的锁是DML(也可称为data locks,数据锁)锁。从封锁粒度(封锁对象的大小)
角度看,Oracle DML锁共有两个层次,即行级锁和表级锁。
1.2.2 Oracle的TX锁行级锁、事务锁
许多对Oracle不太了解的技术人员可能会以为每一个TX锁代表一条被封锁的数据行,其实不然。TX的本义是Transaction(事务)当一个事务第一次执行数据更改(Insert、Update、Delete)或使用SELECT… FOR UPDATE语句进行查询时,它即获得一个TX(事务)锁,直至该事务结束(执行COMMIT或ROLLBACK操作)时,该锁才被释放。所以,一个TX锁,可以对应多个被该事务锁定的数据行(在我们用的时候多是启动一个事务,然后SELECT… FOR UPDATE NOWAIT)。
在Oracle的每行数据上,都有一个标志位来表示该行数据是否被锁定。Oracle不像DB2那样,建立一个链表来维护每一行被加锁的数据,这样就大大减小了行级锁的维护开销,也在很大程度上避免了类似DB2使用行级锁时经常发生的锁数量不够而进行锁升级的情况。数据行上的锁标志一旦被置位,就表明该行数据被加X锁,Oracle在数据行上没有S锁。
1.2.3 Oracle意向锁
意向锁的含义是如果对一个结点加意向锁,则说明该结点的下层结点正在被加锁;对任一结点加锁时,必须先对它的上层结点加意向锁。如:对表中的任一行加锁时,必须先对它所在的表加意向锁,然后再对该行加锁。这样一来,事务对表加锁时,就不再需要检查表中每行记录的锁标志位了,系统效率得以大大提高。
由两种基本的锁类型(S锁、X锁),可以自然地派生出两种意向锁:
另外,基本的锁类型(S、X)与意向锁类型(IS、IX)之间还可以组合出新的锁类型,理论上可以组合出4种,即:S+IS,S+IX,X+IS,X+IX,但稍加分析不难看出,实际上只有S+IX有新的意义,其它三种组合都没有使锁的强度得到提高(即:S+IS=S,X+IS=X,X+IX=X,这里的"="指锁的强度相同)。所谓锁的强度是指对其它锁的排斥程度。
这样我们又可以引入一种新的锁的类型:共享意向排它锁(Shared Intent Exclusive Lock,简称SIX锁):如果对一个数据库对象加SIX锁,表示对它加S锁,再加IX锁,即SIX=S+IX。例如:事务对某个表加SIX锁,则表示该事务要读整个表(所以要对该表加S锁),同时会更新个别行(所以要对该表加IX锁)。
具有意向锁的多粒度封锁方法中任意事务T要对一个数据库对象加锁,必须先对它的上层结点加意向锁。申请封锁时应按自上而下的次序进行;释放封锁时则应按自下而上的次序进行;具有意向锁的多粒度封锁方法提高了系统的并发度,减少了加锁和解锁的开销。
1.2.4 Oracle的TM锁(表级锁)
下表为Oracle数据库TM锁的兼容矩阵(Y=Yes,表示兼容的请求; N=No表示不兼容的请求;-表示没有加锁请求):
1.2.5 Oracle数据库TM锁小结
1.2.6 v$lock视图列出当前系统持有的或正在申请的所有锁的情况
1.2.7 v$locked_object视图列出当前系统中哪些对象正被锁定
1.2.8 oracle小结
1. Oracle通过具有意向锁的多粒度封锁机制进行并发控制,保证数据的一致性。其DML锁(数据锁)分为两个层次(粒度):即表级和行级。通常的DML操作在表级获得的只是意向锁(RS或RX),其真正的封锁粒度还是在行级;DB2也是通过具有意向锁的多粒度封锁机制进行并发控制,保证数据的一致性。其DML锁(数据锁)分为两个层次(粒度):即表级和行级。通常的DML操作在表级获得的只是意向锁(IS,SIX或IX),其真正的封锁粒度也是在行级;另外,在Oracle数据库中,单纯地读数据(SELECT)并不加锁,这些都提高了系统的并发程度,Oracle强调的是能够"读"到数据,并且能够快速的进行数据读取。而DB2的锁强调的是"读一致性",进行读数据(SELECT)时会根据不同的隔离级别(RR,RS,CS)而分别加S,IS,IS锁,只有在使用UR隔离级别时才不加锁。从而保证不同应用程序和用户读取的数据是一致的。
2. 在支持高并发度的同时,DB2和Oracle对锁的操纵机制有所不同:Oracle利用意向锁及数据行上加锁标志位等设计技巧,减小了Oracle维护行级锁的开销,使其在数据库并发控制方面有着一定的优势。而DB2中对每个锁会在锁的内存(locklist)中申请分配一定字节的内存空间,具体是X锁64字节内存,S锁32字节内存(注:DB2 V8之前是X锁72字节内存而S锁36字节内存)。
3. Oracle数据库中不存在锁升级,而DB2数据库中当数据库表中行级锁的使用超过locklist*maxlocks会发生锁升级。
4. 在Oracle中当一个session对表进行insert,update,delete时候,另外一个session仍然可以从Orace回滚段或者还原表空间中读取该表的前映象(before image); 而在DB2中当一个session对表进行insert,update,delete时候,另外一个session仍然在读取该表数据时候会处于lock wait状态,除非使用UR隔离级别可以读取第一个session的未提交的值;所以Oracle同一时刻不同的session有读不一致的现象,而DB2在同一时刻所有的session都是"读一致"的。
1.3 MS SQL SERVER数据库
1.3.1 SQLServer使用以下资源锁模式
共享 (S):用于不更改或不更新数据的操作(只读操作),如 SELECT 语句。
更新 (U): 用于可更新的资源中。防止当多个会话在读取、锁定以及随后可能进行的资源更新时发生常见形式的死锁。
排它(X):用于数据修改操作,例如 INSERT、UPDATE 或 DELETE。确保不会同时对同一资源进行多重更新。
意向:用于建立锁的层次结构。意向锁的类型为:意向共享 (IS)、意向排它 (IX) 以及与意向排它共享 (SIX)。
架构:在执行依赖于表架构的操作时使用。架构锁的类型为:架构修改 (Sch-M) 和架构稳定性 (Sch-S)。
大容量更新(BU):向表中大容量复制数据并指定了 TABLOCK 提示时使用。
1.3.2 架构锁
执行表的数据定义语言 (DDL) 操作(例如添加列或除去表)时使用架构修改 (Sch-M) 锁。
当编译查询时,使用架构稳定性 (Sch-S) 锁。架构稳定性 (Sch-S) 锁不阻塞任何事务锁,包括排它 (X) 锁。因此在编译查询时,其它事务(包括在表上有排它 (X) 锁的事务)都能继续运行。但不能在表上执行 DDL 操作。
大容量更新锁
当将数据大容量复制到表,且指定了 TABLOCK 提示或者使用 sp_tableoption 设置了 table lock
on bulk 表选项时,将使用大容量更新 (BU) 锁。大容量更新 (BU) 锁允许进程将数据并发地大容量复制
到同一表,同时防止其它不进行大容量复制数据的进程访问该表。
1.4 Sybase数据库
1.4.1 Sybase锁的状态
1)意向锁(intend)—是一种表级锁,它表示在一个数据页上获得一个S或X锁的意向。意向锁可以防止其他事务在该数据页的表上获得排它锁。
2)阻塞(blocking,简记blk)—它表明目前加锁进程的状态,带有blk后缀的锁说明该进程目前正阻塞另一个需要获得锁的进程,只有这一进程完成,其他进程才可以进行。
3)需求锁(demand)—表示此时该进程企图得到一个排它锁。它可以防止在这一表或页上加过多的S锁,她表示某一事务是下一个去锁定该表和该页的事务。需求锁是一个内部过程,因此用sp_lock是无法看见的。
1.5 Informix数据库
1.5.1 Informix锁
锁的类型 INFORMIX有三种不同类型的锁。它们在不同的情况下使用。
1. SHARED锁
SHARED锁只保留对象的可读性。当锁存在时,对象不能改变。多个程序可对同个对象加SHARED锁。
2. EXCLUSIVE锁
只能使单个程序使用。在程序要改变对象时使用。当其他锁存在时,EXCLUSIVE锁不能使用。当使用
了EXCLUSIVE 锁后,其他锁不能用于同一对象。
3. PROMOTABLE锁
实现更新的目的。PROMOTABLE锁可以放在已经有SHARED锁的记录,但不能放在已经有PROMOTABLE锁
和EXCLUSIVE 锁的地方。当记录上无其他锁(含SHARED 锁)情况下,这时在程序准备改变锁的记录
PROMOTABLE锁可以提升为EXCLUSIVE锁。如果在已有SHARED锁的记录上设置了PROMOTABLE锁,在
PROMOTABLE锁可以提升到EXCLUSIVE锁之前需要删除SHARED 锁。PROMOTABLE锁只在INFORMIX Universal
Server 中支持。
2 比较分析悲观与乐观并发控制机制的异同
本段以Java/Hibernate中的悲观锁和乐观锁的实现应用为实例:
锁(Locking)
业务逻辑的实现过程中,往往需要保证数据访问的排他性。如在金融系统的日终结算处理中,我们希
望针对某个cut-off时间点的数据进行处理,而不希望在结算进行过程中(可能是几秒种,也可能是几个
小时),数据再发生变化。此时,我们就需要通过一些机制来保证这些数据在某个操作过程中不会被外界
修改,这样的机制,在这里,也就是所谓的“锁”,即给我们选定的目标数据上锁,使其无法被其他程序
修改。
Hibernate支持两种锁机制:即通常所说的“悲观锁(Pessimistic Locking)”和“乐观锁
(Optimistic Locking)”。
2.1 悲观锁
悲观锁,正如其名,它指的是对数据被外界(包括本系统当前的其他事务,以及来自外部系统的事务
处理)修改持保守态度,因此,在整个数据处理过程中,将数据处于锁定状态。悲观锁的实现,往往依靠数据库提供的锁机制(也只有数据库层提供的锁机制才能真正保证数据访问的排他性,否则,即使在本系统中实现了加锁机制,也无法保证外部系统不会修改数据)。
一个典型的倚赖数据库的悲观锁调用:
select * from account where name=”Erica” for update
这条sql 语句锁定了account 表中所有符合检索条件(name=”Erica”)的记录。本次事务提交之前(事务提交时会释放事务过程中的锁),外界无法修改这些记录。
Hibernate的悲观锁,也是基于数据库的锁机制实现。
下面的代码实现了对查询记录的加锁:
String hqlStr = "from TUser as user where user.name=’Erica’";
Query query = session.createQuery(hqlStr);
query.setLockMode("user",LockMode.UPGRADE); //加锁
List userList = query.list();//执行查询,获取数据
query.setLockMode对查询语句中,特定别名所对应的记录进行加锁(我们为TUser类指定了一个别名“user”),这里也就是对返回的所有user记录进行加锁。
观察运行期Hibernate生成的SQL语句:
select tuser0_.id as id, tuser0_.name as name, tuser0_.group_id
as group_id, tuser0_.user_type as user_type, tuser0_.sex as sex
from t_user tuser0_ where (tuser0_.name=’Erica’ ) for update
这里Hibernate通过使用数据库的for update子句实现了悲观锁机制。
Hibernate的加锁模式有:
Ø LockMode.NONE : 无锁机制。
Ø LockMode.WRITE :Hibernate在Insert和Update记录的时候会自动获取。
Ø LockMode.READ : Hibernate在读取记录的时候会自动获取。
以上这三种锁机制一般由Hibernate内部使用,如Hibernate为了保证Update过程中对象不会被外
界修改,会在save方法实现中自动为目标对象加上WRITE锁。
Ø LockMode.UPGRADE :利用数据库的for update子句加锁。
Ø LockMode. UPGRADE_NOWAIT :Oracle的特定实现,利用Oracle的for update nowait子句实现加
锁。
上面这两种锁机制是我们在应用层较为常用的,加锁一般通过以下方法实现:
Criteria.setLockMode
Query.setLockMode
Session.lock
注意,只有在查询开始之前(也就是Hiberate 生成SQL 之前)设定加锁,才会真正通过数据库的锁
机制进行加锁处理,否则,数据已经通过不包含for update子句的Select SQL加载进来,所谓数据库加
锁也就无从谈起。
2.2 乐观锁
相对悲观锁而言,乐观锁机制采取了更加宽松的加锁机制。悲观锁大多数情况下依靠数据库的锁机制实现,以保证操作最大程度的独占性。但随之而来的就是数据库性能的大量开销,特别是对长事务而言,
这样的开销往往无法承受。
如一个金融系统,当某个操作员读取用户的数据,并在读出的用户数据的基础上进行修改时(如更改
用户帐户余额),如果采用悲观锁机制,也就意味着整个操作过程中(从操作员读出数据、开始修改直至
提交修改结果的全过程,甚至还包括操作员中途去煮咖啡的时间),数据库记录始终处于加锁状态,可以
想见,如果面对几百上千个并发,这样的情况将导致怎样的后果。
乐观锁机制在一定程度上解决了这个问题。乐观锁,大多是基于数据版本(Version)记录机制实现。何谓数据版本?即为数据增加一个版本标识,在基于数据库表的版本解决方案中,一般是通过为数
据库表增加一个“version”字段来实现。读取出数据时,将此版本号一同读出,之后更新时,对此版本
号加一。此时,将提交数据的版本数据与数据库表对应记录的当前版本信息进行比对,如果提交的数据版
本号大于数据库表当前版本号,则予以更新,否则认为是过期数据。
对于上面修改用户帐户信息的例子而言,假设数据库中帐户信息表中有一个version字段,当前值为
1;而当前帐户余额字段(balance)为$100。
1 操作员A 此时将其读出(version=1),并从其帐户余额中扣除$50($100-$50)。
2 在操作员A操作的过程中,操作员B也读入此用户信息(version=1),并从其帐户余额中扣除$20($100-$20)。
3 操作员A完成了修改工作,将数据版本号加一(version=2),连同帐户扣除后余额(balance=$50),
提交至数据库更新,此时由于提交数据版本大于数据库记录当前版本,数据被更新,数据库记录version
更新为2。
4 操作员B完成了操作,也将版本号加一(version=2)试图向数据库提交数据(balance=$80),但
此时比对数据库记录版本时发现,操作员B提交的数据版本号为2,数据库记录当前版本也为2,不满足
“提交版本必须大于记录当前版本才能执行更新“的乐观锁策略,因此,操作员B 的提交被驳回。这样,
就避免了操作员B 用基于version=1 的旧数据修改的结果覆盖操作员A的操作结果的可能。从上面的例
子可以看出,乐观锁机制避免了长事务中的数据库加锁开销(操作员A 和操作员B操作过程中,都没有对
数据库数据加锁),大大提升了大并发量下的系统整体性能表现。
需要注意的是,乐观锁机制往往基于系统中的数据存储逻辑,因此也具备一定的局限性,如在上例中,
由于乐观锁机制是在我们的系统中实现,来自外部系统的用户余额更新操作不受我们系统的控制,因此可
能会造成脏数据被更新到数据库中。在系统设计阶段,我们应该充分考虑到这些情况出现的可能性,并进
行相应调整(如将乐观锁策略在数据库存储过程中实现,对外只开放基于此存储过程的数据更新途径,而
不是将数据库表直接对外公开)。
Hibernate 在其数据访问引擎中内置了乐观锁实现。如果不用考虑外部系统对数据库的更新操作,利
用Hibernate提供的透明化乐观锁实现,将大大提升我们的生产力。
Hibernate中可以通过class描述符的optimistic-lock属性结合version描述符指定。
现在,我们为之前示例中的TUser加上乐观锁机制。
1. 首先为TUser的class描述符添加optimistic-lock属性:
<hibernate-mapping>
<class
name="org.hibernate.sample.TUser"
table="t_user"
dynamic-update="true"
dynamic-insert="true"
optimistic-lock="version"
>
……
</class>
</hibernate-mapping>
optimistic-lock属性有如下可选取值:
Ø none
无乐观锁
Ø version
通过版本机制实现乐观锁
Ø dirty
通过检查发生变动过的属性实现乐观锁
Ø all
通过检查所有属性实现乐观锁
其中通过version实现的乐观锁机制是Hibernate官方推荐的乐观锁实现,同时也是Hibernate中,目前唯一在数据对象脱离Session发生修改的情况下依然有效的锁机制。因此,一般情况下,我们都选择version方式作为Hibernate乐观锁实现机制。
2. 添加一个Version属性描述符
代码内容
<hibernate-mapping>
<class
name="org.hibernate.sample.TUser"
table="t_user"
dynamic-update="true"
dynamic-insert="true"
optimistic-lock="version"
>
<id
name="id"
column="id"
type="java.lang.Integer"
>
<generator class="native">
</generator>
</id>
<version
column="version"
name="version"
type="java.lang.Integer"
/>
……
</class>
</hibernate-mapping>
注意version 节点必须出现在ID 节点之后。
这里我们声明了一个version属性,用于存放用户的版本信息,保存在TUser表的version字段中。
此时如果我们尝试编写一段代码,更新TUser表中记录数据,如:
代码内容
Criteria criteria = session.createCriteria(TUser.class);
criteria.add(Expression.eq("name","Erica"));
List userList = criteria.list();
TUser user =(TUser)userList.get(0);
Transaction tx = session.beginTransaction();
user.setUserType(1); //更新UserType字段
tx.commit();
每次对TUser进行更新的时候,我们可以发现,数据库中的version都在递增。而如果我们尝试在
tx.commit 之前,启动另外一个Session,对名为Erica 的用户进行操作,以模拟并发更新时的情形:
代码内容
Session session= getSession();
Criteria criteria = session.createCriteria(TUser.class);
criteria.add(Expression.eq("name","Erica"));
Session session2 = getSession();
Criteria criteria2 = session2.createCriteria(TUser.class);
criteria2.add(Expression.eq("name","Erica"));
List userList = criteria.list();
List userList2 = criteria2.list();TUser user =(TUser)userList.get(0);
TUser user2 =(TUser)userList2.get(0);
Transaction tx = session.beginTransaction();
Transaction tx2 = session2.beginTransaction();
user2.setUserType(99);
tx2.commit();
user.setUserType(1);
tx.commit();
执行以上代码,代码将在tx.commit()处抛出StaleObjectStateException异常,并指出版本检查失
败,当前事务正在试图提交一个过期数据。通过捕捉这个异常,我们就可以在乐观锁校验失败时进行相应
处理。
3 基于加锁粒度的分布式高优先级两段锁的并发控制模型
这种协议里每个局部节点都有一个锁调度机制,该机制负责对该节点数据对象的加锁请求。当事务要访问某节点的数据对象时,它通过该节点的锁调度机制来对它所需的数据对象进行加锁。当该数据对象已被其它事务加锁时,就要按优先级来比较,优先级高的事务将重新获得加锁权,而优先级低的事务将被迫重启,局部事务将局部重启,全局事务将全局重启。这样就出现了一个问题,优先级低的全局事务的执行
代价将会大幅度的增加,多次的重启必然会给整个系统通讯带来重大的负担,同时也使子场地开销增加。
在实际中的很多情况下,对于读写冲突、写写冲突等,只要降低其加锁粒度,就可以化解该冲突操作。
粒度,在并行计算中是用来衡量软件进程所含计算量的尺度,其大小一般用指令数目来表示,我们借用来以指对数据对象的加锁大小,即数据库的加锁层次的度量。如整个数据库、数据库的一个表空间(贮存一个或多个表的一个逻辑存取空间)、表空间的一个表、一个表中的一条记录、一个表中的一个字段。记录或字段的加锁有最细粒度。对于一些关系数据库也可进行页(包含多条记录)的加锁,页通常对应于一个物理存取块,故对页的加锁可转换为对某个物理块的加锁。适当选定加锁的粒度是非常重要的。加锁粒度越细,加锁的次数将会增加,整个加锁管理程序就要用掉更多的系统开销,即更多的存储空间和处理时间,但这样可减少冲突事务,使整个数据库的并发程度提高;加锁粒度越大,加锁的次数将会减小,系统开销将会减少,但并发度也会减低。所以加锁粒度的大小主要是根据实际情况来决定的。如果大量的事务要对数据库进行大方面的修改,则采用较粗的加锁粒度,如果大量的事务仅对数据库进行较小的修改,如对某几个记录或某几个字段时,则可采用较细的加锁粒度。当使用的加锁粒度较大时,就可通过降低加锁粒度来避免冲突,但并不是所有的冲突操作都能通过降低加锁粒度来解决冲突。
在这里引入一种加锁粒度检测机制来检测冲突事务是否能够通过降低加锁粒度来解决冲突。在引入检测机制之前,先介绍几个定义。
最小加锁粒度集:是指加锁数据对象在记录和字段这一级别的集合,即由加锁数据对象的表号、记录号和字段名组成的集合。
加锁粒度级别:是按数据库、表空间、表、(页)、纪录和字段这个粒度的递减次序排列的数据加锁层次。
加锁粒度集:是指加锁数据对象在某一加锁粒度级别的集合。
现在,可以给出加锁粒度检测机制定义:首先检测冲突事务的最小加锁粒度集是否有重合,如果有重合,则说明它们不能通过降低加锁粒度来避免冲突;如果没有重合,则降低冲突事务的加锁粒度级别,直到消除事务的冲突为止。检测结束后对消除冲突的事务优先级加上一固定值,因为通过加锁粒度检测机制的事务的执行代价必然增加,故其优先级也应增加。算法由于采用优先级的方法,故在死锁方面可以由优
先级的高低来排除;而且,在事务重启方面,通过引入“加锁粒度”这样一个概念,降低了事务重启的次数,降低了事务之间的通信开销,提高事务的并发度。但由于加锁粒度检测机制的复杂性,该算法会使系统开销有所增加。但综而观之,该算法在事务并发控制领域中有一定的应用价值。[15]
4 并发控制技术的实现途径
并发控制的实现途径有多种,如果DBMS支持的话,当然最好是运用其自身的并发控制能力。如果系统
不能提供这样的功能,可以借助开发工具的支持,还可以考虑调整数据库应用程序,而且有的时候可以通过调整工作模式来避开这种会影响效率的并发操作。笔者对各种策略做了一个总结,主要有一下几点:
4.1 调整工作模式,修改应用程序,避免不必要的并发
这在某些情况下是可行的,例如规定录入人员只能修改自己所创造的记录,那么就不会出现并发操作
中的各种错误,因为这时各个不同的用户所能更新的记录不会发生重合。这种情况下,需要在数据库表中增加用户列。在用户浏览记录时,将用户列作为一个过滤条件,对应用程序的sql语句做相应的调整。但这种策略的作用有限,因为在大量情况下,并发控制不可避免。
4.2 借助于DBMS的功能
大型关系系统都有比较好的并发控制功能。例如可以采用更新游标、显式加锁、更改事务隔离级别等
等。当然在其使用方面有很多注意的技巧,如:(1)事务定义最好不要包含客户交互部分。(2)只有在数据一致性要求特别严格,但并发度要求不高的时候采用可重复读与可串行读的隔离级别。(3)在同一个事务当中,要适当根据需要来变更数据的锁定级别,但一般情况下不要用TABLOCK这样粗粒度的封锁。(4)不同事务之间可以根据并发度的需要来显式设定隔离级别。(5)在包含客户交互的操作中使用游标,并尽可能缩短交互时间。
4.3 利用开发工具的支持
许多数据库开发工具都有一些方便的选项或部件来支持并发控制,而不论DBMS是否支持并发控制。我们看一下Delphi与Powerbuilder的并发控制方法。
Delphi是一个优秀的c/s开发工具,它用来查询数据的数据库控件是TQuery,它可以和TUpdatesql控件有机的结合起来完成数据库表数据的浏览和更新。其中在TQuery控件中有一个属性是Updatemod(修改模式),他有三种选择:(1)upWhereAll:在浏览和修改期间只要有人修改了此记录某个列,那么不管你是否修改过这个列,你的修改在提交时都不能成功。(2)upWhereChanged:只根据键值列和你已经修改的列来决定你的修改是否成功,如果别人所修改的本记录的列与你修改的列不相交,那么你的修改仍然是成功的。(3)UpWhereOnly:只根据键值是否修改来判断你的更新是否成功。
与TQuery控件配套使用的TUpdatesql控件根据所指定的修改属性,自动生成所需的更新语句,非常方便。第2种模式是最常用的修改模式,只要别人对记录所做的修改不与自己的重合,那么就会提交成功
,这即保证不会发生数据的丢失、覆盖,并且具有较高的并发度。还是上边的例子,比如说客户浏览记录后修改的是记录中Mpayout、Mbalance两列,那么在修改选项upWhereChanged下,Tupdatesql控件
所生成的SQL语句是:
UPDATE acount SET (Mpayout,Mbalance)=($Mpayout,$Mbalance)
WHERE Key=Key_old and Mpayout=Mpayout_old and Mbalance=Mbalance_old;
其中Key_old,Mpayout_old和Mbalance_old是delphi替用户所生成的中间变量,暂存原先数据记录的旧
值,用于比较旧值与现在的值是否相等,如果不相等,说明已经有别的用户更改了该记录,那么为了避免丢失修改,该用户的更新操作不能完成,反之则可以完成。那么当出纳员修改帐户时,如果别人已经修改了这个帐户,那么他的这次修改是不成功的,必须重新刷新记录才可能成功修改。对上面的
例子进行这种改造,就可以避免银行的损失。
与Delphi媲美的一个另一个工具是著名的Powerbuilder,在其DataWindows的设计中,我们选择菜单
Rows|Update…,会出现Specify Update Characteristics的设置窗口,在这个窗口中我们设置Update语
句中Where子句的生成,以此来进行并发控制。在这里有三个选项: (1)Key Columns:生成的Where子句
中只比较表中的主键列的值与最初查询时是否相同来确定要修改的记录。与Delphi中的UpWhereOnly选
项对应。(2)Key and Updateable Columns:生成的Where子句比较表中主键列和可修改列的值与最初查询时否是相同。与Delphi的upWhereall相对应。(3)Key and Modified Columns:与Delphi的upWhereChanged选项对应。Where子句比较主键和要修改的列。
4.4 调整应用
有的数据库没有提供并发控制的功能,例如Foxpro等,象Mysql的某些版本也不支持事务。而且有的开发工具(例如一些网页脚本编辑器等)也没有提供实现并发控制的部件,那么要实现并发控制,就只能借助于调整我们的应用程序和数据库结构的办法了。
可以按照封锁的基本思想来调整应用。在需要进行并发控制的数据库表中增加一个锁字段,这个字段
可以是一个布尔型变量,为true则为锁定,为false则为空闲。此时表的结构变为:
列名称 列代码 列类型
帐户号 Id(键值列) Char(10)
户主 Uname Char(10)
存入金额 Mdeposit Currency
支出金额 Mpayout Currency
存款余额 Mbalance Currency
锁 Lock Boolean
如果一个应用中,客户查询这个表时,可以修改表的记录,那么为了防止别的客户在该用户编辑某记
录期间修改这个记录,那么就需要客户在浏览到该记录的数据时,给该记录加上锁(即将锁字段改为
true),修改完毕后释放锁(将锁字段改为false)。别的客户要修改这个表的记录的话就先检测一下
该记录有没有被加锁,如果已经加锁,则不能进行修改。如果锁字段空闲,那么首先给该记录加锁,
然后取记录给客户浏览、编辑,在此期间别的客户不能修改记录。这就很有效的防止了丢失修改。
上面是一种常用的方法,但是也不是完全没有缺点,它可能会产生这样一种副作用:当一个用户决定
修改一个记录时,该记录被锁定,等待用户修改,但此时正好用户离开了,那么这条记录将一直被锁
定直到用户提交(可能是几个小时之后了)或者会话超时,那么在这一段时间内别的用户就不可以更
改这一条记录,导致并发度很低。有一个绕过这个问题的解决办法就是将浏览到的记录记到
old_record(自定义的变量)中,再将old_record的内容拷贝到一个新记录new_record(自定义的变
量)中,用户编辑new_record,当提交new_record时比较old_record和原先的记录,如果不一样,则
表明已经有用户修改了原先的记录,此时把对new_record做的修改放弃;反之则将其内容提交。
上边的方法还有一个小小的不足:在做新旧记录的比较的时候必须比较整条记录,费时间而且程序写起来比较麻烦。可以考虑在原先的表中增添一个时间戳列(此时可以取消lock列),那么此时表的结
构变为:
列名称 列代码 列类型
帐户号 Id(键值列) Char(10)
户主 Uname Char(10)
存入金额 Mdeposit Currency
支出金额 Mpayout Currency
存款余额 Mbalance Currency
时间戳 Date Datetime
当浏览记录时将时间戳值记到old_date(自定义变量)中,再将记录内容记到一个新记录new_record
中,用户编辑new_record,当提交new_record时比较old_date和原先的记录中的时间戳,如果不一样,则表明已经有用户修改了原先的记录,此时把对new_record做的修改放弃;反之则将其内容提交。
更新语句为:
UPDATE acount SET (Mpayout,Mbalance,Date)=($Mpayout,$Mbalance,$Date)
WHERE Key=Key_old and Date=$Date_old;
5 小结
本章比较了多种数据库的并发控制机制。各有不同。同时也了解了一下并发控制技术的实现途径和并发的控制协议等。
第六章 分布式数据库中的可靠性
二/三阶段协议
与事务处理类似。前面已经讨论了部分,并给了实例。