一句话结论

MySQL InnoDB 通过 MVCC(多版本并发控制) 实现非锁定的一致性读——每个事务看到的是快照数据,读写互不阻塞。隔离级别决定了快照的"新鲜程度"。

核心原理

四大隔离级别

级别

脏读

不可重复读

幻读

InnoDB 默认

READ UNCOMMITTED

✅

✅

✅

READ COMMITTED

❌

✅

✅

REPEATABLE READ

❌

❌

❌*

✅

SERIALIZABLE

❌

❌

❌

* InnoDB 的 REPEATABLE READ 通过临键锁基本解决了幻读。

MVCC 核心:Read View + Undo Log

每行数据隐含两个字段:
  DB_TRX_ID: 最近修改这行的事务 ID
  DB_ROLL_PTR: 指向 Undo Log 中旧版本的指针

Read View: 事务开始时创建的快照
  - m_ids: 当前活跃事务列表
  - min_trx_id: 最小活跃事务 ID
  - max_trx_id: 下一个将分配的事务 ID
  - creator_trx_id: 创建此 Read View 的事务 ID

可见性规则: 一行数据的 DB_TRX_ID 如果:

  • < min_trx_id → 可见(在快照之前已提交)

  • > max_trx_id → 不可见(在快照之后才开始)

  • 在 m_ids 中 → 不可见(活跃事务未提交)

  • 等于 creator_trx_id → 可见(自己修改的)

REPEATABLE READ vs READ COMMITTED

REPEATABLE READ

READ COMMITTED

Read View 创建时机

事务开始时创建一次

每次查询都创建新的

效果

整个事务看到相同快照

每次查询看到最新已提交数据

项目中的应用

在 分布式电商交易系统 中,秒杀库存扣减用 SELECT ... FOR UPDATE 获取最新数据(当前读),而非快照读:

START TRANSACTION;
-- 当前读:加排他锁,读取最新已提交数据
SELECT stock FROM products WHERE id = 1 FOR UPDATE;
-- stock = 10(最新值,不是快照)
UPDATE products SET stock = stock - 1 WHERE id = 1;
COMMIT;

高频面试问题

Q: 当前读和快照读有什么区别?

30 秒回答: 快照读(普通 SELECT)读的是 MVCC 快照,不加锁。当前读(SELECT ... FOR UPDATE / UPDATE / DELETE)读最新已提交版本并加锁,保证读到的是最新的。

Q: InnoDB 怎么解决幻读?

30 秒回答: 快照读通过 MVCC 解决(同一事务内快照不变,看不到其他事务插入的新行)。当前读通过临键锁解决(锁定行 + 行之间的间隙,防止插入)。

最小实验

-- 会话 A: REPEATABLE READ
START TRANSACTION;
SELECT * FROM users WHERE id = 1;  -- name='张三'

-- 会话 B: 修改并提交
UPDATE users SET name = '李四' WHERE id = 1;
COMMIT;

-- 会话 A: 再次查询
SELECT * FROM users WHERE id = 1;  -- 仍然是 '张三'(快照读)
SELECT * FROM users WHERE id = 1 FOR UPDATE;  -- '李四'(当前读)

速记

MVCC = Read View + Undo Log 链。RR 事务开始创快照,RC 每次查询刷新快照。快照读不加锁,当前读加锁并读最新。幻读:快照读靠 MVCC,当前读靠临键锁。


深入原理

一、Read View 创建时机(重要纠正)

常见误区: "REPEATABLE READ 下 Read View 在 BEGIN/START TRANSACTION 时创建。"

正确结论:RR 下 Read View 在第一次快照读时创建,不是 BEGIN 时。

-- 会话 A
START TRANSACTION;                    -- T1: 只标记事务开始,未创建 Read View
-- ... 其他事务在此期间提交 ...
SELECT * FROM users WHERE id = 1;     -- T2: 第一次快照读,创建 Read View
-- 此时 Read View 记录 T2 时刻的活跃事务列表

