☰
从转账案例学透JDBC事务:ACID、隔离级别与连接池实战
2026/10/17 1:36:48 网站建设 项目流程

1. 事务到底在解决什么问题

1.1 从一次转账说起

先抛开晦涩的术语,想一个每天都会发生的场景:你在一个账务系统里把 1000 元从 A 账户转到 B 账户。这背后至少是两条 SQL:

UPDATE t_account SET balance = balance - 1000 WHERE account_no = 'A'; UPDATE t_account SET balance = balance + 1000 WHERE account_no = 'B';

如果第一条执行完,第二条还没执行,数据库突然崩了、网络断了、应用进程挂了,会出现什么?A 的钱扣了,B 的钱没到账。这笔账就永远对不上了。

事务要解决的,就是让这样一组逻辑上不可分割的操作,要么全部成功,要么全部失败。Java 里的一切事务操作,落到 JDBC 层面就是三个方法:setAutoCommit(false)、commit()、rollback()。思路极其朴素,但底层设计比大多数人以为的要深得多。

如果你刚接触这个概念,可能会觉得事务就是“多包一层 try-catch 然后提交”,这个理解方向对,但离真正的“会用”还差得远。下面我要讲的东西,会把 ACID、锁、隔离级别、日志回滚、连接池场景下的坑全部串起来,这些才是面试、生产事故排查、以及写靠谱业务代码真正用得上的部分。

1.2 ACID 不是四个字母那么简单

ACID 是事务四大特性的首字母缩写:原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)、持久性(Durability)。几乎所有教程都会背这四句话,但真正值得琢磨的是“数据库到底靠什么机制让这四条成立”。

  • 原子性:靠的是 undo log(回滚日志)。事务执行过程中,每条写操作都会先记录一份“修改前的数据镜像”,一旦发生回滚,数据库用这份镜像把数据恢复原样。
  • 一致性:最容易被误读的一条。它不是说“数据库保证你的业务数据永远正确”,而是说“事务执行前后,数据库的约束、规则、触发器、级联操作都必须处于合法状态”。业务上 A + B 的总金额不变,那是你的业务代码和 SQL 设计共同承担的,数据库只能保证约束层面的完整。
  • 隔离性:靠的是锁和 MVCC(多版本并发控制)。多个事务同时操作同一行数据时,数据库要保证它们互不干扰,或者通过版本链让读操作不被写操作阻塞。
  • 持久性:靠的是 redo log。事务提交时,先把日志写到磁盘(顺序写,很快),再异步把数据页刷到磁盘(随机写,较慢)。如果中途断电,重启后数据库通过重放 redo log 恢复已提交的数据。你可以把它理解为“我先在本子上记了一笔,回头再誊到正式账本上”。

理解这四条底层机制的最大价值在于:它能解释很多你在生产环境中亲眼见过的诡异现象。比如为什么事务回滚这么慢?因为要逐条倒放 undo log。为什么 MySQL 突然崩溃后重启,数据反而不少不丢?因为 redo log 在撑着。这些不是一个“理论正确”的知识点,而是排障的线索。

2. JDBC 操作事务的核心机制

2.1 自动提交与手工提交的权衡

JDBC 里有一个默认行为常常被忽略:Connection拿到手之后,autoCommit是true。也就是说,你执行的每一条 SQL 都会立即隐式提交,没有回滚机会。

// 默认状态:每一条 SQL 执行完就提交 conn.setAutoCommit(true);

这个默认值是从数据库接口设计之初定下来的,主要考虑是让初学者和简单查询场景无需关心事务。但在真实开发里,尤其是写电商订单、支付、库存这类强一致性业务时,必须手工关掉自动提交:

