0%

记一次PostgreSQL事务中的异常被捕获后发生“意外”回滚的问题

对于一个数据库事务而言,当在我们事务当中将异常捕获后,该事务不应该会被回滚。但事实上这并不是绝对的,这里以PostgreSQL事务为例进行说明

abstract.png

环境搭建

DB环境

这里采用的PostgreSQL数据库版本为13.2。首先我们建立一张表。该表重点在于其username字段存在唯一约束

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
-- 定义一个序列
CREATE SEQUENCE user_info_seq
START WITH 1 -- 序列起始值
INCREMENT BY 1 -- 序列步长
NO MINVALUE -- 序列最小值
NO MAXVALUE -- 序列最大值
CACHE 1;

-- 创建表,并将主键id的默认值设为user_info_seq序列的下一个值
create table user_info (
id int default nextval('user_info_seq'::regclass) not null,
username varchar(255) not null ,
sex varchar(255) not null,
primary key (id)
);

-- 添加字段注释
comment on column user_info.id is '主键ID';
comment on column user_info.username is '姓名';
comment on column user_info.sex is '性别';

-- 在username字段建立唯一索引
create unique index user_info_uindex on user_info (username);

工程代码

这里仅展示部分相关工程代码,其中数据表所对应的entity类如下所示

1
2
3
4
5
6
7
8
9
10
11
@Slf4j
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Data
@TableName("user_info") // 指定数据库的表名
public class UserInfo {
private Integer id;
private String username;
private String sex;
}

然后借助于Mybatis Plus快速实现相应的dao层

1
2
3
4
import com.baomidou.mybatisplus.core.mapper.BaseMapper;

public interface UserInfoMapper extends BaseMapper<UserInfo> {
}

问题复现

插入不违反唯一约束的数据

这里为了方便验证问题,我们搭建一个Controller类PGTest用于控制、操作数据库。首先提供一个test1请求,不难看出。其会向数据库中插入两个不同人的记录。由于不违反唯一约束,故显然可以成功

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
29
30
31
32
33
34
35
36
37
38
@Controller
@ResponseBody
@RequestMapping("PGTest")
@Slf4j
public class PGTest {

@Autowired
private UserInfoMapper userInfoMapper;

@Transactional // 开启事务
@RequestMapping(value = "/test1")
public String test1() {
UserInfo userInfo1 = UserInfo.builder().username("Aaron").sex("男").build();
UserInfo userInfo2 = UserInfo.builder().username("Bob").sex("女").build();
List<UserInfo> list = Arrays.asList(userInfo1, userInfo2);

for (UserInfo userInfo : list) {
try {
userInfoMapper.insert( userInfo );
} catch (Exception e) {
log.error("Happen Exception: {}", e.getMessage());
}
}

return "Test 1 Over";
}


@RequestMapping(value = "/getAllData")
public String getAllData() {
List<UserInfo> list = userInfoMapper.selectList(null);
String res = list.stream()
.sorted( Comparator.comparing(UserInfo::getId) )
.map( e -> e.getId()+" "+e.getUsername()+" "+e.getSex() )
.collect( Collectors.joining("\n") );
return res;
}
}

测试结果如下所示,符合预期

figure 1.jpeg

捕获普通的(非数据库层面的)异常

然后我们在进行另外一个测试,同样是插入一批数据。只不过我们会在事务中先人为的抛出一个异常,然后对其进行捕获。当然插入的数据同样不违反唯一约束

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
29
30
31
32
@Controller
@ResponseBody
@RequestMapping("PGTest")
@Slf4j
public class PGTest {

@Autowired
private UserInfoMapper userInfoMapper;

@Transactional // 开启事务
@RequestMapping(value = "/test2")
public String test2() {
UserInfo userInfo1 = UserInfo.builder().username("Font").sex("男").build();
UserInfo userInfo2 = UserInfo.builder().username("Gil").sex("女").build();
UserInfo userInfo3 = UserInfo.builder().username("Helly").sex("男").build();
List<UserInfo> list = Arrays.asList(userInfo1, userInfo2, userInfo3);

for (UserInfo userInfo : list) {
try {
userInfoMapper.insert( userInfo );
// 抛出一个普通的(非数据库层面的)异常
if( userInfo.getUsername().equals("Gil") ) {
int a = 1/0;
}
} catch (Exception e) {
log.error("Happen Exception: {}", e.getMessage());
}
}

return "Test 2 Over";
}
}

测试结果如下所示,符合预期。因为异常已经在事务当中被捕获,故未发生回滚

figure 2.jpeg

捕获数据库层面的异常

至此一切都是顺利的,故这次测试插入一批数据。但违反了唯一约束。我们看看会发生什么现象

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
29
30
31
@Controller
@ResponseBody
@RequestMapping("PGTest")
@Slf4j
public class PGTest {

@Autowired
private UserInfoMapper userInfoMapper;

@Transactional // 开启事务
@RequestMapping(value = "/test3")
public String test3() {
UserInfo userInfo1 = UserInfo.builder().username("Cat").sex("女").build();
UserInfo userInfo2 = UserInfo.builder().username("Dog").sex("男").build();
UserInfo userInfo3 = UserInfo.builder().username("Dog").sex("男").build();
UserInfo userInfo4 = UserInfo.builder().username("Eat").sex("男").build();
List<UserInfo> list = Arrays.asList(userInfo1, userInfo2, userInfo3, userInfo4);

for (UserInfo userInfo : list) {
log.info("Handle Data Start: {}", userInfo);
try {
userInfoMapper.insert( userInfo );
} catch (Exception e) {
log.error("Happen Exception: {}", e.getMessage());
}
log.info("Handle Data End: {}", userInfo);
}

return "Test 3 Over";
}
}

