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

ProjectDaedalus

共 8378字,需浏览 17分钟

 ·

2022-06-08 17:45


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

abstract.png

环境搭建

DB环境

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

-- 定义一个序列
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(255not null ,
    sex varchar(255not 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类如下所示

@Slf4j
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Data
@TableName("user_info")     // 指定数据库的表名
public class UserInfo {
    private Integer id;
    private String username;
    private String sex;
}

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

import com.baomidou.mybatisplus.core.mapper.BaseMapper;

public interface UserInfoMapper extends BaseMapper<UserInfo{
}

问题复现

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

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

@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 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 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

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

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

@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 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

捕获数据库层面的异常

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

@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 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类来单独放置

@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 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的编程式事务

@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 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



浏览 39
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报