Spring事务失效场景

工作中经常会使用事务,事务保证了操作的一致性,要嘛全部成功,有一个失败则全部失败。类似于与操作。其中事务具备ACID四个特性,包含原子性,一致性,隔离性,持久性。Spring开发了spring-tx模块封装了事务,包含有编程式事务与声明式事务。其中编程式事务使用TransactionTemplate实现,声明式事务使用Transactional注解。但是事务有时候会失效导致脏数据的产生,下面来进行实践测试。

声明式事务

Spring官方推荐使用声明式事务Transactional,Transactional可配置如下一些参数

  • transactionManager: 包含PlatformTransactionManager和ReactiveTransactionManager指定事务管理器类型
  • label:指定事务标签,描述事务信息等
  • propagation: 指定事务传播类型, 包含如下一些枚举
传播类型 描述 是否默认
REQUIRED 如果当前已经存在事务, 那么加入该事务, 如果不存在事务, 创建一个事务, 然后执行事务操作
SUPPORTS 当前有事务使用事务方式执行,没有事务则以非事务方式执行
MANDATORY 当前有事务则直接使用,如果当前没有事务,则抛出异常
REQUIRES_NEW 创建一个新事务,如果存在则暂停当前事务
NOT_SUPPORTED 以非事务方式执行,如果存在则暂停当前事务
NEVER 以非事务方式执行,如果存在事务则抛出异常
NESTED 如果当前事务存在,则在嵌套事务中执行,否则行为类似于REQUIRED
  • isolation: 事务隔离级别
事务隔离级别 描述 是否默认
DEFAULT 使用数据库的默认事务隔离级别
READ_UNCOMMITTED 未提交读,可能发生脏读、不可重复读和幻读
READ_COMMITTED 提交读,可能发生不可重复读取和幻读
REPEATABLE_READ 可重复读,可能发生幻读 MYSQL默认
SERIALIZABLE 串行读,防止脏读、不可重复读和幻读,性能较低
  • timeout: 事务的超时时间(以秒为单位)
  • readOnly: 是否为只读事务,很少使用
  • rollbackFor: 指定哪些异常发生回滚,默认Runtime和Error级别。
  • noRollbackFor: 指定哪些异常不会导致事务回滚

Transactional声明式事务的使用

我们以插入用户为例,如下所示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Transactional(rollbackFor = Exception.class)
@Override
public UserDTO insertUser(UserDTO dto){
Account account = Account.builder()
.mobile(dto.getMobile())
.password(DigestUtils.md5DigestAsHex(dto.getPassword().getBytes(StandardCharsets.UTF_8)))
.build();
if (save(account)) {
UserDetail detail = UserDetail.builder()
.accountId(account.getId())
.username(dto.getUsername())
.avatar(dto.getAvatar())
.build();
int effect = detailMapper.insert(detail);
return effect>0?dto:null;
}
return null;
}

方法中根据DTO数据保存账户数据和用户详细数据,通过Transactional注解保证了事务ACID特性。

编程式事务

Spring通过TransactionTemplate的方式实现事务编程,TransactionTemplate通过调用execute方法执行事务,而方法内部是通过PlatformTransactionManager来管理事务。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Override
public UserDTO insertUserByTransactionTemplate(UserDTO dto) throws IOException {
return template.execute(status -> {
try {
Account account = Account.builder()
.mobile(dto.getMobile())
.password(DigestUtils.md5DigestAsHex(dto.getPassword().getBytes(StandardCharsets.UTF_8)))
.build();
if (save(account)) {
UserDetail detail = UserDetail.builder()
.accountId(account.getId())
.username(dto.getUsername())
.avatar(dto.getAvatar())
.build();
int effect = detailMapper.insert(detail);
return effect>0?dto:null;
}
return null;

}catch (Exception e){
log.error(e.getMessage());
// 主动设置回滚或者主动抛出异常
status.setRollbackOnly();
return null;
}
});
}

Spring事务失效场景

上面记录了Spring的编程式事务和声明式事务的使用,实际编程中大部分情况我们都是使用声明式事务,因为声明式事务更简单方便,声明式事务给与我们方便的同时也带来了一些弊端。比如有如下一些事务失效场景。

物理层不支持事务

