手写Spring事务框架:200行代码揭开@Transactional的神秘面纱( 附完整源代码)

写在前面

之前我在项目里写过好几篇关于事务的文章,比如在mall商城里怎么用@Transactional、碰到哪些诡异的坑、事务传播行为REQUIRED和REQUIRES_NEW的区别啥的。但说实话,每次写完都觉得有点虚,总感觉是在"知道怎么用"和"理解为什么"之间打转。

后来我就想,干脆自己手写一个简化版的事务框架吧,把Spring那套@Transactional的核心逻辑剥出来,看看到底是咋回事。这不,花了半天时间(对,就半天),用JDK+JDBC撸了个200来行的事务管理框架,还真就跑通了。

今天这篇文章,就把这个过程和踩的坑分享给大家。

为什么要手写?看源码不香吗?

有同学可能会问:Spring源码就在那,为啥不直接看?

我当时也这么想过,但看Spring事务源码有个问题 ------ 抽象层太多了。什么PlatformTransactionManager、TransactionDefinition、TransactionStatus,一层套一层,看着看着就晕了,很难抓住本质。

后来我换了个思路:先自己实现最简单能跑通的版本,再去对比Spring的实现。这样反而更清楚哪些是核心逻辑,哪些是为了扩展性做的设计。

就像学做菜,你得先会做个西红柿炒蛋,再去研究米其林大厨的番茄料理,对吧?

事务的本质到底是啥?

我们先把Spring那套复杂的东西放一边,想想事务在数据库层面到底是咋回事。

其实就三步:

java 复制代码
Connection conn = dataSource.getConnection();
conn.setAutoCommit(false);  // 关键:关闭自动提交

try {
    // 执行你的SQL操作
    insertOrder(...);
    insertOrderItems(...);
    
    conn.commit();  // 成功就提交
} catch (Exception e) {
    conn.rollback();  // 失败就回滚
} finally {
    conn.setAutoCommit(true);  // 记得恢复
    conn.close();
}

看,就这么简单!事务的本质就是把Connection的自动提交关了,然后你手动控制commit还是rollback

那Spring的@Transactional做了啥?无非就是帮你把这个try-catch-finally的模板代码给封装了,你只要打个注解就行。

明白这点之后,我们的目标就清晰了:用AOP拦截带@Transactional注解的方法,在方法执行前后自动加上这套逻辑

整体设计:分层要清晰

我当时设计的时候,参考了Spring的思路,把职责分了几层:

graph TB A[业务代码] --> B[动态代理 ProxyFactory] B --> C[事务拦截器 TransactionInterceptor] C --> D[事务管理器 TransactionManager] D --> E[事务上下文 TransactionContext] E --> F[数据库连接 Connection] style C fill:#f9f,stroke:#333,stroke-width:2px style E fill:#bbf,stroke:#333,stroke-width:2px

几个核心组件:

  1. @Transactional注解:标记哪些方法需要事务
  2. ProxyFactory:用JDK动态代理给你的业务类套一层
  3. TransactionInterceptor:拦截器,真正干活的地方
  4. TransactionManager:管理事务的开启、提交、回滚
  5. TransactionContext:用ThreadLocal保存当前线程的事务信息

这个分层其实就是Spring的简化版,但麻雀虽小五脏俱全。

先从最简单的开始:注解定义

注解部分很简单,就几个属性:

java 复制代码
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD, ElementType.TYPE})
public @interface Transactional {
    Propagation propagation() default Propagation.REQUIRED;
    Class<? extends Throwable>[] rollbackFor() default {};
    int timeout() default -1;
    boolean readOnly() default false;
}

public enum Propagation {
    REQUIRED,   // 有事务就加入,没有就新建
    SUPPORTS    // 有事务就加入,没有就算了
}

这里我只实现了REQUIRED和SUPPORTS,因为这俩最常用。REQUIRES_NEW、NESTED那些留到第二阶段再说。

关键中的关键:ThreadLocal管理事务上下文

这块是我当时花时间最多的地方。为什么要用ThreadLocal?

想想这个场景:

java 复制代码
// 外层方法
@Transactional
public void outerMethod() {
    insertOrder();
    innerMethod();  // 调用内层方法
}

// 内层方法
@Transactional
public void innerMethod() {
    insertOrderItems();
}

外层和内层都有@Transactional,它们应该共享同一个数据库连接,要么一起提交,要么一起回滚。怎么做到的?

答案就是ThreadLocal。每个线程持有自己的ConnectionHolder,里面放着Connection和各种状态信息:

java 复制代码
public class TransactionContext {
    private static final ThreadLocal<ConnectionHolder> CONTEXT = new ThreadLocal<>();
    
    // 开启事务:绑定连接到当前线程
    public static void bindNewConnection(DataSource ds, boolean readOnly, Integer timeout) {
        Connection conn = ds.getConnection();
        conn.setAutoCommit(false);  // 关闭自动提交
        conn.setReadOnly(readOnly);
        
        ConnectionHolder holder = new ConnectionHolder(conn);
        holder.setActive(true);
        CONTEXT.set(holder);  // 绑定到ThreadLocal
    }
    
    // 获取当前线程的连接
    public static Connection getCurrentConnection() {
        ConnectionHolder holder = CONTEXT.get();
        return holder != null ? holder.getConnection() : null;
    }
    
    // 清理资源
    public static void clear() {
        ConnectionHolder holder = CONTEXT.get();
        if (holder != null) {
            holder.getConnection().setReadOnly(false);
            holder.getConnection().setAutoCommit(true);  // 恢复自动提交
            holder.getConnection().close();
        }
        CONTEXT.remove();  // 这个很重要,防止内存泄漏
    }
}

这里有个坑我当时被折腾惨了:一定要记得ThreadLocal.remove()

我一开始没加这行,在多线程测试的时候,发现有的线程会莫名其妙拿到别的线程的连接,查了半天才发现是因为Tomcat的线程池会复用线程,如果你不remove,上一次的ConnectionHolder就还在那。

sequenceDiagram participant 线程1 participant ThreadLocal participant 线程池 线程1->>ThreadLocal: set(holder1) 线程1->>线程1: 执行业务 线程1->>ThreadLocal: 忘记remove() 线程1->>线程池: 归还线程 Note over 线程池,ThreadLocal: 线程1被复用执行新任务 线程池->>线程1: 分配给新任务 线程1->>ThreadLocal: get() ThreadLocal-->>线程1: 返回holder1 (旧的!) Note over 线程1: 悲剧:拿到上次的连接

所以我的建议是:在finally块里统一清理,无论commit还是rollback

拦截器:整个框架的灵魂

拦截器是整个框架最复杂的部分,因为它要处理各种情况。我们来看看核心逻辑:

java 复制代码
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
    // 1. 解析@Transactional注解
    TransactionAttribute attr = parseAnnotation(method, target.getClass());
    if (attr == null) {
        return method.invoke(target, args);  // 没注解直接执行
    }
    
    // 2. 判断是否已经有事务
    boolean existing = TransactionContext.isActive();
    boolean started = false;
    
    // 3. REQUIRED传播:没事务就新建
    if (attr.getPropagation() == Propagation.REQUIRED && !existing) {
        txManager.begin(attr.isReadOnly(), attr.getTimeout());
        started = true;
    }
    
    try {
        // 4. 执行业务方法
        Object result = method.invoke(target, args);
        
        // 5. 正常返回:提交
        if (started) {
            if (TransactionContext.isRollbackOnly()) {
                txManager.rollback();  // 有人标记了回滚
            } else {
                txManager.commit();
            }
        }
        return result;
        
    } catch (InvocationTargetException e) {
        Throwable ex = e.getTargetException();
        
        // 6. 判断是否要回滚
        boolean shouldRollback = (ex instanceof RuntimeException || ex instanceof Error)
            || matchRollbackFor(ex, attr.getRollbackFor());
        
        if (shouldRollback) {
            if (started) {
                txManager.rollback();  // 我开启的事务,我负责回滚
            } else {
                TransactionContext.setRollbackOnly();  // 标记给外层回滚
            }
        } else {
            if (started) {
                txManager.commit();  // 受检异常默认提交
            }
        }
        
        throw ex;
    }
}

