【Spring Boot】事务的回滚、传播与常见问题

文章目录


前言

想象一下这种场景,发薪日到了,公司财务给我们的银行卡转账。最终的结果无法就两种:转账成功,公司账户余额减少,个人账号余额增长相应的数字;另外一种就是转账失败,双方账户余额都不改变。

这里我们讨论的场景就是数据的原子性和一致性,这个概念相信属性数据库的朋友都不会陌生,它正是大名鼎鼎的ACID,既原子性(Atomicity)一致性(Consistency)隔离性(Isolation)持久性(Durability)。是保证数据库中事务正确执行的四个基本要素。

今天这篇文章我和大家一起梳理在Spring Boot中去实现复杂逻辑操作的单机事务,并理清事务的传播规则,从而保证数据的一致性。

一、事务(transaction)是什么?

事务的英文是transaction,在科林字典中它被解释为"A transaction is a piece of business, for example an act of buying or selling something.",直译过来就是商品交易。对于商品交易而言,要么卖出去换成金钱,要么就不卖出去。这里其实已经体现出了这是必须完成或不完成的操作。

对于我们计算机中的事务概念来说,它也就是指代一组必须全部完成或全部撤销的操作。事务将多个对数据库的操作视为一个整体,它们要么一起成功,要么一起失败,目的就是为了保证数据的一致性。事务有以下几点特性

  • 原子性(Atomicity):借用物理中的概念,这个操作是最小的,不可拆分的。比如转账时A账户扣钱,把钱转到B账户。这个操作要么都成功,要么都不成功,不会出现A了钱B没到账。
  • 一致性(Consistency):事务执行前后,数据总规则不变。比如转账前A账户和B账户总金额是 1000,转账后总金额还是 1000,不会凭空多或少。
  • 隔离性(Isolation):多个事务同时执行时,互不干扰。比如A查询自己账户余额时,B正在给A的账户转账,A要么查到转账前的金额,要么查到转账后的金额,不会查到 "一半转账" 的中间状态。
  • 持久性(Durability):事务提交后,数据变更永久生效。哪怕数据库宕机、重启,提交后的结果也不会丢失。

事务的核心操作一般分为5个步骤:

  • 开启事务(Begin Transaction):标记事务的开始,接下来的数据库操作都是一组操作
  • 执行操作语句:执行数据库操作语句,但是此刻的执行不会真正影响到数据库的数据,而是写入到一个隔离区里。
  • 事务提交(Commit Transaction):确认事务中所有操作的有效性,将隔离区中的数据永久写入磁盘,事务正式结束。
  • 事务回滚(Rollback Transaction)::若这个事务中,这些数据库操作逻辑里出现错误(不一定是数据库操作本身的错误),就撤销事务中所有已执行的操作,恢复到事务开启前的原始状态
  • 事务结束(End Transaction):数据库释放该事务。

换句话说,事务是聚焦于数据库层面的。也就是没法把一个非数据的操作纳入到事务中。像流行的Redis这种,它自己有一套事务实现,通过Spring Boot中的事务是没法实现Redis回滚。

二、事务的实现------@Transactional

在Spring Boot中实现事务主要依赖于@Transactional这个注解。@Transactional不推荐加在接口上,优先加在 实现类的方法上。如果加在类上,那么表明该类里的每个方法执行的时候都需要开启事务。建议是直接加在方法上避免资源浪费。

假设我们有一个注册用户的业务,需要两个关联表数据插入用户基础信息和用户状态信息

java 复制代码
@Mapper
public interface UserInfoMapper {
    /**
     * 插入用户基础信息,通过 @Options 获取自增 userId 并赋值给 UserInfo 对象
     */
    @Insert("INSERT INTO user_info (phone, nickname, password, create_time, update_time) " +
            "VALUES (#{phone}, #{nickname}, #{password}, NOW(), NOW())")
    @Options(useGeneratedKeys = true, keyProperty = "userId") // 关键:获取自增主键
    int insert(UserInfo userInfo);
}
java 复制代码
@Mapper
public interface UserStatusMapper {
    /**
     * 插入用户状态信息,关联userId
     */
    @Insert("INSERT INTO user_status (user_id, real_name_auth, member_level, login_status, create_time, update_time) " +
            "VALUES (#{userId}, #{realNameAuth}, #{memberLevel}, #{loginStatus}, NOW(), NOW())")
    int insert(UserStatus userStatus);
}

