手写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的实现,涉及到事务挂起和恢复,应该会更有意思。


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

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

下篇文章见!

相关推荐
用户8307196840821 小时前
Spring Boot 集成 RabbitMQ :8 个最佳实践,杜绝消息丢失与队列阻塞
spring boot·后端·rabbitmq
Java水解2 小时前
Spring Boot 视图层与模板引擎
spring boot·后端
Java水解2 小时前
一文搞懂 Spring Boot 默认数据库连接池 HikariCP
spring boot·后端
洋洋技术笔记6 小时前
Spring Boot Web MVC配置详解
spring boot·后端
初次攀爬者1 天前
Kafka 基础介绍
spring boot·kafka·消息队列
用户8307196840821 天前
spring ai alibaba + nacos +mcp 实现mcp服务负载均衡调用实战
spring boot·spring·mcp
Java水解1 天前
SpringBoot3全栈开发实战:从入门到精通的完整指南
spring boot·后端
初次攀爬者2 天前
RocketMQ在Spring Boot上的基础使用
java·spring boot·rocketmq
花花无缺2 天前
搞懂@Autowired 与@Resuorce
java·spring boot·后端