首页 > 编程笔记 > Java笔记 阅读:4

Spring编程式事物和声明式事务(非常详细)

事务管理对于企业应用来说至关重要,当出现异常情况时,它可以保证数据的一致性。Spring 支持编程式事务管理和声明式事务管理两种方式。

首先我们来看看 Spring 框架的事物抽象。Spring 的事务策略由 TransactionManager 接口定义,PlatformTransactionManager 接口和 ReactiveTransactionManager 接口继承了 TransactionManager 接口。我们的程序大多数用的都是 PlatformTransactionManager 接口。

Spring 5.0 之后引入了 Reactive Web 框架 webflux,与 webflux 平级的是 webmvc,webflux 是一个完全的响应式并且非阻塞的 Web 框架,因此 Spring 5.2 之后 Spring 还为响应式 Web 框架提供了事务管理抽象,即 ReactiveTransactionManager 接口。我们下面主要讲解 PlatformTransactionManager 事务管理抽象。

PlatformTransactionManager 接口的源码如下:
public interface PlatformTransactionManager extends TransactionManager {
    TransactionStatus getTransaction(@Nullable TransactionDefinition var1) throws TransactionException;
    void commit(TransactionStatus var1) throws TransactionException;
    void rollback(TransactionStatus var1) throws TransactionException;
}

getTransaction 方法通过传入一个 TransactionDefinition 类型的参数来获取一个 TransactionStatus 对象,如果当前的调用堆栈里已经存在一个匹配的事务,那么 TransactionStatus 代表的就是这个已存在的事务,否则 TransactionStatus 代表一个新的事务。TransactionStatus 接口为事务代码提供了一些控制事务执行和查询事务状态的方法。

TransactionDefinition 是一个接口,该接口里面有一些默认方法,这些默认方法的返回值是声明一个事务的必要属性,比如 getPropagationBehavior()、getIsolationLevel()、getTimeout()、isReadOnly():
由于 TransactionDefinition 是一个接口,因此需要实现才能被使用。实现类既可以覆盖接口的默认方法,也可以不覆盖,如果不覆盖就使用默认值。

commit 方法用于提交事务,rollback 方法用于回滚事务。

声明式事务

声明式事务一般使用 @Transactional 注解,并且使用 @EnableTransactionManagement 开启事务管理,接下来我们讲解其背后的工作原理。

声明式事务是建立在 AOP 之上的,首先我们的应用程序会通过 XML 的方式或者注解的方式提供元数据,AOP 与事务元数据结合产生一个代理。当执行目标方法时拦截器 TransactionInterceptor 会对目标方法进行拦截,然后在目标方法的前后调用代理。其本质是在目标方法开始之前创建或者加入一个事务,在执行完目标方法之后根据执行情况提交或者回滚事务。

拦截器 TransactionInterceptor 通过检查方法返回类型来检测是哪种事务管理风格。如果返回的是响应式类型(例如 Publisher)的方法,就符合响应式事务管理的条件,其他返回类型(包括 void)则使用 PlatformTransactionManager。

@Transactional 注解是基于注解的声明式事务,当然基于 XMl 配置的也可以,但是要在 Spring Boot 应用中使用,所以一律使用基于注解的声明式事务。

@Transactional 可以作用于接口定义、接口方法、类定义和类的公共方法上,如果作用于私有方法或者包可见的方法上,虽然不会引发错误,但是并不会激活事务的一些行为。另外,将 @Transactional 作用于类上要比作用于接口上更好,下面说明一下原因。

在 Spring AOP 框架中有两种模式,分别是基于代理和基于 AspectJ。基于代理又分为两种,一种是基于接口的,一种是基于类的。如果是基于类的代理或是基于 AspectJ,那么将 @Transactional 作用于接口上不会起到任何作用。

