Bootstrap

Spring中事务失效的常见场景及解决方法


Spring中事务失效的场景有很多,本文主要分析四种常见的事务失效场景及解决方法

1. 准备工作

本次演示使用的是MySQL数据库

1.1 新建表并往表中插入测试数据

新建一张名为 account 的表,并往表中插入两条测试数据

-- ----------------------------
-- Table structure for account
-- ----------------------------
DROP TABLE IF EXISTS `account`;
CREATE TABLE `account`  (
  `id` int NOT NULL AUTO_INCREMENT,
  `name` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL,
  `money` decimal(10, 0) NULL DEFAULT 0,
  INDEX `account_id_index`(`id` ASC) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 3 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;

-- ----------------------------
-- Records of account
-- ----------------------------
INSERT INTO `account` VALUES (1, '高启强', 10000);
INSERT INTO `account` VALUES (2, '陈书婷', 10000);

最开始的数据如下

在这里插入图片描述

1.2 编写简单的service层代码

import com.itheima.dao.AccountMapper;
import com.itheima.pojo.Account;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class AccountService {

    @Autowired
    private AccountMapper accountDao;

    /**
     * 转账
     *
     * @param from  减钱的用户id
     * @param to    加钱的用户id
     * @param money 金额
     */
    @Transactional
    public void update(Integer from, Integer to, Double money) {
        // 转账的用户不能为空
        Account fromAccount = accountDao.selectById(from);
        // 判断用户的钱是否够转账
        if (fromAccount.getMoney() - money >= 0) {
            fromAccount.setMoney(fromAccount.getMoney() - money);
            accountDao.updateById(fromAccount);

            // 被转账的用户
            Account toAccount = accountDao.selectById(to);
            toAccount.setMoney(toAccount.getMoney() + money);
            accountDao.updateById(toAccount);
        }
    }
}

1.3 编写简单的测试类

import com.itheima.Application;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;

@SpringBootTest(classes = Application.class)
@RunWith(SpringRunner.class)
public class AccountServiceTest {

    @Autowired
    private AccountService accountService;

    @Test
    public void update() {
        accountService.update(1, 2, 500d);
    }
}

2. 失效场景一(重点)

2.1 失效原因:手动捕获了异常,没有将异常抛出

在service层的代码中添加 int temp = 1 / 0; ,手动制造异常,并将该异常捕获

import com.itheima.dao.AccountMapper;
import com.itheima.pojo.Account;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class AccountService {

    @Autowired
    private AccountMapper accountDao;

    /**
     * 转账
     *
     * @param from  减钱的用户id
     * @param to    加钱的用户id
     * @param money 金额
     */
    @Transactional
    public void update(Integer from, Integer to, Double money) {
        try {
            // 转账的用户不能为空
            Account fromAccount = accountDao.selectById(from);
            // 判断用户的钱是否够转账
            if (fromAccount.getMoney() - money >= 0) {
                fromAccount.setMoney(fromAccount.getMoney() - money);
                accountDao.updateById(fromAccount);

                int temp = 1 / 0;

                // 被转账的用户
                Account toAccount = accountDao.selectById(to);
                toAccount.setMoney(toAccount.getMoney() + money);
                accountDao.updateById(toAccount);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

不出意外,运行测试类后报错了

在这里插入图片描述

在数据库中查看两人的账户余额,发现高启强的账户余额减少了500,但是陈书婷的账户余额却没有增加500,出现了数据不一致的情况

在这里插入图片描述

2.2 解决办法:手动捕获异常后,再次手动抛出一个新的异常

我们将异常捕获后,手动抛出一个新的异常(RunTimeException)

import com.itheima.dao.AccountMapper;
import com.itheima.pojo.Account;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class AccountService {

    @Autowired
    private AccountMapper accountDao;

    /**
     * 转账
     *
     * @param from  减钱的用户id
     * @param to    加钱的用户id
     * @param money 金额
     */
    @Transactional
    public void update(Integer from, Integer to, Double money) {
        try {
            // 转账的用户不能为空
            Account fromAccount = accountDao.selectById(from);
            // 判断用户的钱是否够转账
            if (fromAccount.getMoney() - money >= 0) {
                fromAccount.setMoney(fromAccount.getMoney() - money);
                accountDao.updateById(fromAccount);

                int temp = 1 / 0;

                // 被转账的用户
                Account toAccount = accountDao.selectById(to);
                toAccount.setMoney(toAccount.getMoney() + money);
                accountDao.updateById(toAccount);
            }
        } catch (Exception e) {
            throw new RuntimeException("转账失败");
        }
    }
}

再次运行测试类

在这里插入图片描述

在数据库中查看两人的账户余额

可以看到,虽然这次转账失败了,但两个用户的账户余额没有改变,说明Spring帮我们回滚了事务

在这里插入图片描述

3. 失效场景二(重点)

3.1 补充:检查异常的概念

  • 在Java编程语言中,异常分为两种主要类型:检查异常(Checked Exceptions)和非检查异常(Unchecked Exceptions)
  • 非检查异常(Unchecked Exceptions)包括运行时异常(Runtime Exceptions)和错误(Errors)
  • 检查异常是那些在编译时必须被显式捕获或声明的异常。这意味着如果一个方法可能会抛出一个检查异常,那么调用这个方法的地方必须提供一个异常处理机制,要么是通过 try-catch 语句捕获异常,要么是在方法签名中使用throws关键字声明异常,让异常传播到更高级别的处理代码
  • 检查异常通常用于表示那些在程序运行期间可能发生的、合理的、可以恢复的错误情况,比如输入/输出错误(如文件不存在)、数据库错误、网络问题等。这些异常是编译器检查的一部分,因此称为“检查异常”

常见的检查异常有

  1. I/O 异常 - 当进行文件读写、网络操作等I/O操作时可能出现的异常
    • java.io.IOException:通用I/O异常的基类
    • java.io.FileNotFoundException:尝试打开不存在的文件时抛出
    • java.io.EOFException:当预期中应该有更多数据时却遇到文件结束符抛出
    • java.io.UnsupportedEncodingException:不支持指定的字符编码时抛出
  2. 类加载和反射异常 - 当加载类或使用反射时可能出现的异常
    • java.lang.ClassNotFoundException:尝试加载不存在的类时抛出
    • java.lang.NoSuchMethodException:请求的方法不存在时抛出
    • java.lang.IllegalAccessException:尝试反射地执行一个没有访问权限的方法时抛出
  3. 数据库异常 - 当与数据库交互时可能出现的异常
    • java.sql.SQLException:SQL执行错误或数据库访问错误时抛出
  4. 远程方法调用(RMI)异常 - 当使用Java的远程方法调用技术时可能出现的异常
    • java.rmi.RemoteException:远程方法调用过程中出现的异常。
  5. 网络异常 - 当进行网络相关的操作时可能出现的异常
    • java.net.SocketException:socket操作错误时抛出
  6. XML解析异常 - 当解析XML文档时可能出现的异常
    • javax.xml.parsers.ParserConfigurationException:解析器配置错误时抛出
    • org.xml.sax.SAXException:SAX解析过程中出现的异常
  7. 数据转换和格式化异常 - 当进行数据类型转换或格式化时可能出现的异常
    • java.text.ParseException:解析字符串时格式不正确时抛出
  8. 配置异常 - 当读取或解析配置文件时可能出现的异常
    • java.util.PropertiesFormatException:解析属性文件格式错误时抛出

3.2 失效原因:抛出的异常为检查异常

我们在代码中读取一个不存在的文件,并将原来的 try-catch 语句去掉,直接在方法中抛出 FileNotFoundException 异常

import com.itheima.dao.AccountMapper;
import com.itheima.pojo.Account;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.io.FileInputStream;
import java.io.FileNotFoundException;

@Service
public class AccountService {

    @Autowired
    private AccountMapper accountDao;

    /**
     * 转账
     *
     * @param from  减钱的用户id
     * @param to    加钱的用户id
     * @param money 金额
     */
    @Transactional
    public void update(Integer from, Integer to, Double money) throws FileNotFoundException {
        // 转账的用户不能为空
        Account fromAccount = accountDao.selectById(from);
        // 判断用户的钱是否够转账
        if (fromAccount.getMoney() - money >= 0) {
            fromAccount.setMoney(fromAccount.getMoney() - money);
            accountDao.updateById(fromAccount);

            FileInputStream fileInputStream = new FileInputStream("Tom.txt");

            // 被转账的用户
            Account toAccount = accountDao.selectById(to);
            toAccount.setMoney(toAccount.getMoney() + money);
            accountDao.updateById(toAccount);
        }
    }
}

不出意外,运行测试类后程序报错了

在这里插入图片描述

在数据库中查看两人的账户余额,发现高启强的账户余额减少了500,但是陈书婷的账户余额却没有增加,出现了数据不一致的情况

在这里插入图片描述

3.3 解决方法:在@Transactional注解中指定触发事务回滚的异常类型

默认情况下,只有抛出的异常类型为非检查异常,Spring才会帮我们回滚事务

我们可以在 @Transactional 注解中指定触发事务回滚的异常类型为 Exception ,只要抛出了异常,就回滚事务,具体格式为 @Transactional(rollbackFor = Exception.class)

import com.itheima.dao.AccountMapper;
import com.itheima.pojo.Account;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.io.FileInputStream;
import java.io.FileNotFoundException;

@Service
public class AccountService {

    @Autowired
    private AccountMapper accountDao;

    /**
     * 转账
     *
     * @param from  减钱的用户id
     * @param to    加钱的用户id
     * @param money 金额
     */
    @Transactional(rollbackFor = Exception.class)
    public void update(Integer from, Integer to, Double money) throws FileNotFoundException {
        // 转账的用户不能为空
        Account fromAccount = accountDao.selectById(from);
        // 判断用户的钱是否够转账
        if (fromAccount.getMoney() - money >= 0) {
            fromAccount.setMoney(fromAccount.getMoney() - money);
            accountDao.updateById(fromAccount);

            FileInputStream fileInputStream = new FileInputStream("Tom.txt");

            // 被转账的用户
            Account toAccount = accountDao.selectById(to);
            toAccount.setMoney(toAccount.getMoney() + money);
            accountDao.updateById(toAccount);
        }
    }
}

再次运行测试类,可以发现,虽然抛出了异常,但数据库中的数据并没有发生变化,说明Spring帮我们回滚了事务

在这里插入图片描述

在这里插入图片描述

4. 失效场景三

4.1 失效原因:使用@Transactional注解的方法的权限不是public

将update方法前面的public关键字去掉,重新运行测试类

@Service
public class AccountService {

    @Autowired
    private AccountMapper accountDao;

    /**
     * 转账
     *
     * @param from  减钱的用户id
     * @param to    加钱的用户id
     * @param money 金额
     */
    @Transactional(rollbackFor = Exception.class)
    void update(Integer from, Integer to, Double money) throws FileNotFoundException {
        // 转账的用户不能为空
        Account fromAccount = accountDao.selectById(from);
        // 判断用户的钱是否够转账
        if (fromAccount.getMoney() - money >= 0) {
            fromAccount.setMoney(fromAccount.getMoney() - money);
            accountDao.updateById(fromAccount);

            FileInputStream fileInputStream = new FileInputStream("Tom.txt");

            // 被转账的用户
            Account toAccount = accountDao.selectById(to);
            toAccount.setMoney(toAccount.getMoney() + money);
            accountDao.updateById(toAccount);
        }
    }
}

运行测试类后,程序报错,但数据库中两个用户的账号余额却出现了不一致的情况(高启强的账户余额减少了500,但是陈书婷的账户余额却没有增加500)

在这里插入图片描述

在这里插入图片描述

如果你使用@Transactional注解的方法的权限不是public,IDEA会给出明确的警告(已经上升到错误级别)

在这里插入图片描述

平时多留意IDEA给出的警告,能大大减少程序出错的概率

4.2 解决方法:将使用@Transactional注解的方法的权限设置为public

5. 失效场景四(重点)

5.1 失效原因:一个非事务方法调用了一个标记为 @Transactional 的事务方法

我们在AccountService中添加一个名为callUpdate的方法,在该方法中调用update方法

import com.itheima.dao.AccountMapper;
import com.itheima.pojo.Account;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class AccountService {

    @Autowired
    private AccountMapper accountDao;


    /**
     * 调用转账方法
     *
     * @param from  减钱的用户id
     * @param to    加钱的用户id
     * @param money 金额
     */
    public void callUpdate(Integer from, Integer to, Double money) {
        update(from, to, money);
    }

    /**
     * 转账
     *
     * @param from  减钱的用户id
     * @param to    加钱的用户id
     * @param money 金额
     */
    @Transactional(rollbackFor = Exception.class)
    public void update(Integer from, Integer to, Double money) {
        try {
            // 转账的用户不能为空
            Account fromAccount = accountDao.selectById(from);
            // 判断用户的钱是否够转账
            if (fromAccount.getMoney() - money >= 0) {
                fromAccount.setMoney(fromAccount.getMoney() - money);
                accountDao.updateById(fromAccount);

                int temp = 1 / 0;

                // 被转账的用户
                Account toAccount = accountDao.selectById(to);
                toAccount.setMoney(toAccount.getMoney() + money);
                accountDao.updateById(toAccount);
            }
        } catch (Exception e) {
            throw new RuntimeException("转账失败");
        }
    }

}

转账前双方的余额

在这里插入图片描述

运行测试类的testCallUpdate方法,抛出异常后,高启强的账户余额减少了500,但是陈书婷的账户余额却没有增加500

在这里插入图片描述

5.2 失效原因详解

5.2.1 补充:自调用的概念

在详细分析事务失效之前,我们先了解什么是自调用

自调用:在同一个类中,非事务方法直接调用事务方法称为自调用

IEDA对自调用给出了明确的警告(多看IDEA的提示,能大大减少程序出错的概率

在这里插入图片描述

5.2.2 Spring事务管理的原理

Spring 的事务管理的底层基于 Spring AOP(面向切面编程)代理模式

当在 Spring 应用程序中定义一个 bean 并使用 @Transactional 注解这个bean的某个方法时,Spring 容器会创建一个代理对象(基于 JDK 动态代理或 CGLIB动态代理)来包装原始的 bean

这个代理对象会拦截所有被 @Transactional 注解标记的方法的调用,并在方法前后加上与事务相关的逻辑


以上述的update方法为例,当update方法加上@Transactional注解后,代理对象执行的方法类似于以下代码

try {
    // 开启事务
    
    update();
    
    // 提交事务
} catch (Exception exception){
    // 回滚事务
}

  • 实际上,callUpdate()是一个简写,完整写法是this.callUpdate(),也就是说,执行的是当前对象的callUpdate()方法
  • 如果当前对象不是代理对象,update方法就是一个普通的方法,callUpdate()方法调用update方法时并不会被代理对象拦截,Spring就不会对此次方法调用进行事务管理

5.3 解决方法

5.3.1 使用AopContext获取到当前代理类

第一步:在启动类加上以下代码

@EnableAspectJAutoProxy(exposeProxy = true)

第二步:在非事务方法中获取当前代理类,再通过代理类调用需要进行事务管理的方法

/**
 * 调用转账方法
 *
 * @param from  减钱的用户id
 * @param to    加钱的用户id
 * @param money 金额
 */
public void callUpdate(Integer from, Integer to, Double money) {
    AccountService accountService = (AccountService) AopContext.currentProxy();
    accountService.update(from, to, money);
}

第三步:测试

转账前双方的余额

在这里插入图片描述

调用callUpdate()方法转账后双方的余额

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

程序依旧抛出了异常,但Spring进行了事务管理,数据库的数据保持了一致性

5.3.2 通过ApplicationContext获取容器中的代理对象(推荐使用)

第一步:在AccountService类中注入ApplicationContext

@Autowired
private ApplicationContext applicationContext;

第二步:从容器中获取当前类的代理对象,再通过代理对象调用需要进行事务管理的方法

/**
 * 调用转账方法
 *
 * @param from  减钱的用户id
 * @param to    加钱的用户id
 * @param money 金额
 */
public void callUpdate(Integer from, Integer to, Double money) {
    AccountService accountService = applicationContext.getBean(AccountService.class);
    accountService.update(from, to, money);
}

第三步:测试

转账前双方的余额

在这里插入图片描述

调用callUpdate()方法转账后双方的余额

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

程序依旧抛出了异常,但Spring进行了事务管理,数据库的数据保持了一致性

;