Skip to content

事务与隔离级别 ​

S3-004必学进阶约 90 分钟JDK 21企业开发基础 · MySQL 与数据建模

1. 本节定位 ​

事务解决的是"多个操作要么全做、要么全不做"的问题——转账扣了钱没加钱,就是事务没用好。

而隔离级别是事务里最抽象的部分:它描述"多个事务同时跑时,彼此能看到对方多少"。本节用一个程序把四种现象全部实测出来——看到真实数据,比背定义有效得多。

2. 学习目标 ​

完成本节后,你应该能够:

  • 说出 ACID 四个特性各自的含义;
  • 说出事务的三个边界操作(BEGIN / COMMIT / ROLLBACK);
  • 说出三种并发问题(脏读、不可重复读、幻读)的区别,并知道它们分别由哪个隔离级别防住;
  • 知道 PostgreSQL 与 MySQL 的默认隔离级别不同,以及这带来的实际影响;
  • 说出长事务的三个危害,并知道怎么避免。

3. 前置知识检查 ​

4. 为什么需要这个知识 ​

事务是"数据正确性"的最后一道防线。看一个没有事务的转账:

text
1. 从 A 扣 100        ← 执行成功
2. 系统在此时崩溃       ← 崩溃点
3. 给 B 加 100         ← 没有执行

结果:A 少了 100,B 没多 —— 钱凭空消失了。

加上事务后:

sql
BEGIN;
UPDATE account SET balance = balance - 100 WHERE id = 1;
UPDATE account SET balance = balance + 100 WHERE id = 2;   -- 崩溃发生在这里
COMMIT;                                                     -- 没执行到 → 整体回滚

两条 UPDATE 一起撤销,钱还在 A 那里 ✓ 这就是原子性。

5. 核心概念 ​

5.1 ACID 四个特性 ​

特性含义靠什么保证
A 原子性(Atomicity)事务内的操作要么全成功、要么全撤销ROLLBACK / 崩溃恢复
C 一致性(Consistency)事务前后数据满足业务约束约束 + 应用逻辑(《表设计与约束》)
I 隔离性(Isolation)并发事务互不干扰隔离级别 + 锁
D 持久性(Durability)提交后的数据不会丢日志(WAL / redo log)+ 落盘

注意 C 的特殊性:一致性不是数据库单方面保证的——它需要"约束 + 正确的业务逻辑"共同达成。数据库能保证"余额不能为负"(约束),但保证不了"转账要扣对账户"(业务逻辑)。

5.2 事务的三个边界 ​

sql
BEGIN;                            -- 开始(PostgreSQL 里 BEGIN 与 START TRANSACTION 等价)
UPDATE ...;                       -- 事务内的操作
COMMIT;                           -- 提交(永久生效)
-- 或
ROLLBACK;                         -- 回滚(全部撤销)

JDBC 里的对应:

java
connection.setAutoCommit(false);   // 相当于 BEGIN 模式:不再每条语句自动提交
try {
    statement.executeUpdate("UPDATE ...");
    connection.commit();           // 提交
} catch (SQLException exception) {
    connection.rollback();         // 回滚
    throw exception;
}

注意 JDBC 的实际行为:setAutoCommit(false) 不发送 BEGIN——真正的事务在第一条语句执行时才开始。这个细节影响"隔离级别设置"的时机(必须在第一条语句前)。

5.3 三种并发问题(本节核心) ​

脏读:读到了别人"尚未提交"的数据 ​

text
时刻 1  事务 A:把余额改成 9999(未提交)
时刻 2  事务 B:读到 9999              ← 脏读
时刻 3  事务 A:回滚                       ← 9999 从未存在过!

危害:B 基于一个从未存在过的数据做了决策。

不可重复读:同一事务内两次读,结果不同 ​

text
时刻 1  事务 B:读到余额 1000
时刻 2  事务 A:改成 900 并提交
时刻 3  事务 B:再读一次 → 900          ← 不可重复读

危害:B 在同一个事务里做了两次判断,用了两个不同版本的数据。

幻读:两次范围查询,第二次多出了行 ​

text
时刻 1  事务 B:SELECT COUNT(*) WHERE amount > 1000  → 3 行
时刻 2  事务 A:INSERT 一条 amount = 8000 并提交
时刻 3  事务 B:同样的查询 → 4 行        ← 幻读

与不可重复读的区别:不可重复读是"同一行的值变了",幻读是"结果集里多了行"。

5.4 四种隔离级别与实测结果(本节重点) ​

本节的实测输出(真实数据库、两条连接):

text
=== 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 可能让事务因为冲突而失败——应用必须实现重试逻辑,否则用户会看到莫名其妙的错误。

