J2EE事务并发控制策略总结

本文结合hibernate以及JPA标准,对J2EE当前持久层设计所遇到的几个问题进行总结:

第一:事务并发访问控制策略
当前J2EE项目中,面临的一个共同问题就是如果控制事务的并发访问,虽然有些持久层框架已经为我们做了很多工作,但是理解原理,对于我们开发来说还是很有用处的。

事务并发访问主要可以分为两类,分别是同一个系统事务和跨事务访问的并发访问控制,其中同一个系统事务可以采取乐观锁以及悲观锁策略,而跨多个系统事务时则需要乐观离线锁和悲观离线锁。在讨论这四种并发访问控制策略之前,先需要明确一下数据库事务隔离级别的问题,ANSI标准规定了四个数据库事务隔离级别,它们分别是:

读取未提交(Read Uncommitted):这是最低的事务隔离级别,读事务不会阻塞读事务和写事务,写事务也不会阻塞读事务,但是会阻塞写事务。这样造成的一个结果就是当一个写事务没有提交的时候,读事务照样可以读取,那么造成了脏读的现象。

读取已提交(Read Committed):采用此种隔离界别的时候,写事务就会阻塞读事务和写事务,但是读事务不会阻塞读事务和写事务,这样因为写事务会阻塞读取事务,那么从而读取事务就不能读到脏数据,但是因为读事务不会阻塞其它的事务,这样还是会造成不可重复读的问题。

可重复读(Repeatable Read):采用此种隔离级别,读事务会阻塞写事务,但是读事务不会阻塞读事务,但是写事务会阻塞写事务和读事务。因为读事务阻塞了写事务,这样以来就不会造成不可重复读的问题,但是这样还是不能避免幻影读问题。

序列化(serializable):此种隔离级别是最严格的隔离级别,如果设置成这个级别,那么就不会出现以上所有的问题(脏读,不可重复读,幻影读)。但是这样以来会极大的影响到我们系统的性能,因此我们应该避免设置成为这种隔离级别,相反的,我们应该采用较低的隔离界别,然后再采用并发控制策略来进行事务的并发访问控制)。

其实我们也可以把事务隔离级别设置为serializable,这样就不需要采用并发控制策略了,数据库就会为我们做好一切并发控制,但是这样以来会严重影响我们系统的伸缩性和性能,所以在实践中,我们一般采用读取已提交或者更低的事务隔离级别,配合各种并发访问控制策略来达到并发事务控制的目的。下面总结一下常用的控制策略:

1 乐观锁
乐观锁是在同一个数据库事务中我们常采取的策略,因为它能使得我们的系统保持高的性能的情况下,提高很好的并发访问控制。乐观锁,顾名思义就是保持一种乐观的态度,我们认为系统中的事务并发更新不会很频繁,即使冲突了也没事,大不了重新再来一次。它的基本思想就是每次提交一个事务更新时,我们想看看要修改的东西从上次读取以后有没有被其它事务修改过,如果修改过,那么更新就会失败,。

最后我们需要明确一个问题,因为乐观锁其实并不会锁定任何记录,所以如果我们数据库的事务隔离级别设置为读取已提交或者更低的隔离界别,那么是不能避免不可重复读问题的(因为此时读事务不会阻塞其它事务),所以采用乐观锁的时候,系统应该要容许不可重复读问题的出现。

了解了乐观锁的概念以后,那么当前我们系统中又是如何来使用这种策略的呢?一般可以采用以下三种方法:

版本(Version)字段:在我们的实体中增加一个版本控制字段,每次事务更新后就将版本字段的值加1.

时间戳(timestamps):采取这种策略后,当每次要提交更新的时候就会将系统当前时间和实体加载时的时间进行比较,如果不一致,那么就报告乐观锁失败,从而回滚事务或者重新尝试提交。采用时间戳有一些不足,比如在集群环境下,每个节点的时间同步也许会成问题,并且如果并发事务间隔时间小于当前平台最小的时钟单位,那么就会发生覆盖前一个事务结果的问题。因此一般采用版本字段比较好。

基于所有属性进行检测:采用这种策略的时候,需要比较每个字段在读取以后有没有被修改过,所以这种策略实现起来比较麻烦,要求对每个属性都进行比较,如果采用hiernate的话,因为hibernate在一级缓存中可以进行脏检测,那么可以判断哪些字段被修改过,从而动态的生成sql语句进行更新。

下面再总结一下如何在JDBC和Hibernate中使用乐观锁

JDBC中使用乐观锁:如果我们采用JDBC来实现持久层的话,那么就可以采用以上将的三种支持乐观锁的策略,在实体中增加一个version字段或者一个Date字段,也可以采用基于所有属性的策略,下面就采用version字段来做一演示:

