主题
界面
主题
事务解决的是"多个操作要么全做、要么全不做"的问题——转账扣了钱没加钱,就是事务没用好。
而隔离级别是事务里最抽象的部分:它描述"多个事务同时跑时,彼此能看到对方多少"。本节用一个程序把四种现象全部实测出来——看到真实数据,比背定义有效得多。
完成本节后,你应该能够:
BEGIN / COMMIT / ROLLBACK);UPDATE 与 SELECT 吗?(《SQL 基础》)事务是"数据正确性"的最后一道防线。看一个没有事务的转账:
1. 从 A 扣 100 ← 执行成功
2. 系统在此时崩溃 ← 崩溃点
3. 给 B 加 100 ← 没有执行结果:A 少了 100,B 没多 —— 钱凭空消失了。
加上事务后:
BEGIN;
UPDATE account SET balance = balance - 100 WHERE id = 1;
UPDATE account SET balance = balance + 100 WHERE id = 2; -- 崩溃发生在这里
COMMIT; -- 没执行到 → 整体回滚两条 UPDATE 一起撤销,钱还在 A 那里 ✓ 这就是原子性。
| 特性 | 含义 | 靠什么保证 |
|---|---|---|
| A 原子性(Atomicity) | 事务内的操作要么全成功、要么全撤销 | ROLLBACK / 崩溃恢复 |
| C 一致性(Consistency) | 事务前后数据满足业务约束 | 约束 + 应用逻辑(《表设计与约束》) |
| I 隔离性(Isolation) | 并发事务互不干扰 | 隔离级别 + 锁 |
| D 持久性(Durability) | 提交后的数据不会丢 | 日志(WAL / redo log)+ 落盘 |
注意 C 的特殊性:一致性不是数据库单方面保证的——它需要"约束 + 正确的业务逻辑"共同达成。数据库能保证"余额不能为负"(约束),但保证不了"转账要扣对账户"(业务逻辑)。
BEGIN; -- 开始(PostgreSQL 里 BEGIN 与 START TRANSACTION 等价)
UPDATE ...; -- 事务内的操作
COMMIT; -- 提交(永久生效)
-- 或
ROLLBACK; -- 回滚(全部撤销)JDBC 里的对应:
connection.setAutoCommit(false); // 相当于 BEGIN 模式:不再每条语句自动提交
try {
statement.executeUpdate("UPDATE ...");
connection.commit(); // 提交
} catch (SQLException exception) {
connection.rollback(); // 回滚
throw exception;
}注意 JDBC 的实际行为:setAutoCommit(false) 不发送 BEGIN——真正的事务在第一条语句执行时才开始。这个细节影响"隔离级别设置"的时机(必须在第一条语句前)。
时刻 1 事务 A:把余额改成 9999(未提交)
时刻 2 事务 B:读到 9999 ← 脏读
时刻 3 事务 A:回滚 ← 9999 从未存在过!危害:B 基于一个从未存在过的数据做了决策。
时刻 1 事务 B:读到余额 1000
时刻 2 事务 A:改成 900 并提交
时刻 3 事务 B:再读一次 → 900 ← 不可重复读危害:B 在同一个事务里做了两次判断,用了两个不同版本的数据。
时刻 1 事务 B:SELECT COUNT(*) WHERE amount > 1000 → 3 行
时刻 2 事务 A:INSERT 一条 amount = 8000 并提交
时刻 3 事务 B:同样的查询 → 4 行 ← 幻读与不可重复读的区别:不可重复读是"同一行的值变了",幻读是"结果集里多了行"。
本节的实测输出(真实数据库、两条连接):
=== 2. READ COMMITTED(默认):不可重复读 ===
【连接 B】第一次读到:1000
【连接 B】第二次读到:900 ← 同一事务内两次读不一致:不可重复读
=== 3. REPEATABLE READ:把同样的操作再做一遍 ===
【连接 B】第一次读到:900
【连接 B】第二次读到:900 ← 一致了:REPEATABLE READ 用快照隔离保证了可重复读
=== 4. 幻读:范围查询的结果集多出了行 ===
READ COMMITTED:第一次 3 行,第二次 4 行 ← 出现幻读
REPEATABLE READ:第一次 3 行,第二次 3 行 ← 没有幻读
=== 5. 脏读:PostgreSQL 根本不让你读到未提交的数据 ===
【连接 A】把余额改成 9999,但**没有提交**
【连接 B】用 READ UNCOMMITTED 读到:800
← 仍是旧值:PostgreSQL 把 READ UNCOMMITTED 当作 READ COMMITTED 处理四级别对照表:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 并发性能 |
|---|---|---|---|---|
READ UNCOMMITTED | 可能 | 可能 | 可能 | 最高 |
READ COMMITTED | 否 | 可能 | 可能 | 高(PostgreSQL 默认) |
REPEATABLE READ | 否 | 否 | 可能(标准)/ 否(实际) | 中(MySQL 默认) |
SERIALIZABLE | 否 | 否 | 否 | 低(可能失败重试) |
三个必须知道的细节:
| 细节 | 说明 |
|---|---|
PostgreSQL 默认是 READ COMMITTED | 而 MySQL 默认是 REPEATABLE READ——同一个应用迁移数据库后,并发行为可能完全不同 |
PostgreSQL 没有真正的 READ UNCOMMITTED | 它会"向上取整"成 READ COMMITTED(实测已验证);MySQL 的 READ UNCOMMITTED 能真的脏读 |
PostgreSQL 的 REPEATABLE READ 实际不出现幻读 | 它用的是"快照隔离",比 SQL 标准的 RR 更严格;MySQL 的 RR 则用间隙锁(《锁机制:行锁、表锁与乐观悲观锁》)防幻读 |
隔离级别的选择:
| 场景 | 建议 |
|---|---|
| 大多数业务 | 用默认级别(PG:RC / MySQL:RR) |
| 需要"同一事务内读到一致快照"(如报表、对账) | REPEATABLE READ |
| 严格的金融场景(余额、库存扣减) | SERIALIZABLE,或用显式锁/乐观锁(《锁机制:行锁、表锁与乐观悲观锁》) |
一个重要认知:
隔离级别是"性能与正确性的权衡",不是"越高越好"。
SERIALIZABLE可能让事务因为冲突而失败——应用必须实现重试逻辑,否则用户会看到莫名其妙的错误。
长事务 = 从 BEGIN 到 COMMIT 之间隔了很久(几秒到几分钟)。
| 危害 | 说明 |
|---|---|
| 锁持有时间长 | 其他事务被阻塞(《锁机制:行锁、表锁与乐观悲观锁》) |
| 膨胀 undo / 快照 | PostgreSQL 要保留旧版本;MySQL 的 undo log 会涨 |
| 连接被占用 | 连接池里的连接被长期占住 |
| 阻塞 DDL | 加索引、改表结构都要等它 |
最常见的成因:在事务里做远程调用。
connection.setAutoCommit(false);
orderRepository.save(order); // ← 事务开始
paymentClient.call(); // ✗ 远程调用(可能超时 30 秒)
connection.commit();修复:把远程调用移到事务外。
String paymentId = paymentClient.call(); // 先调用
connection.setAutoCommit(false); // 再开事务
orderRepository.save(order);
connection.commit();connection.setAutoCommit(false);
statement.executeUpdate("UPDATE ...");
// 忘了 commit():连接归还连接池时,事务可能被回滚,也可能一直挂着修复:用 try-catch-finally 或 try-with-resources 保证事务一定结束。
connection.rollback(); // 数据库回滚了
emailService.send(order); // ✗ 邮件已经发出去了——它不会被回滚修复:把不可回滚的副作用放到提交之后(如"事务提交成功后发消息"),或用事务性发件箱模式(outbox pattern)。
// 默认 autoCommit = true
statement.executeUpdate("UPDATE account SET balance = balance - 100 WHERE id = 1");
statement.executeUpdate("UPDATE account SET balance = balance + 100 WHERE id = 2");
// 两条语句各自是一个事务:中间崩溃 → 钱还是消失了修复:需要原子性时必须显式关闭自动提交。
db.begin();
db.execute("UPDATE account SET balance = balance - 100 WHERE id = 1");
db.execute("UPDATE account SET balance = balance + 100 WHERE id = 2");
db.commit(); // 或 db.rollback()b.begin();
b.setIsolation("REPEATABLE READ");
long first = Long.parseLong(b.scalar("SELECT balance FROM account WHERE id = 1"));
a.execute("UPDATE account SET balance = balance - 100 WHERE id = 1");
long second = Long.parseLong(b.scalar("SELECT balance FROM account WHERE id = 1"));
b.commit();关键点:"并发"不一定要多线程——两条连接各自维护独立的事务状态,在单线程里交错执行就能演示。
见 5.5 陷阱一。
文件:labs/sql-advanced/src/main/java/cn/kb/learn/sql/adv/TransactionLesson.java(节选)
public class TransactionLesson {
public static void main(String[] args) {
try (PgRunner a = PgRunner.open(); PgRunner b = PgRunner.open()) {
prepare(a);
atomicity(a);
repeatableReadProblem(a, b);
repeatableReadSolved(a, b);
phantomRead(a, b);
noDirtyRead(a, b);
}
}
/** 原子性:一个事务里的多步操作,要么全部生效,要么全部撤销 */
private static void atomicity(PgRunner db) {
db.begin();
db.execute("UPDATE account SET balance = balance - 100 WHERE id = 1");
db.execute("UPDATE account SET balance = balance + 100 WHERE id = 2");
System.out.print(db.query("SELECT * FROM account ORDER BY id"));
db.rollback();
System.out.print(db.query("SELECT * FROM account ORDER BY id"));
}
/** READ COMMITTED 下:同一事务内两次读,可能读到不同的值 */
private static void repeatableReadProblem(PgRunner a, PgRunner b) {
b.begin();
b.setIsolation("READ COMMITTED");
long first = Long.parseLong(b.scalar("SELECT balance FROM account WHERE id = 1"));
a.execute("UPDATE account SET balance = balance - 100 WHERE id = 1");
long second = Long.parseLong(b.scalar("SELECT balance FROM account WHERE id = 1"));
b.commit();
// 打印 first 与 second 的对比
}
/** 幻读:同一事务内两次"范围查询",第二次多出了新的行 */
private static void phantomRead(PgRunner a, PgRunner b) {
for (String level : List.of("READ COMMITTED", "REPEATABLE READ")) {
b.begin();
b.setIsolation(level);
int first = Integer.parseInt(b.scalar("SELECT COUNT(*) FROM record WHERE amount > 1000"));
a.execute("INSERT INTO record VALUES (11, 8000)");
int second = Integer.parseInt(b.scalar("SELECT COUNT(*) FROM record WHERE amount > 1000"));
b.commit();
// 打印两种级别下的行数变化
}
}
}运行命令:
cd labs
$env:PGPASSWORD = "你的密码"
./mvnw -pl sql-advanced compile exec:exec -Dexec.mainClass=cn.kb.learn.sql.adv.TransactionLesson预期输出:
=== 0. 准备数据 ===
默认隔离级别:read committed
id | name | balance
--------------------
1 | A | 1000
2 | B | 1000
=== 1. 原子性:转账失败时整体回滚 ===
事务内(尚未提交):
1 | A | 900
2 | B | 1100
回滚后:
1 | A | 1000
2 | B | 1000
两笔改动都撤销了——这就是「原子性」:事务内的操作不可分割。
=== 2. READ COMMITTED(PostgreSQL 默认):不可重复读 ===
【连接 B】第一次读到:1000
【连接 B】第二次读到:900 ← 同一事务内两次读不一致:不可重复读
=== 3. REPEATABLE READ:把同样的操作再做一遍 ===
【连接 B】第一次读到:900
【连接 B】第二次读到:900 ← 一致了:REPEATABLE READ 用快照隔离保证了可重复读
=== 4. 幻读:范围查询的结果集多出了行 ===
READ COMMITTED:第一次 3 行,第二次 4 行 ← 出现幻读
REPEATABLE READ:第一次 3 行,第二次 3 行 ← 没有幻读
=== 5. 脏读:PostgreSQL 根本不让你读到未提交的数据 ===
【连接 A】把余额改成 9999,但**没有提交**
【连接 B】用 READ UNCOMMITTED 读到:800
← 仍是旧值:PostgreSQL 把 READ UNCOMMITTED 当作 READ COMMITTED 处理四处值得对照:
| 输出 | 含义 |
|---|---|
默认隔离级别:read committed | PostgreSQL 的默认值(MySQL 默认是 repeatable read) |
| 第 1 步:事务内 900/1100 → 回滚后 1000/1000 | 原子性:未提交的改动全部撤销 |
第 2、3 步:1000→900 与 900→900 | 同一个操作,两种隔离级别,两种结果 |
第 4 步:3→4 与 3→3 | 幻读只在 READ COMMITTED 下出现 |
db.begin() 处断点,然后观察数据库里的活动事务:在另一个客户端执行 SELECT * FROM pg_stat_activity WHERE state <> 'idle'——能看到这个连接的状态变成 idle in transaction(这正是"长事务"的形态);rollback 前后的数据不同——这就是"事务是隔离的";setIsolation 处断点:观察它必须是"事务内第一条语句之前"——如果放在后面会报错("SET TRANSACTION must be first statement");scalar 调用:代码完全一样,只因为隔离级别不同,返回值就不同——这是本节最值得停下来的地方。见 5.5 陷阱四。修复:需要原子性时显式 setAutoCommit(false)。
见 5.5 陷阱一。这是生产环境里"长事务"的头号成因。
SERIALIZABLE 的失败重试 症状:用了 SERIALIZABLE 后,线上偶尔出现"序列化失败",用户看到 500。
原因:SERIALIZABLE 下,冲突的事务会被数据库主动中止(要求应用重试)。
修复:
int attempts = 0;
while (true) {
try {
// 事务逻辑
break;
} catch (SQLException exception) {
if (!"40001".equals(exception.getSQLState()) || ++attempts >= 3) { // 40001 = 序列化失败
throw exception;
}
// 重试
}
}这正是"隔离级别越高,应用代码越复杂"的体现。
READ COMMITTED 能读到别人已提交的修改——这正是"不可重复读"的来源。
如果需要"事务全程看到一致的数据快照"(如对账、报表),必须用 REPEATABLE READ。
connection.rollback();
statement.executeQuery("SELECT ..."); // 通常没问题,但要确认连接状态更要注意的是:某些数据库在发生错误后会把事务标记为"已中止"(PostgreSQL 里称为 current transaction is aborted)——此时除了 ROLLBACK 之外的所有语句都会报错。
修复:捕获异常后立即回滚,再继续后续操作。
目标:把四种隔离现象亲手做一遍。
步骤:
TransactionLesson 开头加 db.execute("SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL REPEATABLE READ"),重跑——观察第 2 步的"不可重复读"是否消失;UPDATE 再 SELECT,确认本连接能看到自己的改动(这与"别人看不到"并不矛盾);SELECT * FROM pg_stat_activity WHERE state = 'idle in transaction'——观察它;UPDATE 同一行——观察它被阻塞(这是 《锁机制:行锁、表锁与乐观悲观锁》 锁的内容,先感受一下);REPEATABLE READ,验证两次统计结果一致。验收方式:能说出第 2 步的现象变化、第 5 步是否被阻塞。
扩展任务:给你的转账逻辑加上"重试"——用 SERIALIZABLE 隔离级别,捕获 40001 错误后重试最多 3 次,并写一个测试证明"并发冲突时最终能成功"。
基础题
setAutoCommit(false) 会立即向数据库发送 BEGIN。(对 / 错)代码阅读题
try (SqlRunner db = SqlRunner.open()) {
db.begin();
long first = Long.parseLong(db.scalar("SELECT balance FROM account WHERE id = 1"));
db.execute("UPDATE account SET balance = balance - 100 WHERE id = 1");
long second = Long.parseLong(db.scalar("SELECT balance FROM account WHERE id = 1"));
db.rollback();
System.out.println(first + " / " + second);
}rollback() 改成 commit(),输出会变吗?编程题
boolean transfer(long fromId, long toId, long amountInCents);setAutoCommit(false) + commit / rollback);综合任务
提示 1(方向):第 4 题的关键是"同一个事务里能看到自己的改动"——与隔离级别无关。
提示 2(关键点):第 6 题的"总额不变"是因为转账前后"所有账户的余额之和"应该恒定——这是不变量(invariant),它能抓住任何"扣了没加"或"加了没扣"的错误。
提示 3(接近答案):第 7 题里"秒杀"场景光靠隔离级别不够——需要行锁(SELECT ... FOR UPDATE)或乐观锁,见 《锁机制:行锁、表锁与乐观悲观锁》。
setAutoCommit(false) 只是关闭自动提交模式——真正的事务在第一条语句执行时才开始。这个细节的影响是:SET TRANSACTION ISOLATION LEVEL 必须在那之前执行(否则报 "must be first statement in transaction")。| 层面 | 谁能保证 |
|---|---|
| 字段类型、非空、唯一、外键、CHECK | 数据库(《表设计与约束》) |
| "转账要扣对账户""库存不能超卖""订单状态流转合法" | 应用逻辑 |
数据库能保证"余额不为负",但保证不了"你扣的是不是该扣的那个账户"。 所以一致性是约束 + 业务逻辑共同的结果——这也是为什么要写测试(T1-005 测试金字塔)。
第 4 题:输出 1000 / 900,两个值不相等。
原因:同一个连接、同一个事务能看到自己未提交的改动(这是事务的基本语义——否则事务内部的逻辑没法连贯)。
db.begin();
long first = ...; // 1000(事务开始时的值)
db.execute("UPDATE ... - 100"); // 改成 900(未提交)
long second = ...; // 900 ← 读到自己的改动
db.rollback(); // 撤销注意这里没有"不可重复读"——因为两次读的差异是自己改的,不是别人改的。不可重复读的定义特指"别人提交的改动被看到了"(第 2 步的实验里,两次读之间是连接 A 在改)。
第 5 题:输出仍然是 1000 / 900。
因为两个值都来自事务内部——commit() 只是让改动对其他连接可见,不会改变本连接看到的顺序。
"提交"与"可见性"的关系:
| 动作 | 本连接 | 其他连接 |
|---|---|---|
| 事务内修改(未提交) | ✅ 能看到 | ❌ 看不到 |
commit() 之后 | ✅ 能看到 | ✅ 能看到(在 RC 级别下立刻可见) |
rollback() 之后 | 回到原值 | 从未看到过变化 |
package cn.kb.learn.sql.adv;
/** 用事务保证原子性的转账 */
public class SafeTransfer {
static boolean transfer(PgRunner db, long fromId, long toId, long amount) {
db.begin();
try {
// 读余额(在同一事务内)。
// 注:PgRunner 是演示工具,只接收整句 SQL;这里值均为 long 无注入风险 ——
// 生产代码必须用 PreparedStatement 参数化(见本节第 7 题与 S3-010)
long balance = Long.parseLong(
db.scalar("SELECT balance FROM account WHERE id = " + fromId));
if (balance < amount) {
db.rollback();
return false;
}
db.execute("UPDATE account SET balance = balance - " + amount + " WHERE id = " + fromId);
db.execute("UPDATE account SET balance = balance + " + amount + " WHERE id = " + toId);
db.commit();
return true;
} catch (RuntimeException exception) {
db.rollback();
throw exception;
}
}
public static void main(String[] args) {
try (PgRunner db = PgRunner.open()) {
db.execute("DROP TABLE IF EXISTS account");
db.execute("CREATE TABLE account (id BIGINT PRIMARY KEY, balance BIGINT NOT NULL)");
db.execute("INSERT INTO account VALUES (1, 1000), (2, 1000)");
String totalBefore = db.scalar("SELECT SUM(balance) FROM account");
System.out.println("转账前总额:" + totalBefore);
System.out.println("转 300:" + transfer(db, 1, 2, 300));
System.out.println("转 5000(余额不足):" + transfer(db, 1, 2, 5000));
String totalAfter = db.scalar("SELECT SUM(balance) FROM account");
System.out.println("转账后总额:" + totalAfter);
System.out.println("总额不变:" + totalBefore.equals(totalAfter));
}
}
}为什么"总额不变"这个断言最有价值:
| 断言 | 能抓到什么 |
|---|---|
| "A 的余额减少了 300" | 只能发现"没扣钱" |
| "B 的余额增加了 300" | 只能发现"没加钱" |
| "总额不变" | 任何"扣了没加""加了没扣""重复扣"都能抓到 |
它验证的是一个"不变量"(invariant)——转账不创造也不销毁钱。
不变量式断言的价值:
这类断言的设计思路:不要只断言"我做了什么",还要断言"世界应该保持什么"。
隔离级别选择方案:
| 场景 | 隔离级别 | 是否够用 | 理由 |
|---|---|---|---|
| 订单列表(只读) | 默认(RC) | ✅ 够 | 只读查询,偶发不一致对用户无感;用 RR 反而让快照保留更久 |
| 下单(扣库存 + 生成订单) | 默认(RC)+ 显式锁 | ⚠️ 不够 | 隔离级别只保证"读到的数据一致",不保证"读到的库存不会被别人同时扣掉"——需要 SELECT ... FOR UPDATE 或乐观锁 |
| 财务对账(统计总额) | REPEATABLE READ | ✅ 够 | 需要"同一事务内多次统计结果一致"——RC 下第二次统计会看到别人的新数据 |
| 秒杀 | 默认 + 行锁/乐观锁 + 限流 | ⚠️ 远不够 | 隔离级别解决不了"高并发下的性能与超卖"——需要锁 + 队列 + 缓存 + 限流 |
关键结论:
隔离级别解决的是"可见性"问题,不解决"互斥"问题。
"别人改的数据我能看到吗" → 隔离级别; "别人能不能同时改我要改的数据" → 锁(《锁机制:行锁、表锁与乐观悲观锁》)。
"下单"场景的完整事务代码:
public boolean placeOrder(long userId, long productId, int quantity) {
Connection connection = dataSource.getConnection();
try {
connection.setAutoCommit(false);
// 1. 扣库存(带条件的 UPDATE:把"判断"和"扣减"合并成一条原子语句)
int updated = update(connection,
"UPDATE product SET stock = stock - ? WHERE id = ? AND stock >= ?",
quantity, productId, quantity);
if (updated == 0) {
connection.rollback();
return false; // 库存不足
}
// 2. 生成订单
insert(connection,
"INSERT INTO shop_order (user_id, product_id, quantity, status) VALUES (?, ?, ?, 'CREATED')",
userId, productId, quantity);
connection.commit();
return true;
} catch (SQLException exception) {
rollbackQuietly(connection);
throw new IllegalStateException("下单失败", exception);
} finally {
closeQuietly(connection); // 归还连接池
}
}这段代码里最值得学的一处:
UPDATE product SET stock = stock - ? WHERE id = ? AND stock >= ?把"先查库存、再判断、再扣减"三步合并成一条 UPDATE——这是避免超卖最简洁的方式(不需要显式加锁,因为 UPDATE 本身会加行锁,且条件判断在同一语句内是原子的)。
对比"三步走"的写法:
// ✗ 危险:三步之间可能被其他事务插入
int stock = query("SELECT stock FROM product WHERE id = ?");
if (stock < quantity) return false; // ← 两个事务可能都判断通过
update("UPDATE product SET stock = stock - ? WHERE id = ?");这正是 《锁机制》 要展开的内容——在并发下,"先检查后执行"是经典的竞态条件。
最后一条实践建议:
| 事务保持"短"的三个做法 |
|---|
| ① 远程调用放在事务外(先调支付、再开事务) |
| ② 只把必须原子化的操作放进事务(日志、通知放到提交后) |
③ 给事务加超时(SET LOCAL statement_timeout = '5s'),避免慢查询拖住整个事务 |
setAutoCommit(false) 不等于立即 BEGIN;《锁机制》:行锁、表锁、悲观锁与乐观锁,以及死锁怎么避免。