conn.setAutoCommit(false); try { // 执行多条 SQL conn.commit(); } catch (Exception e) { conn.rollback(); } finally { // 恢复自动提交(如果要归还连接的话) conn.setAutoCommit(true); }

注意一个细节:setAutoCommit(true)这个操作本身会触发一次隐式提交。也就是说,如果你在一个事务里执行到一半,突然调用了setAutoCommit(true),前面未提交的操作也会被提交。这在连接池复用的场景里是个极其隐蔽的坑,后面单独讲。

2.2 JDBC 事务 API 逐个拆解

JDBC 的事务能力全部定义在java.sql.Connection接口里,核心方法就几个:

方法作用注意点
setAutoCommit(boolean)开启/关闭自动提交关闭后所有 SQL 纳入当前事务
commit()提交事务只有关闭自动提交后调用才有意义
rollback()回滚整个事务会撤销当前事务内所有未提交操作
setSavepoint(String)设置保存点可部分回滚,发挥空间有限
releaseSavepoint(Savepoint)释放保存点提交/回滚后自动释放
setTransactionIsolation(int)设置隔离级别会影响锁行为和并发表现
getTransactionIsolation()查询当前隔离级别排查时常用

来点实际的代码演示,这是最典型的一个事务流程:

String url = "jdbc:mysql://localhost:3306/demo_bank?serverTimezone=Asia/Shanghai&useSSL=false"; try (Connection conn = DriverManager.getConnection(url, "root", "password")) { // 1. 关闭自动提交,开启事务 conn.setAutoCommit(false); try (PreparedStatement ps1 = conn.prepareStatement( "UPDATE t_account SET balance = balance - ? WHERE account_no = ?")) { ps1.setBigDecimal(1, new BigDecimal("1000.00")); ps1.setString(2, "A001"); ps1.executeUpdate(); } try (PreparedStatement ps2 = conn.prepareStatement( "UPDATE t_account SET balance = balance + ? WHERE account_no = ?")) { ps2.setBigDecimal(1, new BigDecimal("1000.00")); ps2.setString(2, "B001"); ps2.executeUpdate(); } // 2. 两条 SQL 都执行成功,提交 conn.commit(); } catch (SQLException e) { // 3. 任何一条失败,整体回滚 if (conn != null) { try { conn.rollback(); } catch (SQLException ex) { // 回滚失败属于严重的资源性问题,必须记录日志告警 log.error("rollback error", ex); } } throw new RuntimeException("转账失败,已回滚", e); }

这个例子是标准的教科书写法,但有几个容易被忽略的点需要强调:

第一,PreparedStatement一定要用 try-with-resources 关闭,否则 MySQL 端会堆积大量未释放的语句对象,最终触发Too many statements一类的报错。

第二,rollback()本身也可能抛异常。回滚失败通常意味着连接已经处于诡异状态(比如网络中断),这时候别继续塞新的操作,直接销毁连接。

第三,commit 之前,业务代码里所有“校验成功、计算完成”之类的逻辑必须已经跑完。聪明的做法是让校验逻辑的异常在 catch 里被统一拦截,而不是分多个 try-catch 把事务切片。

2.3 隔离级别正确打开方式

JDBC 提供的隔离级别直接映射 MySQL 的事务隔离机制,配置方式如下:

// 设置隔离级别为可重复读(MySQL 默认) conn.setTransactionIsolation(Connection.TRANSACTION_REPEATABLE_READ);

MySQL 的 InnoDB 引擎支持四种隔离级别,从低到高分别是读未提交(READ UNCOMMITTED)、读已提交(READ COMMITTED)、可重复读(REPEATABLE READ)、串行化(SERIALIZABLE)。JDBC 里对应四个常量:

JDBC 常量对应 SQL 隔离级别允许的并发异常适用场景
TRANSACTION_READ_UNCOMMITTEDREAD UNCOMMITTED脏读、不可重复读、幻读几乎不用,仅做数据统计类兜底
TRANSACTION_READ_COMMITTEDREAD COMMITTED不可重复读、幻读多数业务系统可选,读不加锁
TRANSACTION_REPEATABLE_READREPEATABLE READ幻读(InnoDB 通过间隙锁解决大部分)MySQL 默认,行级锁较平衡
TRANSACTION_SERIALIZABLESERIALIZABLE无(全部串行)银行转账类强一致场景,并发极低

这里解释两个最容易搞混的概念:

  • 脏读(Dirty Read):事务 A 读取了事务 B 尚未提交的数据,然后 B 回滚了,A 就读取了一个“根本不存在的脏数据”。
  • 不可重复读(Non-Repeatable Read):事务 A 内两次读取同一行,事务 B 在中间提交了一次修改,导致 A 两次读到不同的值。
  • 幻读(Phantom Read):事务 A 按某个条件查询一批数据,事务 B 向该结果集插入了一行新数据并提交,A 再次查询时发现多了一行“幻影数据”。

生活化类比:如果一个事务像开会,读未提交就是“别人还没散会你就能听他的草稿”,读已提交是“听最终定稿”,可重复读是“一进门就拍了照片,之后只看这张照片”,串行化是“每次只能一个人进屋”。

MySQL 默认用 REPEATABLE READ,这块需要特别注意:可重复读在 InnoDB 下并不允许出现“不可重复读”,但它通过 MVCC 让普通读走快照读,不会加锁阻塞;而当前读(SELECT ... FOR UPDATE、UPDATE、DELETE)走行锁 + 间隙锁,也因此能比较彻底地挡住幻读。这跟其他数据库(比如 Oracle 默认 READ COMMITTED)差异很大,跨数据库写代码时要格外注意。

3. 一个完整转账案例的实操拆解

3.1 场景设定与表结构准备

为了把事务讲透,我构造一个接近真实的模拟项目:一个极简账务系统,只有一张账户表和一张流水表,业务规则是转账操作必须做到“账户扣钱、账户加钱、流水记录”三件事同时成功或同时失败。

CREATE DATABASE demo_bank DEFAULT CHARACTER SET utf8mb4; CREATE TABLE t_account ( id BIGINT PRIMARY KEY AUTO_INCREMENT, account_no VARCHAR(32) NOT NULL UNIQUE, balance DECIMAL(12,2) NOT NULL DEFAULT 0, update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB; CREATE TABLE t_trans_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, from_account VARCHAR(32) NOT NULL, to_account VARCHAR(32) NOT NULL, amount DECIMAL(12,2) NOT NULL, trans_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB; INSERT INTO t_account(account_no, balance) VALUES ('A001', 10000.00), ('B001', 10000.00);

注意几个选型的“为什么”:

  • 表引擎必须用 InnoDB。事务和行级锁都是 InnoDB 的特性,MyISAM 完全没有事务支持。这是一个能在生产环境引发大事故的坑,换 MyISAM 后事务静默失效,SQL 照常执行,数据错乱时你根本找不到原因。
  • 余额字段用DECIMAL(12,2)而不是DOUBLE。浮点类型在存储 0.1 这类小数时会丢失精度,涉及钱的项目用 DOUBLE 就是定时炸弹。
  • 所有表都带主键,InnoDB 按主键聚簇存储,行锁也挂载在索引上。没有主键或没有索引时,MySQL 可能升级为锁全表,即使你用 WHERE 精准命中一行。

3.2 转账代码的分层设计

实际操作里,我习惯把代码分成三层:数据访问层只管 SQL、事务控制层管提交回滚、业务入口层管参数校验和结果转化。一个常见的返工错误是事务控制代码和业务逻辑全都塞进一个方法里,测试、排查、扩展全都难受。

第一层,数据访问层,职责是执行 SQL,不关心事务边界:

public class AccountDao { public void deduct(Connection conn, String accountNo, BigDecimal amount) throws SQLException { String sql = "UPDATE t_account SET balance = balance - ? WHERE account_no = ? AND balance >= ?"; try (PreparedStatement ps = conn.prepareStatement(sql)) { ps.setBigDecimal(1, amount); ps.setString(2, accountNo); ps.setBigDecimal(3, amount); int rows = ps.executeUpdate(); if (rows == 0) { throw new SQLException("账户余额不足或账户不存在: " + accountNo); } } } public void add(Connection conn, String accountNo, BigDecimal amount) throws SQLException { String sql = "UPDATE t_account SET balance = balance + ? WHERE account_no = ?"; try (PreparedStatement ps = conn.prepareStatement(sql)) { ps.setBigDecimal(1, amount); ps.setString(2, accountNo); ps.executeUpdate(); } } public void log(Connection conn, String fromAccount, String toAccount, BigDecimal amount) throws SQLException { String sql = "INSERT INTO t_trans_log(from_account, to_account, amount) VALUES (?, ?, ?)"; try (PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, fromAccount); ps.setString(2, toAccount); ps.setBigDecimal(3, amount); ps.executeUpdate(); } } }

这个deduct方法值得细细品一下。它的 SQL 上带了一个balance >= ?条件,然后检查executeUpdate()的返回值。这个设计不是多余,而是一个贴近生产的细节:如果你在 Java 代码里先SELECT查余额,再UPDATE扣钱,中间隔着两次网络往返,余额完全可能被另一个并发事务改掉。把“余额充足”的判断下沉到 SQL 条件里,利用数据库行锁保证原子性,才是靠谱的防超扣方案。

第二层,事务控制层,负责把三张表操作包进同一个事务:

public class TransferService { private final AccountDao accountDao = new AccountDao(); public void transfer(String fromAccount, String toAccount, BigDecimal amount) throws SQLException { if (fromAccount.equals(toAccount)) { throw new IllegalArgumentException("同账户转账不是事务问题,是参数校验问题"); } if (amount.compareTo(BigDecimal.ZERO) <= 0) { throw new IllegalArgumentException("金额必须为正数"); } String url = "jdbc:mysql://localhost:3306/demo_bank?serverTimezone=Asia/Shanghai&useSSL=false"; try (Connection conn = DriverManager.getConnection(url, "root", "password")) { conn.setAutoCommit(false); try { accountDao.deduct(conn, fromAccount, amount); accountDao.add(conn, toAccount, amount); accountDao.log(conn, fromAccount, toAccount, amount); conn.commit(); } catch (SQLException e) { try { conn.rollback(); } catch (SQLException rollbackEx) { e.addSuppressed(rollbackEx); } throw e; } } } }

第三层,业务入口层,把异常翻译成调用方能理解的错误信息。这部分代码很常规就不放完整实现了,核心思想是catch (SQLException e)之后做层级包装,不要直接抛 SQLException 给上层业务。

这个分层结构的价值在于:事务边界具有传染性——一个方法如果内部开了事务,调用它的所有方法都得知道这个前提,否则很容易“内外事务混在一起”。明确分层后,事务只出现在 Service 层,DAO 层永远不知道事务存在,这样责任边界就清晰了。

3.3 验证事务回滚的实战方法

代码写完了,直接跑一遍,但你得知道怎么验证“回滚真的发生了”。我的习惯是在事务内故意制造异常,然后查询数据库状态。

public static void main(String[] args) { TransferService service = new TransferService(); try { // 把 B001 的余额改成 0,让 deduct 抛出“余额不足” service.transfer("A001", "B001", new BigDecimal("500.00")); } catch (Exception e) { System.out.println("转账失败: " + e.getMessage()); } // 验证结果:A001 余额依然是 10000,B001 余额依然是 10000,流水表没有新增记录 }

如果事务失效,会看到 A 少了 500 但 B 没加钱,流水表记录一条失败欠账;如果事务正常,A、B、流水表三个状态全都纹丝不动,仿佛什么都没发生过。这就是事务的“全有或全无”:从最终结果看,失败的事务就像从未执行过。

实际生产中还要加一层验证:并发压测。开两个线程同时用同一个 A001 账户给不同账户转账,观察是否出现超扣。这个在命令行里写个多线程循环去调transfer方法就能测,如果单量很小也会暴露超扣问题,说明你的事务或者 SQL 的锁机制出现了问题。

4. 事务实操中的常见坑与排查手册

4.1 连接池场景下的“幽灵事务”

开发环境用DriverManager.getConnection()每次创建连接没问题,生产环境几乎清一色使用连接池(比如常见的数据库连接池中间件)。连接池有个优化行为:连接物理不关闭,而是归还到池子里复用。

这就引出一个极其常见的生产事故:某段代码开了事务,忘了 commit 也忘了 rollback,直接把连接还回池子。下一个请求拿到这条“残留事务”的连接,执行一条 SQL 后神秘地发现数据没生效,或者更诡异的是,上一笔未提交的数据被“意外提交”了。

排查这类问题,最有效的方式是开启 MySQL 的 general log,观察连接的Query节奏:

SET GLOBAL general_log = ON; SET GLOBAL log_output = 'TABLE'; SELECT * FROM mysql.general_log ORDER BY event_time DESC LIMIT 20;

然后你会清楚看到:一个连接的set autocommit=0之后,如果没有对应 commit/rollback,那这个连接上的所有操作都串在同一事务里。修复方法不复杂,但两条纪律必须刻在脑子里:

第一,事务代码里commit()和rollback()要成对出现,并且最好放在同一个 try-catch-finally 结构内。

第二,连接归还前,务必通过框架机制确保事务已终结。如果手写连接管理,finally块里要把setAutoCommit(true)设回去,这是一个很多人不知道但非常重要的操作:setAutoCommit(true)会隐式提交当前事务,让连接回到干净状态。

4.2 保存点的“回头路”到底能走多远

Savepoint是 JDBC 里一个看起来很灵活、实际用得很少的能力。它的思路是:设置一个保存点,后续操作出问题时可以只回滚到保存点,而不是回滚整个事务。

conn.setAutoCommit(false); Savepoint sp1 = conn.setSavepoint("STEP1"); // 执行操作A try { // 执行操作B,假设这里抛异常 } catch (SQLException e) { conn.rollback(sp1); // 只回滚操作B,保留操作A } conn.commit(); // 提交操作A

这个机制在 MySQL InnoDB 下可以实现部分回滚,但实际生产里我基本不推荐依赖它。原因有三个:

  • 事务设计中“要么全有要么全无”的语义通常更适合业务理解,部分回滚会让事务状态变得难跟踪。
  • 保存点之间若跨越了 DDL 语句(CREATE TABLE、ALTER TABLE等),MySQL 会自动隐式提交,之前的保存点全部失效。这是 InnoDB 的一个硬限制。
  • 排查问题时,“回滚为什么只撤销了部分操作”本身就是一件很难解释的事,多人协作时极易引发误会。

认真想明白业务,把事务拆细,比在事务内部用保存点“打补丁”要靠谱得多。

4.3 超时、锁等待与死锁的实战处理

事务不是开了就万事大吉。两个事务同时操作同一行时,后到的事务会进入锁等待。MySQL 的默认锁等待时间是 50 秒,如果在你的业务里事务频繁互相等待,用户体感就是某个接口卡住半分钟然后报错。

遇到锁等待错误,MySQL 会抛Deadlock found when trying to get lock; try restarting transaction,属于 1213 错误码。这是死锁信号,而不是代码 bug——死锁本身就是并发系统的正常现象,关键在于你的代码能否处理它。

处理死锁的标准策略是重试。带重试的转账方法看起来像这样:

public void transferWithRetry(String from, String to, BigDecimal amount, int maxRetries) throws SQLException { int attempts = 0; while (attempts < maxRetries) { attempts++; try { transfer(from, to, amount); return; } catch (SQLException e) { if (e.getErrorCode() == 1213 && attempts < maxRetries) { // 睡眠一小段随机时间,错开竞争 Thread.sleep(50 + ThreadLocalRandom.current().nextInt(100)); continue; } throw e; } } throw new SQLException("重试失败,事务仍然死锁"); }

降低死锁概率的经验是:多个事务同时操作多张表时,尽量保持相同的操作顺序。比如转账场景里,总是先更新转出账户,再更新转入账户,可以有效减少两个事务互相持有对方所需锁的概率。这个规律跟生活中排队过闸机一样,遵守同一个方向,拥堵就少。

监控方面,可以定期执行这套排查 SQL,快速找到当前锁竞争的热点:

-- 查看当前正在运行的 InnoDB 事务 SELECT * FROM information_schema.INNODB_TRX\G; -- 查看锁等待关系 SELECT * FROM sys.innodb_lock_waits\G;

4.4 DDL 语句引发的“隐式提交”陷阱

这是很多老手都会踩的坑:你以为自己在事务里,结果执行了一条CREATE TABLE或ALTER TABLE,MySQL 二话不说帮你把当前事务提交了。InnoDB 对 DDL 语句的执行策略是“先提交当前事务,再执行 DDL”。

落到真实场景就是:你的事务里前面已经更新了 5000 条数据,然后代码里顺手执行了一条建临时表的 SQL,之后业务抛异常,你调用 rollback——结果发现那条 5000 条的更新已经提交了,根本无法回滚。

这类问题的排查思路很简单:看看事务内部是否混入了 DDL。规避方法是把建表、加索引这类结构性操作放进独立的执行流程,不在业务事务里做。MySQL 官方文档对会导致隐式提交的语句列了很长的清单,包括但不限于:

  • CREATE TABLE、ALTER TABLE、DROP TABLE、TRUNCATE TABLE
  • CREATE INDEX、DROP INDEX
  • RENAME TABLE
  • CREATE DATABASE、ALTER DATABASE、DROP DATABASE
  • GRANT、REVOKE、SET PASSWORD等账号管理语句

所以如果你在事务里用了这些语句,记得先确认清楚现状,别让“已提交的事务”在关键时刻给你来一记背刺。

5. 事务与并发编程的交汇点

5.1 事务边界和 Java 异常机制的配合

JDBC 事务跟 Java 的异常机制有一个很容易踩的错位:RuntimeException和受检异常的捕获时机不同,事务回滚的触发条件因此也不同。你的代码里如果把所有异常都统一捕获再统一回滚,通常没问题;但如果你写了类似这样的代码,就要小心:

try { conn.setAutoCommit(false); // 执行SQL conn.commit(); } catch (SQLException e) { conn.rollback(); throw e; }

问题来了:如果业务代码里抛出一个IllegalArgumentException(非 SQLException),这个 catch 块不会触发,事务就不会回滚。这也是很多新手写事务代码“回滚无效”的原因所在——异常类型没对上。

更稳妥的做法是捕获Exception(或者在你用的框架里捕Throwable):

try { conn.setAutoCommit(false); // 业务操作 conn.commit(); } catch (Exception e) { conn.rollback(); throw e; }

另外一个 Java 层面的坑是:数据库操作的相关方法如果声明了throws SQLException,在 Java 8 的 lambda 里处理会比较麻烦。实际项目里我更推荐把 DAO 方法里的SQLException转换成自定义的运行时异常(比如DataAccessException),这样事务边界的代码不会被编译器的受检异常限制束缚,业务层也能统一处理异常。

5.2 事务内不要做远程调用和耗时操作

这是我在实际项目中踩过最深的坑之一。事务开启后,可能会持有数据库的行锁,这个锁在 commit 或 rollback 之前不会被释放。如果你的事务内部调用了外部的 HTTP 接口,或者执行了大量计算,这段时间内所有并发事务都在等你释放锁,数据库连接池可能被瞬间打满,整个系统的吞吐量断崖式下跌。

一个健康的事务边界应该是:

// 1. 远程调用、业务校验、金额计算,全都在事务外完成 BigDecimal fee = feeService.calc(fromAccount, toAccount, amount); boolean verified = verificationService.check(fromAccount); // 2. 拿到所有前置结果之后,才打开数据库事务 conn.setAutoCommit(false); try { accountDao.deduct(conn, fromAccount, amount.add(fee)); accountDao.add(conn, toAccount, amount); accountDao.log(conn, fromAccount, toAccount, amount.add(fee)); conn.commit(); } catch (Exception e) { conn.rollback(); throw e; }

优化原则总结成一句话:事务只做数据库写操作和必要的读校验,其他无关工作全部移出事务。这样事务的执行耗时才会从“秒级”压缩到“毫秒级”,锁的持有时间自然也跟着缩短,并发能力会有质的提升。

5.3 隔离级别选型的实战参考

事务隔离级别的选择没有放之四海而皆准的答案,但有一个比较实用的决策逻辑可以参考:

  • 金融交易、账务类系统:优先考虑REPEATABLE READ(MySQL 默认)或SERIALIZABLE。前者能通过 MVCC + 间隙锁解决绝大多数并发写冲突,后者把并发度牺牲到极致,但能彻底消除幻读。
  • 电商库存、订单类系统:READ COMMITTED通常是个不错的折中。它允许不可重复读,但避免脏读,并发性更好,配合乐观锁(版本号)可以解决大部分超卖问题。
  • 统计分析、报表查询:对一致性要求不高的场景,可以用READ UNCOMMITTED来避免加锁、提升查询速度,但这种场景生产环境很少见,知道就好。

一个真实的踩坑案例:某同学在模拟项目里默认用了REPEATABLE READ,测试并发扣减时怎么也压不出超卖,就以为万事大吉。后来一次性并发量上调到数千时,大量连接开始报锁等待超时。排查后才发现,REPEATABLE READ下SELECT ... FOR UPDATE的间隙锁范围比想象中大得多,导致并发写冲突被放大。最终方案改成了READ COMMITTED+ 乐观锁版本号,性能立刻上来了。

这个案例想说明的其实是一个通用道理:隔离级别选型不是越严格越好,要结合并发量、可接受的异常类型、锁开销三者一起权衡。

6. 从 JDBC 到框架事务的思维升级

6.1 框架事务和原生 JDBC 的关系

现在大多数项目都用框架管理事务,比如在业务方法上标个@Transactional注解。但你要记住一个底层事实:框架事务最终也是通过setAutoCommit(false)、commit()、rollback()这套 JDBC API 完成的,框架只是在外面包了一层更优雅的切割逻辑。

@Transactional的本质是把事务边界声明在方法级别,方法开始前打开事务,方法正常返回后提交,抛出运行时异常则回滚。它的确省掉了大量样板代码,但也引入了几个新坑:

  • 自调用陷阱:同一个类里一个方法调用另一个被@Transactional标注的方法,事务不会生效,因为框架基于代理实现,内部调用拿不到这个代理。
  • 事务传播行为:多个事务方法相互嵌套时,默认REQUIRED是沿用已有事务还是新起一个,规则比原生 JDBC 更复杂。
  • 只有 RuntimeException 才触发回滚:默认情况下,方法抛出受检异常不会回滚,很多人不知道这个差异,导致“明明抛异常了数据怎么还是提交了”的困惑。

理解原生 JDBC 的事务机制,不是为了整天手写 JDBC,而是为了在框架事务无法满足需求时(比如多数据源、长事务拆解),知道底层还能怎么操作。

6.2 手写一个最小版事务管理器的思路

如果你想把事务理解得更透,可以尝试手动封装一个极简的事务管理器,核心思想就是把连接绑定到当前线程:

public class LocalTransactionManager { private static final ThreadLocal<Connection> HOLDER = new ThreadLocal<>(); public static Connection getConnection() throws SQLException { Connection conn = HOLDER.get(); if (conn == null) { conn = DriverManager.getConnection("jdbc:mysql://localhost:3306/demo_bank", "root", "password"); conn.setAutoCommit(false); HOLDER.set(conn); } return conn; } public static void commit() throws SQLException { Connection conn = HOLDER.get(); if (conn != null) { conn.commit(); conn.close(); HOLDER.remove(); } } public static void rollback() throws SQLException { Connection conn = HOLDER.get(); if (conn != null) { conn.rollback(); conn.close(); HOLDER.remove(); } } }

这个封装利用了ThreadLocal保证同一个线程内所有 dao 操作拿到同一个连接,从而共享同一个事务。虽然它过于粗糙、完全没有处理连接池和异常链,但它的核心思想跟各大框架的事务管理器一脉相承:事务的本质是连接绑定 + 提交/回滚控制。自己写一遍之后,再去看框架源码,会发现一切都非常熟悉。

我在带人的时候经常让他们手写这个最小管理器,效果比看十篇博客都管用。因为只有亲手经历了“为什么同一个线程必须共享同一个连接”,才能理解大量事务传播机制的由来。

7. 结尾:一个值得反复琢磨的设计观

如果你完整读到这里,会发现事务并不是“多包一层 try-catch 再提交”那么单薄。它的背后是一整套数据库引擎级的设计:日志提供原子性和持久性,锁和 MVCC 提供隔离性,约束和业务代码共同保障一致性。

我个人在实际操作中体会最深的一件事是:事务的难点从来不在 API,而在边界。哪里开启、哪里提交、哪里回滚、锁持有多久、远程调用放没放在事务里、连接池归还时有没有清理干净,这些才是决定系统稳定性的关键。API 只是几个方法,边界设计才是工程素养的体现。

最后再分享一个小技巧:排查任何事务问题时,第一件事永远不是看代码,而是看information_schema.INNODB_TRX里当前活跃事务的数量和它们在干什么。数据会告诉你一切——事务有没有正常关闭、有没有锁等待、有没有泄漏。这个习惯帮你节省过的时间,远比任何“事务最佳实践”清单都来得实在。

希望你下次在 Connection、事务、MySQL 这三者之间游走时,不再把它们当成三个孤立的名词,而是一套有机配合的机制。祝写出的每一笔钱,都能安全到账。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询