假如系统中有一个Account的实体类,我们在Account中多加一个version字段,那么我们JDBC Sql语句将如下写:
Select a.version....from Account as a where (where condition..)
Update Account set version = version+1.....(another field) where version =?...(another contidition)

这样以来我们就可以通过更新结果的行数来进行判断,如果更新结果的行数为0,那么说明实体从加载以来已经被其它事务更改了,所以就抛出自定义的乐观锁定异常(或者也可以采用spring封装的异常体系)。具体实例如下:
.......
int rowsUpdated = statement.executeUpdate(sql);
If(rowsUpdated= =0){
throws new OptimisticLockingFailureException();
}
........

在使用JDBC API的情况下,我们需要在每个update语句中,都要进行版本字段的更新以及判断,因此如果稍不小心就会出现版本字段没有更新的问题,相反当前的ORM框架却为我们做好了一切,我们仅仅需要做的就是在每个实体中都增加version或者是Date字段。

Hibernate中使用乐观锁:如果我们采用hibernate做为持久层的框架,那么实现乐观锁将变得非常容易,因为框架会帮我们生成相应的sql语句,不仅减少了开发人员的负担,而且不容易出错。下面同样采用version字段的方式来总结一下:
同样假如系统中有一个Account的实体类,我们在Account中多加一个version字段,
public class Account{
Long id ;
.......
@Version //也可以采用XML文件进行配置
Int version
.......

}

这样以来每次我们提交事务时,hibernate内部会生成相应的SQL语句将版本字段加1,并且进行相应的版本检测,如果检测到并发乐观锁定异常,那么就抛出StaleObjectStateException.

2 悲观锁
所谓悲观锁,顾名思义就是采用一种悲观的态度来对待事务并发问题,我们认为系统中的并发更新会非常频繁,并且事务失败了以后重来的开销很大,这样以来,我们就需要采用真正意义上的锁来进行实现。悲观锁的基本思想就是每次一个事务读取某一条记录后,就会把这条记录锁住,这样其它的事务要想更新,必须等以前的事务提交或者回滚解除锁。

