手写spring声明式事务@Transactional(二):手写mini版(附源码)

1,922 阅读4分钟

小知识,大挑战!本文正在参与“程序员必备小知识”创作活动。
本文已参与「掘力星计划」,赢取创作大礼包,挑战创作激励金。

文章涉及的源码均已上传到了码云,参考【README.md】文件部署运行即可
手写系列码云地址: git@gitee.com:tangjingshan/tjs-study-m…
本文代码路径:{@link tjs.styudy.mini.springboot.demo.transaction.DoTestOfAppplaction#main}

前言

本文主要是总结下如何手写声明式事务@Transactional的mini版本,至于其具体的源码实现,可以参考之前的博客
手写声明式事务@Transactional(一):手把手解析源码(配gif动态图)

一. 如何运行demo

  1. 执行以下脚本,创建所需的数据库
DROP TABLE IF EXISTS `user_test`;
CREATE TABLE `user_test` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `user_name` varchar(255) DEFAULT NULL,
  `balance` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=9 DEFAULT CHARSET=utf8;
INSERT INTO `user_test` VALUES (1, 'a', 10);
INSERT INTO `user_test` VALUES (2, 'b', 20);
  1. 引入文章开头中的码云项目

  2. 找到测试类tjs.styudy.mini.springboot.demo.transaction.DoTestOfAppplaction#main
    image.png

  3. 更改测试类中的数据源信息

  4. 观察测试代码所做之事 updateSonOfBalance调用updateFatherOfUserName方法,其中updateFatherOfUserName事物传播级别为Propagation.REQUIRES_NEW),会重新创建一个新的事物

@MiniTransactional
@Service
public class TransactionalService {
    @Autowired
    JdbcTemplate jdbcTemplate;
    @Autowired
    TransactionalService transactionalService;

    //@MiniTransactional(propagation = Propagation.REQUIRED)
    public void updateSonOfBalance(boolean isThrowException) {
        jdbcTemplate.execute("UPDATE user_test ut SET ut.balance = ut.balance + 1 WHERE ut.id = 1;");
        // 注入的bean,才能走代理
        transactionalService.updateFatherOfUserName(isThrowException);

        // 直接调用updateFatherOfUserName,updateFatherOfUserName是不会走代理的
        // 但是如果此前,已经绑定了已关闭自动提交的Connection到ThreadLocal,则即使没有走代理,也会是在同一事物下
        // this.updateFatherOfUserName(isThrowException);
        System.out.println("updateFatherOfUserName已执行完成,数据已入库。。。");
    }

    @MiniTransactional(propagation = Propagation.REQUIRES_NEW)//创建一个新事务,如果当前存在事务,将这个事务挂起
    public void updateFatherOfUserName(boolean isThrowException) {
        // 如果隔离级别为 Propagation.REQUIRES_NEW
        // 由于updateSonOfBalance修改了id为1的数据,但是并没有提交事务,此时id为1的那行数据仍旧在加锁状态,尚未释放
        // 此时再去修改id为1的数据,就会产生死锁。
        //jdbcTemplate.execute("UPDATE user_test ut SET ut.user_name = CONCAT(ut.user_name, "_", ut.balance+1)  WHERE ut.id = 1;");

        jdbcTemplate.execute("UPDATE user_test ut SET ut.user_name = CONCAT(ut.user_name, "_", ut.balance+1)  WHERE ut.id = 2;");
        if (isThrowException) {
            throw new RuntimeException("test rollback.....");
        }
    }

    public void find(String pre) {
        List<JSONObject> userTests = jdbcTemplate.query("select * from user_test ut where ut.id = 1;");
        System.out.println(pre + ":" + JSON.toJSONString(userTests));
    }
}

初始化脚本后,回滚成功与否标识如下

  • 回滚成功 image.png

  • 回滚失败 image.png

  1. 注释掉@MiniTransactional注解,演示回滚失败场景

tran-mini_回滚失败1.gif

  1. 加上@MiniTransactional注解,演示回滚成功场景

tran-mini_回滚成功1.gif

  1. 加上@MiniTransactional注解,演示分段提交成功场景 更改启动类为不报错,并在以下位置打上断点 image.png image.png

