企业应用中存在两个核心主题:协作与过期。不管是否用户主观,只要应用中存在多人或程序修改同一份数据,就存在协作。存在协作就存在过期,读取的数据可能已被修改,从某种意义上来说,用户在界面上看到的数据永运是过期的数据。在B/S架构下,这种现象更为明显,防止丢失更新等数据不一致性,保证完整性成为应用的关键所在。本文基于关系型数据库,介绍企业应用并发控制。
一、 数据库并发控制理论
1. 事务
事务是数据库并发控制的单元,是数据库的逻辑工作单元,用户定义的操作序列,可以由一条或一组SQL语句组成。事务的开始和结束可以由用户控制。数据库缺省自动划分事务,一般第一条执行语句为事条开始,事务结束一般由用户显示控制,如COMMIT,ROLLBACK语句。DDL语句,系统异常,会话结束,都会自动提交事务。
事务具有四种属性:原子性,一致性,隔离性,持久性。
原子性
保证事务所包含的操作是原子不可分的,这些操作是一个整体,对数据库而言要么全做,要么全不做。原子性通过通数据库的操作日志来实现。
一致性
事务执行后,要求数据库从一个一致状态转到另一个一致的状态。一致性包含两方面,一种是操作的一致生,与原子性密切相关,如银行转账,从A账户扣减,B账户必定增加,无论发生任何情况,都不能只执行一半;另一方面,指用户定义的完整性,指规则的完整性,由事务的隔离性实现。
隔离性
事务内部的操作及数据对其它事务隔离。操作系统并发调度是随机的,数据库并发调度可串行化,是数据库并发调整正确性的准则。事务的隔离性通过锁来实现。
持久性
事务提交后,事务所作的修改是持久的,无论发生任务机器故障。持久性通过数据库操作记录来保证。
2. 数据不一致性
数据不一致性是破坏了事务隔离性,数据不一致性包括:丢失更新,读脏数据,不可重复读,幻读。
T1 |
|
T2 |
|
T1 |
|
T2 |
|
T1 |
|
T2 |
|
T1 |
|
T2 |
Read A=20 |
|
|
|
Read B =10 B= B+10 Write B= 20 |
|
|
|
Read C=100 D=50 Sum(C,D)=150 |
|
|
|
Query Result R1=Q(c) |
|
|
|
|
Read A=20 |
|
|
|
Read B=20 |
|
|
|
Read D =50 D=D+50 Write D=100 |
|
|
|
Delete .... Insert |
A = A+1 Write A =21 |
|
|
|
Rollback B =10 |
|
|
|
Read C=100 D=100 Sum(C,D)=200 |
|
|
|
Query Result R2=Q(c)
Size(R1)!= Size(R2) |
|
|
|
|
A =A+1 Write A=21 |
|
|
|
|
|
|
|
|
|
|
|
|
丢失更新 |
|
读脏数据 |
|
不可重复读 |
|
幻读 |
丢失更新:事务 T1 读取数据 A,然后对 A 进行运算修改,最后写回数据库。如果在 T1 读取和写回数据库之间,有其他事务修改了 A 值,就造成了丢失更新。
读脏数据:一个事务读取了另一个事未提交的数据。
不可重复读:一个事务两次读取之间,其它事务对数据做了修改。
幻读:一个事务两次相同查询之间,其它事务插入或删除了数据。
只要有两个以上事务操作同一份数据,并且其中有一个写操作,就可能发生数据不一致性。丢失更新为写不一致性,读脏数据、不可重复读、幻读为读不一致性。
并发控制机制的好坏是衡量一个数据库的重要标志,一般通过封锁来解决并发问题。
3. 封锁
封锁是实现并发技术的一个非常重要手段,封锁是事务在对数据进行操作之前,对其加锁,加锁后事务就对该数据具有一定的控制能力。基本的封锁类型有两种:排它锁(Exclusive Locks,简称X锁,写锁),共享锁(Share Locks,简称S锁,读锁)。
排它锁,若事务T对数据对象A加上X锁,则只允许T读取和修改A,其它事务不能加任何其它锁。
共享锁,若事务T对数据对象A加上S锁,则只允许T读取但不能修改A,其它事务只能加S锁,不能加排它锁。
4. 封锁协议
并发控制,除了锁之外,还需要定义一些规则:何时申请锁,持锁时间,何时释放锁,称之为封锁协议。比较有名的有三级封锁协议与两段锁协议。三级封锁协议为并发操作的正确调度提供一定的保证,不同级别的封锁对应不同级别的数据一致性。两段锁协议保证了并发调度的可串行化。
三级封锁协议
一级封锁协议:事务在修改数据之前必须先对其加X锁,直到事务结束才释放。一级封锁协议解决了丢失修改问题,也就是写不一致性,但如果仅仅是读,是不需要加锁的,所以不能保证读不一致性问题。
二级封锁协议:一级封锁协议基础上加上事务在读数据之前必须对其加S锁,读完即释,即X锁+舜时S锁。二级封锁协议能解决丢失更新,读脏数据问题,但不能保证可重复读,不出现幻读。
三级封协议:一级封锁协义加上事务在读取数据之前必须对其加S锁,事务结束后释放。与二级封锁协议的差别在于,S锁的释放时机。三级封锁协议能解决丢失更新,读脏数据,不可重复读问题,但不能保证不出现幻读。新增或删除的记录数仍可以被统计。
两段锁协议
操作系统对并发事务的调度是随机的,不同的调度产生不同的结果,并发操作可串行化是并发操作调度正确性的标准。从理论上讲,事务的顺序执行,一定是可串行化的调度,但这这调度策略简单粗暴,降低了并发性。
两段锁协议在对任何数据进行读、写之前,首先要申请并获得该数据的封锁,在释放一个锁后,事务不能再获得其它锁。
两段锁协议利用封锁方法实现保证并发调度可串行化,从而保证了数据的一致性,禁止幻读。
5. 丢失更新
丢失更新(lostupdate)包含两种:
第一类丢失更新(lostupdate):在完全未隔离事务的情况下,两个事物更新同一条数据资源,某一事物异常终止,回滚造成第一个完成的更新也同时丢失。也就是在事务未结束时,两个事务可以修改同一条数据。
第二类丢失更新(second lost updates):是不可重复读的特殊情况,如果两个事务都读取同一行,然后两个都进行写操作,并提交,第一个事务所做的改变就会丢失。这是不可重复读的特例,隔离级别与不可重复读相同。
虽然这两类都是丢失更新,但实现机制与隔离级别是完全不一样的。在实际应用系统中,常常提及的丢失更新是第二类丢失更新,但也常常混淆了其隔离级别。
6. 隔离级别
事务的隔离级别描述了事务相互暴露程度,并反映了某种程度的数据致一致性。SQL-92定义了四种隔离级别:Read Uncommitted, Read Committed, Repeatable Read, Serializable,针对三种数据不一致现象:DirtyReads(脏读), Non-Repeatable Reads(不可重复读), PhantomReads(幻读) ,但没有规定实现机制。
ReadUncommitted,对应一级封锁协议,防止第一类丢失更新,会出现脏读,不可重复读(包含第二类丢失更新),幻读;
ReadCommitted,对应二级封锁协议,防止丢失更新,脏读,会出现不可重复读(包含第二类丢失更新),幻读;
RepeatableRead,对应三级封锁协议,防止丢失更新,脏读,不可重复读(包含第二类丢失更新),会出现幻读;
Serializable,对应两段锁协议,防止丢失更新,脏讯,不可重复读(包含第二类丢失更新),幻读。
从上可以看出,防止第二类丢失更新,隔离级别必须在可重复读以上。但如果事务更新数据,不是基于前面读出来的数据更新(一条SQL语句搞定),那么,在以上任一个隔离级别也不会发生丢失更新。也就是常见的数值增量更新(实际增量更新必须满足业务等其它限制条件,如更新结果不许小于零等),不会发生丢失更新。
JDBC事务级别与此四种级别对应。但并不是每个数据库都支持这种四级别,ORACLE提供了标准的READ COMMITTED,SERIALIZABLE,同时提供了非标准的READ ONLY。READ COMMITTED是ORACLE缺省的的事务隔离级别。DBMS可以设置事务级别,ORACLE如下:
SETTRANSACTION ISOLATION LEVEL READ COMMITTED
二、 企业应用并发控制
在企业或WEB应用中,业务处理包含一系例的用户交互和数据库访问。应用事务包含从用户角度来看的操作系列,应用程序事务可能跨越多次请求,包含多个数据库事务。
企业应用并发控制一般都基于数据库并发控制,应用事务最终体现在数据库事务上。企业应用并发控制从包含数据库事务数量及是否包含用户交互分成两类,一类是,一个应用事务由一个数据库事务构成,不包含用户交互,对于这类并发控制,归类于数据库并发控制;另一类,应用事务是一个长业务事务,由许多数据库事务及用户交互构成,这类也是企业应用经常需要处理的,归类于应用事务。
1. 数据库事务划分
数据库事务长短决定了应用的并发能力。事务越长,应用程序的可伸缩性与并发能力越弱。数据库连接是种稀缺的资源,事务持有数据库连接时间越长,其它事务获取数据库连接机会就越少,可伸缩性减弱;长事务锁住了数据库资源,如表,记录,阻止其它事务访问这些资源,降低了系统并发能力。
正确的划分事务范围,对数据库一致性与并发能力非常重要。常规事务划分方法有,一次请求一个数据库事务、一个用例或一个会话一个数据库事务、一次数据库访问一个数据库事务、一个方法一个数据库事务。比较常用的方式是请求一个事务;一次数据库访问一个事务,是一种反模式,一般不会用在企业应用开发当中;一个用例一个数据库事务带来的代价比较高,只适应特定场景。当一次请求一个处理时间较长,在保证数据一致性的前提上,一些场景将一个方法定义为一个事务,以提高并发能力。
2. 数据库事务并发控制
可串行化调度是事务并发执行正确性的准则。当一个事务与另一个事务完全隔离时,其执行结果一定是可串行化的。
串行化执行
串行化执行可以从两个层次考虑:应用层、数据库层。应用程序可以采取如增量更新而非全量覆盖更新,CQRS(采用异步、队列、单线程),这需要从整个系统角度考虑加以支持,不深入探讨;
数据库层次使事务完全隔离,保证了调度的可串行化。从上面可知防止第二类丢失更新,隔离级别必须在Repeatable Read以上。Repeatable Read还可能出现幻读,如在一个事务当中读取订单明细行,两次读取明细行不一样。完全事务隔离,需要采取Serializable隔离级别。
采取RepeatableRead 和Serializable隔离级别牺牲了系统性能与可伸缩性。优点:
l 实现简单,设置JDBC或数据库隔离级别就行了,不需要额外的实现
l 保证了数据一致性
缺点是:开销大,容易发生死锁。当要求数据强读一致性,并可以接受这种系统开销时,可以使用这种并发控制策略。
一般企业应用中很少采用这种并发控制策略,更多采用:数据库层次采用Read Committed,阻止第一类丢失更新和读脏数据,提高并发性;应用层次使用乐观或悲观锁,检测或阻止第二类丢失更失,允许一定程度上的读不一致性。
乐观控制策略
数据库层次的串行化,无论是否发性并发写操作,都要较大的开销。而在实际应用中,读远大于写,所以比较理想的方法是把这种开销放在发生第二类丢失更新时,允许一定程度的读不一致性,乐观控制策略就是采取这种思想。
乐观锁并没有锁任何东西,认为数据很少发生同时存取,很少冲突,使用检测来解决第二类丢失更新:事务更新数据时验证数据是否读取后发生了修改和删除。乐观控制包含以下几步:
l 读取数据
l 修改数据
l 检查数据:检查数据在读取数据后是否发生修改或删除
l 提交或回滚
跟踪修改
乐观控制策略有三种手段跟踪数据是否发生变化。第一种方法,在数据表中增加一个版本号,每次修改时增加版号,通过比较版号来检证数据。第二种方法是通过时间戳比较,每次修改时,记录时间戳。但当两个事务更新数据间隔较小时,存在可能发第二类丢失更新。第三种方法通过属性比较,比较数据属性,检查数据是否发生变化。这种方法UPDATE语句比较复杂,而且一些空值与浮点数比较麻烦。
乐观锁检查
当验证数据与提交数据是一个原子操作时,乐观锁容易发生TOCTTOU问题,在检测与修改之间,其它事务可以修改数据。如:
if(po.getVesion()!=pojo.getVesion)throws ConcurrentExeception ....执行其它逻辑... dao.saveModify(pojo)
从SQL层次,乐观锁典型检查方法实现如下:
UPDATEWHOLESALEORDER SET STATE = 'APPROVED' , VERSION = VERSION +1
WHEREBILLNUMBER = ? AND VERSION = ?
当更新影响结果行数为0时,回滚事务。JDBC和MYBATIS需要程序实现检查,而HIBERNATE和JDO等持久化框架,提供自动支持,其实现方式,在SQL层次方法相同。
实例
用例:
赊销协议具有有效期,有效期结束时间小于当前,赊销协议自动过期。
事务1:过期 |
事务2:中止 |
Select a.version,... from MCSOpenAccAgr a where a.billnuber = 'xxx' and state = 'effected'; Read version = v1; |
|
Select a.version,... from MCSOpenAccAgr a where a.billnuber = 'xxx' and state = 'effected'; Read version = v1; |
|
Update set mcsopenaccagr set state = 'overdue',version = version +1 where billnuber = 'xxx' and version = v1; commit; |
|
Update set mcsopenaccagr set state = 'terminated',version = version +1 where billnuber = 'xxx' and version = v1; rollback; |
事务2 Update执行返回结果数为零,检查到数据已被修改,事务2回滚,然后重新开始,协议不会被中止,防止了第二类丢失修改。
乐观锁并发能力较高,实现简单。但是所有发生数据冲突的地方都必须使用乐观锁,乐观锁不能保证读一致性,不能保证所有的修改都可以保存。
乐观控制策略适应于数据争取少,并且不保证一定可以更新,能承受偶尔回滚事务带来的开锁的场景中。
悲观控制策略
当不支持乐观锁,如在遗留系统中无法增加时间戳或版号,也无法通过比较属性实现,或应用必须保证读取数据后一定可以更新,或数据冲突激烈,事务回滚比加锁开销大。另一种并发控制策略是悲观控制策略。
悲观控制假设数据冲突一定会发生,所以用悲观锁锁住所有读的数据,这样阻止了其它事务读或更新这些数据,直到事务提交或回滚释放这些锁。
悲观锁的现实与具体数据库相关,在ORACLE中用SELECT FOR UPDATE(在ORACLE中,还有SELECT FOR UPDATE NO WAIT等方式) 锁定所有读取的数据。一般实现如下:
Select... from MCSOpenAccAgr a where a.billnuber = 'xxx' and state = 'effected' forupdate;
事务1:过期 |
事务2:中止 |
Select ... from MCSOpenAccAgr a where a.billnuber = 'xxx' and state = 'effected'for update; |
|
Select ... from MCSOpenAccAgr a where a.billnuber = 'xxx' and state = 'effected'for update; block; |
|
Update set mcsopenaccagr set state = where billnuber = 'xxx' and state = 'effected'; commit; |
|
悲观锁能防止第二类丢失更新,保证可重复读,事务1锁住数据后,事务2不能删除与修改,但可以插入,所以可能出现幻读。
使用悲观锁不需要修改表示结构,增加字段,但需要数据库支持,相比来说,开销小于数据库层次的串行化事务。但是所有可能发生数据冲突的地方,必加上悲观锁,如ORACALE必须用SLECT FOR UPDATE,否则会发生数据丢失。悲观锁降低了并发能力,增加了死锁的可能性。
应用程序使用持久化框架,如果持久化框架不支持悲观锁,那么应用程序无法基于持久化框架实现悲观控制。悲观控制也无法使用缓存,它必须访问数据库以锁定数据。
3. 应用事务并发控制
用例:
用户搜索订单,打开订单,编缉,保存。
多用户同时操作界面,需要防止用户丢失修改。理论上,可在一个数据库事务实现这个用例,进行并发控制:打开订单开启事务,保存修改提交事务,通过数据库层次的串行化事务、乐观锁或悲观实现并发控制。但在企业应用中,无法接受一个数据库事务跨用户交互,因为用户交互时间不可控,长事务带来的开销太大。一般通多个数据库事务,实现应用事务。
应用事务一般包括多个数据库事务和人机交互,从用户角度必须保证数据一致性。数据库层次锁的生命周期存在于一个事务范围,事务结束,锁就被释放了。数据库层次的封锁只能处理事务当中或并发事务当中并发操作,无法处理跨数据库事务中数据不一致问题,如一个数据库事物获取数据,另外一个数据库事务更新数据,可能导致丢失更新,所以串行化数据库事务,数据库层次悲观锁无法处理跨数据事务数据丢失问题。
可以通过乐观离线锁与悲观离线锁,实现应用事务的并发控制。悲观离线锁通过应用程序层次的锁机制实现;乐观离线锁,扩展了乐观锁,检查用例开始后,也许经过了几个数据库事务,数据是否发生变化。
乐观离线锁(Optimistic Offline locking)
乐观离线锁与乐观锁一样,唯一差别在于,乐观离线锁读和写分别在两个不同的事务当中,而乐观锁读与写在一个事务当中,实现方式相同。
悲观离线锁(Pessimistic Offline Locking)
乐观离线锁,不保证读取的数据都能保存,不能保证每个用户操作都能成功,冲突数据需要合并或重新开始。在有些场景中无法接受,如在电话席座服务中,用户一边接电话,一边操作界面,冲突后,已经无法重新开始(电话挂了)。悲观离线锁锁住共享数据阻止并发操作,保证每个用户操都能保存成功。当数据冲突概率高或冲突处理代价大,使用悲观离钱锁较合适。悲观离线锁与悲观锁差机制类似,不过一个是在应用程序层加锁,一个是在数据库层次加锁。悲观离线锁一次只允许一个用户编缉一份数据。
悲观离线锁实现代价较大,而且容易出错,需要保证锁一致性,代码需要精心设计。悲观离线锁考虑的需要问题较多:锁什么?什么时候锁和释放锁?采用什么样的锁?以什么样的单位维护锁?本文不深入。
参考:
1. http://blog.sina.com.cn/s/blog_6d7fe20d0100qxqg.html
2. http://docs.jboss.org/hibernate/entitymanager/3.5/reference/en/html/transactions.html
3. http://course.cug.edu.cn/cug/database/netclass/CHAPT8/8.5/8.5.htm
4. http://www.iteedu.com/webtech/j2ee/hibernatediary/50.php
5. POJOs in Action
6. http://www.telerik.com/help/openaccess-orm/transactions-isolation-levels.html
7. http://dcx.sybase.com/1101/en/dbprogramming_en11/sqlapp-s-4654501.html
8. http://www.oracle.com/technetwork/issue-archive/2005/05-nov/o65asktom-082389.html
9. http://stackoverflow.com/questions/8201430/minimum-transaction-isolation-level-to-avoid-lost-updates?rq=1