-- 如果 T2 之前会话 B 提交了修改,会话 A 能读到吗?能!
-- 因为 Read View 在 T2 才创建,T2 之前已提交的修改对 Read View 是可见的。

-- 第二次同样是快照读,复用同一个 Read View(RR 关键特性)
SELECT * FROM users WHERE id = 1;     -- T3: 复用 T2 的 Read View,结果不变
时间线(REPEATABLE READ 模式):
─────────────────────────────────────────────────────────
事务A:  BEGIN         第一次SELECT        第二次SELECT    第三次SELECT
         |                |                  |               |
         |  不创建View     |  创建View #1     |  复用View #1  |  复用View #1
         |                |  (m_ids: [B,C])  |               |
事务B:      UPDATE COMMIT      |
事务C:              UPDATE ────|─── COMMIT
事务D:                         |  UPDATE ────── COMMIT
                               |
                         此时 Read View 记录:
                         m_ids = [A, C]   (B 已提交不在列表)
                         D 此时还不存在
                         
                         结果:B 的修改可见(创建时已提交)
                              C 的修改不可见(创建时活跃)
                              D 的修改不可见(创建时还不存在)

READ COMMITTED 的区别: RC 每次快照读都创建新的 Read View,所以每次都能看到最新已提交的数据。

REPEATABLE READ

READ COMMITTED

Read View 创建时机

第一次快照读时创建,整个事务复用

每次快照读都创建新的

是否在 BEGIN 时创建

否

否

效果

同一事务内所有快照读看到相同数据

每次快照读看到最新已提交数据

二、Undo Log 版本链遍历过程

当一行数据被多次修改,Undo Log 形成版本链:

当前行(聚簇索引叶子页):
┌──────────────────────────────────────────────┐
│ id=1 │ name='王五' │ DB_TRX_ID=300 │ DB_ROLL_PTR │──┐
└──────────────────────────────────────────────┘  │
                                                  ▼
