Spring Framework 学习笔记5:事务

news2024/11/25 21:33:44

Spring Framework 学习笔记5:事务

1.快速入门

1.1.准备工作

这里提供一个示例项目 transaction-demo,这个项目包含 Spring 框架、MyBatis 以及 JUnit。

对应的表结构见 bank.sql。

服务层有一个方法可以用于在不同的账户间进行转账:

@Service
public class AccountServiceImpl implements AccountService {
    @Autowired
    private AccountMapper accountMapper;

    @Override
    public Account getAcountByName(String name) {
        return accountMapper.selectByName(name);
    }

    @Override
    public void transfer(String from, String to, double amount) {
        //从转出账户扣款
        accountMapper.delAmount(from, amount);
        //给转入账户加钱
        accountMapper.addAmount(to, amount);
    }
}

这里我编写了一个简单的测试用例用于测试:

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = SpringConfig.class)
public class AccountServiceTests {
    @Autowired
    private AccountService accountService;

    @Test
    public void testTransfer(){
        this.printAccounts();
        accountService.transfer("jack", "icexmoon", 20);
        this.printAccounts();
    }

    private void printAccounts(){
        System.out.println(accountService.getAcountByName("icexmoon"));
        System.out.println(accountService.getAcountByName("jack"));
    }
}

但实际上这里是有 bug 的,如果转出账户的余额小于要转出的金额,转出账户的金额就会变成负数。

最朴素的想法是在转出金额前先检查账户余额是否足够:

@Override
public void transfer(String from, String to, double amount) {
    //查询并检查转出账户的余额是否足够
    Account account = accountMapper.selectByName(from);
    if (account == null) {
        throw new RuntimeException("账户 %s 不存在");
    }
    if (account.getAmount() - amount < 0) {
        throw new RuntimeException("账户 %s 的余额不足");
    }
    //从转出账户扣款
    accountMapper.delAmount(from, amount);
    //给转入账户加钱
    accountMapper.addAmount(to, amount);
}

将测试用例中的转账金额改成一个很大的数字(比如10000)后再次测试,就能发现会抛出异常,转账不会进行。

1.2.并发问题

似乎这样做已经没有问题了。但是,显然我们的数据库操作是可以并行的,同时不可能只存在一个对 account 表的操作。如果同时存在多个对同一个账户的操作,会发生什么?

看这个测试用例:

@Test
public void testTransfer2() throws InterruptedException {
    this.printAccounts();
    new Thread(()->{
        accountService.saveMoney("jack", 1000);
        try {
            Thread.sleep(500);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        accountService.getBackMoney("jack", 1000);
    }).start();
    new Thread(()->{
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        accountService.transfer("jack", "icexmoon", 3000);
    }).start();
    Thread.sleep(2000);
    this.printAccounts();
}

这里有两个线程,一个尝试进行转账,从 jack 账户转账 3000 到 icexmoon 账户。另一个线程会先存 1000 再取 1000。

数据库里此时 jack 账户 2000,icexmoon 账户 1000。

理想情况是应该有两种结果:

  • 转账成功,存钱成功但取钱失败(因为存钱和取钱并不能同时发生)。
  • 转账失败,存钱和取钱操作成功。

如果你多执行几次,应该就能看到某次结果如下:

Account(id=1, name=icexmoon, amount=1000.0)
Account(id=2, name=jack, amount=2000.0)
Account(id=1, name=icexmoon, amount=4000.0)
Account(id=2, name=jack, amount=-1000.0)

这相当诡异,着表明转账、存钱和取钱都成功了。且 jack 账户余额变成了负数,明明我们有提前检查余额是否足够了。

为了能够“恰好”出现这种情况,我在代码中添加了一些 Thread.sleep(),以确保这种错误出现的概率提高。

出现这种情况的原因本质上和多线程的问题是一致的,即资源共享。本质上 account 表上 name 为 jack 的数据行在这里充当了共享资源。如果我们在访问该资源时不对其“锁定”(独占),就有可能出现:

  • A 线程存入 1000,余额为3000
  • B 线程尝试转账,发现余额足够,执行转账操作
  • A 线程取钱,发现余额足够,执行取钱操作
  • 转账操作执行,扣除2000
  • 取钱操作执行,扣除1000
  • 此时账户余额 -1000

要解决这个问题也很简单,使用 Spring 事务。

1.3.使用事务

使用事务要定义一个PlatformTransactionManager

@Configuration
public class TransactionConfig {
    @Bean
    public PlatformTransactionManager transactionManager(DataSource dataSource){
        DataSourceTransactionManager dataSourceTransactionManager = new DataSourceTransactionManager();
        dataSourceTransactionManager.setDataSource(dataSource);
        return dataSourceTransactionManager;
    }
}

DataSourceTransactionManagerPlatformTransactionManager的一个实现类,它底层使用 JDBC 的事务,所以需要设置一个数据源。

还需要在配置类上添加@EnableTransactionManagement注解以开启事务:

@EnableTransactionManagement
public class SpringConfig {
}

在 Service 接口的相关方法上添加@Transactional

public interface AccountService {
    /**
     * 查看账户信息
     *
     * @return
     */
    @Transactional
    Account getAcountByName(String name);

    /**
     * 转账
     *
     * @param from   转出账户
     * @param to     转入账户
     * @param amount
     */
    @Transactional
    void transfer(String from, String to, double amount);

    /**
     * 存钱
     *
     * @param name   账户名
     * @param amount 金额
     */
    @Transactional
    void saveMoney(String name, double amount);

    /**
     * 取钱
     *
     * @param name   账户名
     * @param amount 金额
     */
    @Transactional
    void getBackMoney(String name, double amount);
}

如果接口的所有方法都需要开启事务,可以在接口上使用@Transactional注解:

@Transactional
public interface AccountService {
}

当然也可以在实现类或方法上使用@Transactional注解,但在接口上使用更灵活——如果替换了实现类依然会使用事务。实际上 Spring 的事务是用 AOP 实现的,所以这种规则实际上是 AOP 的通知匹配 Bean 的规则。

如果测试用例中使用 Spring 事务,还需要在测试套件上添加注解:

@Transactional(transactionManager = "transactionManager")
@Rollback(value = false)
public class AccountServiceTests {
	// ...
}

现在再执行测试用例,就不会出现金额为负数的情况。

2.事务角色

Spring 事务除了可以发挥 JDBC 事务的用途——锁定共享资源以外。另一个重要的用途就是保证数据一致性,也就是说 Spring 事务生效的过程中,任意的异常产生都会让事务涉及的数据层操作回滚。

之所以 Spring 事务可以做到这一点,是因为 Spring 事务通过两个角色,将多个数据层事务(JDBC 事务)纳入了Spring 事务(通常定义在 Service 层)的管理,并形成一个事务整体。

在 Spring 事务中,两个角色分别是:

  • 事务管理员:发起事务方,在Spring中通常指代业务层开启事务的方法
  • 事务协调员:加入事务方,在Spring中通常指代数据层方法,也可以是业务层方法

具体到我们这个示例中,在服务层代码中:

public interface AccountService {
	// ...
    @Transactional
    void transfer(String from, String to, double amount);
}

@Service
public class AccountServiceImpl implements AccountService {
    // ...
    @Override
    @SneakyThrows
    public void transfer(String from, String to, double amount) {
        //查询并检查转出账户的余额是否足够
        this.checkAccountAmountIsEnough(from, amount);
        Thread.sleep(1000);
        //从转出账户扣款
        accountMapper.delAmount(from, amount);
        //给转入账户加钱
        accountMapper.addAmount(to, amount);
        System.out.println("转账成功");
    }
}

AccountService.transfer方法上的 Spring 事务就是事务管理员,这个方法中调用的两个数据层方法accountMapper.delAmountaccountMapper.addAmount上的 JDBC 事务就是事务协调员。

其实还调用了数据层的查询方法,这里省略。

之所以 Spring 可以做到这一点(统一管理 JDBC 事务),是因为我们定义的事务管理器(DataSourceTransactionManager)中使用的数据源(DataSource)和数据层(MyBatis)使用的数据源是同一个数据源。

3.事务属性

3.1.rollbackFor

Spring 事务并非对所有异常的产生都会回滚,比如:

@Override
public void transfer(String from, String to, double amount) throws InterruptedException, IOException {
    //查询并检查转出账户的余额是否足够
    this.checkAccountAmountIsEnough(from, amount);
    Thread.sleep(1000);
    //从转出账户扣款
    accountMapper.delAmount(from, amount);
    if (true) throw new IOException();
    //给转入账户加钱
    accountMapper.addAmount(to, amount);
    System.out.println("转账成功");
}

这里强制抛出一个IOException类型的异常。

  • 注意,这里没有使用@SneakyThrow处理异常,原因之后会说明。
  • if(true)是为了骗过编译器的语法检查。

执行测试用例:

@Test
@SneakyThrows
public void testTransfer() {
    this.printAccounts();
    accountService.transfer("jack", "icexmoon", 1000);
    this.printAccounts();
}

会发现 jack 账户的钱减少了,但 icexmoon 账户的钱没有增加,这说明事务回滚并没有生效。

原因是,默认情况下,Spring 事务只会对 ErrorRuntimeException类型的异常进行回滚

换言之,Spring 事务不会对“被检查的异常”进行回滚。而在上面的示例中,IOException就是一个被检查的异常。

很容易分辨异常是不是“被检查异常”,因为如果代码中有被检查的异常存在,编译器就会强制要求你进行处理(转换为运行时异常或在方法签名中声明异常抛出)。

解决的方式也很简单,将被检查的异常加入@Transactionalrollback属性:

public interface AccountService {
    // ...
    @Transactional(rollbackFor = {InterruptedException.class, IOException.class})
    void transfer(String from, String to, double amount) throws InterruptedException, IOException;
}

现在再执行测试用例,事务回滚就会正常生效。

此外,还可以用noRollbackFor属性指定哪些异常发生后不进行回滚。

当然,也可以将被检查的异常转换为运行时异常:

@Override
public void transfer(String from, String to, double amount) throws InterruptedException {
    //查询并检查转出账户的余额是否足够
    this.checkAccountAmountIsEnough(from, amount);
    Thread.sleep(1000);
    //从转出账户扣款
    accountMapper.delAmount(from, amount);
    try {
        if (true) throw new IOException();
    }
    catch (Exception e){
        throw new RuntimeException(e);
    }
    //给转入账户加钱
    accountMapper.addAmount(to, amount);
    System.out.println("转账成功");
}

这样就不存在我们之前说的问题,同样可以触发事务回滚。

在这里我们并不能使用@SneakyThrows,因为@SneakyThrows仅仅是骗过编译器,在不用在方法签名中声明异常的情况下抛出异常,并不会将被检查的异常转换为运行时异常:

@Override
@SneakyThrows
public void transfer(String from, String to, double amount) {
    //查询并检查转出账户的余额是否足够
    this.checkAccountAmountIsEnough(from, amount);
    Thread.sleep(1000);
    //从转出账户扣款
    accountMapper.delAmount(from, amount);
    if (true) throw new IOException();
    //给转入账户加钱
    accountMapper.addAmount(to, amount);
    System.out.println("转账成功");
}

如果你像上面示例中那样做了,实际上代码将抛出一个方法签名中不存在的被检查异常IOException,显然不会触发事务回滚。此外因为方法签名中没有声明的被检查异常被抛出,JVM 会抛出一个UndeclaredThrowableException

这件事告诉我们,要谨慎使用@SneakyThrows

3.2.案例:为转账添加日志

添加一张日志表:

CREATE TABLE `log` (
  `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '标识符',
  `content` varchar(255) COLLATE utf8mb4_general_ci NOT NULL COMMENT '内容',
  `create_time` datetime NOT NULL COMMENT '创建时间',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='日志表'

添加 Mapper:

public interface LogMapper {
    @Insert("insert into log(content,create_time) values (#{content},NOW())")
    void addLog(String content);
}

添加 Service:

public interface LogService {
    @Transactional
    void addTransferLog(String from, String to, double amount);
}

@Service
public class LogServiceImpl implements LogService {
    @Autowired
    private LogMapper logMapper;

    @Override
    public void addTransferLog(String from, String to, double amount) {
        logMapper.addLog("%s 转账 %.2f 到 %s".formatted(from, amount, to));
    }
}

在转账操作中添加日志记录:

@Override
public void transfer(String from, String to, double amount) {
    try {
        //查询并检查转出账户的余额是否足够
        this.checkAccountAmountIsEnough(from, amount);
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
            throw new RuntimeException(e);
        }
        //从转出账户扣款
        accountMapper.delAmount(from, amount);
        //给转入账户加钱
        accountMapper.addAmount(to, amount);
        System.out.println("转账成功");
    } finally {
        logService.addTransferLog(from, to, amount);
    }
}

现在可以成功转账,并写入日志信息。

但这里存在一个问题,如果我们希望无论转账是否成功,都写一条日志信息。就会发现一些问题。

在转账逻辑中添加一条代码,触发“除零异常”:

@Override
public void transfer(String from, String to, double amount) {
    try {
        //查询并检查转出账户的余额是否足够
        this.checkAccountAmountIsEnough(from, amount);
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
            throw new RuntimeException(e);
        }
        //从转出账户扣款
        accountMapper.delAmount(from, amount);
        int i = 1/0;
        //给转入账户加钱
        accountMapper.addAmount(to, amount);
        System.out.println("转账成功");
    } finally {
        logService.addTransferLog(from, to, amount);
    }
}

这是一个运行时异常,所以事务回滚被触发,账户金额不会改变。但问题在于,日志同样没有写入。

因为在上面这个示例中,LogService.addTransferLog()方法的事务是一个事务协调员,它同样加入了AccountService.transfer()方法管理的事务,所以在异常发生后被一同回滚了。

要让日志添加操作不被回滚,我们就需要将其设置为单独的事务。

方法也很简单:

public interface LogService {
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    void addTransferLog(String from, String to, double amount);
}

现在LogService.addTransferLog()将会在单独事务中执行,所以无论转账成功与否,都会有日志信息添加。

3.3.事务传播行为

在上面案例中,我们修改了@Transactionalpropagation属性,实际上是修改了“事务的传播行为”。

事务协调员的传播行为会影响到最终的执行效果,传播行为分为以下几种:

1630254257628

  • REQUIRED,默认行为。如果事务管理员开启了事务,就加入该事务。如果没有,新建事务。
  • REQUIRES_NEW,无论事务管理员是否开启事务,都新建一个事务。
  • SUPPORTS,如果事务管理员开启了事务,加入。如果没有,不使用事务。
  • NOT_SUPPORTED,无论事务管理员是否开启事务,都不使用事务。
  • MANDATORY,如果事务管理员开启了事务,加入。如果没有,报错。
  • NEVER,与MANDATORY规则相反。如果事务管理员开启了事务,报错。如果没有,不使用事务。
  • NESTED,设置回滚点,让事务回滚到指定的回滚点。

本文的完整示例可以从这里获取。

4.参考资料

  • 黑马程序员SSM框架教程

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1053559.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

云原生Kubernetes:对外服务之 Ingress

目录 一、理论 1.Ingress 2.部署 nginx-ingress-controller(第一种方式) 3.部署 nginx-ingress-controller(第二种方式) 二、实验 1.部署 nginx-ingress-controller(第一种方式) 2.部署 nginx-ingress-controller(第二种方式) 三、问题 1.启动 nginx-ingress-controll…

Python海洋专题五之水深地形图海岸填充

Python海洋专题五之水深地形图海岸填充 海洋与大气科学 上期读取nc水深文件&#xff0c;并出图 但是存在一些不完美&#xff0c;本期修饰 本期内容 障眼法&#xff1a;把大于零的数据填充为陆地的灰色&#xff1b; 把等于零的数据画等深线为陆地和海洋的分界线&#xff01;…

怒刷LeetCode的第21天(Java版)

目录 第一题 题目来源 题目内容 解决方法 方法一&#xff1a;哈希表 方法二&#xff1a;计数器数组 第二题 题目来源 题目内容 解决方法 方法一&#xff1a;分治法 方法二&#xff1a;快速幂 迭代 方法三&#xff1a;快速幂 递归 第三题 题目来源 题目内容 …

JUC第十二讲:JUC锁: 锁核心类AQS详解

JUC第十二讲&#xff1a;JUC锁: 锁核心类AQS详解 本文是JUC第十二讲&#xff0c;JUC锁: 锁核心类AQS详解。AbstractQueuedSynchronizer抽象类是核心&#xff0c;需要重点掌握。它提供了一个基于FIFO队列&#xff0c;可以用于构建锁或者其他相关同步装置的基础框架。 文章目录 J…

aarch64 平台 musl gcc 工具链手动编译方法

目标 手动编译一个 aarch64 平台的 musl gcc 工具链 musl libc 与 glibc、uclibc 等,都是 标准C 库, musl libc 是基于系统调用之上的 标准C 库,也就是用户态的 标准C 库。 musl libc 轻量、开源、免费,是一些 操作系统的选择,当前 Lite-OS 与 RT-Smart 等均采用自制的 mu…

【算法训练-贪心算法】一 买卖股票的最佳时机II

废话不多说&#xff0c;喊一句号子鼓励自己&#xff1a;程序员永不失业&#xff0c;程序员走向架构&#xff01;本篇Blog的主题是【贪心算法】&#xff0c;使用【数组】这个基本的数据结构来实现&#xff0c;这个高频题的站点是&#xff1a;CodeTop&#xff0c;筛选条件为&…

开源校园服务小程序源码 校园综合服务小程序源码 包含快递代取 打印服务 校园跑腿【带详细部署教程】

校园综合服务小程序开源源码是一款功能强大的小程序&#xff0c;可用于搭建校园综合服务平台。共有6个选项可供选择&#xff0c;包括快递代取、打印服务、校园跑腿、代替服务、上门维修和其他帮助。 使用该源码需要自备服务器和备案过的域名&#xff0c;推荐使用2核4G服务器。最…

【python海洋专题一】查看数据nc文件的属性并输出属性到txt文件

【python海洋专题一】查看数据nc文件的属性并输出属性到txt文件 海洋与大气科学 软件 选择此软件是因为习惯了&#xff0c;matlab能看得到的界面。 新建文本 导入相关库 import netCDF4,numpy netCDF4:该包作用&#xff1a;读、写netCDF files. numpy:该包作用&#xff1a;…

【JavaScript】读取本地json文件并绘制表格

本文为避免跨域问题&#xff0c;使用了改造过的本地json文件的方法实现读取json数据并绘制表格。 如果发起http请求获取本地 json文件中数据&#xff0c;需要架设本地服务器&#xff0c;本文不做阐述。 概述 1、json在本地&#xff0c;并不需要从服务器下载。 2、采用jquery…

八、垃圾收集高级

JVM由浅入深系列一、关于Java性能的误解二、Java性能概述三、了解JVM概述四、探索JVM架构五、垃圾收集基础六、HotSpot中的垃圾收集七、垃圾收集中级八、垃圾收集高级👋垃圾收集高级 ⚽️1. CMS CMS 收集器是专为老年代空间设计的一个延迟极低的收集器,它通常会与一个稍微…

【每日一题】1498. 满足条件的子序列数目

1498. 满足条件的子序列数目 - 力扣&#xff08;LeetCode&#xff09; 给你一个整数数组 nums 和一个整数 target 。 请你统计并返回 nums 中能满足其最小元素与最大元素的 和 小于或等于 target 的 非空 子序列的数目。 由于答案可能很大&#xff0c;请将结果对 109 7 取余后…

buuctf-[WUSTCTF2020]CV Maker

打开环境 随便登录注册一下 进入到了profile.php 其他没有什么页面&#xff0c;只能更换头像上传文件&#xff0c;所以猜测是文件上传漏洞 上传一句话木马看看 <?php eval($_POST[a]);?>回显 搜索一下 添加文件头GIF89a。上传php文件 查看页面源代码&#xff0c;看…

设计模式7、桥接模式 Bridge

解释说明&#xff1a;将抽象部分与它的实现部分解耦&#xff0c;使得两者都能够独立变化 桥接模式将两个独立变化的维度设计成两个独立的继承等级结构&#xff08;而不会将两者耦合在一起形成多层继承结构&#xff09;&#xff0c;在抽象层将二者建立起一个抽象关联&#xff0c…

安卓 kuaishou 设备did和egid 学习分析

did和egid注册 接口 https://gdfp.ksapisrv.com/rest/infra/gdfp/report/kuaishou/android did 是本地生成的16进制 或者 获取的 android_id public static final Random f16237a new Random(System.currentTimeMillis()); public static long m19668a() { return f1623…

c#设计模式-结构型模式 之装饰者模式

&#x1f680;介绍 在装饰者模式中&#xff0c;装饰者类通常对原始类的功能进行增强或减弱。这种模式是在不必改变原始类的情况下&#xff0c;动态地扩展一个对象的功能。这种类型的设计模式属于结构型模式&#xff0c;因为这种模式涉及到两个类型之间的关系&#xff0c;这两个…

优化用户体验:解决element中el-tabs组件切换闪屏问题

前言 在现代 web 应用中&#xff0c;用户体验是至关重要的。然而&#xff0c;在使用 element 中的 el-tabs 组件时&#xff0c;相信有不少开发者都会遇到切换时的闪屏问题。这个问题可能导致用户在切换标签页时感到不适&#xff0c;降低了用户体验&#xff0c;本文将探讨这个问…

LeetCode面向运气之Javascript—第58题-最后一个单词的长度-99.83%

LeetCode第58题-最后一个单词的长度 题目要求 给你一个字符串 s&#xff0c;由若干单词组成&#xff0c;单词前后用一些空格字符隔开。返回字符串中 最后一个 单词的长度。 举例 输入&#xff1a;s “Hello World” 输出&#xff1a;5 输入&#xff1a;s " fly me to …

力扣 -- 97. 交错字符串

解题步骤&#xff1a; 参考代码&#xff1a; class Solution { public:bool isInterleave(string s1, string s2, string s3) {int ms1.size();int ns2.size();//先判断s1的长度s2的长度是否等于s3的长度&#xff0c;如果不等&#xff0c;则s1和s2不可能拼接成s3if(mn!s3.size…

C++11(列表初始化,声明,范围for)

目录 一、列表初始化 1、一般的列表初始化 2、容器的列表初始化 二、声明 1、 auto 2、decltype 3、nullptr 三、 范围for 一、列表初始化 1、一般的列表初始化 在C98中&#xff0c;标准允许使用花括号{}对数组或者结构体元素进行统一的列表初始值设定。 int main() {…

CAS( 比较并交换-乐观锁机制-锁自旋 )

1 概念及特性 CAS&#xff08;Compare And Swap/Set&#xff09;比较并交换&#xff0c;CAS 算法的过程是这样&#xff1a;它包含 3 个参数 CAS(V,E,N)。V 表示要更新的变量(内存值)&#xff0c;E 表示预期值(旧的)&#xff0c;N 表示新值。当且仅当 V 值等于 E 值时&#xff0…