5.5 事务的常见陷阱 ​

陷阱一:长事务 ​

长事务 = 从 BEGIN 到 COMMIT 之间隔了很久(几秒到几分钟)。

危害说明
锁持有时间长其他事务被阻塞(《锁机制:行锁、表锁与乐观悲观锁》)
膨胀 undo / 快照PostgreSQL 要保留旧版本;MySQL 的 undo log 会涨
连接被占用连接池里的连接被长期占住
阻塞 DDL加索引、改表结构都要等它

最常见的成因:在事务里做远程调用。

java
connection.setAutoCommit(false);
orderRepository.save(order);            // ← 事务开始
paymentClient.call();                   // ✗ 远程调用(可能超时 30 秒)
connection.commit();

修复:把远程调用移到事务外。

java
String paymentId = paymentClient.call();     // 先调用
connection.setAutoCommit(false);             // 再开事务
orderRepository.save(order);
connection.commit();

陷阱二:忘记提交或回滚 ​

java
connection.setAutoCommit(false);
statement.executeUpdate("UPDATE ...");
// 忘了 commit():连接归还连接池时,事务可能被回滚,也可能一直挂着

修复:用 try-catch-finally 或 try-with-resources 保证事务一定结束。

陷阱三:以为"回滚"能撤销一切 ​

java
connection.rollback();                   // 数据库回滚了
emailService.send(order);                // ✗ 邮件已经发出去了——它不会被回滚

修复:把不可回滚的副作用放到提交之后(如"事务提交成功后发消息"),或用事务性发件箱模式(outbox pattern)。

陷阱四:自动提交模式下以为自己在用事务 ​

java
// 默认 autoCommit = true
statement.executeUpdate("UPDATE account SET balance = balance - 100 WHERE id = 1");
statement.executeUpdate("UPDATE account SET balance = balance + 100 WHERE id = 2");
// 两条语句各自是一个事务:中间崩溃 → 钱还是消失了

修复:需要原子性时必须显式关闭自动提交。

6. 递进示例 ​

6.1 最小示例:原子性 ​

java
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()

6.2 业务示例:两条连接演示隔离级别 ​

java
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();

关键点:"并发"不一定要多线程——两条连接各自维护独立的事务状态,在单线程里交错执行就能演示。

6.3 反例:在事务里调用远程服务 ​

见 5.5 陷阱一。

7. 完整可运行代码 ​

文件:labs/sql-advanced/src/main/java/cn/kb/learn/sql/adv/TransactionLesson.java(节选)

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();
            // 打印两种级别下的行数变化
        }
    }
}

运行命令:

powershell
cd labs
$env:PGPASSWORD = "你的密码"
./mvnw -pl sql-advanced compile exec:exec -Dexec.mainClass=cn.kb.learn.sql.adv.TransactionLesson

预期输出:

text
=== 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 committedPostgreSQL 的默认值(MySQL 默认是 repeatable read)
第 1 步:事务内 900/1100 → 回滚后 1000/1000原子性:未提交的改动全部撤销
第 2、3 步:1000→900 与 900→900同一个操作,两种隔离级别,两种结果
第 4 步:3→4 与 3→3幻读只在 READ COMMITTED 下出现

8. Debug 观察点 ​

  1. 在 db.begin() 处断点,然后观察数据库里的活动事务:在另一个客户端执行 SELECT * FROM pg_stat_activity WHERE state <> 'idle'——能看到这个连接的状态变成 idle in transaction(这正是"长事务"的形态);
  2. 观察第 1 步的两个查询之间的差异:同一个连接、同一个事务,rollback 前后的数据不同——这就是"事务是隔离的";
  3. 在 setIsolation 处断点:观察它必须是"事务内第一条语句之前"——如果放在后面会报错("SET TRANSACTION must be first statement");
  4. 对比第 2、3 步的两次 scalar 调用:代码完全一样,只因为隔离级别不同,返回值就不同——这是本节最值得停下来的地方。

9. 常见错误 ​

错误 1:自动提交模式下以为自己在用事务 ​

见 5.5 陷阱四。修复:需要原子性时显式 setAutoCommit(false)。

错误 2:在事务里做远程调用 ​

见 5.5 陷阱一。这是生产环境里"长事务"的头号成因。

错误 3:忘记处理 SERIALIZABLE 的失败重试 ​

症状:用了 SERIALIZABLE 后,线上偶尔出现"序列化失败",用户看到 500。

原因:SERIALIZABLE 下,冲突的事务会被数据库主动中止(要求应用重试)。

修复:

java
int attempts = 0;
while (true) {
    try {
        // 事务逻辑
        break;
    } catch (SQLException exception) {
        if (!"40001".equals(exception.getSQLState()) || ++attempts >= 3) {   // 40001 = 序列化失败
            throw exception;
        }
        // 重试
    }
}