以MySQL为例,如果使用的存储引擎为MyISAM,那么业务层加事务是无效的,因为MyISAM存储引擎并不支持事务。所以如果需要使用MySQL到事务,那么请用InnoDB

私有方法

如果@Transactional使用在私有方法上,事务并不会生效,因为声明式事务是通过AOP实现,而AOP内部是通过CGLIB和JAVA代理实现,私有方法无法使用代理,自然无法使用事务(这种错误IDEA会有提示,一般来说不会有这种情况)

发生非运行时异常或非Error

比如下面的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Transactional
@Override
public UserDTO insertUser(UserDTO dto) throws IOException {
Account account = Account.builder()
.mobile(dto.getMobile())
.password(DigestUtils.md5DigestAsHex(dto.getPassword().getBytes(StandardCharsets.UTF_8)))
.build();
int effect = getBaseMapper().insert(account);
if (effect > 0) {
insertDetail(dto, account.getId());
return dto;
}
return null;
}

public void insertDetail(UserDTO dto, Long id) throws IOException {
UserDetail detail = UserDetail.builder()
.accountId(id)
.username(dto.getUsername())
.avatar(dto.getAvatar())
.build();
Files.write(Paths.get("/usr/info"), "哈哈".getBytes(StandardCharsets.UTF_8));
detailMapper.insert(detail);
}

这里在保存用户详细信息之前做了一次IO操作,但是操作失败了,导致账户数据保存成功,但是用户详细信息保存失败。原因是因为抛出的异常为IO异常,非运行时异常,在@Transactional中有这么一段注释

默认情况下,事务将在RuntimeException和Error上回滚。这里有两种解决方式,

  • 第一种指定回滚异常类为Expetion, @Transactional(rollbackFor = Exception.class)
  • 第二种将异常捕获后主动抛出运行时异常

主动处理了异常

还是以刚才的例子,将代码换成下面这样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Transactional(rollbackFor = Exception.class)
@Override
public UserDTO insertUser(UserDTO dto){
Account account = Account.builder()
.mobile(dto.getMobile())
.password(DigestUtils.md5DigestAsHex(dto.getPassword().getBytes(StandardCharsets.UTF_8)))
.build();
int effect = getBaseMapper().insert(account);
if (effect > 0) {
insertDetail(dto, account.getId());
return dto;
}
return null;
}

public void insertDetail(UserDTO dto, Long id) {
UserDetail detail = UserDetail.builder()
.accountId(id)
.username(dto.getUsername())
.avatar(dto.getAvatar())
.build();
try {
Files.write(Paths.get("/usr/info"), "哈哈".getBytes(StandardCharsets.UTF_8));
} catch (IOException e) {
e.printStackTrace();
}
detailMapper.insert(detail);
}

在执行文件写入时,主动将异常捕获处理了。这样导致异常不能传递给代理进行回滚。最后执行的效果就是,账户保存成功,而在保存用户详细信息之前抛出了IO异常,都没有执行到存储方法。

非事务方法内部调用事务方法

如果调用者的方法为非事务方法,在同一个类中内部调用一个事务方法,如下所示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Override
public UserDTO insertUser(UserDTO dto) throws IOException {
Account account = Account.builder()
.mobile(dto.getMobile())
.password(DigestUtils.md5DigestAsHex(dto.getPassword().getBytes(StandardCharsets.UTF_8)))
.build();
int effect = getBaseMapper().insert(account);
if (effect > 0) {
insertDetail(dto, account.getId());
return dto;
}
return null;
}

@Transactional(rollbackFor = Exception.class)
public void insertDetail(UserDTO dto, Long id) throws IOException {
UserDetail detail = UserDetail.builder()
.accountId(id)
.username(dto.getUsername())
.avatar(dto.getAvatar())
.build();
Files.write(Paths.get("/usr/info"), "哈哈".getBytes(StandardCharsets.UTF_8));
detailMapper.insert(detail);
}

原理和AOP代理调用是一样的,因为调用者并非是通过代理类调用,而是自身调用的。所以无法实现事务,最后账户保存成功,用户详细信息执行失败却并未回滚。

事务传播类型设置错误

上面提到了事务传播类型,如果配置了传播模式为SUPPORTS,NOT_SUPPORTED,NEVER则可能出现方法执行未使用事务。

作者

Labradors

发布于

2022-03-29

更新于

2022-03-29

许可协议

评论