由于updateFatherOfUserName使用的是新的Connection,所以updateFatherOfUserName方法内对数据库的操作应该在updateFatherOfUserName方法调用完后就入库了
tran-mini-部分提交1.gif image.png

至此,mini版声明式事务 @MiniTransactional 已能满足大部分单机应用的使用场景,至于分布式事务,还需解决如何跨服务触发提交/回滚事务的问题,后续再总结。

二. 分析待做事项

通过上一篇博客的分析,可知声明式事务@Transactional主要做的事情如下

  1. 加载相关配置类到IOC
  2. 切面拦截事务,切面所作之事,伪代码如下
//1. 实例化TransactionInfo
 try {
    //2. 调用目标方法
} catch (Exception ex) {
    //3.1 回滚事务,释放资源
    throw ex;
}
//3.2 提交事务,释放资源
  1. 编写JdbcTemplate
  2. 编写测试类

三. 开始手写

接下来就按着上面的分析,开始手写
具体的手写代码见码云项目,代码量较多这里就不贴了
这里就贴一下关键节点的代码位置

/**
 * 切面类:{@link tjs.styudy.mini.springboot.transaction.aop.TransactionAspect#around(org.aspectj.lang.ProceedingJoinPoint)}
 * 提交事务:{@link tjs.styudy.mini.springboot.transaction.config.TransactionManager#commit(tjs.styudy.mini.springboot.transaction.config.TransactionInfo)}
 * 回滚事务:{@link tjs.styudy.mini.springboot.transaction.config.TransactionManager#rollBack(tjs.styudy.mini.springboot.transaction.config.TransactionInfo)}
 * 清理资源:{@link tjs.styudy.mini.springboot.transaction.config.TransactionManager#cleanupAfterCompletion()}
 * 获取当前连接:{@link tjs.styudy.mini.springboot.transaction.config.ConnectionHolder#getResource()}
 */

四. 遇到的问题

4.1 Propagation.REQUIRES_NEW导致死锁

image.png

原因:

  1. updateSonOfBalance的ConnectionA,修改了id为1的那行数据,对其加上了行锁
  2. updateFatherOfUserName的ConnectionB,也准备修改id为1的那行数据,但是updateSonOfBalance事务还未提交,行锁还未释放
  3. 最终导致死锁,等到超时 此种场景不只于mini版本,spring的版本也会有此问题

4.2 还原ThreadLocal

要做的事情:

  1. 进入updateSonOfBalance,ThreadLocal当前值为ConnectionA
  2. 进入updateFatherOfUserName,ThreadLocal当前值为ConnectionB
  3. 结束updateFatherOfUserName,ThreadLocal当前值还原为ConnectionA

如何还原?mini版本(spring也是类似的做法)是存入ThreadLocal的值为一个对象tjs.styudy.mini.springboot.transaction.config.ConnectionHolder,其有两个属性 image.png 每次方法快结束,释放资源时,将ThreadLocal当前值重置为上一个方法的ConnectionHolder image.png 这样就可以同时满足以下两种需求

  1. 开启新事物的方法,在结束调用时,还原为上一个方法的Connection 结束updateFatherOfUserName,ThreadLocal当前值还原为ConnectionA

  2. 顶层方法,在结束调用时,会将ThreadLocal当前值置为null,间接清理了资源 结束updateSonOfBalance,ThreadLocal当前值还原为null

4.3 处理无注解的情况

如果没有标识注解,则应该直接从连接池中拿连接,用完后直接还回去,JdbcTemplate代码如下

public void execute(String sql) {
    // 标识MiniTransactional注解,从threadLocal中获取当前连接
    Connection connection = ConnectionHolder.getCurConnectionStatic();
    if (connection != null) {
        try (Statement stmt = connection.createStatement()) {
            stmt.execute(sql);
            return;
        } catch (SQLException throwables) {
            throw new RuntimeException(throwables);
        }
    }

    // 没有标识MiniTransactional注解,从连接池中获取当前连接
    try (Connection connectionNew = this.dataSource.getConnection();
         Statement stmtNew = connectionNew.createStatement()) {
        stmtNew.execute(sql);
    } catch (SQLException throwables) {
        throw new RuntimeException(throwables);
    }
}