这正是"隔离级别越高,应用代码越复杂"的体现。

错误 4:把"读已提交"当成"不会读到别人改的数据" ​

READ COMMITTED 能读到别人已提交的修改——这正是"不可重复读"的来源。

如果需要"事务全程看到一致的数据快照"(如对账、报表),必须用 REPEATABLE READ。

错误 5:回滚后继续使用该连接 ​

java
connection.rollback();
statement.executeQuery("SELECT ...");     // 通常没问题,但要确认连接状态

更要注意的是:某些数据库在发生错误后会把事务标记为"已中止"(PostgreSQL 里称为 current transaction is aborted)——此时除了 ROLLBACK 之外的所有语句都会报错。

修复:捕获异常后立即回滚,再继续后续操作。

10. 动手实验 ​

目标:把四种隔离现象亲手做一遍。

步骤:

  1. 运行第 7 节的命令,核对输出;
  2. 改默认隔离级别:在 TransactionLesson 开头加 db.execute("SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL REPEATABLE READ"),重跑——观察第 2 步的"不可重复读"是否消失;
  3. 验证"读自己的未提交改动":在同一个事务内,先 UPDATE 再 SELECT,确认本连接能看到自己的改动(这与"别人看不到"并不矛盾);
  4. 制造一个长事务:开一个事务、改一行、不提交,然后在另一个客户端执行 SELECT * FROM pg_stat_activity WHERE state = 'idle in transaction'——观察它;
  5. 体验事务隔离下的行为:在长事务未提交时,用另一条连接 UPDATE 同一行——观察它被阻塞(这是 《锁机制:行锁、表锁与乐观悲观锁》 锁的内容,先感受一下);
  6. 对账场景:写一个"统计总金额"的事务,用 REPEATABLE READ,验证两次统计结果一致。

验收方式:能说出第 2 步的现象变化、第 5 步是否被阻塞。

扩展任务:给你的转账逻辑加上"重试"——用 SERIALIZABLE 隔离级别,捕获 40001 错误后重试最多 3 次,并写一个测试证明"并发冲突时最终能成功"。

11. 练习题 ​

基础题

  1. 判断:setAutoCommit(false) 会立即向数据库发送 BEGIN。(对 / 错)
  2. 填空:不可重复读是"同一行的值变了",幻读是"______"。
  3. 简答:为什么"一致性(C)"不能只靠数据库保证?

代码阅读题

java
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);
}
  1. 这段代码输出什么?两个值相等吗?为什么?
  2. 如果把 rollback() 改成 commit(),输出会变吗?

编程题

  1. 实现一个"安全转账"方法并验证:
    • 方法签名 boolean transfer(long fromId, long toId, long amountInCents);
    • 要求:余额不足时返回 false 且不做任何修改;成功时两边余额都更新;
    • 用事务保证原子性(提示:setAutoCommit(false) + commit / rollback);
    • 写测试验证三个场景:正常转账、余额不足、转账后总额不变(这个是关键断言);
    • 说明:为什么"总额不变"这个断言最有价值?

综合任务

  1. 设计一个"并发场景下的隔离级别选择方案":
    • 场景一:用户查看自己的订单列表(只读);
    • 场景二:用户下单(要扣库存 + 生成订单);
    • 场景三:财务对账(要统计一段时间内的总额);
    • 场景四:秒杀(大量并发抢同一商品);
    • 为每个场景选择隔离级别并说明理由;
    • 说明:哪些场景光靠隔离级别不够,还需要显式锁或乐观锁?
    • 额外要求:给"下单"场景写出完整的事务代码(含异常处理与回滚)。
分级提示

提示 1(方向):第 4 题的关键是"同一个事务里能看到自己的改动"——与隔离级别无关。

提示 2(关键点):第 6 题的"总额不变"是因为转账前后"所有账户的余额之和"应该恒定——这是不变量(invariant),它能抓住任何"扣了没加"或"加了没扣"的错误。

提示 3(接近答案):第 7 题里"秒杀"场景光靠隔离级别不够——需要行锁(SELECT ... FOR UPDATE)或乐观锁,见 《锁机制:行锁、表锁与乐观悲观锁》。

12. 参考答案 ​

基础题
  1. 错。setAutoCommit(false) 只是关闭自动提交模式——真正的事务在第一条语句执行时才开始。这个细节的影响是:SET TRANSACTION ISOLATION LEVEL 必须在那之前执行(否则报 "must be first statement in transaction")。
  2. 结果集里多了行(两次范围查询返回的行数不同)。
  3. 因为一致性包含"业务规则"层面的要求,而数据库只知道"约束":