最后我们还是需要明确一个问题,假如我们数据库事务的隔离级别设置为读取已提交或者更低,那么通过悲观锁,我们控制了不可重复读的问题,但是不能避免幻影读的问题(因为要想避免我们就需要设置数据库隔离级别为Serializable,而一般情况下我们都会采取读取已提交或者更低隔离级别,并配合乐观或者悲观锁来实现并发控制,所以幻影读问题是不能避免的,如果想避免幻影读问题,那么你只能依靠数据库的serializable隔离级别(幸运的是幻影读问题一般情况下不严重)。

下面就分别以JDBC和hibernate来总结一下:

JDBC中使用悲观锁:在JDBC中使用悲观锁,需要使用select for update语句,假如我们系统中有一个Account的类,我们可以采用如下的方式来进行:
Select * from Account where ...(where condition).. for update.

当使用了for update语句后,每次在读取或者加载一条记录的时候,都会锁住被加载的记录,那么当其他事务如果要更新或者是加载此条记录就会因为不能获得锁而阻塞,这样就避免了不可重复读以及脏读的问题,但是其他事务还是可以插入和删除记录,这样也许同一个事务中的两次读取会得到不同的结果集,但是这不是悲观锁锁造成的问题,这是我们数据库隔离级别所造成的问题。

最后还需要注意的一点就是每个冲突的事务中,我们必须使用select for update 语句来进行数据库的访问,如果一些事务没有使用select for update语句,那么就会很容易造成错误,这也是采用JDBC进行悲观控制的缺点。

Hibernate中使用悲观锁:相比于JDBC使用悲观锁来说,在hibernate中使用悲观锁将会容易很多,因为hibernate有API让我们来调用,从而避免直接写SQL语句。下面就hibernate使用悲观锁做一总结:

首先先要明确一下hibernate中支持悲观锁的两种模式LockMode.UPGRADE以LockMode.UPGRADE_NO_WAIT.(PS:在JPA中,对应的锁模式是LockModeType.Read,这与hibernate是不一样的呵呵)
假如我们系统中有一个Account的类,那么具体的操作可以像这样:
.......
session.lock(account, LockMode.UPGRADE);
......

或者也可以采用如下方式来加载对象:
session.get(Account.class,identity,LockMode.UPGRADE).

这样以来当加载对象时,hibernate内部会生成相应的select for update语句来加载对象,从而锁定对应的记录,避免其它事务并发更新。

以上两种策略都是针对同一个事务而言的,如果我们要实现跨多个事务的并发控制就要采用其它两种并发控制策略了,下面做一总结:

[该贴被admin于2008-12-17 09:19修改过]

3 乐观离线锁

乐观离线锁的思想和乐观锁是一致的,不同的地方就是乐观锁是针对一个事务周期的(也可以说是一个request周期),而乐观离线锁是跨多个事务周期的(可以理解为一个会话周期(conversation),它包括了用户思考的时间。

因为乐观离线锁是跨多个事务周期的,那么我们就遇到了一个问题,在几个事务之间通过什么来保存我们的实体状态呢?当然我们会想到httpsession,知道了这个,我们又会遇到一个问题,我们是通过业务层来判断有没有发生乐观并发异常还是通过持久层框架来实现(因为目前持久层框架一般都有一个脱管(detached object)的概念,我们通过它可以很容易实现乐观离线锁),下面就以上两种方式分别做一总结(假设持久层框架采用hibernate):

在进行总结之前,先让我们来选个场景来描述,因为最近在网上投了几份简历,我发现投简历之后,在HR处理简历之前,我们可以修改简历,我觉得这个例子就很符合乐观理线锁的问题域,所以我就以它来分析一下。我们先假设系统中有一个简历类Resume(因为只是一个例子,为了让问题更加清楚,我将其它的属性省略):


public class Resume implements Serializable{
public long id ;

.............

public int version ;

............

}
业务层负责判断乐观并发异常:在此种情况下,我们一般需要在Resume中增加一个获取字段的getVersion()方法,因为我们要将获得的版本字段值保存到Httpsession里面,首先第一个请求过来,我们获得版本字段将其保存到httpsession中,然后当用户修改完简历后打算更新的时候,那么我们的业务层怎么实现呢?可以如下:
..............
Resume resumeToModify = resumeRepository.getResume(id);
If(beforeModifyVersion == resumeToModify.getVersion()){
//这里进行修改操作
}else
throw new OptimisticLockFailureException();

这样我们就可以通过对比httpsession中的版本字段来和当前数据库系统中的字段进行比较,如果一样就提交,否则就抛出异常。(呵呵,当然这个仅仅是作为例子,其实如果你修改了很大一会简历,但是发现提交的时候不能完成,那你应该会很郁闷,觉得这网站做的不人性化,然而这是采用乐观离线锁不能避免的,所以这也就出现了使用乐观离线锁的一个前提:系统中并发更新少,并且即使冲突了,让用户重来一遍的开销也不会很大,如果开销大的话,那么我们就需要采用悲观离线锁模式)。

通过detached object实现:如果通过持久层提供的脱管对象来实现,那么我们就可以依赖于持久层框架的乐观检测来实现乐观离线锁,这样我们不需要在业务层进行比较,持久层框架会帮我们进行比较,如果发现数据已经被更改过,那么就会抛出相应的异常(比如hibernate将抛出StaleObjectStateExcpetion)。我们还是拿刚才的那个例子来做一描述,当用户要修改简历时,第一个请求会将简历的内容读取并显示给用户,我们需要做的就是将脱离了持久层的脱管对象保存到httpsession中,然后当用户修改完以后,要提交更新的时候,我们可以如下实现:
............
resumeRepository.update(detachedResume);//内部通过调用hibernate session接口的update方法,将脱管对象重附(reattch)到当前的持久化上下文
.............

这样以来,当持久层框架发现数据已经被修改后就会抛出相应的异常,我们的业务层只要捕获异常就OK啦。

好了,总结完了两种方式以后,我们来看看它们有哪些优缺点:

对于第一种策略,因为需要们显示的来判断到底有没有出现异常,所以略显麻烦,但是优点就是因为我们只需要保存一个版本字段,不会出现将整个对象都保存到httpsession中,这样造成httpsession被充爆。如果采用第二种策略(detached object),不需要我们显示来实现,只需要捕获持久层抛出的异常就OK,但是也有个不好地方,前面也说了,那就是会造成我们httpsession爆炸,这样很浪费内存。再说点题外话,既然httpsession会引起内存浪费太多,那么有没有更好的方案呢?当然我们会想到EJB的有状态会话bean,它为业务状态提供了钝化技术,所以可以避免占用太大的内存,并且它还有个更好的优点就是不仅有状态管理,而且还支持完整的事务语义,所以EJB的statefull session bean还是有很大好处的,并不像有些人所说的statefull session bean 用处非常小。

4 悲观离线锁
悲观离线锁的基本思想和离线锁差不多,不同的地方就是悲观锁是依赖于数据库的锁机制来实现,而悲观离线锁则需要我们开发人员自己来控制锁的释放和获取,所以增加了一定的复杂性。
下面需要明确一下什么情况下需要使用悲观离线锁,因为悲观离线锁会在整个会话期间锁住数据,所以会影响到系统的伸缩性和性能,选择时要谨慎考虑几方面的因素:

1 用例是否通过几个数据库事务来实现,第一个事务读取数据,最后一个事务更新数据,首先要满足这个条件我们才会选择用悲观离线锁,否则如果在同一个数据库事务,直接用离线锁即可。

2 用例是否要求用户提交的更新一定要成功,以及用户提交事务后重新来一次的开销大不大,如果要求用户提交更新一定要成功,或者用户提交更新失败后,再重新来一次的开销比较大,那么就采取悲观离线锁,否则的话,我们可以采用乐观离线锁。
注:详细的悲观离线锁讨论请参考马丁大叔的POEAA.
[该贴被admin于2008-12-17 09:20修改过]

因为论坛限制每帖的字数,所以只能分开写,请谅解。此贴内容是我以前写的总结,今天copy到论坛和大家讨论,希望各位大哥大姐给出宝贵的意见。

还有两个问题:分别是持久化上下文的传播以及如何实现会话,此两个话题改天再发上来。

非常好的总结,特别是隔离级别抓住读事务和写事务之间关系将四个隔离级别本质的描述出来,易理解和掌握。期待楼主下一篇上下文的文章,非常好。你的理解如此深刻,恐怕比很多面试官要强多啊,他考不出你这个水平怎么办呢。这是技术以外的事情了。

实践中,Oracle缺省是读取已提交级别,而SQL server MySQL Sysbase都是性能较差,安全较高的可重复读,所以,对后面这些数据库,我们一般特别手工配置为读取已提交级别。

乐观锁在实际中使用普遍,因为带来良好的性能,仔细考虑乐观锁,会发现主要是由我们程序来控制的,这里就有一个更进一步的思路,如果我们追求更高的性能,是否可以全部由我们程序来控制,直接对内存缓存中的某个对象进行类似乐观锁的控制呢?
http://www.jdon.com/article/34773.html

在这种情况下,依赖数据库锁的事务模式将转化为并发线程安全模式,这样的好处就是带来更好的伸缩性和性能扩展,特别是借助兵马俑Terracotta((http://www.terracotta.org/)这些分布式内存锁技术,我们可以摆脱集中式计算模型,使用类似google那样多台服务器群的集群分布式模型。

老外的一个开源云计算产品使用我们中国名词兵马俑来命名,可谓含义深刻啊。2000年前伟大中国皇帝秦始皇就通过展示兵马俑集群兵阵来显示帝国的强盛,为什么2000年以后我们这些所谓先秦诸子的后人却不能在计算机领域欣然接受分布式计算这样可伸缩架构,无法摆脱津津乐道于单机集中式性能的提升?

两个案例说明这个现象:首先是我一直在Jdon.com倡导的数据库已死概念,就是打破集中计算模式,倡导分布式计算,但是遭遇很多人的抗议;还有一则小事:前几天听新闻报道,我国推出一台只有普通PC 2倍大小的计算机,计算性能却是普通PC的5倍,真是可笑,十台普通PC借助云计算肯定搞过你这个一台装在BOX里计算机,价格便宜,可以普及,老百姓都能自己实现;没见新闻报道:我国XX研究所100台兵马俑式服务器群打破世界计算性能记录,这才是真正的实力,是软实力,是巧的实力。

真诚希望我们昔日的辉煌能够重现今日,到处可见象征昔日辉煌名词显现在我们自己中国人自己的计算机领域。有楼主这样的人才,就大有希望啊。

[该贴被banq于2008-12-17 10:03修改过]

刚才发的帖子怎么不见了?总之是感谢楼主的文章,解决了我的一个疑惑。
banq大哥,论坛是不是有bug啊,呵呵

偶然间搜到了一个和LZ同样的文章,那里是LZ的博客吧。

>>偶然间搜到了一个和LZ同样的文章,那里是LZ的博客吧。
是的,那个是我的博客,我比较喜欢写博客呵呵。

>>直接对内存缓存中的某个对象进行类似乐观锁的控制呢?
banq老师的这个帖子我也看了,我当时看的时候非常震撼,确实是一种新的思路,正好最近我也正在深入学习Java并发编程,有共鸣。

>>你的理解如此深刻,恐怕比很多面试官要强多啊,他考不出你这个水平怎么办呢。这是技术以外的事情了。
说的面试,我前段时间面试了几次,到后来都变成我提问题,面试官答,或者我和面试官讨论了,每次面试都面的很顺利,尤其一次在一个上市公司面试,等我说完了对EJB的理解,以及J2EE开发的理解后,面试官说“你是不是你们软件学院最好的学生之列”,我当时有点激动,还有人说我是最好的学生,我从来就不是最好的学生,我对面试官说“我只是把J2EE当做我的爱好,我学习J2EE不是为了找工作,是为了让自己开心,因为我J2EE在我们学院来说,学的应该是最好的,但是其他方面就不行了,比如.net我压根都没咋学,C++学的也不怎么好”,总之我觉得只要我们把它当做一种爱好,兴趣,不要以利益的驱使去学习,一定会有成果,并且也一定会学好的。(目前学校好多招聘C++,并且一个月工资,应届生都要5,6千,而我做JAVA目前虽然签了一个还算比较大的软件公司(上市公司),但是薪水也只有2千多,但是我还是喜欢J2EE,钱多少不要紧,关键是开心就好)。

如果你这样想,毕业三年以后你的薪水会超过用C++的那些人的。

>>特别是借助兵马俑Terracotta((http://www.terracotta.org/)这些分布式内存锁技术,我们可以摆脱集中式计算模型,使用类似google那样多台服务器群的集群分布式模型。

确实,任何物质的发展都是多方面的作用里作用的结果,当前做软件系统,确实是需要将垂直伸缩和水平伸缩先结合,而垂直伸缩总之是有限制的(成本高,买个强CPU和买个内存的价格是完全不一样的),并且我们寄托希望给那些硬件工程师们,让他们去提高单机的性能吧,而我们软件工程师则更加关注与水平伸缩性,通过集群来提高系统的水平伸缩性。

>我们软件工程师则更加关注与水平伸缩性
这个思想很重要,不亚于当初人类直立行走的意义,这个意义就是软件不再受硬件资源条件的限制,进一步拓展开来,软件所实现的应用需求也就不再受硬件资源条件的限制。

进一步演绎:也就是说:我们可以完全关心业务需求,而不必担心硬件资源等条件的限制,这就是DSL/DSM,这就是软件的革命。

这种摆脱资源限制的思维和C/C++那种螺丝壳里做道场的微观思维是严格冲突的,因为当前我们大学基础教育都是基于C/C++,根据统计,他们毕业后有一半要转行做Java,由于思维没有革新,就会把Java做死,例如:几年前借助EJB2发展中不足,否定EJB,EJB2是当时条件下最具有伸缩的架构技术。当然思维改变不是我一人在这里吆喝会改变的,这是整个软件教育体系多年“畸形”培养的结果。

我在这里唯一强调的是:不要小看软件伸缩性,不是节省几个CPU这样简单的事情,而是架构大思维的改变。就象兵马俑兵阵和张三丰个人武侠高手的区别。是西洋火器和义和团式单打武术的区别。快醒醒。。。。

[该贴被banq于2008-12-17 13:09修改过]

C++一样有分布式的结构,COBRA是最早实现分布式的协议,java的RMI还要与COBRA兼容的。
C++这方面的资料很多不比java少,而且性能更高。通常是电信级的设计。比如反应堆模式,前摄器模式,这都是C++在不同的服务器上为了提高相应而采用的方式。这些java都是没有的。

能不能做和做得是否巧妙优雅完全是两件事。就象人类直立行走,猴子偶尔也能直立行走,狗也能,但是这里面有本质区别。

我们国人思维可能是:厚重有余,灵巧不足啊。包括我自己也有时是这样。

厚道反而失道,灵龙才能随道,这是我对道的个人理解,呵呵,有失传统意义的解释啊。


[该贴被banq于2008-12-17 14:33修改过]

>>java的RMI还要与COBRA兼容的。
呵呵,C++也是一门伟大的语言,就是因为时间原因,我只能选择一个方向来学习.EJB本来就是分布式组件,所以要想实现分布式,那么当然要考虑和其它分布式组件的互操作,目前只有RMI-IIOP协议是兼容corba的,RMI-JRMP协议就不与CORBA兼容,目前的EJB容器要求必须支持RMI-IIOP协议,这样才能保证JAVA与其他语言写的系统的集成。其实分布式是一种有了很多年但是很先进的思想,大家你做你的东西,我做我的东西,等我们都做好了,好,来咱集成起来,这岂不更好哈哈。

所以我也满喜欢和看好EJB的。正是因为有了EJB,有了RMI-IIOP才使得JAVA更加牛X,这样JAVA写的程序可以和C++,CORBA组件集成,多好哈哈。
[该贴被xmuzyu于2008-12-17 14:55修改过]