上面说到了事务是基于 AOP 的,那么如何让事务支持多种模式呢?@EnableTransactionManagement 注解提供了相关的支持,@EnableTransactionManagement 注解的源码如下:
public @interface EnableTransactionManagement {
    boolean proxyTargetClass() default false;
    AdviceMode mode() default AdviceMode.PROXY;
    int order() default 2147483647;
}
事务管理默认模式为基于代理,即 mode=AdviceMode.PROXY,且默认的代理方式为基于接口的,因为 proxyTargetClass=false。

@Transactional 注解的属性如下表所示:

表:@Transactional注解的属性
属性 类型 描述
value String 指定事务管理器
propagation enum: Propagation 事务传播行为
isolation enum: Isolation 事务隔离级别
timeout int(单位为秒) 事务超时时间
readOnly boolean 是否只读
rollbackFor Class[] 引起回滚的异常类数组
rollbackForClassName String[] 引起回滚的异常类名称数组
noRollbackFor Class[] 不会引起回滚的异常类数组
noRollbackForClassName String[] 不会引起回滚的异常类名称数组

关于声明式事务就先介绍这些,下面用代码的方式了解事务的行为。

以银行转账业务为例,现有四张表,分别为银行工作人员表、银行客户表、银行账户表和操作记录表。为了简便起见,客户表与账户表为一对一关系,即每位客户都只有一张银行卡。业务场景为 A 客户通过银行工作人员给 B 客户转账,如果转账成功,则账户表中对应 A 和 B 的两条记录要进行账户余额的修改,操作记录表中会新增两条记录,分别是 A 出账的记录和 B 入账的记录。因为转账业务必须是一个事务,要么都成功,要么都失败,所以两条 update 语句和两条 insert 语句必须被事务包围,如果发生异常,就必须回滚。

数据库采用 MySQL,表的结构如下图所示:


创建表的脚本如下:
-- 银行账户表
CREATE TABLE bank_account (
id varchar(255) NOT NULL COMMENT '银行卡号',
bank_name varchar(255) DEFAULT NULL COMMENT '银行名称',
balance decimal(20,3) DEFAULT NULL COMMENT '账户余额',
create_time datetime DEFAULT NULL COMMENT '创建时间',
update_time datetime DEFAULT NULL COMMENT '修改时间',
PRIMARY KEY (id)
);
-- 银行工作人员表
CREATE TABLE bank_staff (
id int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT '员工ID',
name varchar(255) DEFAULT NULL COMMENT '员工姓名',
password varchar(255) DEFAULT NULL COMMENT '密码',
phone varchar(255) DEFAULT NULL COMMENT '手机号',
create_time datetime DEFAULT NULL COMMENT '创建时间',
update_time datetime DEFAULT NULL COMMENT '修改时间',
PRIMARY KEY (id)
);
-- 银行客户表
CREATE TABLE bank_customer (
id int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT '客户ID',
name varchar(255) DEFAULT NULL COMMENT '客户姓名',
password varchar(255) DEFAULT NULL COMMENT '密码',
account_id varchar(255) DEFAULT NULL COMMENT '银行卡号',
phone varchar(255) DEFAULT NULL COMMENT '手机号',
create_time datetime DEFAULT NULL COMMENT '创建时间',
update_time datetime DEFAULT NULL COMMENT '修改时间',
PRIMARY KEY (id)
);
-- 操作表
CREATE TABLE bank_operate (
id int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT '流水号',
customer_id int(11) DEFAULT NULL COMMENT '客户ID',
staff_id int(11) DEFAULT NULL COMMENT '员工ID',
type int(11) DEFAULT NULL COMMENT '操作类型,0表示入账,1表示出账',
last_balance decimal(10,3) DEFAULT NULL COMMENT '上次余额',
balance decimal(10,3) DEFAULT NULL COMMENT '当前余额',
money decimal(10,3) DEFAULT NULL COMMENT '交易金额,且(上次余额-当前余额)的绝对
值=交易金额',
create_time datetime DEFAULT NULL COMMENT '创建时间',
update_time datetime DEFAULT NULL COMMENT '修改时间',
PRIMARY KEY (id)
);