层面谁能保证
字段类型、非空、唯一、外键、CHECK数据库(《表设计与约束》)
"转账要扣对账户""库存不能超卖""订单状态流转合法"应用逻辑

数据库能保证"余额不为负",但保证不了"你扣的是不是该扣的那个账户"。 所以一致性是约束 + 业务逻辑共同的结果——这也是为什么要写测试(T1-005 测试金字塔)。

代码阅读题(第 4、5 题)

第 4 题:输出 1000 / 900,两个值不相等。

原因:同一个连接、同一个事务能看到自己未提交的改动(这是事务的基本语义——否则事务内部的逻辑没法连贯)。

java
db.begin();
long first = ...;                    // 1000(事务开始时的值)
db.execute("UPDATE ... - 100");      // 改成 900(未提交)
long second = ...;                   // 900 ← 读到自己的改动
db.rollback();                       // 撤销

注意这里没有"不可重复读"——因为两次读的差异是自己改的,不是别人改的。不可重复读的定义特指"别人提交的改动被看到了"(第 2 步的实验里,两次读之间是连接 A 在改)。

第 5 题:输出仍然是 1000 / 900。

因为两个值都来自事务内部——commit() 只是让改动对其他连接可见,不会改变本连接看到的顺序。

"提交"与"可见性"的关系:

动作本连接其他连接
事务内修改(未提交)✅ 能看到❌ 看不到
commit() 之后✅ 能看到✅ 能看到(在 RC 级别下立刻可见)
rollback() 之后回到原值从未看到过变化
编程题(第 6 题)
java
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)——转账不创造也不销毁钱。

不变量式断言的价值:

  1. 它不依赖具体的实现细节(不管你怎么写,总额都该不变);
  2. 它能覆盖"没想到的错误组合"(如"扣了两次、加了一次");
  3. 它在新场景下依然成立(加上"手续费"后,总额 = 原总额 − 手续费,断言稍作调整仍然有用)。

这类断言的设计思路:不要只断言"我做了什么",还要断言"世界应该保持什么"。

综合任务(第 7 题)

隔离级别选择方案:

场景隔离级别是否够用理由
订单列表(只读)默认(RC)✅ 够只读查询,偶发不一致对用户无感;用 RR 反而让快照保留更久
下单(扣库存 + 生成订单)默认(RC)+ 显式锁⚠️ 不够隔离级别只保证"读到的数据一致",不保证"读到的库存不会被别人同时扣掉"——需要 SELECT ... FOR UPDATE 或乐观锁
财务对账(统计总额)REPEATABLE READ✅ 够需要"同一事务内多次统计结果一致"——RC 下第二次统计会看到别人的新数据
秒杀默认 + 行锁/乐观锁 + 限流⚠️ 远不够隔离级别解决不了"高并发下的性能与超卖"——需要锁 + 队列 + 缓存 + 限流

关键结论:

隔离级别解决的是"可见性"问题,不解决"互斥"问题。

"别人改的数据我能看到吗" → 隔离级别; "别人能不能同时改我要改的数据" → 锁(《锁机制:行锁、表锁与乐观悲观锁》)。

"下单"场景的完整事务代码:

java
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);                // 归还连接池
    }
}

这段代码里最值得学的一处:

sql
UPDATE product SET stock = stock - ? WHERE id = ? AND stock >= ?

把"先查库存、再判断、再扣减"三步合并成一条 UPDATE——这是避免超卖最简洁的方式(不需要显式加锁,因为 UPDATE 本身会加行锁,且条件判断在同一语句内是原子的)。

对比"三步走"的写法:

java
// ✗ 危险:三步之间可能被其他事务插入
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'),避免慢查询拖住整个事务

13. 掌握标准 ​

  • [ ] 能说出 ACID 四个特性,以及"一致性为什么不能只靠数据库";
  • [ ] 知道 setAutoCommit(false) 不等于立即 BEGIN;
  • [ ] 能说出脏读、不可重复读、幻读的区别,并知道哪个级别防住哪个;
  • [ ] 知道 PostgreSQL 默认 RC、MySQL 默认 RR,且 PG 没有真正的脏读;
  • [ ] 能说出长事务的三个危害,并知道"远程调用放事务外"这条硬规矩;
  • [ ] 知道"隔离级别管可见性、锁管互斥",能判断一个场景该用哪个。

14. 扩展资源 ​

15. 下一步学习 ​

《锁机制》:行锁、表锁、悲观锁与乐观锁,以及死锁怎么避免。

进度只保存在本机浏览器,可随时清除

个人学习项目:AI 生成内容均经人工复核,第三方资源仅作索引