Undo Log 版本链:
┌─────────────────────────────────────────────────────┐
│ [Undo Log Record #2]                                │
│ 旧值: name='李四'                                   │
│ DB_TRX_ID = 200                                     │
│ DB_ROLL_PTR ──────────────────────────┐             │
└────────────────────────────────────────┼────────────┘
                                         ▼
┌─────────────────────────────────────────────────────┐
│ [Undo Log Record #1]                                │
│ 旧值: name='张三'(最初值)                          │
│ DB_TRX_ID = 100                                     │
│ DB_ROLL_PTR = NULL(链尾)                           │
└─────────────────────────────────────────────────────┘

可见性判断流程(快照读):

SELECT * FROM users WHERE id = 1;
→ 存储引擎读取聚簇索引中当前行
→ 检查 DB_TRX_ID = 300 对当前 Read View 是否可见

1. DB_TRX_ID = 300 < min_trx_id? → 否
2. DB_TRX_ID = 300 > max_trx_id? → 否  
3. DB_TRX_ID = 300 在 m_ids 中? → 是(事务 300 活跃)→ 不可见!

→ 沿着 DB_ROLL_PTR 读取 Undo Log Record #2
→ 检查 DB_TRX_ID = 200 是否可见

1. DB_TRX_ID = 200 < min_trx_id? → 是 → 可见!
→ 返回 name='李四'

关键细节:

  • Undo Log 中的版本也包含 DB_TRX_ID,用于可见性判断

  • 如果遍历到链尾仍无可见版本 → 该行对当前事务不可见(如同不存在)

  • RR 事务中,一旦确定了可见版本,后续同一行的快照读直接返回该版本(因为 Read View 不变)

三、Purge 线程——清理旧版本

Undo Log 中的历史版本不可能永久保留。Purge 线程负责清理不再需要的旧版本。

Purge 工作流程:
1. 事务提交时,Undo Log 标记为"待清理"(而非立即删除)
2. Purge 线程定期扫描 Undo Log
3. 对每条 Undo Log Record,检查其 DB_TRX_ID:
   - 如果当前系统中**没有任何活跃 Read View 需要这个版本** → 物理删除
   - 如果仍有某个长事务的 Read View 的 min_trx_id 小于此 DB_TRX_ID → 保留
4. 清理后回收 Undo 表空间

为什么不是事务提交时立即删除?
  → 因为其他事务的快照读可能还需要这些旧版本(如 RR 的 Read View 在第一次SELECT时创建,后续SELECT仍需看到当时的数据)
  → 长事务会导致 Undo Log 膨胀(历史版本无法清理)

长事务的危害之一:Undo Log 堆积

-- 危险的长事务
START TRANSACTION;
SELECT * FROM orders WHERE id = 1;  -- 创建 Read View,min_trx_id = 1000
-- ... 执行了 1 小时的业务逻辑 ...
-- ... 期间 orders 表被修改了 100 万次 ...
-- ... 所有 Undo Log 版本都因为 min_trx_id=1000 而无法被 Purge 清理 ...
COMMIT;  -- 此时 Purge 才能清理这些旧版本

四、当前读 vs 快照读完整对比

维度

快照读(Snapshot Read)

当前读(Current Read)

定义

读取事务快照中的历史版本

读取最新已提交版本

加锁

不加锁(非锁定读)

加锁(锁定读)

依赖

MVCC(Read View + Undo Log)

行锁/表锁

触发 SQL

普通 SELECT

SELECT ... FOR UPDATE / SELECT ... LOCK IN SHARE MODE / UPDATE / DELETE / INSERT

读到什么

取决于隔离级别 + Read View

永远是最新已提交数据

阻塞关系

不阻塞写、不被写阻塞

与其他当前读互斥(等锁)

适用场景

报表、展示、不要求强一致的查询

库存扣减、余额变更等需要准确数据的操作

触发当前读的所有操作:

-- 1. 显示锁定读
SELECT * FROM products WHERE id = 1 FOR UPDATE;          -- 排他锁(X 锁)
SELECT * FROM products WHERE id = 1 LOCK IN SHARE MODE;  -- 共享锁(S 锁)MySQL 8.0+ 用 FOR SHARE

-- 2. DML 的"读取"阶段(读取待修改的行时触发当前读)
UPDATE products SET stock = stock - 1 WHERE id = 1;  -- 内部先当前读,再加 X 锁,再修改
DELETE FROM orders WHERE id = 100;                    -- 同上
INSERT INTO orders (...) VALUES (...);                -- 检查唯一约束时触发当前读

-- 3. 外键检查
-- 插入子表行时,对父表引用行加 S 锁(当前读)

五、临键锁加锁规则(深入)

核心规则: 加锁的基本单位是 Next-Key Lock(临键锁),即 行锁 + 该行前面的间隙锁。

但原则上有两条退化规则:

  1. 唯一索引等值查询命中 → 退化为 Record Lock(只锁行,不加 Gap Lock)

  2. 等值查询未命中 → 退化为 Gap Lock(只锁间隙,不锁行)

加锁规则总结表:

| 索引类型 | 查询类型 | 是否命中 | 实际加的锁 | 示例 |
|---------|---------|---------|-----------|------|
| 主键/唯一索引 | 等值 | 命中 | Record Lock | WHERE id=10 |
| 主键/唯一索引 | 等值 | 未命中 | Gap Lock | WHERE id=7 (表中无7) |
| 普通索引 | 等值 | 命中 | Next-Key Lock + 额外的 Gap Lock | WHERE user_id=5 |
| 普通索引 | 等值 | 未命中 | Gap Lock | WHERE user_id=7 |
| 任意索引 | 范围 | — | Next-Key Lock 多段 | WHERE id>10 AND id<20 |

为什么唯一索引等值命中退化为行锁?
因为唯一索引保证不会插入重复值——既然值 10 已经存在了,绝不可能有另一个事务插入 id=10,所以不需间隙锁防插入。

为什么普通索引要加额外 Gap Lock?
因为普通索引的同值行之间可以插入新的同值行——如 user_id=5 有多行(不同主键),另一个事务可能插入新的 user_id=5 行,所以需要对 user_id=5 前后的间隙都加锁。

六、死锁检测:Wait-For Graph

InnoDB 通过等待图(Wait-For Graph)检测死锁:

死锁场景:
  事务A: 持有 id=10 的 X 锁,等待 id=20 的 X 锁
  事务B: 持有 id=20 的 X 锁,等待 id=10 的 X 锁

Wait-For Graph:
  事务A ──等待──→ 事务B
    ↑               │
    └──等待─────────┘

  检测到环 → 死锁!
  InnoDB 选择回滚"undo 量最小"的事务(通常是后开始的那个)

死锁检测算法: 每次事务等待锁时,InnoDB 启动深度优先搜索(DFS)遍历 Wait-For Graph,检测是否存在环。检测开销 O(N+E),其中 N 是事务数,E 是等待边数。

参数控制:

  • innodb_deadlock_detect = ON(默认),MySQL 8.0+ 支持关闭(极高并发时检测开销大)

  • innodb_lock_wait_timeout = 50(秒),关闭死锁检测后靠超时释放

七、死锁案例代码 + 排查 + 解决

案例:秒杀库存扣减 + 订单插入导致死锁

-- 表结构
CREATE TABLE products (
  id INT PRIMARY KEY,
  stock INT NOT NULL
);
INSERT INTO products VALUES (1, 100), (2, 50);

-- 会话 A(扣减商品 1 和 2 的库存)
START TRANSACTION;
UPDATE products SET stock = stock - 1 WHERE id = 1;  -- 锁住 id=1
-- 此时 A 持有 id=1 的 X 锁

-- 会话 B(扣减商品 2 和 1 的库存)  
START TRANSACTION;
UPDATE products SET stock = stock - 1 WHERE id = 2;  -- 锁住 id=2
-- 此时 B 持有 id=2 的 X 锁

-- 会话 A 继续
UPDATE products SET stock = stock - 1 WHERE id = 2;  -- 等待 B 释放 id=2 → 阻塞

-- 会话 B 继续
UPDATE products SET stock = stock - 1 WHERE id = 1;  -- 等待 A 释放 id=1 → 死锁!
-- ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction

排查命令:

-- 查看最近死锁详情(包含涉及的事务、SQL、锁信息)
SHOW ENGINE INNODB STATUS\G
-- 滚动到 LATEST DETECTED DEADLOCK 段落

-- 查看当前活跃事务
SELECT * FROM information_schema.INNODB_TRX\G

-- MySQL 8.0+ 查看锁等待关系
SELECT 
  r.trx_id waiting_trx_id,
  r.trx_mysql_thread_id waiting_thread,
  r.trx_query waiting_query,
  b.trx_id blocking_trx_id,
  b.trx_mysql_thread_id blocking_thread,
  b.trx_query blocking_query
FROM information_schema.INNODB_LOCK_WAITS w
JOIN information_schema.INNODB_TRX r ON w.requesting_trx_id = r.trx_id
JOIN information_schema.INNODB_TRX b ON w.blocking_trx_id = b.trx_id;

解决方案:

-- 方案 1:固定加锁顺序(最重要)
-- 规定所有事务必须先锁 id=1 再锁 id=2
-- ✅ 就不会出现 A 等 B、B 等 A 的情况

-- 方案 2:一次锁定所有需要的行
SELECT * FROM products WHERE id IN (1, 2) FOR UPDATE;  -- 此时按主键顺序加锁

-- 方案 3:应用层重试
try {
  // 执行事务
} catch (DeadlockLoserDataAccessException e) {
  // 休眠随机毫秒后重试
  Thread.sleep(random(10, 100));
  retry();
}

在 分布式电商交易系统 中的死锁预防:

// 电商项目三 - 订单创建中的死锁预防
public void createOrder(Long userId, Long productId, Integer quantity) {
    // 策略 1:先锁商品再锁库存——所有订单统一顺序
    Product product = productMapper.selectForUpdate(productId);       // 1. 先锁商品
    Stock stock = stockMapper.selectForUpdate(product.getStockId()); // 2. 再锁库存
    
    // 策略 2:事务尽量短——不在事务中做 RPC 调用、发消息、写日志
    if (stock.getQuantity() >= quantity) {
        stockMapper.decreaseStock(stock.getId(), quantity);
        orderMapper.insert(new Order(userId, productId, quantity));
    }
    // 事务在此提交——不在事务中做其他耗时操作
}

八、四种隔离级别的实现方式

隔离级别

实现方式

具体机制

READ UNCOMMITTED

直接读最新数据

不加锁,不看 Undo Log。读到未提交的数据(脏读)

READ COMMITTED

MVCC + 每次读创建新 Read View

每次快照读都取最新 Read View → 看到最新已提交数据。当前读加行锁

REPEATABLE READ

MVCC + 第一次读创建 Read View + 临键锁

快照读:复用同一个 Read View → 可重复读。当前读:临键锁防幻读

SERIALIZABLE

所有 SELECT 隐式转为 SELECT ... LOCK IN SHARE MODE

所有读都是当前读、都加锁。读写串行化

隔离级别强度 vs 并发性能:

READ UNCOMMITTED        READ COMMITTED          REPEATABLE READ        SERIALIZABLE
    弱 ←──────────────────────────────────────────────────────────────→ 强
    高并发                                              	     	    低并发
[几乎不用]            [Oracle/PostgreSQL默认]    [MySQL InnoDB默认]        [极端需求]

MySQL InnoDB 为什么默认 RR 而不是 RC?
历史原因:MySQL 5.0 之前 binlog 只有 STATEMENT 格式,RC + STATEMENT 会导致主从数据不一致(binlog 回放时的执行顺序与主库不同,导致结果不同)。RR 下同样的 SQL 看到同样的快照,主从一致。此问题在 ROW 格式 binlog 中不存在,但 RR 已沿用为默认。

九、项目三秒杀场景隔离级别选择

场景: 项目三-分布式电商交易系统 秒杀,高并发库存扣减。

推荐方案: 使用 READ COMMITTED + 乐观锁(或 Redis 扣库存)。

为什么不推荐 REPEATABLE READ?

RR 的临键锁在秒杀场景的副作用:

UPDATE products SET stock = stock - 1 WHERE id = 1;
  → 主键唯一索引等值命中 → 退化为 Record Lock → 只锁 id=1 这一行
  
  BUT!如果秒杀还有其他查询如:
  SELECT * FROM products WHERE stock > 0 FOR UPDATE;
  → 范围查询 → 加 Next-Key Lock 多段 → 大量间隙被锁 → 并发度骤降

RC 在秒杀中的优势:

  1. 间隙锁更少: RC 下只有行锁(Record Lock),无 Gap Lock,并发度更高

  2. 库存扣减用当前读: SELECT ... FOR UPDATE 在 RC 下同样读到最新已提交数据

  3. 配合乐观锁: UPDATE products SET stock = stock - 1 WHERE id = 1 AND stock >= 1 —— 无 SELECT FOR UPDATE 的阻塞,靠 affected_rows 判断是否成功

// 项目三秒杀库存扣减 - RC + 乐观锁方案
@Transactional(rollbackFor = Exception.class)
public boolean decreaseStock(Long productId) {
    // 直接 UPDATE + WHERE 条件,不加 SELECT FOR UPDATE
    int affected = productMapper.decreaseStock(productId);
    // UPDATE products SET stock = stock - 1
    // WHERE id = #{productId} AND stock >= 1
    return affected > 0;  // affected=0 表示库存不足
}

但需要注意: RC 下 binlog 必须用 ROW 格式(不能用 STATEMENT),否则主从不一致。MySQL 5.7+ 默认就是 ROW 格式。


深度面试追问

Q1:RR 隔离级别下,一个事务内两次相同的 SELECT 返回不同结果,可能吗?

30 秒回答: 快照读不会不同(同一个 Read View)。但如果中间发生了当前读(如 SELECT ... FOR UPDATE),当前读看到的是最新数据,和快照读结果不同。

深入回答: RR 下快照读复用同一个 Read View,所以同一事务内多次普通 SELECT 结果一定相同。但如果事务内混合快照读和当前读,就会看到"不一致":

START TRANSACTION;
SELECT stock FROM products WHERE id = 1;           -- 快照读 → stock=10
-- 另一个事务在此修改 stock 为 9 并提交
SELECT stock FROM products WHERE id = 1;           -- 快照读 → 仍是 10(复用 Read View)
SELECT stock FROM products WHERE id = 1 FOR UPDATE; -- 当前读 → stock=9(最新值)

这是正确的 MVCC 行为,不是 bug。业务中需要时用 FOR UPDATE 取得最新值。

追问 1: 为什么有人误以为 RR 下 UPDATE t SET c=c+1 会基于快照值更新?

UPDATE 内部先做当前读获取最新行——不会基于快照值。如果当前值是 10,更新后是 11,不会因为快照是 8 就更新成 9。

追问 2: RR 是否能完全解决幻读?

快照读层面:完全解决(Read View 不变)。当前读层面:临键锁基本解决,但仍有边缘情况——如事务 A SELECT * WHERE id>10 FOR UPDATE 锁了范围,事务 B 插入 id=11 被阻塞。看似解决了,但如果事务 A 只做快照读,事务 B 插入成功,然后事务 A 执行 UPDATE t SET c=1 WHERE id>10(当前读),会发现多了一行——这是幻读的仅存场景。所以严格说 RR 解决了快照读的幻读,当前读的幻读绝大部分也解决了。


Q2:MySQL 的 MVCC 在 RR 和 RC 下具体差异是什么?

30 秒回答: 核心差异在 Read View 的创建时机。RR 在第一次快照读时创建 Read View,整个事务复用。RC 每次快照读都创建新的 Read View。

深入回答:

REPEATABLE READ

READ COMMITTED

Read View 创建

第一次快照读时

每次快照读时

Read View 数量

1 个/事务

N 个/事务(每次 SELECT 1 个)

可见性

以第一次 SELECT 时刻为基准

以每次 SELECT 时刻为基准

加锁(当前读)

临键锁(防幻读)

行锁(有幻读风险)

Undo Log 清理

可能被长事务阻塞

更容易被清理(Read View 短命)

实现原理相同(都是 DB_TRX_ID + DB_ROLL_PTR + Undo Log),差异只在 Read View 的管理策略。

追问 1: 为什么 RC 更容易出现幻读?

RC 每次读创建新 Read View → 每次都能看到别的事务新插入的行。加上 RC 的当前读只加行锁不加间隙锁 → 其他事务可以插入新行 → 下次当前读会看到更多行。

追问 2: Oracle 默认 RC,PostgreSQL 默认 RC,为什么它们不需要 RR?

Oracle 的 MVCC 实现不同(UNDO 在回滚段,快照基于 SCN),PostgreSQL 的 MVCC 也不同(旧版本存在数据页内部)。它们的 RC 配合各自的多版本机制能满足大多数场景。但在 MySQL 中,如果应用依赖 binlog STATEMENT 格式,必须用 RR。


Q3:一个长事务(运行 1 小时)会有什么影响?

30 秒回答: 1) Undo Log 堆积(Purge 线程无法清理旧版本,可能导致 Undo 表空间无限膨胀);2) 锁不释放(持有的行锁/临键锁阻塞其他事务);3) 从库延迟(binlog 在事务提交时才写入,导致从库长时间收不到该事务的更新)。