现在往表里插入初始化数据,与前面说过的业务场景对应起来,分别是一条工作人员记录、两条客户记录、两条账户记录,操作记录表则不插入初始化数据。insert 脚本如下:
INSERT INTO bank_account(id,bank_name,balance,create_time,update_time) VALUES ('6217000011112222333','中国建设银行',10000.000,'2020-10-31 17:10:10','2020-10-31 17:10:17');
INSERT INTO bank_account(id,bank_name,balance,create_time,update_time) VALUES ('6217000011112222444','中国建设银行',20000.000,'2020-10-31 17:11:25','2020-10-31 17:11:27');
INSERT INTO bank_customer(id,name,password,account_id,phone,create_time,update_time) VALUES (1,'张三','abcdef','6217000011112222333','13712345678','2020-10-31 17:13:55','2020-10-31 17:13:58');
INSERT INTO bank_customer(id,name,password,account_id,phone,create_time,update_time) VALUES (2,'李四','123456','6217000011112222444','13712340000','2020-10-31 17:14:28','2020-10-31 17:14:30');
INSERT INTO bank_staff(id,name,password,phone,create_time,update_time) VALUES (1,'员工一号','admin','13700001111','2020-10-31 17:15:07','2020-10-31 17:15:10');

框架主要为 Spring Boot+MyBatis 组合,Spring Boot 集成 MyBatis 的配置前面已经学过,下面只展示一些关键代码:

BankOperateController.java(核心代码):
@RestController
public class BankOperateController {

    @Autowired
    private BankOperateService bankOperateService;

    /**
     * @param staffId        工作人员 ID
     * @param fromCustomerId 转账客户 ID
     * @param toCustomerId   被转账客户 ID
     * @param money          转账金额
     */
    @GetMapping("/transfer")
    public void transfer(Integer staffId,
                         Integer fromCustomerId,
                         Integer toCustomerId,
                         BigDecimal money) {
        bankOperateService.transfer(staffId, fromCustomerId, toCustomerId, money);
    }
}

BankOperateServiceImpl.java:
@Service
public class BankOperateServiceImpl implements BankOperateService {

    @Autowired
    private BankMapper bankMapper;

    @Autowired
    private TransactionTemplate transactionTemplate;

    @Override
    @Transactional
    public boolean transfer(Integer staffId,
                            Integer fromCustomerId,
                            Integer toCustomerId,
                            BigDecimal money) {

        BankCustomer fromCustomer = bankMapper.selectBankCustomerById(fromCustomerId);
        BankCustomer toCustomer   = bankMapper.selectBankCustomerById(toCustomerId);

        // 第一步:给转账客户扣钱
        bankMapper.updateBankAccountBalance(
                fromCustomer.getBankAccount().getId(),
                money.negate()
        );

        // 模拟交易过程中发生异常
        // int a = 1 / 0;

        // 第二步:记录转账客户流水
        BankOperate fromOperate = new BankOperate();
        fromOperate.setCustomerId(fromCustomerId);
        fromOperate.setStaffId(staffId);
        fromOperate.setType(1);
        fromOperate.setLastBalance(fromCustomer.getBankAccount().getBalance());
        fromOperate.setBalance(
                fromCustomer.getBankAccount().getBalance().subtract(money)
        );
        fromOperate.setMoney(money);

        Date date1 = new Date();
        fromOperate.setCreateTime(date1);
        fromOperate.setUpdateTime(date1);
        bankMapper.insertBankOperate(fromOperate);

        // 第三步:给被转账客户加钱
        bankMapper.updateBankAccountBalance(
                toCustomer.getBankAccount().getId(),
                money
        );

        // 第四步:记录被转账客户流水
        BankOperate toOperate = new BankOperate();
        toOperate.setCustomerId(toCustomerId);
        toOperate.setStaffId(staffId);
        toOperate.setType(0);
        toOperate.setLastBalance(toCustomer.getBankAccount().getBalance());
        toOperate.setBalance(
                toCustomer.getBankAccount().getBalance().add(money)
        );
        toOperate.setMoney(money);

        Date date2 = new Date();
        toOperate.setCreateTime(date2);
        toOperate.setUpdateTime(date2);
        bankMapper.insertBankOperate(toOperate);

        return true;
    }
}
在 transfer 方法中,主要做了四件事:给转账客户扣钱、记录扣钱流水、给被转账客户加钱、记录加钱流水。这四步操作密不可分,必须在同一个事务内进行,因此在 transfer 方法上加了 @Transactional 注解,通过声明式事务的方式来保障转账业务的安全性。

