一、转账案例

假设有一个最简单的转账业务:

public void transfer(String sourceName, String targetName, Double money) {
    Account sourceAccount = accountDao.findAccountByName(sourceName); // 操作1
    Account targetAccount = accountDao.findAccountByName(targetName); // 操作2
    sourceAccount.setMoney(sourceAccount.getMoney() - money);
    targetAccount.setMoney(targetAccount.getMoney() + money);
    accountDao.updateAccount(sourceAccount); // 操作3:扣钱
    int i = 1 / 0;                           // 模拟业务异常
    accountDao.updateAccount(targetAccount); // 操作4:加钱(不会执行到)
}

运行之后你会发现:扣钱成功了,但加钱没有执行。钱就这么凭空消失了。

原因是:操作 3 和操作 4 分别从连接池拿了两个不同的 Connection,各自自动提交,根本不在同一个事务里。出了异常,操作 3 已经提交,操作 4 没有执行,无法回滚。


二、问题根源分析

要让事务生效,必须满足一个前提:同一个事务内的所有数据库操作,必须使用同一个 Connection。

但 Service 调用 DAO 的多个方法时,每个方法内部都会单独去连接池拿连接,很难保证拿到同一个。

最直接的解法是手动传连接:

// 非常丑陋的写法
public void transfer(...) {
    Connection conn = dataSource.getConnection();
    conn.setAutoCommit(false);
    accountDao.findAccountByName(conn, sourceName); // 手动传 conn
    accountDao.updateAccount(conn, sourceAccount);  // 手动传 conn
    conn.commit();
}

这种写法侵入性极强,而且 Service 层直接持有 Connection,违反了分层原则。

更优雅的做法:用 ThreadLocal 把连接绑定到当前线程上,让 DAO 自己去线程里取。


三、解决方案:两个工具类

3.1 ConnectionUtils:连接与线程绑定

/**
 * 连接工具类:从数据源获取连接,并与当前线程绑定
 */
public class ConnectionUtils {

    // 关键:ThreadLocal 变量,全局只有一个实例
    private ThreadLocal<Connection> tl = new ThreadLocal<Connection>();

    private DataSource dataSource;

    public void setDataSource(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    /**
     * 获取与当前线程绑定的连接
     * 如果没有,就从连接池拿一个并绑定到当前线程
     */
    public Connection getThreadConnection() {
        try {
            Connection connection = tl.get();    // 先从当前线程的口袋里找
            if (connection == null) {
                connection = dataSource.getConnection(); // 没有才去连接池拿
                tl.set(connection);              // 存入当前线程的口袋
            }
            return connection;
        } catch (SQLException e) {
            throw new RuntimeException(e);
        }
    }

    /**
     * 将当前线程与连接解绑
     */
    public void removeConnection() {
        tl.remove();
    }
}

核心逻辑:第一次调用时去连接池拿,并存到当前线程;之后再调用,直接从线程里取。

同一个线程里无论调用多少次 getThreadConnection(),拿到的永远是同一个 Connection

3.2 TransactionManager:事务控制器

/**
 * 事务管理工具类:开启、提交、回滚、释放
 */
public class TransactionManager {

    private ConnectionUtils connectionUtils;

    public void setConnectionUtils(ConnectionUtils connectionUtils) {
        this.connectionUtils = connectionUtils;
    }