这两条语句分别是插入主表中的user_info信息,并且将user_info生成的主键userId返回,然后再插入用户状态信息,关联userId。这两个插入需要同时成功,或者同时失败。这时候我们只需要在业务执行的方法外部加上@Transactional 注解,那么该方法内的多次数据库操作都会被当作一个事务,要么

全部成功,要么全部失败。

java 复制代码
	@Override
    @Transactional
    public Long register(UserInfo userInfo) {
        String encryptedPwd = passwordEncoder.encode(userInfo.getPassword());
        userInfo.setPassword(encryptedPwd);

        int infoInsertCount = userInfoMapper.insert(userInfo);
        if (infoInsertCount != 1) { 
            throw new RuntimeException("用户基础信息插入失败");
        }

        Long userId = userInfo.getUserId();
        if (userId == null) {
            throw new RuntimeException("获取用户ID失败");
        }
			
        UserStatus userStatus = new UserStatus();
        userStatus.setUserId(userId);
        int statusInsertCount = userStatusMapper.insert(userStatus);
        if (statusInsertCount != 1) {
            throw new RuntimeException("用户状态信息插入失败");
        }

        return userId;
 	}

三、事务的回滚

我们在如上代码的基础上手动添加一个错误代码,代码执行到int error = 1/0会报除以0的错误,这个时候观察数据库表会明显发现两个数据都没有插入到数据里。

java 复制代码
	@Transactional
    public Long register(UserInfo userInfo) {
        /*省略*/

        int infoInsertCount = userInfoMapper.insert(userInfo);
        /*省略*/
        int error = 1/0
        /*省略*/
        int statusInsertCount = userStatusMapper.insert(userStatus);
        /*省略*/
    }    

但是当我们把除以0的报错换成一个抛出的异常,会发现用户基础信息表里多了一条记录,但是用户状态信息里没有多记录。这是因为@Transactional注解内有一个回滚的触发配置rollbackFor,rollbackFor默认是空数组,此时Spring会对RuntimeException和Error及其子类自动触发回滚,检查异常(如 IOException)不会回滚。

java 复制代码
	@Transactional
    public Long register(UserInfo userInfo) {
        /*省略*/

        int infoInsertCount = userInfoMapper.insert(userInfo);
        /*省略*/
        if (1 == 1) {
            throw new RuntimeException("用户基础信息插入失败");
        }
        /*省略*/
        int statusInsertCount = userStatusMapper.insert(userStatus);
        /*省略*/
    }    
java 复制代码
	/**
	 * Defines zero (0) or more exception {@linkplain Class types}, which must be
	 * subclasses of {@link Throwable}, indicating which exception types must cause
	 * a transaction rollback.
	 * <p>By default, a transaction will be rolled back on {@link RuntimeException}
	 * and {@link Error} but not on checked exceptions (business exceptions). See
	 * {@link org.springframework.transaction.interceptor.DefaultTransactionAttribute#rollbackOn(Throwable)}
	 * for a detailed explanation.
	 * <p>This is the preferred way to construct a rollback rule (in contrast to
	 * {@link #rollbackForClassName}), matching the exception type and its subclasses
	 * in a type-safe manner. See the {@linkplain Transactional class-level javadocs}
	 * for further details on rollback rule semantics.
	 * @see #rollbackForClassName
	 * @see org.springframework.transaction.interceptor.RollbackRuleAttribute#RollbackRuleAttribute(Class)
	 * @see org.springframework.transaction.interceptor.DefaultTransactionAttribute#rollbackOn(Throwable)
	 */
	Class<? extends Throwable>[] rollbackFor() default {};

我们可以通过在@Transactional内添加rollbackFor参数,比如rollbackFor = Exception.class这种,这样任何异常都会触发回滚。

