Loading...

手写一遍 Spring 事务,你就再也不会忘了

2026-05-09
0
-
- 分钟
|

手写一遍 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 想干嘛。

要让事务成立,必须做到三件事:

  1. 关掉 autoCommit,SQL 不再自动提交;
  2. 所有 SQL 共用同一个 Connection;
  3. 调用链结束时统一决定 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());
        }
    }
}

文章目录