在第一步操作后,通过模拟交易过程中发送异常来检验 @Transactional 注解是否生效。注意,只有 service 方法将异常抛出来,Spring 才会知道发生了异常,并且抛出来的异常必须是 RuntimeException 或者其子类(比如 java.lang. NullPointerException、java.lang.ArithmeticException 等)才会回滚事务;如果抛出的是其他异常,则不会回滚。

下面通过访问接口来模拟转账业务。转账前张三和李四的账户余额如下图所示:


启动项目后,访问接口 http://localhost:8080/transfer?staffId=1&fromCustomerId=1&toCustomerId=2&money=1000 模拟张三给李四转 1000 块钱。访问后张三和李四的账户余额如下图所示。


这是因为第一步操作成功了,但是紧接着发生了异常,所以将第一步给张三扣钱的 update 语句回滚了。

将 @Transactional 注解注释掉,重启项目,再次访问一遍那个接口。访问后张三和李四的账户余额如下图所示。


张三的账户扣钱了,李四的账户却没有加钱。另外,bank_operate 表也没有新增流水记录。显然,张三的钱白白减少了 1000 块且没有任何记录。由此可见,没有事务管理的业务是非常危险的。

编程式事务

Spring 提供了以下 3 种支持编程式事务的方式:
下面主要介绍 TransactionTemplate。这种方式用于响应式 Web 应用中,是最常用的。

TransactionTemplate 与 Spring 提供的其他模板相似,如 JdbcTemplate。TransactionTemplate 使用回调方法来让应用程序代码从获取和释放事务性资源的样板操作中解放出来,也就意味着使用 TransactionTemplate 不用手动开启事务和提交事务。应用程序代码只用关注业务,其他的交给 TransactionTemplate 即可。

TransactionTemplate 使用 execute 方法传入一个 TransactionCallback 类型的参数来实现编程式事务。TransactionCallback 类型的参数通常可以通过匿名内部类来做,需要重写它的 doInTransaction 方法。将业务逻辑放到 doInTransaction 方法中(其中方法有一个 TransactionStatus 类型的参数),并将整个 doInTransaction 方法的代码块用 try…catch 包围,再在 catch 语句中调用 TransactionStatus 对应的 setRollbackOnly 方法来进行事务回滚。如果没有发生异常,事务就会正常提交。

TransactionTemplate 的 execute 方法声明如下:
public <T> T execute(TransactionCallback<T> action) throws TransactionException;

TransactionTemplate 还提供了一些 API,用于设置事务的相关属性:
setTransactionManager(PlatformTransactionManager transactionManager);
setIsolationLevel(int isolationLevel);
setPropagationBehavior(int propagationBehavior);
setTimeout(int timeout);
setReadOnly(boolean readOnly);

