Spring Framework 学习笔记5:事务

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属性,实际上是修改了“事务的传播行为”。

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

Spring Framework 学习笔记5:事务_第1张图片

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

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

4.参考资料

  • 黑马程序员SSM框架教程

你可能感兴趣的:(JAVA,spring,事务)