从测试结果不难看出,本次测试的数据一条也没被插入。换言之,发生了回滚

figure 3.jpeg

看上去似乎有点不合理,因为即使由于存在重复数据而导致插入失败,我们实际上也对其异常进行了捕获啊。为什么会发生回滚,使得其他正常的数据也没有被插入。现在我们结合日志来进行分析

  • 对于蓝色框部分而言,此时由于数据并未违反唯一约束,故插入到数据库,且未有任何异常
  • 对于桃色框部分而言,由于重复插入了username值为Dog的记录,违反了唯一约束,进而抛出异常
  • 对于绿色框部分而言,由于我们之前捕获了异常,故业务代码实际上并未被打断,会继续向DB插入新的数据。显然这条新的数据没有违反唯一约束,但实际上该条数据并未被DB插入。且又抛出了一个新的异常。从本次异常信息——current transaction is aborted, commands ignored until end of transaction block,我们不难看出。对于DB而言,由于违反唯一约束而引发异常使得事务被终止了!故对于事务中的剩余SQL语句,DB也不再会执行、选择忽略。最后将该事务之前已经执行的部分也全部回滚掉。这也就是此次我们真正需要关注的问题所在

figure 4.jpeg

至此,相信大家已经不难理解了。虽然我们在业务代码中可以通过捕获异常,来避免由于事务直接抛出异常而导致的回滚。但这并不能完全保证事务肯定会被提交。对于某些数据库层面的异常(比如这里的违反唯一约束)来说,PostgreSQL会认为此时该事务已经遭到了破坏。故其会直接中止当前事务,进而导致回滚

解决方案

拆分事务

至此我们已经知道该场景下事务发生回滚的原因,那最直接的解决方法就是对原事务进行拆分,缩小每个事务的范围。使得某条SQL发生异常、事务回滚时,不会对其他事务的SQL造成任何影响。在基于Spring Boot框架下使用声明式事务时,我们可以直接利用 REQUIRES_NEW 事务传播行为 来实现,其可以保证每次调用该方法时均会开启一个新的事务。需要注意的是,由于声明式事务是基于AOP实现的,故不能在类内部调用事务方法。其会导致 @Transactional 事务注解 无法生效。具体地,我们通过一个PGService类来单独放置

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
@Controller
@ResponseBody
@RequestMapping("PGTest")
@Slf4j
public class PGTest {

@Autowired
private PGService pgService;

/**
* 解决方案1: 拆分事务
* @return
*/
@Transactional // 开启事务
@RequestMapping(value = "/fix1")
public String fix1() {
UserInfo userInfo1 = UserInfo.builder().username("Cat").sex("女").build();
UserInfo userInfo2 = UserInfo.builder().username("Dog").sex("男").build();
UserInfo userInfo3 = UserInfo.builder().username("Dog").sex("男").build();
UserInfo userInfo4 = UserInfo.builder().username("Eat").sex("男").build();
List<UserInfo> list = Arrays.asList(userInfo1, userInfo2, userInfo3, userInfo4);

for (UserInfo userInfo : list) {
pgService.addData(userInfo);
}

return "Fix 1 Over";
}
}

...

@Service
@Slf4j
public class PGService {

@Autowired
private UserInfoMapper userInfoMapper;

// 该方法每次被调用时, 均会开启一个新的事务
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void addData(UserInfo userInfo) {
log.info("Handle Data Start: {}", userInfo);
try {
userInfoMapper.insert( userInfo );
} catch (Exception e) {
log.error("Happen Exception: {}", e.getMessage());
}
log.info("Handle Data End: {}", userInfo);
}
}

测试结果如下所示,数据被正确地添加到了DB当中

figure 5.jpeg

通过保存点进行回滚

在一个事务中,如果某条SQL插入的数据违反了唯一约束。导致事务遭到了破坏。我们还可以通过save point保存点实现回滚到执行这条异常SQL之前。以恢复事务到正常状态,避免发生全部回滚的情形。这里我们使用基于TransactionTemplate的编程式事务

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
@Controller
@ResponseBody
@RequestMapping("PGTest")
@Slf4j
public class PGTest {

@Autowired
private UserInfoMapper userInfoMapper;

@Autowired
private TransactionTemplate transactionTemplate;

/**
* 解决方案2: 基于保存点进行手动回滚
* @return
*/
@RequestMapping(value = "/fix2")
public String fix2() {
UserInfo userInfo1 = UserInfo.builder().username("Cat").sex("女").build();
UserInfo userInfo2 = UserInfo.builder().username("Dog").sex("男").build();
UserInfo userInfo3 = UserInfo.builder().username("Dog").sex("男").build();
UserInfo userInfo4 = UserInfo.builder().username("Eat").sex("男").build();
List<UserInfo> list = Arrays.asList(userInfo1, userInfo2, userInfo3, userInfo4);

// 基于TransactionTemplate的编程式事务
transactionTemplate.executeWithoutResult( transactionStatus -> {
Object savepoint = null;
for (UserInfo userInfo : list) {
log.info("Handle Data Start: {}", userInfo);
try {
userInfoMapper.insert( userInfo );
// SQL执行成功创建保存点
savepoint = transactionStatus.createSavepoint();
} catch (Exception e) {
// SQL执行失败, 则回滚到指定保存点
transactionStatus.rollbackToSavepoint( savepoint );
log.error("Happen Exception: {}", e.getMessage());
}
log.info("Handle Data End: {}", userInfo);
}
});

return "Fix 2 Over";
}
}

测试结果如下,符合预期

figure 6.jpeg

请我喝杯咖啡捏~

欢迎关注我的微信公众号:青灯抽丝