最近遇到一个场景:用户表的主键从另一个表(主键表)中根据一个字段的值手工增加来获取

主键表key_constant是这样的:

id seq version
1 1001 1
获取主键的方法大概是这样的:
@Transactional
publicsynchronizedStringgetKey(Stringtype){
//sql:select * from key_constnt where id=1
KeyConstantPOkey =this.keyConstantDAO.queryById(1);
intseq =key.getSeq()+1;
key.setSeq(seq);
//sql:update key_constant set seq=#seq# where id=#id# and version=#version#
this.keyConstantDAO.update(keyMod);
returnseq;
}
使用loadrunner一压测,发现会报好多主键重复的异常,经过多次思考,最终得以解决:
第1次思考
误区:我以为这个代码在并发情况下没问题,因为有同步synchronized啊,A先执行这个方法,B就得等待A执行完再执行,应该不会有问题
解释:这个同步确定是保证了A和B得串行执行这个方法里面的代码,但是,数据库操作提交是依赖spring拦截器,这里用synchronized却是无法让拦截器提交事务的代码同步起来,所以可能存在A在spring的拦截器提交事务的过程中,B进到方法去查数据了,结果查出来的是旧的
办法:可以把事务提交在方法里面以编程式的方式来手工提交,这样提交事务的代码也被同步起来了,那就没问题了
第2次思考
误区:由于系统是统一的注解配制事务,所以并不想做另外处理,另外,当系统在集群环境下,用同步也会有问题,你只能同步一个进程里面的代码,多机器就没用了。我想要把同步给去掉,还是用乐观锁来实现好一点,悲观锁我们觉得效率较低。代码被改成了如下的方式,使用乐观锁:
@Transactional
publicStringgetKey(Stringtype){
intseq =0;
KeyConstantPOkey =null;
do{
//sql:select * from key_constnt where id=1
key =this.keyConstantDAO.queryById(1);
seq =key.getSeq()+1;
key.setSeq(seq);
//sql:update key_constant set seq=#seq# where id=#id# and version=#version#
}while(this.keyConstantDAO.update(key)==0);
returnseq;
}

可惜,还是报主键重复,悲剧

解释:使用乐观锁的思想来解决这个问题是没错的,但是乐观锁的用法错了啊,version每次都是一样的,没变过,造成每个人去更新数据时,影响行数都是1

办法:是sql语句有问题,应该是这样的:

update key_constant setseq=#seq#, version=version+1where id=#id# and version=#version#

看到这里,你以为上面的改动必须会成功,那么,你错了,实际上还是失败的。

第3次思考

误区:可能很多人看到上面的Sql修改后,会认为是成功的,但是实际上在多线程测试中,只有1个成功,其它全失败。why? 我也觉得奇怪,按道理,上面的应该肯定是对的啊。我添加sysout输出日志,根据观察,发现除了第1个线程第1次更新成功外,后面所有人都陷入了死循环(更新影响行数是0),第1个成功的人的sql是这样的:

update key_constant setseq=2,version=version+1whereid=1andversion=1//这个是成功的

其它线程因为并发原因,第1次更新失败,因为它们也是上面这个Sql,这个可以理解;但是紧接着,它们会又去数据库中查询最新值,再更新seq,应该会可能成功的,但是实际上,它们在循环中每次查出来的数据都是跟第1次是一样的,就是说seq=1 version=1,明明其它线程(也可以认为是其它事务)已经提交了最新的更新,为什么这些线程查询老是旧值呢?我开始以为是ibatis有缓存什么的,后来查了,没有。

解释:这是隔离级别造成的问题,在系统中,事务是没显示指定隔离级别的,查看@Transactional:

//注解org.springframework.transaction.annotation.Transactional的isolation定义
Isolationisolation()defaultIsolation.DEFAULT;// 默认是DEFAULT
//继续跟踪代码 org.springframework.transaction.annotation.Isolation
/**
*Usethe defaultisolation level of the underlying datastore.
*Allother levels correspond to the JDBC isolation levels.
*@seejava.sql.Connection
*/
DEFAULT(TransactionDefinition.ISOLATION_DEFAULT), //很明显,使用底层数据存储的默认隔离级别
下面看下我的mysql5的默认隔离级别:
//查看数据库版本
SELECT VERSION();
5.1.47-log
//查看mysql5的默认隔离级别
SELECT @@global.tx_isolation
REPEATABLE-READ
SQL标准用3个必须在并发事务中避免的现象定义了4个隔离级别:
隔离级别
脏读(Dirty Read):读取了其它事务未提交的数据
不可重复读(NonRepeatable Read):读取了其它事务修改的数据
幻读(Phantom Read) :读取了其它事务增加/删除的数据
读未提交(Read uncommitted)
可能
可能
可能
读已提交(Read committed)
不可能
可能
可能
可重复读(Repeatable read)
不可能
不可能
可能
串行化(Serializable )
不可能
不可能
不可能

现在,可以解释了,上面的代码它的事务的隔离级别是:repeate read,那就是说,一个线程读取到seq=1然后将其+1更新到数据库失败后(影响行数为0),然后接着再读数据库,这时,它不能读取到其它线程更新的数据,所以这个线程读取的数据永远是seq=1的,version也是旧的,就这样一直会陷入死循环中。

办法:可以把代码再改一下,显示设置隔离级别为:read commited

@Transactional(isolation=Isolation.READ_COMMITTED)

最后测试,OK。

这里有个问题,前几天看了下资料,说是mysql5.0你用read commited是没问题,但是在>=5.1以后,如果你同时使用了mysql的复制功能,它是基于bin log的,这时read commited可能会造成log错乱,最终造成数据同步出问题,所以>=5.1时,不要用此方案,切记!!!

第4次思考

前面第2次思考后,已经改造成了大家常见的乐观锁实现,但是因为隔离级别的问题,造成在一个事务中重复读取,无法读取到别人的更新。今天早上,我突然想到其实有个更简单的办法:getKey方法去掉事务

publicStringgetKey(Stringtype){
}

这样这个方法里面的查询,更新两个操作每次都是新开一个事务,而当更新失败,需要再从数据库中查最新的时候,这时是一个新事务了,它就可以看到别人更新的数据了,问题就解决了。

这时,可能有个新的疑问产生了,到底是前面第3次的效率高呢,还是这个第4次的效率高呢?

乐观锁的实现方案会存在多次查询,更新才成功的情况,虽然一般情况下这个次数其实不多,我使用jdk线程池来测试上面的方法,每个线程大概一般不会超过3次失败,然后就会成功。

从隔离级别的角度来看:应该是第3次的比第4次效率高,因为前者的隔离级别是read commited,面后者是默认的repeatable;

从事务方面来看:第3次一共只开1个事务,而第4次会是每个查询,更新都开1个事务,但是,第3次的事务可能是长事务,第4次的每1个事务都是短事务。我们使用spring来管理事务的时候,每次开启一个事务的时候都要获取连接Connection,所以,我觉得并发不高,就用第3次的吧,足够了。