写在前面
之前我在项目里写过好几篇关于事务的文章,比如在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的思路,把职责分了几层:
几个核心组件:
- @Transactional注解:标记哪些方法需要事务
- ProxyFactory:用JDK动态代理给你的业务类套一层
- TransactionInterceptor:拦截器,真正干活的地方
- TransactionManager:管理事务的开启、提交、回滚
- 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就还在那。
所以我的建议是:在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,内层抛异常了咋办?
关键点:内层不能直接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事务的理解,从"会用"升级到了"知道为什么"。下一步我打算:
- 加入CGLIB支持:现在只能代理接口,无接口的类还不行
- 实现REQUIRES_NEW:这个需要挂起当前事务,涉及到事务栈的管理
- 加入Druid监控:看看事务执行耗时、连接获取情况
- 研究分布式事务:从2PC到TCC到Seata,慢慢啃
说实话,分布式事务那块我还没研究透,Seata的AT模式看着有点懵,准备先把原理摸清楚再说。
写在最后
这次手写事务框架,最大的收获是:不要怕造轮子。
很多时候我们会觉得"Spring都实现了,我还造啥轮子"。但其实,自己动手实现一遍,和只看源码、看文档,理解深度完全不一样。
就像我现在,以后看到@Transactional,脑子里能直接浮现出那个invoke方法、ThreadLocal的上下文、commit和rollback的逻辑,这种感觉还是挺爽的。
而且,这200行代码写完,我对事务相关的面试题基本都能答上来了:
- 事务传播行为是啥?
- 为什么同类方法调用事务不生效?
- 受检异常默认不回滚是为啥?
- 多线程情况下事务怎么隔离?
这些问题,以前是靠背,现在是真懂了。
代码我放到gitee了(链接:gitee.com/sh_wangwanb... ) 有兴趣的同学可以clone下来跑跑看。下一篇我打算写REQUIRES_NEW的实现,涉及到事务挂起和恢复,应该会更有意思。
你们在用事务的时候,踩过哪些坑?欢迎评论区聊聊,说不定能帮我避免后面的一些问题。
如果文章对您有帮助,辛苦点个赞支持下呗。您的支持,是我创作的最大动力,谢谢。
下篇文章见!