下面对前面展示的由声明式事务所控制的转账方法进行改造。为了检验编程式事务是否有效,一定不要在 service 接口或实现类上使用 @Transational 注解。
@Service
public class BankOperateServiceImpl implements BankOperateService {
    @Autowired
    private BankMapper bankMapper;
    @Autowired
    private TransactionTemplate transactionTemplate;
    @Override
    public boolean transfer(Integer staffId,
                            Integer fromCustomerId,
                            Integer toCustomerId,
                            BigDecimal money) {

        BankCustomer fromCustomer = bankMapper.selectBankCustomerById(fromCustomerId);
        BankCustomer toCustomer   = bankMapper.selectBankCustomerById(toCustomerId);

        // 编程式事务
        return transactionTemplate.execute(new TransactionCallback<Boolean>() {
            @Override
            public Boolean doInTransaction(TransactionStatus transactionStatus) {
                try {
                    // 第一步:给转账客户扣钱
                    bankMapper.updateBankAccountBalance(
                            fromCustomer.getBankAccount().getId(),
                            money.negate()
                    );

                    // 模拟交易过程中发生异常
                    // int a = 1 / 0;

                    // 第二步:记录转账客户流水
                    BankOperate fromOperate = new BankOperate();
                    fromOperate.setCustomerId(fromCustomerId);
                    fromOperate.setStaffId(staffId);
                    fromOperate.setType(1);
                    fromOperate.setLastBalance(
                            fromCustomer.getBankAccount().getBalance()
                    );
                    fromOperate.setBalance(
                            fromCustomer.getBankAccount().getBalance().subtract(money)
                    );
                    fromOperate.setMoney(money);

                    Date date1 = new Date();
                    fromOperate.setCreateTime(date1);
                    fromOperate.setUpdateTime(date1);
                    bankMapper.insertBankOperate(fromOperate);

                    // 第三步:给被转账客户加钱
                    bankMapper.updateBankAccountBalance(
                            toCustomer.getBankAccount().getId(),
                            money
                    );

                    // 第四步:记录被转账客户流水
                    BankOperate toOperate = new BankOperate();
                    toOperate.setCustomerId(toCustomerId);
                    toOperate.setStaffId(staffId);
                    toOperate.setType(0);
                    toOperate.setLastBalance(
                            toCustomer.getBankAccount().getBalance()
                    );
                    toOperate.setBalance(
                            toCustomer.getBankAccount().getBalance().add(money)
                    );
                    toOperate.setMoney(money);

                    Date date2 = new Date();
                    toOperate.setCreateTime(date2);
                    toOperate.setUpdateTime(date2);
                    bankMapper.insertBankOperate(toOperate);

                    return true;

                } catch (RuntimeException e) {
                    // 遇到运行时异常就回滚
                    transactionStatus.setRollbackOnly();
                    return false;
                }
            }
        });
    }
}
下面通过访问接口来测试编程式事务是否生效。转账前张三和李四的账户余额如下图所示:


启动项目后,访问接口 http://localhost:8080/transfer?staffId=1&fromCustomerId=1&toCustomerId=2&money=1000 模拟张三给李四转 1000 块钱。访问后张三和李四的账户余额如下图所示:


由此可见,虽然声明式事务和编程式事务的事务管理实现方式略有不同,但是其本质是没有区别的,最终呈现出来的效果也是相同的。

声明式事务和编程式事务对比

声明式事务最大的优点就是不需要通过编程的方式管理事务,不需要在业务逻辑代码中掺杂事务管理的代码,只需在配置文件中做相关的事务规则声明(或通过基于 @Transactional 注解的方式)便可以将事务规则应用到业务逻辑中。

声明式事务管理优于编程式事务管理,是 Spring 倡导的非侵入式的开发方式。声明式事务管理使业务代码不受污染,一个普通的 POJO 对象只要加上注解,就可以获得完全的事务支持。和编程式事务相比,声明式事务唯一不足的是,它的最细粒度只能作用到方法级别,无法像编程式事务那样作用到代码块级别。即便如此,也存在很多变通的方法,比如将需要进行事务管理的代码块独立为方法等。

声明式事务管理也有两种常用的方式,一种是基于 tx 和 aop 名字空间的XML配置文件,另一种是基于 @Transactional 注解。基于注解的方式更简单易用、更清爽。

相关文章