事务的那些坑
总结一下事务在使用过程中的一些坑。
在介绍之前,先普及一些基础知识
一、基础知识
事务隔离等级
一般事务的隔离等级有Read uncommitted,Read committed,Repeatable read,Serializable。其中隔离程度越来越严格,到了Serializable已经不支持并发事务了。
Read uncommitted
会造成脏读,可重复读,幻读。什么是脏读,看一下关键词uncommitted,英文很直白。网上很多抄来抄去的文章很多把脏读和不可重复读混淆。特别是乐观锁的文章。脏读也就是说读取到别人未提交的事务中的改动。举个例子:
原数据 id=1 value=6
事务A 事务B
修改id=1的数据value=8
读取id=1的数据value=8
commit
Read committed
会造成可重复读,幻读。read committed字面上已经表明了只能读取到事务提交后的数据,所以避免了脏读。什么是可重复读,可重复读是指在事务中前后读取两次同一数据,由于在此期间,存在其他事务修改该数据并提交,造成前后读取不一致。举个例子
原数据 id=1 value=6
事务A 事务B
读取id=1的数据value=6
修改id=1的数据value=8
commit
读取id=1的数据value=8
Repeatable read
会造成幻读。Repeatable read字面上也表明了可以在事务中重复读取。比如mysql中的实现方式是事务开始后的select将会把结果存在临时数据中。事务中再次读取时直接从临时数据中取值,因此可以重复读取。但是这只保证了update操作,对于insert操作,则会产生幻读。举个例子:
原数据 id=1 value=6
事务A 事务B
读取id=1的数据 value=6
新增一行数据id=1 value=8
commit
读取id=1的数据 value=6
value=8
这样得到了两条数据,则是幻读。
Serializable
不允许并发事务,不会产生任何并发问题。
乐观锁
乐观锁和之前的事务隔离等级一样,也是为了解决一些并发问题。乐观锁解决的问题如下:
不可重复读
从之前的事务隔离等级已经知道Repeatable read则可以解决不可重复读,乐观锁和它之间还是有区别的。
如果在一个事务中防止读取另一个已提交事务造成的重复读,在这一点上和Repeatable read的区别是,如果是Repeatable read,还是可以获得事务开始时的值,而乐观锁你只知道值变了,无法获得原始值。
更新丢失
乐观锁的作用范围超过了事务。考虑如下场景,用户A打开了修改界面,修改界面读取当前数据显示。然后用户A去做其他事情了。这时候用户B修改了同样的数据。用户A回来继续修改,提交事务则覆盖了用户B的修改。
只读事务
在数据库层面,只读事务的作用也是解决不可重复读的问题,并且不允许该事务中存在除了查询以外的操作。
事务传播等级
spring提出的概念,这里只介绍容易混淆的等级
**PROPAGATION\_REQUIRED**:如果当前不存在事务,则新建事务,否则使用当前事务。如果遇到内部事务,把外部事务作为内部事务。
**PROPAGATION\_REQUIRES\_NEW**:不论何时,总是新建事务。如果遇到内部事务,则挂起外部事务,内部事务处理完,继续外部事务。
**PROPAGATION\_NESTED**:嵌套事务,规则是这样的,外部事务在进入内部事务的时候,建立一个save point,并且挂起,如果内部事务回滚,则回滚到save point,然后继续执行外部事务。如果外部事务回滚,则同时回滚内部事务。
通过举例子的方式来说明 这几个事务传播等级的区别,主要是作为内部事务的情形。
由于只讨论内部事务,外部事务假设为任何一种等级的事务。例子均用伪码表示。
内部事务为PROPAGATION_REQUIRED
例子1:
@Transactional
public void method(){
doSomeThingInDB();
methodRequired();
throw newException();
}
方法methodRequired使用propagation\_required事务,并且成功提交。外部事务回滚,methodRequired的操作也回滚。
原因:内部事务和外部事务是同一个。
例子2:
@Transactional
public void method(){
doSomeThingInDB();
methodRequiredRollBack();
}
方法methodRequiredRollBack使用propagation\_required事务,并且回滚。外部事务同样回滚。
原因:内部事务和外部事务是同一个。
内部事务为PROPAGATION_REQUIRED_NEW
例子1:
@Transactional
public void method(){
doSomeThingInDB();
methodRequiredNew();
throw new Exception();
}
方法methodRequiredNew使用propagation\_required\_new事务,外部事务回滚,不影响内部事务。
原因:内部事务和外部事务是不同的事务。
例子2:
@Transactional
public void method(){
doSomeThingInDB();
methodRequiredNewRollBack();
}
方法methodRequiredNewRollBack使用propagation\_required\_new事务,并且回滚,不影响外部事务。
原因:内部事务和外部事务是不同的事务。
内部事务为PROPAGATION_NESTED
例子1
@Transactional
public void method(){
doSomeThingInDB();
methodNested();
throw new Exception();
}
方法methodNested使用propagation\_nested事务,外部事务回滚,内部事务也回滚。
原因:嵌套事务外部事务回滚,内部事务也回滚。
例子2
@Transactional
public void method(){
doSomeThingInDB();
try{
methodNestedRollBack();
}catch(Exception e){
doSomeThingInDB();
}
}
方法methodNestedRollBack使用propagation\_nested事务,并且回滚,此时外部事务可以继续catch中的操作,如果没有抛出,可正常提交外部事务。
@Transactional
public void method(){
doSomeThingInDB();
try{
methodNestedRollBack();
}catch(Exception e){
doSomeThingInDB();
throw new Exception();
}
}
方法methodNestedRollBack使用propagation\_nested事务,并且回滚,外部事务在catch中抛错回滚。此时,内部事务和外部事务均回滚。
原因:嵌套事务中,如果内部事务回滚,不影响外部事务。但是外部事务回滚,内部事务也会回滚。
二、事务中的坑
乐观锁与可重复读
乐观锁其实在更大范围确保了可重复读的问题,如果你的事务隔离等级为可重复读,mysql的默认等级,你又使用了乐观锁的话,你一定要小心谨慎,不然很可能出错。举个很经典的例子,死循环。
public void update(String id){
DataVo vo=mapper.getById(id);
while(mapper.update(vo)==0){
vo=mapper.getById(id)
}
}
其中vo中包含版本号或时间戳。死循环的过程如下:
事务A 事务B
执行update方法
mapper.getById(1)获得版本号为1
修改id=1的数据,版本号变为2
commit
由于版本号为2,没有数据被更新,返回0
执行循环中的mapper.getById(1)获得版本号为1
由于版本号为2,没有数据被更新,返回0
执行循环中的mapper.getById(1)获得版本号为1
无限循环这两个步骤
readonly是否为只读事务
从数据库的层面我们知道只读事务可以解决重复读的问题,并且防止该事务中的任何非查询操作。但是我们在数据库驱动中设置的readonly属性真的就是数据库层面的只读事务吗。
这个问题没有确切的答案,需要看具体的驱动实现。比如:在老版本的oralce驱动中确实是设置了只读事务,而在新驱动中则只设置连接为只读。而且甚至在事务中存在非查询操作也不会报错。
下面来看看mysql中的情况,驱动为mysql-connector-java-5.1.38.jar。
看一下源码,exec方法
if(!checkReadOnlySafeStatement()) {
throw SQLError.createSQLException(Messages.getString("PreparedStatement.20") //$NON-NLS-1$
+ Messages.getString("PreparedStatement.21"), //$NON-NLS-1$
SQLError.SQL_STATE_ILLEGAL_ARGUMENT, getExceptionInterceptor());
}
其中checkReadOnlySafeStatement的源码
protected boolean checkReadOnlySafeStatement() throws SQLException {
synchronized (checkClosed()) {
return ((!this.connection.isReadOnly()) || (this.firstCharOfStmt == 'S'));
}
}
我们看到这里的readonly仅仅是connection的readonly,并不是事务的readonly。因此,基于驱动的spring中的@Transactional(readOnly=true)自然也不是只读事务。不过它还是会对非查询操作回滚,比oralce的某些驱动好些。
我们可以做个试验
1、事务隔离级别设为READ\_COMMITTED,readOnly=true,中途修改数据
在获取了用户以后线程sleep3秒,在这个时候修改数据库,把该用户的loginname进行修改
@Override
@Transactional(isolation=Isolation.READ_COMMITTED,readOnly=true)
public DataGrid<UserVo> auditor(UserVo vo) throws Exception {
UserVo v=userMapper.findUserById("3af99f64-df20-4ffe-95df-a1d7ac5e84e8");
System.out.println(v.getLoginname());
TimeUnit.SECONDS.sleep(3);
v=userMapper.findUserById("3af99f64-df20-4ffe-95df-a1d7ac5e84e8");
System.out.println(v.getLoginname());
DataGrid<UserVo> vos=datagridMap(vo, userMapper, DBTYPE_MYSQL, "findCurrentAuditor","foundCount");
return vos;
}
结果如下:
结论,事务隔离等级比Repeatable read低的情况下,会造成不可重复读,readOnly并不是只读事务。
2、readOnly=true,其中有删除操作
@Override
@Transactional(readOnly=true)
public DataGrid<UserVo> auditor(UserVo vo) throws Exception {
Map<String,Object> params=new HashMap<>();
params.put("id", "1");
userMapper.deleteRoles(params);
DataGrid<UserVo> vos=datagridMap(vo, userMapper, DBTYPE_MYSQL, "findCurrentAuditor","foundCount");
return vos;
}
其中存在删除操作,会抛出异常
结论,虽然不是只读事务,但是任然不允许处查询操作之外的操作
3、readOnly=true,或不设置readOnly=true,中途修改数据
@Transactional(readOnly=true)
public DataGrid<UserVo> auditor(UserVo vo) throws Exception {
UserVo v=userMapper.findUserById("3af99f64-df20-4ffe-95df-a1d7ac5e84e8");
System.out.println(v.getLoginname());
TimeUnit.SECONDS.sleep(3);
v=userMapper.findUserById("3af99f64-df20-4ffe-95df-a1d7ac5e84e8");
System.out.println(v.getLoginname());
DataGrid<UserVo> vos=datagridMap(vo, userMapper, DBTYPE_MYSQL, "findCurrentAuditor","foundCount");
return vos;
}
@Transactional
public DataGrid<UserVo> auditor(UserVo vo) throws Exception {
UserVo v=userMapper.findUserById("3af99f64-df20-4ffe-95df-a1d7ac5e84e8");
System.out.println(v.getLoginname());
TimeUnit.SECONDS.sleep(3);
v=userMapper.findUserById("3af99f64-df20-4ffe-95df-a1d7ac5e84e8");
System.out.println(v.getLoginname());
DataGrid<UserVo> vos=datagridMap(vo, userMapper, DBTYPE_MYSQL, "findCurrentAuditor","foundCount");
return vos;
}
在获取了用户以后线程sleep3秒,在这个时候修改数据库,把该用户的loginname进行修改
两者结果如下:
结论:默认事务隔离等级Repeatable read确保了可重复读
同类中的事务传播等级
同一个类中的方法之间调用,事务传播等级会失效。原因是事务是通过动态代理实现的,同类中的方法调用不是代理类。
解决办法是引入aspectjweaver.jar,然后开启暴露aop代理给ThreadLocal,比如xml的配置如下:
<aop:aspectj-autoproxy proxy-target-class="true" expose-proxy="true"/>
代码中如下调用:
((Service) AopContext).method();
事务与缓存可重复读
隔离等级为Repeatable read的事务可以重复读取同一数据,然而同时使用缓存的时候要格外小心,因为缓存很可能会破坏可重复读。
举个例子:
@Override
@Transactional
@CacheEvict(value="user",allEntries=true)
public void updateTest(String username) throws Exception{
UserVo vo=new UserVo();
vo.setLoginname("super");
vo.setUsername(username);
vo.setDeptId("c5145caa-fc06-11e5-8d93-1d3ee1acb99b");
vo.setPhone("test");
vo.setUserRole("0");
vo.setId("1");
userMapper.update(vo);
}
updateTest方法修改数据库的同时,会清空所有的缓存
@Cacheable(value="user",keyGenerator="baseCacheKeyGenerator")
public Json getUser(String userId){
System.out.println("no cache");
Optional<Json> json=Optional.of(new Json());
UserVo vo=userMapper.findUserById(userId);
Optional.ofNullable(vo).map(v -> {
v.setId(UUID.randomUUID().toString());
return v;
});
Optional.ofNullable(vo).ifPresent(json.get() :: setObj);
return json.get();
}
getUser会把相同userId的缓存起来。其中baseCacheKeyGenerator的策略为类,方法名,参数hash code值均相等才被视为同一查询。
@Override
@Transactional
public Json doSomething(UserVo vo) throws Exception {
Json json=userService.getUser("1");
Optional.ofNullable(json).ifPresent(j -> {
Optional.ofNullable((UserVo) j.getObj()).ifPresent(v -> {
System.out.println(v.getUsername());
});
});
java.util.concurrent.TimeUnit.SECONDS.sleep(10);
json=userService.getUser("1");
Optional.ofNullable(json).ifPresent(j -> {
Optional.ofNullable((UserVo) j.getObj()).ifPresent(v -> {
System.out.println(v.getUsername());
});
});
return new Json();
}
doSomething方法会调用getUser两次,其中间隔10秒,在10秒之中,另外一个事务开启,调用updateTest方法,将该用户的username改为test。
当getUser没有缓存id为1的用户的时候,结果如下:
发现两次都没有走缓存,获得的结果一致,不存在不可重复读问题。
原因:第一次查询由于没有缓存,所有从数据库查询。接着该用户名被另一事务修改,并且清空缓存。第二次查询时由于缓存被清空,所以同样通过数据库查询,这时候事务的隔离等级Repeatable read起作用,还是获得事务开始前的用户名。
这个时候,如果我们再一次调用doSomething方法,并在第一次查询结束的10秒类,通过另一个事务调用updateTest方法,将该用户的username改为super。
结果如下:
可以看到,第一次查询因为有缓存,所以直接从缓存中取,获得用户名为super,这个时候由于没有从数据库查询,所以不属于可重复读的第一次查询。然后修改username为test,并且清空了缓存。再次查询的时候,由于没有缓存,所以通过数据库第一次查询用户名为test。这样就造成了可重复读的破坏。可以推导出,如果这个时候再次修改用户为super,然后在之前的事务再查询用户名,这个时候事务的可重复读才会生效,因为这里的第三次查询被认为了第二次查询,将返回用户名为test。
以上就是这次总结中事务的全部内容,对于存在坑的地方,使用一定要小心。
还没有评论,来说两句吧...