深入回答:

长事务的连锁影响:

事务 A: BEGIN → SELECT (创建 Read View, min_trx_id = 100) → ... 1 小时后 COMMIT

1. Undo Log 膨胀:
   这 1 小时内,其他事务修改的所有行的旧版本(DB_TRX_ID >= 100)
   都因为事务 A 的 Read View 可能还需要 → Purge 线程无法清理
   → Undo Log 文件不断增长,可能耗尽磁盘

2. 锁持有时间:
   如果事务 A 中执行了 UPDATE/DELETE/SELECT FOR UPDATE
   → 行锁/临键锁持有 1 小时 → 大量其他事务排队等待 → 连接池耗尽

3. 从库延迟:
   MySQL binlog 在事务 COMMIT 时才写入
   → 1 小时内从库收不到任何该连接相关的 binlog 事件
   → 从库延迟增大(虽然其他事务正常同步)

4. 从库一致性:
   如果从库并行复制,长事务提交瞬间可能产生大量的 binlog 事件
   → 从库回放压力大

追问 1: 如何监控和预防长事务?

监控:SELECT * FROM information_schema.INNODB_TRX WHERE trx_started < NOW() - INTERVAL 60 SECOND;。预防:设置 max_execution_time(MySQL 5.7+)限制单条 SQL 执行时间;设置 innodb_lock_wait_timeout 限制锁等待;应用层事务粒度最小化原则。

