手写一遍 Spring 事务,你就再也不会忘了
全文围绕一个名为
aop-demo的小项目展开,六个 demo 从最朴素的"自己写代理"一路演进到 Spring Boot 全自动事务。读完之后,你应该能对面试官讲出来:Spring 事务的本质是 AOP 代理 + ThreadLocal + PlatformTransactionManager,而且每一句都举得出例子。项目代码全部贴在文中,可以直接复制粘贴跑起来。
写在最前
刚学 Spring 的时候,我对事务的理解大概是这样的:
“在方法上加个
@Transactional,出异常了它就帮我回滚。”
这种理解能干活,但只要面试官追一句"那它怎么知道要回滚?",我就开始磕巴了。后来真正搞懂这件事
- demo1:不依赖 Spring 任何高级特性,自己写一个最小可用的事务工具(JDK 动态代理 + ThreadLocal)。
- demo2:把 demo1 中"自己造代理"这一步,交给 Spring AOP。
- demo3:把 AOP 的五种通知一次说清楚。
- demo4:把 JDBC 升级成 JdbcTemplate,顺便复现"无事务导致钱凭空消失"。
- demo5:用 XML 声明式事务把 demo4 的问题修好。
- demo6:把 XML 改成
@Transactional+ JavaConfig,这就是你日常写的代码。
最后再回过头看
Spring Boot **
的自动配置,你会发现 demo6 里那个 SpringConfig,Spring Boot 全帮你写好了。
项目准备
先把脚手架搭起来,后面六个 demo 全部跑在这一套配置上。
目录结构
aop-demo/
├── pom.xml
├── README.md
├── src/
│ ├── main/
│ │ ├── java/com/qcbyjy/
│ │ │ ├── AopDemoApplication.java
│ │ │ ├── demo1/ (JDK 动态代理 + 手写事务)
│ │ │ ├── demo2/ (Spring AOP 入门)
│ │ │ ├── demo3/ (AOP 五种通知)
│ │ │ ├── demo4/ (JdbcTemplate 转账案例)
│ │ │ ├── demo5/ (XML 声明式事务)
│ │ │ └── demo6/ (注解事务 + JavaConfig)
│ │ └── resources/
│ │ ├── application.yml
│ │ ├── applicationContext_demo5.xml
│ │ └── sql/init.sql
│ └── test/
│ └── java/com/qcbyjy/test/
│ ├── Demo1Test.java
│ ├── Demo2Test.java
│ ├── Demo3Test.java
│ ├── Demo4Test.java
│ ├── Demo5Test.java
│ └── Demo6Test.java
pom. xml
技术栈是 Spring Boot 2.7.18 + JDK 11 + MySQL 8 + Druid。
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.18</version>
<relativePath/>
</parent>
<groupId>com.qcbyjy</groupId>
<artifactId>aop-demo</artifactId>
<version>1.0.0</version>
<properties>
<java.version>11</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.33</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.2.20</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.30</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
application.yml
server:
port: 8080
spring:
application:
name: aop-demo
datasource:
type: com.alibaba.druid.pool.DruidDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/test?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false
username: test123
password: test123
druid:
initial-size: 5
min-idle: 5
max-active: 20
max-wait: 60000
logging:
level:
root: info
com.qcbyjy: debug
org.springframework.aop: debug
pattern:
console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
spring.aop:
auto: true
proxy-target-class: false
sql/init.sql
USE test;
DROP TABLE IF EXISTS account;
CREATE TABLE account (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(50) NOT NULL,
money DECIMAL(10, 2) NOT NULL
);
INSERT INTO account(name, money) VALUES ('熊大', 1000.00);
INSERT INTO account(name, money) VALUES ('熊二', 1000.00);
启动 类
注意这里 exclude = DataSourceAutoConfiguration.class,因为 demo1 要自己管理数据源,如果不排除,Spring Boot 启动时会去连数据库,demo1 的演示就不纯粹了。后面 demo4 ~ demo6 用 @SpringBootTest(classes=...) 或 @SpringJUnitConfig 单独装载配置,跟启动类没关系。
package com.qcbyjy;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class})
public class AopDemoApplication {
public static void main(String[] args) {
SpringApplication.run(AopDemoApplication.class, args);
}
}
开始之前先想清楚一件事:事务到底是什么
我把每一节的"为什么"放在前面,代码贴在后面。读起来可能慢,但你不会迷路。
account 表里有两个账户,熊大和熊二,各 1000 元。"熊大转账给熊二 100 元"这件小事,需要执行两条 SQL:
UPDATE account SET money = money - 100 WHERE name = '熊大';
UPDATE account SET money = money + 100 WHERE name = '熊二';
这两条必须同时成功,或同时失败。否则就会出现一种很灵异的场景:钱从熊大账户扣了,但熊二没收到 —— 100 元凭空消失。
所以事务的本质用一句话讲:
同一个 Connection 上的一系列操作,要么全部 commit,要么全部 rollback。
听起来挺朴素的吧?但里面藏着一个特别容易被忽略的前提:两条 SQL 必须用同一个 Connection。
我们看下面这段错误代码,它有事务的"形",但没有事务的"魂":
public void transfer() {
Connection connA = dataSource.getConnection();
PreparedStatement ps1 = connA.prepareStatement("UPDATE ... money - 100");
ps1.executeUpdate();
connA.close();
Connection connB = dataSource.getConnection();
PreparedStatement ps2 = connB.prepareStatement("UPDATE ... money + 100");
ps2.executeUpdate();
connB.close();
}
JDBC 默认 autoCommit = true,SQL 一发出去就立刻
commit **
了。哪怕你后面 connA.rollback(),熊二在 connB 上的入账也早就落库,根本回滚不了 —— 因为它根本不知道 connA 想干嘛。
要让事务成立,必须做到三件事:
- 关掉
autoCommit,SQL 不再自动提交; - 所有 SQL 共用同一个 Connection;
- 调用链结束时统一决定 commit 还是 rollback。
第二点是整个 demo1 的核心矛盾,也是 ThreadLocal 登场的地方。
下面正式开始。
demo1:自己写一遍事务,从根上理解 ThreadLocal
1.1 这个 demo 想说什么
demo1 不依赖 Spring 任何"高级特性",从零写一份事务工具。我希望你写完之后能脱口而出:
“Spring 事务的本质就是 ThreadLocal 绑连接 + 代理拦截方法。”
我们做四件事:
- 用 Druid 当连接池;
- 用
ThreadLocal<Connection>绑定当前线程的连接; - 写一个
JdkProxy,在方法前后开/提/回事务; - 让 Service / DAO 完全不关心事务。
1.2 完整代码
Account. java
实体类,没什么好说的。
package com.qcbyjy.demo1;
public class Account {
private Integer id;
private String name;
private Double money;
public Account() {}
public Account(String name, Double money) {
this.name = name;
this.money = money;
}
public Integer getId() { return id; }
public void setId(Integer id) { this.id = id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public Double getMoney() { return money; }
public void setMoney(Double money) { this.money = money; }
@Override
public String toString() {
return "Account{id=" + id + ", name='" + name + "', money=" + money + '}';
}
}
AccountDao.java
package com.qcbyjy.demo1;
import java.sql.SQLException;
public interface AccountDao {
void save(Account account) throws SQLException;
}
AccountDaoImpl.java
package com.qcbyjy.demo1;
import org.springframework.stereotype.Repository;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.SQLException;
@Repository
public class AccountDaoImpl implements AccountDao {
@Override
public void save(Account account) throws SQLException {
Connection conn = TxUtils.getConnection(); // 注意:不是 dataSource.getConnection()
String sql = "INSERT INTO account (name, money) VALUES (?, ?)";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setString(1, account.getName());
ps.setDouble(2, account.getMoney());
ps.executeUpdate();
}
// 注意:这里不关闭 Connection,事务还没结束
}
}
DAO 只写 SQL,不创建连接,不关闭连接。这一点非常关键,后面解释为什么。
AccountService.java
package com.qcbyjy.demo1;
import java.sql.SQLException;
public interface AccountService {
void saveAll(Account account1, Account account2) throws SQLException;
}
AccountServiceImpl.java
业务方法里夹了一个 1 / 0,故意制造异常,用来触发回滚。
package com.qcbyjy.demo1;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.sql.SQLException;
@Service
public class AccountServiceImpl implements AccountService {
@Autowired
private AccountDao accountDao;
@Override
public void saveAll(Account account1, Account account2) throws SQLException {
accountDao.save(account1);
int i = 1 / 0; // 故意制造异常
accountDao.save(account2);
}
public void setAccountDao(AccountDao accountDao) {
this.accountDao = accountDao;
}
}
TxUtils.java(整个 demo1 的灵魂)
package com.qcbyjy.demo1;
import com.alibaba.druid.pool.DruidDataSource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import java.sql.Connection;
import java.sql.SQLException;
@Component
public class TxUtils {
@Value("${spring.datasource.driver-class-name}")
private String driverClassName;
@Value("${spring.datasource.url}")
private String url;
@Value("${spring.datasource.username}")
private String username;
@Value("${spring.datasource.password}")
private String password;
private static TxUtils INSTANCE;
private static DruidDataSource dataSource;
private static boolean initialized = false;
/** 关键:把 Connection 绑定到当前线程 */
private static final ThreadLocal<Connection> CONNECTION_HOLDER = new ThreadLocal<>();
@PostConstruct
public void init() {
INSTANCE = this;
}
private synchronized void initDataSource() {
if (initialized) return;
try {
dataSource = new DruidDataSource();
dataSource.setDriverClassName(driverClassName);
dataSource.setUrl(url);
dataSource.setUsername(username);
dataSource.setPassword(password);
dataSource.setInitialSize(5);
dataSource.setMinIdle(5);
dataSource.setMaxActive(20);
dataSource.init();
initialized = true;
} catch (SQLException e) {
throw new RuntimeException("Druid 初始化失败", e);
}
}
private void ensureInitialized() {
if (!initialized) initDataSource();
}
public static Connection getConnection() throws SQLException {
if (INSTANCE == null) throw new IllegalStateException("TxUtils 还没被 Spring 初始化");
INSTANCE.ensureInitialized();
Connection conn = CONNECTION_HOLDER.get();
if (conn == null) {
conn = dataSource.getConnection(); // 从连接池借一根
CONNECTION_HOLDER.set(conn); // 绑到当前线程
}
return conn;
}
public static void startTransaction() throws SQLException {
getConnection().setAutoCommit(false);
}
public static void commit() throws SQLException {
Connection conn = CONNECTION_HOLDER.get();
if (conn != null) conn.commit();
}
public static void rollback() throws SQLException {
Connection conn = CONNECTION_HOLDER.get();
if (conn != null) conn.rollback();
}
public static void close() throws SQLException {
Connection conn = CONNECTION_HOLDER.get();
if (conn != null) {
conn.close();
CONNECTION_HOLDER.remove();
}
}
}
这个类我建议你多看几遍。它干的事情非常少,但每一行都精准对应事务的一个关键步骤:
CONNECTION_HOLDER:把 Connection 绑到线程上;getConnection():有就用,没有就借;startTransaction():把autoCommit关掉;commit / rollback / close:统一管理生命周期。
这就是后面 Spring 里 TransactionSynchronizationManager 的雏形,只是少了几百个 if-else。
JdkProxy.java(事务织入器)
package com.qcbyjy.demo1;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
public class JdkProxy {
public static AccountService createProxy(AccountService target) {
return (AccountService) Proxy.newProxyInstance(
target.getClass().getClassLoader(),
target.getClass().getInterfaces(),
new TransactionInvocationHandler(target)
);
}
private static class TransactionInvocationHandler implements InvocationHandler {
private final AccountService target;
TransactionInvocationHandler(AccountService target) {
this.target = target;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
Object result;
try {
System.out.println("[Proxy] 开始事务");
TxUtils.startTransaction();
result = method.invoke(target, args);
TxUtils.commit();
System.out.println("[Proxy] 提交事务");
} catch (Exception e) {
Throwable real = (e.getCause() != null) ? e.getCause() : e;
System.out.println("[Proxy] 异常,准备回滚:" + real.getMessage());
TxUtils.rollback();
throw real;
} finally {
TxUtils.close();
}
return result;
}
}
}
Demo1Test.java
package com.qcbyjy.test;
import com.qcbyjy.demo1.Account;
import com.qcbyjy.demo1.AccountService;
import com.qcbyjy.demo1.JdkProxy;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
public class Demo1Test {
@Autowired
private AccountService accountService;
@Test
public void testSaveAllCommit() throws Exception {
Account a1 = new Account("张三", 1000.0);
Account a2 = new Account("李四", 2000.0);
AccountService proxy = JdkProxy.createProxy(accountService);
proxy.saveAll(a1, a2);
}
@Test
public void testSaveAllRollback() {
Account a1 = new Account("王五", 3000.0);
Account a2 = new Account("赵六", 4000.0);
AccountService proxy = JdkProxy.createProxy(accountService);
try {
proxy.saveAll(a1, a2);
} catch (Exception e) {
System.out.println("捕获异常:" + e.getMessage());
}
}
}