    // 开启事务:关闭自动提交
    public void beginTransaction() {
        try {
            connectionUtils.getThreadConnection().setAutoCommit(false);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    // 提交事务
    public void commit() {
        try {
            connectionUtils.getThreadConnection().commit();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    // 回滚事务
    public void rollback() {
        try {
            connectionUtils.getThreadConnection().rollback();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    /**
     * 释放资源
     * 注意:close() 只是把连接还给连接池,线程和连接的绑定还在
     * 必须调用 removeConnection() 彻底解绑,否则下次这个线程被复用时会拿到旧连接
     */
    public void release() {
        try {
            connectionUtils.getThreadConnection().close(); // 还给连接池
            connectionUtils.removeConnection();            // 解除线程绑定
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

四、改造 DAO 层

DAO 不再从连接池拿连接,改为从 ConnectionUtils 拿当前线程绑定的连接:

public class AccountDaoImpl implements AccountDao {

    private QueryRunner runner;
    private ConnectionUtils connectionUtils; // 注入工具类

    public Account findAccountByName(String name) {
        try {
            // 关键:传入当前线程的连接,而不是让 runner 自己去连接池拿
            List<Account> list = runner.query(
                connectionUtils.getThreadConnection(), // 从线程口袋里取连接
                "select * from account where name=?",
                new BeanListHandler<Account>(Account.class),
                name
            );
            if (list == null || list.size() == 0) return null;
            if (list.size() > 1) throw new RuntimeException("结果不唯一");
            return list.get(0);
        } catch (SQLException e) {
            throw new RuntimeException(e);
        }
    }

    public void updateAccount(Account account) {
        try {
            runner.update(
                connectionUtils.getThreadConnection(), // 同一个线程,同一个连接
                "update account set money=? where name=?",
                account.getMoney(),
                account.getName()
            );
        } catch (SQLException e) {
            e.printStackTrace();
        }
    }
}

五、改造 Service 层

Service 层负责用 TransactionManager 包裹业务逻辑:

public class AccountServiceImpl implements AccountService {

    private AccountDao accountDao;
    private TransactionManager transactionManager; // 注入事务管理器

    public void transfer(String sourceName, String targetName, Double money) {
        try {
            transactionManager.beginTransaction(); // 开启事务(关闭自动提交)

            Account sourceAccount = accountDao.findAccountByName(sourceName);
            Account targetAccount = accountDao.findAccountByName(targetName);

            sourceAccount.setMoney(sourceAccount.getMoney() - money);
            targetAccount.setMoney(targetAccount.getMoney() + money);

            accountDao.updateAccount(sourceAccount); // 扣钱

            // int i = 1 / 0; // 取消注释可以测试事务回滚

            accountDao.updateAccount(targetAccount); // 加钱

            transactionManager.commit(); // 提交事务

        } catch (Exception e) {
            e.printStackTrace();
            transactionManager.rollback(); // 出异常,回滚
        } finally {
            transactionManager.release(); // 无论如何,释放连接
        }
    }
}

六、整体流程

以一次转账请求为例,假设由线程 Thread-007 执行,逐步拆解每个环节发生了什么。

第一步:开启事务

transactionManager.beginTransaction() 内部调用 connectionUtils.getThreadConnection()。此时 Thread-007 的 ThreadLocalMap 里还没有连接,于是从连接池拿一个 Connection-A,存入 Thread-007 的 ThreadLocalMap,再执行 Connection-A.setAutoCommit(false) 关闭自动提交,事务正式开启。

第二步:查询转出账户

accountDao.findAccountByName(sourceName) 内部再次调用 getThreadConnection()。这次 Thread-007 的口袋里已经有 Connection-A 了,直接返回,不再去连接池拿新的。

第三步:查询转入账户

同上,accountDao.findAccountByName(targetName) 拿到的依然是 Connection-A。

第四步:执行扣钱和加钱

accountDao.updateAccount(sourceAccount)accountDao.updateAccount(targetAccount) 两次更新操作,拿到的都是 Connection-A。此时四次数据库操作全部在同一个连接、同一个事务里执行。

第五步:提交事务

业务正常完成,transactionManager.commit() 对 Connection-A 执行 commit(),四步操作一次性全部提交。若中途抛出异常,则走 rollback(),所有操作一并撤销。

第六步:释放资源

transactionManager.release() 先把 Connection-A 还给连接池,再调用 tl.remove() 解除 Thread-007 与连接的绑定,避免线程被复用时拿到脏连接。

四次数据库操作,用的是同一个 Connection-A,在同一个事务里,要么全成功,要么全回滚。


七、多线程并发下会怎样?

ConnectionUtilsTransactionManager 在 Spring 中都是单例的。你可能担心:单例对象下多线程并发会不会乱套?

完全不会,这正是 ThreadLocal 的精妙之处。

维度状态
ConnectionUtils 实例单例,全局共享
ThreadLocal 实例 (tl)单例,全局共享
Connection 对象每个线程各有一个,隔离
ThreadLocalMap每个 Thread 对象私有,隔离

假设线程 A 和线程 B 同时发起转账:

  • 线程 A 调用 tl.get():JVM 找到线程 A 自己的 ThreadLocalMap,取出 Connection-A
  • 线程 B 调用 tl.get():JVM 找到线程 B 自己的 ThreadLocalMap,取出 Connection-B

虽然调用的是同一个 tl 对象的同一个 get() 方法,但 JVM 自动根据"谁在调用"来决定去哪个线程的 Map 里取数据。两个线程的连接完全隔离,互不干扰。

结论:单例工具类 + ThreadLocal = 逻辑统一管理,数据物理隔离。


八、release() 里为什么要调用 removeConnection()?

这是一个很容易被忽略的细节。

connection.close() 在连接池场景下,并不是真正关闭连接,而是把连接还给连接池。还回去之后,这个 Connection 对象可能被下一个请求复用。

但问题是:tl.remove() 如果不调用,当前线程的 ThreadLocalMap 里还存着对这个 Connection 的引用。

当这个线程(来自线程池)被复用来处理下一个请求时,调用 tl.get() 会发现口袋里"有连接",但那个连接可能已经被其他线程拿走用了,导致数据污染,严重时出现事务混乱。

所以 release() 的正确顺序是:

connection.close();       // 1. 先把连接还给连接池
tl.remove();              // 2. 再解除线程与连接的绑定

九、总结

这套手写事务的核心思路只有一点:

用 ThreadLocal 把 Connection 绑定到线程上,让同一个线程内的所有 DAO 操作共享同一个连接,从而实现事务控制。

其实这正是 Spring 事务管理的底层原理。Spring 的 @Transactional 注解背后,TransactionSynchronizationManager 就是用 ThreadLocal 来存储当前线程的连接资源的——只不过它帮你把这些繁琐的工作都自动化了。

理解了这套手写版本,再去看 Spring 的声明式事务,会清晰很多。


下一篇:ThreadLocal 原理深度解析

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