追问 2: 如果已经出现长事务,除了 KILL 还有什么办法?

无法"优雅"解决——只有 KILL 连接或等待它自己完成。KILL 后事务回滚可能也很耗时(Undo 量大)。这就是为什么要预防长事务:事务内不做 RPC、不发消息、不写文件、不做复杂计算。


Q4:既然 MVCC 这么好,为什么还需要锁?

30 秒回答: MVCC 解决读-写冲突(读写互不阻塞),但不能解决写-写冲突。当两个事务同时修改同一行时,必须用锁来串行化。

深入回答:

MVCC 能解决的:
  事务A 读 X ──→ 事务B 同时写 X ──→ A 读到旧版本,不被阻塞 ✅

MVCC 不能解决的:
  事务A 写 X ──→ 事务B 同时写 X ──→ 必须等 A 释放锁 ❌(写写冲突)

为什么写写冲突不能靠 MVCC?
  → 因为最终数据页上只能有 1 个版本
  → LOST UPDATE 问题:如果没有锁,A 和 B 同时读 stock=10,
     A 改成 9,B 改成 8 —— 应该变成 7,但变成 8(后写入覆盖前写入)

所以总结:读-读无冲突;读-写用 MVCC 解决;写-写必须用锁解决。

追问 1: 那 UPDATE products SET stock = stock - 1 WHERE id = 1 是怎么避免写写冲突的?

UPDATE 内部流程:1) 对 id=1 加 X 锁(当前读);2) 读取最新 stock 值(假设 10);3) 计算 new_stock = 9;4) 写入。如果另一个 UPDATE 同时到达,它会在步骤 1 被阻塞,等待第一个事务提交释放锁。这样第二个 UPDATE 读到的是 9,改成 8——结果正确。

追问 2: 乐观锁(CAS)是不是不需要数据库锁?

乐观锁本质是应用层的"冲突检测"而非"冲突避免"。UPDATE t SET v=v+1 WHERE id=1 AND v=10 如果 affected_rows=0 说明冲突了,应用重试。数据库内部仍然要对这行加锁(UPDATE 的当前读阶段),但锁持有时间极短(毫秒级)。所以乐观锁并不消除锁,只是让锁持有时间极短。