大家好,我是程序员柏风,AI 最全的 Vibecoding 学习地图 http://coderoadmap.cn 作者。
事务基础
这一课你将能做到
- 用 START TRANSACTION / COMMIT / ROLLBACK 手动管理事务,并用转账例子演示「要么全成、要么全不做」
- 用自己的话解释 ACID 四性和 MySQL 的四个隔离级别(默认 REPEATABLE READ)
- 把 SQL 事务映射到 Spring Boot 的
@Transactional,说出至少三个注解失效场景
前置:第一课建库脚本已跑(前面课改过余额的话先重跑复原,保证对得上输出)。
为什么要学
真实业务的一条逻辑往往要连续改好几张表:买课 = 扣余额 + 插订单 + 生成学习记录。三步做到一半服务崩了怎么办?余额扣了订单没生成,钱就凭空消失。事务就是数据库给的答案:把多条 SQL 捆成一个「全做或全不做」的单元。这也是 InnoDB 压倒 MyISAM 的核心优势,Java 后端面试的必考题(ACID、隔离级别、@Transactional 失效场景三连问)。
先建立图像
转账 = 两步 UPDATE,必须同生共死
START TRANSACTION;
老陈 balance 128 → 78 (步骤①)
小林 balance 39 → 89 (步骤②)
┌─ 一切正常 ──────────────→ COMMIT:两步对外同时生效
└─ 中间任何环节出错 ──────→ ROLLBACK:两步一起当没发生过
COMMIT 之前,改动只有当前连接自己看得见;COMMIT 之后才对所有人生效、落盘持久。
跟着做
步骤 1:认识 autocommit
SELECT @@autocommit; -- 1
MySQL 默认开启自动提交:每条语句自带一个隐式事务,执行完立即生效。前面课里的 INSERT/UPDATE 所以「一敲就生效」。显式事务就是把多条语句从「各自为政」改成「打包结算」。
步骤 2:ROLLBACK——全没发生过
START TRANSACTION;
UPDATE student SET balance = balance - 50 WHERE id = 1; -- 老陈 128 → 78
UPDATE student SET balance = balance + 50 WHERE id = 2; -- 小林 39 → 89
SELECT id, name, balance FROM student WHERE id IN (1, 2);
-- 当前事务里看到:78 / 89
ROLLBACK;
SELECT id, name, balance FROM student WHERE id IN (1, 2);
-- 回到 128 / 39,两步 UPDATE 一起作废
步骤 3:COMMIT——一起生效
START TRANSACTION;
UPDATE student SET balance = balance - 50 WHERE id = 1;
UPDATE student SET balance = balance + 50 WHERE id = 2;
COMMIT; -- 结算,改动对外生效并持久化
SELECT id, name, balance FROM student WHERE id IN (1, 2); -- 78 / 89
把老陈和小林改回初始值,方便后面课对照:
START TRANSACTION;
UPDATE student SET balance = balance + 50 WHERE id = 1;
UPDATE student SET balance = balance - 50 WHERE id = 2;
COMMIT;
步骤 4:应用层异常 → 自动回滚
先在 SQL 里看「扣钱前检查余额」的防御式写法(这条语句本身就该这么写):
-- 余额不够就什么都不发生:affected rows = 0,应用层据此中断流程
UPDATE student SET balance = balance - 500
WHERE id = 1 AND balance >= 500;
-- 老陈只有 128,影响 0 行
再到 Java 侧,@Transactional 会在抛异常时替你执行 ROLLBACK(完整示例见步骤 6)。
步骤 5:隔离级别初体验(开两个 mysql 客户端窗口)
先看默认级别:
SELECT @@transaction_isolation; -- REPEATABLE READ(MySQL 默认)
实验:窗口 A 开事务并读一次余额;窗口 B 在事务外把老陈余额加 100 并提交;窗口 A 再读——
-- 窗口 A
START TRANSACTION;
SELECT balance FROM student WHERE id = 1; -- 128
-- (切到窗口 B 执行 UPDATE + COMMIT,再回来)
SELECT balance FROM student WHERE id = 1; -- 仍然是 128:可重复读
COMMIT;
SELECT balance FROM student WHERE id = 1; -- 228:本事务结束后才能看到新值
这就是「可重复读」:同一事务内多次读同一行结果一致,不受别人已提交修改的影响。四个隔离级别与三类读问题:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| READ UNCOMMITTED | 可能 | 可能 | 可能 |
| READ COMMITTED | 避免 | 可能 | 可能 |
| REPEATABLE READ(MySQL 默认) | 避免 | 避免 | 基本避免(MVCC + 间隙锁) |
| SERIALIZABLE | 避免 | 避免 | 避免 |
名词解释:脏读=读到别人还没提交的数据;不可重复读=同一事务两次读同一行值不同;幻读=同一事务两次范围查询行数不同。
步骤 6:Spring Boot 对照(Java 后端视角)
@Service
public class CourseOrderService {
private final JdbcTemplate jdbc;
public CourseOrderService(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@Transactional // 方法内所有 SQL 共用一个事务
public void buyCourse(long studentId, long courseId) {
BigDecimal price = jdbc.queryForObject(
"SELECT price FROM course WHERE id = ?",
BigDecimal.class, courseId);
// 扣款:余额不足则影响 0 行
int updated = jdbc.update(
"UPDATE student SET balance = balance - ? WHERE id = ? AND balance >= ?",
price, studentId, price);
if (updated != 1) {
throw new IllegalStateException("余额不足"); // 抛 RuntimeException → 整个事务回滚
}
jdbc.update(
"INSERT INTO pay_order (student_id, course_id, amount, status) VALUES (?, ?, ?, 'PAID')",
studentId, courseId, price);
// 方法正常返回 → Spring 提交事务
}
}
三件事对上号:@Transactional ≈ 自动 START TRANSACTION;正常返回 ≈ COMMIT;抛 RuntimeException ≈ ROLLBACK。SQL 里 AND balance >= ? 的兜底条件仍然保留——并发下两笔扣款同时通过「先查余额」的校验,只有带条件的 UPDATE 能挡住超扣。
讲清楚要点
- 事务 = 多条 SQL 的打包结算单元:COMMIT 前随时可整体作废,COMMIT 后永久生效。只有 InnoDB 支持事务,MyISAM 表上 BEGIN 是摆设。
- ACID 人话版:原子性(全做或全不做,靠 undo log 回滚)、一致性(前后都处于合法状态,是前三者的结果)、隔离性(并发事务互不干扰,靠锁 + MVCC)、持久性(COMMIT 后断电不丢,靠 redo log)。
- autocommit=1 时每条语句隐式提交;显式事务用
START TRANSACTION(或BEGIN)开启,COMMIT/ROLLBACK结束。应用连接池里事务边界在方法/请求级别,别把 START TRANSACTION 留在手里不管。 - 隔离级别四档,MySQL 默认 REPEATABLE READ,靠 MVCC 让读不加锁、写不阻塞读,并用间隙锁抑制幻读;多数业务默认档就够,改级别要给出明确理由。
- 事务要短:长事务持有 undo 和锁不放,拖垮并发、撑大回滚段。事务里绝不调 HTTP/RPC/发消息/等用户输入——先算好参数,事务内只做纯数据库操作。
- DDL 会隐式提交:事务中途执行 ALTER/TRUNCATE,之前未提交的修改全部定死,ROLLBACK 救不回来。事务内只放 DML。
你可能会懵的地方
-
ROLLBACK 能撤销已 COMMIT 的操作吗? 不能。COMMIT 是「结算完成」。所以第 6 课的安全流程是「BEGIN → 执行 → SELECT 复核 → 确认无误才 COMMIT」——复核必须发生在 COMMIT 之前。
-
不写 BEGIN 就没有事务吗? 有,autocommit 模式下每条语句各自是一个隐式事务,只是粒度小到感觉不到。
-
@Transactional什么时候会失效? 高频答案:①同类内部this.method()自调用,绕过了 Spring 代理;②方法不是 public;③异常被 try-catch 吞掉,事务以为一切正常;④抛的是受检异常(默认只对 RuntimeException 回滚,除非配rollbackFor);⑤存储引擎不支持事务。 -
隔离级别越高越好吗? 隔离越严并发越差。SERIALIZABLE 串行化了读写,吞吐大跌;MySQL 默认的 REPEATABLE READ 用 MVCC 在一致性和并发之间取得平衡,无特殊理由不动它。
-
死锁是怎么回事? 事务 A 锁着行 1 等行 2,事务 B 锁着行 2 等行 1。InnoDB 检测到后会回滚代价小的一方,应用捕获异常重试即可。多表更新时按固定顺序访问资源,能大幅减少死锁。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
| 事务里调外部 HTTP/MQ | 长事务,连接和锁被占满 | 事务外做 IO,事务内只碰数据库 |
| catch(Exception){} 吞异常 | Spring 认为成功,不回滚 | 让异常抛出,或手动 setRollbackOnly() |
同类里 this.buyCourse() 自调用 | 代理绕过,注解失效 | 拆到另一个 Bean,或注入自身代理 |
| 事务里混 DDL | 隐式提交,回滚失效 | DDL 独立在事务外执行 |
| 用 MyISAM 表跑事务 | 压根没有事务和行锁 | 统一 InnoDB |
| 只靠「先查再改」防超扣 | 并发窗口内双双通过校验 | UPDATE 带条件 + affected rows 判断 |
卡点急救
- 「我明明改了怎么没生效」:先确认自己的事务 COMMIT 了没有;再确认查的是同一个库的同一个连接;最后确认没有别的会话在事务里改同一行。
- 表被锁住、查询卡死:另开窗口
SHOW PROCESSLIST;看等待中的语句,找到长事务连接 KILL;事务提交后锁即释放。 - 遇到死锁报错 1213:业务代码捕获后重试;
SHOW ENGINE INNODB STATUS\G能看到最近一次死锁的双方语句。 - ROLLBACK 了但数据还是变了:检查事务里是否混入了 DDL(隐式提交);或者你回滚的连接和改数据的连接不是同一个。
面试常问
-
ACID 分别指什么?靠什么实现? 原子性靠 undo log 回滚;持久性靠 redo log 先写日志后刷盘;隔离性靠锁 + MVCC;一致性是前三者共同保证的业务结果。
-
四个隔离级别?MySQL 默认哪个?默认档怎么防幻读? 读未提交/读已提交/可重复读/串行化,MySQL 默认可重复读;快照读靠 MVCC 读旧版本,当前读靠临键锁(记录锁+间隙锁)挡住范围插入。
-
Spring
@Transactional的失效场景? 自调用、非 public 方法、异常被吞、受检异常默认不回滚、引擎不支持事务、多线程里另开连接。能展开「代理机制」是加分项。 -
长事务有什么危害? 占连接、持锁引发阻塞和死锁、undo log 膨胀、MVCC 旧版本堆积拖慢全库读、主从延迟。治理:拆小事务、IO 移出事务、监控
information_schema.innodb_trx。
自测三问
- 转账的两个 UPDATE,如果第二步因为断连没执行,会发生什么?谁保证金额对得上?
- 窗口 A 未提交的事务里,为什么读不到窗口 B 已提交的修改?
@Transactional方法里 try-catch 吞了异常,事务会回滚吗?该怎么处理?
小结一句话
事务把多条 SQL 打包成「全做或全不做」:COMMIT 前可整体回滚,InnoDB 用 undo/redo/MVCC 兑现 ACID;应用侧用 @Transactional 划边界,事务短小、异常别吞、条件兜底。
延伸
- MySQL 事务隔离级别
- 最后一课:综合练习与本站数据模型——把 9 课本领全部用上,并对照 CodeRoadMap 真实库写查询。