这里面有几个点要注意:

1. 异常回滚规则

Spring的默认规则是:

  • RuntimeException和Error:回滚
  • 受检异常(Exception):提交

为什么这样设计?我理解是因为受检异常一般是可预期的业务异常,不应该导致整个事务回滚。但你可以通过rollbackFor显式指定:

java 复制代码
@Transactional(rollbackFor = Exception.class)
public void placeOrder() throws Exception {
    // 现在所有异常都回滚
}

2. 内外层事务的协作

这是个容易搞混的地方。假设外层开启了事务,内层也有@Transactional,内层抛异常了咋办?

sequenceDiagram participant 外层方法 participant 内层方法 participant TransactionContext 外层方法->>TransactionContext: begin() 开启事务 外层方法->>内层方法: 调用 内层方法->>内层方法: 发现已有事务,加入 内层方法->>内层方法: 执行SQL 内层方法-->>外层方法: 抛异常 内层方法->>TransactionContext: setRollbackOnly() 打标记 外层方法->>外层方法: catch到异常 外层方法->>TransactionContext: commit() 时检查标记 TransactionContext->>TransactionContext: 发现rollbackOnly=true TransactionContext->>TransactionContext: 执行rollback

关键点:内层不能直接rollback,只能打个标记,让外层来统一处理。否则外层还想commit的时候,连接已经被回滚了,就乱套了。

一个被我忽略的坑:并发唯一键冲突

写测试用例的时候,我犯了个低级错误。订单号我用时间戳生成的:

java 复制代码
String orderNo = "ORD" + System.currentTimeMillis();

结果多线程测试的时候,经常报唯一键冲突。后来想想也对,currentTimeMillis()精度才到毫秒,并发一高肯定会重复。

后来改成UUID就好了:

java 复制代码
String orderNo = "ORD-" + UUID.randomUUID();

这个坑虽然简单,但提醒我们:生产环境的业务主键一定要用分布式ID生成器,比如雪花算法、号段模式啥的。时间戳这种玩具级别的方案真不能用。

几个要注意的地方

1. 同类方法调用不生效

这个是Spring事务的经典问题,我这个简化版也一样:

java 复制代码
public class OrderService {
    @Transactional
    public void methodA() {
        methodB();  // 这样调用,methodB的@Transactional不生效
    }
    
    @Transactional
    public void methodB() {
        // ...
    }
}

为啥?因为代理只能拦截从外部进来的调用,内部调用走的是this,不经过代理。

解决办法:

  • 要么拆到不同的类
  • 要么通过注入的方式拿到代理对象自己

2. 资源清理一定要放finally

我一开始把clear()放在commit()和rollback()里,结果有些异常场景资源没释放,连接池很快就耗尽了。

后来统一放到finally块:

java 复制代码
try {
    if (holder.isRollbackOnly()) {
        conn.rollback();
    } else {
        conn.commit();
    }
} finally {
    TransactionContext.clear();  // 无论如何都要清理
}

3. 注解解析的优先级

方法级注解 > 类级注解 > 接口方法注解

这个顺序要记住,面试可能会问。

测试验证:眼见为实

写完代码,我跑了几个测试场景:

场景1:正常提交

java 复制代码
service.placeOrder("U1001", "SKU-001", 2);
// 数据库查询:有记录 

场景2:运行时异常回滚

java 复制代码
service.placeOrder("U1002", "OUT_OF_STOCK", 1);  // 内部抛RuntimeException
// 数据库查询:无记录 

场景3:受检异常需要rollbackFor

java 复制代码
@Transactional(rollbackFor = Exception.class)
public void placeOrder(...) throws Exception {
    // 抛Exception也会回滚 
}

场景4:传播行为REQUIRED

java 复制代码
@Transactional
public void outerMethod() {
    insertOrder();
    innerMethod();  // 共享同一个事务
}

// 内层抛异常,外层也回滚 

场景5:多线程隔离