java 复制代码
    @Transactional(rollbackFor = Exception.class)
    public Long register(UserInfo userInfo) {

四、事务的传播

如果在一个事务内引入另一个事务,这便会触发事务传播这个特质。本质上就是当一个事务方法调用另一个事务方法时,两个事务如何协同工作的问题。

我们相信一下上面的注册业务需要保存插入日志,在每一次执行后往数据库里写入一条记录,因为它本质上也是一个保存到数据库的操作。我们也给它添加@Transactional注解,定义成一个事务方法。

java 复制代码
    @Override
    @Transactional
    public void insertLog(Log empLog) {
        logMapper.insert(empLog);
    }

那么完成的业务方法就变成了父子嵌套事务,父事务就是我们之前的业务逻辑,子事务是一个数据库日志插入。但是默认情况下父事务的回滚会影响到嵌套的子事务,也就是说我们在register方法内加一个报错,引起的回滚,会导致用户基础信息,用防护状态信息和日志都不保存。这里面变涉及了一个事务传播性的特质。

java 复制代码
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Long register(UserInfo userInfo) {
        try{
            /*省略*/
            int infoInsertCount = userInfoMapper.insert(userInfo);

            /*省略*/
            int statusInsertCount = userStatusMapper.insert(userStatus);
            /*省略*/
        }finally {
            logService.insertLog(new Log(null, LocalDateTime.now(),  userInfo.toString()));       
        }

    }

Spring中提供了一个Propagation枚举来控制事务传播。

传播属性 核心含义 父事务存在时(如register调用日志方法)
REQUIRED(默认) 支持当前事务,不存在则新建 子事务复用父事务(共用一个事务),父子任一失败,整体回滚
SUPPORTS 支持当前事务,不存在则以非事务方式执行 子事务复用父事务
MANDATORY 必须在当前事务中执行,否则抛出异常(IllegalTransactionStateException) 子事务复用父事务
REQUIRES_NEW 新建独立事务,挂起当前事务(父事务等待子事务完成后再继续) 父子事务完全独立:子事务失败仅回滚自身,不影响父事务;父事务失败不影响已提交的子事务
NOT_SUPPORTED 以非事务方式执行,挂起当前事务 子事务无事务(即使父事务存在,也按普通方法执行)
NEVER 以非事务方式执行,存在当前事务则抛出异常 直接抛出异常(禁止在事务中执行)
NESTED 嵌套事务(父事务的子事务),依赖数据库的保存点(Savepoint) 子事务依赖父事务:子事务失败可回滚到保存点(不影响父事务);父事务失败,子事务一同回滚

这里我们如果给日志的Transactional注解上添加属性propagation = Propagation.REQUIRES_NEW,那么它会作为新建独立事务,挂起当前事务。


相关推荐
q***57501 小时前
Redis服务安装自启动(Windows版)
数据库·windows·redis
Databend1 小时前
DATA AI Databend Meetup 2025上海站邀您共话未来
数据库
火星数据-Tina1 小时前
让电竞数据实时跳动:Spring Boot 后端 + Vue 前端的完美融合实践
前端·vue.js·spring boot
后端小张2 小时前
【JAVA 进阶】SpringBoot 事务深度解析:从理论到实践的完整指南
java·开发语言·spring boot·后端·spring·spring cloud·事务
合作小小程序员小小店2 小时前
web网页开发,在线%宠物销售%系统,基于Idea,html,css,jQuery,java,ssh,mysql。
java·前端·数据库·mysql·jdk·intellij-idea·宠物
不知更鸟2 小时前
Django 的配置文件 INSTALLED_APPS
数据库·sqlite
-大头.2 小时前
SpringBoot 全面深度解析:从原理到实践,从入门到专家
java·spring boot·后端
合作小小程序员小小店2 小时前
web网页开发,在线%物流配送管理%系统,基于Idea,html,css,jQuery,java,ssh,mysql。
java·前端·css·数据库·jdk·html·intellij-idea
2501_941142932 小时前
基于区块链的数字身份管理:探索安全与隐私的未来
网络·数据库·人工智能