java 复制代码
// 开5个线程并发下单
ExecutorService executor = Executors.newFixedThreadPool(5);
for (int i = 0; i < 5; i++) {
    executor.submit(() -> service.placeOrder(...));
}
// 每个线程独立的事务,互不影响 

这几个场景跑下来,基本的事务功能就都验证了。

和Spring事务的对比

写完之后,我又回去看了下Spring的源码,发现我这个简化版其实抓住了核心:

功能 我的实现 Spring实现
注解驱动 @Transactional @Transactional
代理方式 JDK动态代理 JDK + CGLIB
传播行为 REQUIRED, SUPPORTS 7种传播行为
回滚规则 RuntimeException/Error + rollbackFor 同左
线程隔离 ThreadLocal 同左
资源管理 手动clear TransactionSynchronizationManager

Spring多的那些抽象层,主要是为了:

  • 支持更多传播行为(REQUIRES_NEW需要挂起当前事务)
  • 支持JTA分布式事务
  • 支持多种持久化框架(Hibernate、JPA等)
  • 提供编程式事务API

核心原理是一样的:ThreadLocal + AOP + Connection控制。

现在明白了,下一步该干啥?

现在我对Spring事务的理解,从"会用"升级到了"知道为什么"。下一步我打算:

  1. 加入CGLIB支持:现在只能代理接口,无接口的类还不行
  2. 实现REQUIRES_NEW:这个需要挂起当前事务,涉及到事务栈的管理
  3. 加入Druid监控:看看事务执行耗时、连接获取情况
  4. 研究分布式事务:从2PC到TCC到Seata,慢慢啃

说实话,分布式事务那块我还没研究透,Seata的AT模式看着有点懵,准备先把原理摸清楚再说。

写在最后

这次手写事务框架,最大的收获是:不要怕造轮子

很多时候我们会觉得"Spring都实现了,我还造啥轮子"。但其实,自己动手实现一遍,和只看源码、看文档,理解深度完全不一样。

就像我现在,以后看到@Transactional,脑子里能直接浮现出那个invoke方法、ThreadLocal的上下文、commit和rollback的逻辑,这种感觉还是挺爽的。

而且,这200行代码写完,我对事务相关的面试题基本都能答上来了:

  • 事务传播行为是啥?
  • 为什么同类方法调用事务不生效?
  • 受检异常默认不回滚是为啥?
  • 多线程情况下事务怎么隔离?

这些问题,以前是靠背,现在是真懂了。

代码我放到gitee了(链接:gitee.com/sh_wangwanb... ) 有兴趣的同学可以clone下来跑跑看。下一篇我打算写REQUIRES_NEW的实现,涉及到事务挂起和恢复,应该会更有意思。


你们在用事务的时候,踩过哪些坑?欢迎评论区聊聊,说不定能帮我避免后面的一些问题。

如果文章对您有帮助,辛苦点个赞支持下呗。您的支持,是我创作的最大动力,谢谢。

下篇文章见!

相关推荐
R***62311 小时前
Spring Boot 整合 log4j2 日志配置教程
spring boot·单元测试·log4j
0***h9421 小时前
使用 java -jar 命令启动 Spring Boot 应用时,指定特定的配置文件的几种实现方式
java·spring boot·jar
刘一说1 小时前
Nacos 与 Spring Cloud Alibaba 集成详解:依赖、配置、实战与避坑指南
spring boot·spring cloud·微服务·架构
i***48611 小时前
微服务生态组件之Spring Cloud LoadBalancer详解和源码分析
java·spring cloud·微服务
一 乐2 小时前
购物|明星周边商城|基于springboot的明星周边商城系统设计与实现(源码+数据库+文档)
java·数据库·spring boot·后端·spring
y1y1z2 小时前
Spring框架教程
java·后端·spring
q***51892 小时前
Spring Boot中Tomcat配置
spring boot·tomcat·firefox
V***u4532 小时前
【学术会议论文投稿】Spring Boot实战:零基础打造你的Web应用新纪元
前端·spring boot·后端
A***F1572 小时前
SpringBoot(整合MyBatis + MyBatis-Plus + MyBatisX插件使用)
spring boot·tomcat·mybatis