Skip to content

第四阶段:事务与锁机制


4.1 事务基础

4.1.1 什么是事务?

事务(Transaction)是一组不可分割的数据库操作序列,要么全部成功,要么全部失败回滚

经典场景 —— 银行转账:

  张三账户:1000 元          李四账户:500 元
        │                        │
  ┌─────┴────────────────────────┴─────┐
  │          事务:张三给李四转 200 元     │
  │                                     │
  │  Step 1: 张三余额 -= 200  → 800 元  │
  │  Step 2: 李四余额 += 200  → 700 元  │
  │                                     │
  │  ✅ 两步都成功 → COMMIT(提交)       │
  │  ❌ 任一步失败 → ROLLBACK(回滚)     │
  │     张三余额恢复 1000,李四保持 500   │
  └───────────────────────────────────────┘

如果没有事务:
  Step 1 成功(张三 → 800)
  Step 2 失败(系统崩溃!)
  → 200 元凭空消失!💀

4.1.2 ACID 特性

事务的四大特性是数据库可靠性的基石:

                    ┌─────────────────────────────────┐
                    │          ACID 特性                │
                    ├─────────┬───────────────────────┤
                    │         │                       │
              ┌─────┴──┐  ┌──┴───┐  ┌──────┐  ┌─────┴──┐
              │  A     │  │  C   │  │  I   │  │  D     │
              │原子性  │  │一致性│  │隔离性│  │持久性  │
              │Atomicity│ │Consis│ │Isola │  │Durabi │
              │        │  │tency │  │tion  │  │lity   │
              └────────┘  └──────┘  └──────┘  └────────┘
                  │          │         │          │
              Undo Log    约束+     MVCC+      Redo Log
              保证回滚    应用逻辑   锁机制     保证写盘
特性含义实现机制类比
原子性 (Atomicity)事务中的操作要么全做,要么全不做Undo Log(回滚日志)要么全额转账,要么一分不转
一致性 (Consistency)事务前后,数据满足所有约束规则约束 + 应用逻辑 + AID 共同保证转账前后总金额不变
隔离性 (Isolation)并发事务之间互不干扰MVCC + 锁机制两个柜台同时转账,互不影响
持久性 (Durability)事务提交后,数据永久保存Redo Log(重做日志)转账成功后断电也不丢失

4.1.3 事务控制语句

sql
-- ==================== 1. 基本事务控制 ====================

-- 开启事务(三种写法等价)
START TRANSACTION;
-- 或
BEGIN;
-- 或
BEGIN WORK;

-- 提交事务
COMMIT;

-- 回滚事务
ROLLBACK;

-- ==================== 2. 完整转账示例 ====================

START TRANSACTION;

-- 检查张三余额是否足够
SELECT balance INTO @balance FROM accounts WHERE name = '张三' FOR UPDATE;

-- 余额足够则执行转账
UPDATE accounts SET balance = balance - 200 WHERE name = '张三';
UPDATE accounts SET balance = balance + 200 WHERE name = '李四';

-- 验证总金额不变(一致性检查)
SELECT SUM(balance) FROM accounts;

COMMIT;

-- ==================== 3. SAVEPOINT 保存点 ====================
-- 可以部分回滚到某个保存点

START TRANSACTION;

INSERT INTO orders (user_id, amount) VALUES (1, 100);
SAVEPOINT sp_order;  -- 创建保存点

INSERT INTO order_items (order_id, product_id) VALUES (1, 999);
-- 发现商品不存在,回滚到保存点
ROLLBACK TO SAVEPOINT sp_order;

-- 继续其他操作
INSERT INTO order_items (order_id, product_id) VALUES (1, 101);

COMMIT;  -- 提交,order_items 中只有 product_id=101

-- ==================== 4. 自动提交模式 ====================

-- 查看当前自动提交状态
SHOW VARIABLES LIKE 'autocommit';
-- +---------------+-------+
-- | Variable_name | Value |
-- +---------------+-------+
-- | autocommit    | ON    |   ← 默认开启
-- +---------------+-------+

-- autocommit = ON 时,每条 SQL 都是一个独立事务
-- 除非用 BEGIN/START TRANSACTION 显式开启事务

-- 关闭自动提交(当前会话)
SET autocommit = 0;
-- 此后每条 SQL 不会自动提交,需手动 COMMIT

4.1.4 隐式提交

某些语句会隐式提交当前事务,需特别注意:

类别隐式提交的语句
DDL 语句CREATE TABLEALTER TABLEDROP TABLETRUNCATE
DCL 语句GRANTREVOKECREATE USER
锁定语句LOCK TABLESUNLOCK TABLES
其他LOAD DATA INFILESET autocommit = 1
sql
-- ⚠️ 隐式提交陷阱
START TRANSACTION;
UPDATE accounts SET balance = balance - 200 WHERE name = '张三';

-- DDL 语句触发隐式提交!
ALTER TABLE accounts ADD COLUMN phone VARCHAR(20);
-- 此时 UPDATE 已经被隐式 COMMIT,无法 ROLLBACK!

ROLLBACK;  -- ← 无效!UPDATE 已经提交了

4.2 并发事务问题

4.2.1 四大并发问题

当多个事务并发执行时,可能出现以下问题:

1. 脏读(Dirty Read)—— 读到未提交的数据
┌──────────────────────┐  ┌──────────────────────┐
│      事务 A           │  │      事务 B           │
├──────────────────────┤  ├──────────────────────┤
│ BEGIN;               │  │ BEGIN;               │
│ UPDATE accounts      │  │                      │
│ SET balance = 800    │  │                      │
│ WHERE name='张三';   │  │                      │
│                      │  │ SELECT balance       │
│                      │  │ FROM accounts        │
│                      │  │ WHERE name='张三';   │
│                      │  │ → 读到 800(脏数据!)│
│ ROLLBACK;            │  │                      │
│ → 余额恢复 1000      │  │ -- 基于 800 做计算    │
│                      │  │ -- 但实际是 1000!    │
│                      │  │ COMMIT;              │
└──────────────────────┘  └──────────────────────┘

2. 不可重复读(Non-Repeatable Read)—— 同一事务内两次读取结果不同
┌──────────────────────┐  ┌──────────────────────┐
│      事务 A           │  │      事务 B           │
├──────────────────────┤  ├──────────────────────┤
│ BEGIN;               │  │ BEGIN;               │
│ SELECT balance       │  │                      │
│ FROM accounts        │  │                      │
│ WHERE name='张三';   │  │                      │
│ → 读到 1000          │  │                      │
│                      │  │ UPDATE accounts      │
│                      │  │ SET balance = 800    │
│                      │  │ WHERE name='张三';   │
│                      │  │ COMMIT;              │
│ SELECT balance       │  │                      │
│ FROM accounts        │  │                      │
│ WHERE name='张三';   │  │                      │
│ → 读到 800 !        │  │                      │
│ (同一事务内两次      │  │                      │
│  读取结果不同)       │  │                      │
│ COMMIT;              │  │                      │
└──────────────────────┘  └──────────────────────┘

3. 幻读(Phantom Read)—— 同一查询条件,两次读取行数不同
┌──────────────────────┐  ┌──────────────────────┐
│      事务 A           │  │      事务 B           │
├──────────────────────┤  ├──────────────────────┤
│ BEGIN;               │  │ BEGIN;               │
│ SELECT COUNT(*)      │  │                      │
│ FROM accounts        │  │                      │
│ WHERE balance > 500; │  │                      │
│ → 结果:3 条         │  │                      │
│                      │  │ INSERT INTO accounts │
│                      │  │ (name, balance)      │
│                      │  │ VALUES ('王五', 800);│
│                      │  │ COMMIT;              │
│ SELECT COUNT(*)      │  │                      │
│ FROM accounts        │  │                      │
│ WHERE balance > 500; │  │                      │
│ → 结果:4 条 !      │  │                      │
│ (多了一行幻影数据) │  │                      │
│ COMMIT;              │  │                      │
└──────────────────────┘  └──────────────────────┘

4.2.2 事务隔离级别

MySQL 提供四种隔离级别来解决并发问题:

隔离级别脏读不可重复读幻读性能使用场景
READ UNCOMMITTED❌ 可能❌ 可能❌ 可能最高几乎不用
READ COMMITTED✅ 解决❌ 可能❌ 可能Oracle 默认,适合大部分 OLTP
REPEATABLE READ✅ 解决✅ 解决⚠️ 部分解决MySQL 默认,InnoDB 通过 MVCC+Gap Lock 大部分解决幻读
SERIALIZABLE✅ 解决✅ 解决✅ 解决最低对一致性要求极高的金融场景
sql
-- ==================== 查看与设置隔离级别 ====================

-- 查看当前隔离级别
SELECT @@transaction_isolation;
-- +-------------------------+
-- | @@transaction_isolation |
-- +-------------------------+
-- | REPEATABLE-READ         |   ← MySQL 默认
-- +-------------------------+

-- 查看全局和会话级别
SELECT @@global.transaction_isolation;
SELECT @@session.transaction_isolation;

-- 设置当前会话隔离级别
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

-- 设置全局隔离级别(新连接生效)
SET GLOBAL TRANSACTION ISOLATION LEVEL REPEATABLE READ;

-- 仅对下一个事务生效
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;

4.3 MVCC 多版本并发控制

4.3.1 MVCC 是什么?

MVCC(Multi-Version Concurrency Control)是 InnoDB 实现非锁定读(快照读)的核心机制。通过保存数据的多个版本,让读操作不阻塞写操作,写操作不阻塞读操作。

MVCC 核心思想:

  ┌──────────────────────────────────────────────────────────┐
  │  传统锁方式:读写互斥                                      │
  │                                                          │
  │  事务A (读):  ████████████░░░░░░░░░░░░░░                │
  │  事务B (写):  ░░░░░░░░░░░░████████████░░░  ← 必须等待    │
  │                                                          │
  │  MVCC 方式:读写不阻塞                                     │
  │                                                          │
  │  事务A (读):  ████████████████████████████  ← 读取快照版本│
  │  事务B (写):  ██████████████████████████░░  ← 同时写入    │
  │                                                          │
  │  读取的是数据的历史版本,写入的是最新版本,互不干扰           │
  └──────────────────────────────────────────────────────────┘

4.3.2 MVCC 实现原理

MVCC 依赖三个核心组件:

MVCC 三大组件:

  ┌─────────────────────────────────────────────────────┐
  │                   MVCC 实现                          │
  │                                                     │
  │  ┌──────────────┐  ┌──────────┐  ┌──────────────┐  │
  │  │ 隐藏字段      │  │ Undo Log │  │ Read View    │  │
  │  │              │  │ 版本链    │  │ 读视图        │  │
  │  │ • trx_id     │  │          │  │              │  │
  │  │ • roll_ptr   │  │ 旧版本   │  │ 判断可见性    │  │
  │  └──────────────┘  └──────────┘  └──────────────┘  │
  └─────────────────────────────────────────────────────┘

1. 隐藏字段

InnoDB 为每行数据自动添加两个隐藏列:

隐藏字段长度说明
trx_id6 字节最近修改该行的事务 ID
roll_pointer7 字节指向 Undo Log 中该行的上一个版本

2. Undo Log 版本链

每次修改都会把旧版本写入 Undo Log,通过 roll_pointer 串成链表:

假设有一行数据 (id=1, name='张三', balance=1000)

事务 100 修改:UPDATE SET balance = 800
事务 200 修改:UPDATE SET balance = 500

当前数据(最新版本):
┌──────────────────────────────────────────────┐
│ id=1 │ name=张三 │ balance=500 │ trx_id=200  │
│                               │ roll_ptr ──────┐
└──────────────────────────────────────────────┘ │

Undo Log 版本链:                                  ↓
┌──────────────────────────────────────────────┐
│ id=1 │ name=张三 │ balance=800 │ trx_id=100  │  ← 事务 100 的版本
│                               │ roll_ptr ──────┐
└──────────────────────────────────────────────┘ │


┌──────────────────────────────────────────────┐
│ id=1 │ name=张三 │ balance=1000│ trx_id=50   │  ← 最初版本
│                               │ roll_ptr=NULL │
└──────────────────────────────────────────────┘

3. Read View(读视图)

Read View 是事务执行快照读时创建的"可见性判定规则":

Read View 包含的关键信息:

┌─────────────────────────────────────────────────┐
│                  Read View                       │
│                                                  │
│  m_ids:          [100, 200]  ← 创建时活跃事务列表│
│  min_trx_id:     100         ← 活跃事务最小 ID    │
│  max_trx_id:     201         ← 下一个要分配的ID   │
│  creator_trx_id: 300         ← 创建该视图的事务ID  │
└─────────────────────────────────────────────────┘

可见性判断规则:
对于某行数据的 trx_id:

  ┌─────────────────────────────────────────────┐
  │ trx_id < min_trx_id ?                       │
  │   → YES: 该版本在创建 View 前已提交 → ✅ 可见 │
  │   → NO: 继续判断 ↓                           │
  ├─────────────────────────────────────────────┤
  │ trx_id >= max_trx_id ?                      │
  │   → YES: 该版本在创建 View 后才出现 → ❌ 不可见│
  │   → NO: 继续判断 ↓                           │
  ├─────────────────────────────────────────────┤
  │ trx_id 在 m_ids 列表中?                     │
  │   → YES: 该事务还未提交 → ❌ 不可见            │
  │   → NO:  该事务已提交 → ✅ 可见               │
  ├─────────────────────────────────────────────┤
  │ trx_id == creator_trx_id ?                  │
  │   → YES: 自己的修改 → ✅ 可见                 │
  └─────────────────────────────────────────────┘

  如果当前版本不可见 → 沿 roll_pointer 找上一版本再判断

4.3.3 不同隔离级别下 Read View 的差异

┌────────────────────────────────────────────────────────┐
│            Read View 创建时机对比                        │
│                                                        │
│  READ COMMITTED(RC):                                 │
│  ┌──────────────────────────────────────┐              │
│  │ 每次 SELECT 都创建新的 Read View      │              │
│  │                                      │              │
│  │ BEGIN;                               │              │
│  │ SELECT ... → 创建 ReadView #1        │              │
│  │ SELECT ... → 创建 ReadView #2(新的)│              │
│  │ SELECT ... → 创建 ReadView #3(新的)│              │
│  │ COMMIT;                              │              │
│  │                                      │              │
│  │ → 每次都能看到最新已提交的数据          │              │
│  │ → 可能出现不可重复读                   │              │
│  └──────────────────────────────────────┘              │
│                                                        │
│  REPEATABLE READ(RR,MySQL 默认):                    │
│  ┌──────────────────────────────────────┐              │
│  │ 事务中第一次 SELECT 创建 Read View    │              │
│  │ 后续 SELECT 复用同一个 Read View      │              │
│  │                                      │              │
│  │ BEGIN;                               │              │
│  │ SELECT ... → 创建 ReadView #1        │              │
│  │ SELECT ... → 复用 ReadView #1        │              │
│  │ SELECT ... → 复用 ReadView #1        │              │
│  │ COMMIT;                              │              │
│  │                                      │              │
│  │ → 整个事务看到的数据一致(快照)        │              │
│  │ → 解决了不可重复读                     │              │
│  └──────────────────────────────────────┘              │
└────────────────────────────────────────────────────────┘

4.3.4 当前读 vs 快照读

┌─────────────────────────────────────────────────────┐
│              当前读 vs 快照读                          │
│                                                      │
│  快照读(Snapshot Read):                             │
│  ┌────────────────────────────────────────────────┐  │
│  │ • 普通 SELECT 语句                              │  │
│  │ • 读取的是 MVCC 版本链中的历史快照                │  │
│  │ • 不加锁,不阻塞其他事务                         │  │
│  │                                                │  │
│  │ SELECT * FROM accounts WHERE id = 1;           │  │
│  └────────────────────────────────────────────────┘  │
│                                                      │
│  当前读(Current Read):                              │
│  ┌────────────────────────────────────────────────┐  │
│  │ • 读取数据的最新版本                             │  │
│  │ • 会加锁(共享锁或排他锁)                       │  │
│  │                                                │  │
│  │ SELECT * FROM accounts WHERE id = 1 FOR SHARE; │  │
│  │ SELECT * FROM accounts WHERE id = 1 FOR UPDATE;│  │
│  │ INSERT INTO ...                                │  │
│  │ UPDATE accounts SET ...                        │  │
│  │ DELETE FROM accounts WHERE ...                 │  │
│  └────────────────────────────────────────────────┘  │
└─────────────────────────────────────────────────────┘

⚠️ 重要:MVCC 只对快照读生效,当前读走锁机制!

4.4 InnoDB 锁机制

4.4.1 锁的分类

InnoDB 锁体系全景:

  ┌───────────────────────────────────────────────────────┐
  │                    InnoDB 锁分类                       │
  │                                                       │
  │  按粒度分:                                            │
  │  ┌──────────┐  ┌──────────┐  ┌──────────┐            │
  │  │ 全局锁    │  │ 表级锁    │  │ 行级锁    │            │
  │  │ FTWRL    │  │          │  │          │            │
  │  └──────────┘  └──────────┘  └──────────┘            │
  │                     │              │                   │
  │              ┌──────┴──────┐  ┌───┴────────────┐      │
  │              │             │  │                │      │
  │           表锁          意向锁  ┌────┐ ┌────┐ ┌────┐ │
  │       (TABLE LOCK)    (IS/IX) │记录锁│ │间隙锁│ │临键锁│ │
  │                               │Record│ │Gap  │ │Next │ │
  │                               │Lock  │ │Lock │ │-Key │ │
  │  按模式分:                     └────┘ └────┘ └────┘ │
  │  ┌──────────┐  ┌──────────┐                          │
  │  │ 共享锁(S) │  │ 排他锁(X) │                          │
  │  │ 读锁     │  │ 写锁      │                          │
  │  └──────────┘  └──────────┘                          │
  └───────────────────────────────────────────────────────┘

4.4.2 全局锁

sql
-- 全局锁:锁定整个数据库实例,使其处于只读状态
-- 典型用途:全库逻辑备份

-- 加全局锁
FLUSH TABLES WITH READ LOCK;  -- (FTWRL)

-- 此时整个库只读:
-- ✅ SELECT 可以
-- ❌ INSERT/UPDATE/DELETE 不行
-- ❌ DDL 不行

-- 释放全局锁
UNLOCK TABLES;

-- 💡 更好的备份方式:使用 --single-transaction(InnoDB 利用 MVCC 一致性快照)
-- mysqldump --single-transaction -u root -p database_name > backup.sql

4.4.3 表级锁

sql
-- ==================== 1. 表锁 ====================
-- 手动加表锁
LOCK TABLES accounts READ;   -- 加读锁(所有会话只读)
LOCK TABLES accounts WRITE;  -- 加写锁(仅当前会话读写,其他阻塞)

-- 释放
UNLOCK TABLES;

-- ⚠️ InnoDB 一般不用表锁,行锁更细粒度

-- ==================== 2. 意向锁(Intention Lock)====================
-- 意向锁是 InnoDB 自动维护的表级锁,用于快速判断表中是否有行锁
-- 无需手动操作

-- 意向共享锁(IS):事务准备给某些行加 S 锁前,先加 IS
-- 意向排他锁(IX):事务准备给某些行加 X 锁前,先加 IX

意向锁兼容性矩阵:

ISIXSX
IS✅ 兼容✅ 兼容✅ 兼容❌ 冲突
IX✅ 兼容✅ 兼容❌ 冲突❌ 冲突
S✅ 兼容❌ 冲突✅ 兼容❌ 冲突
X❌ 冲突❌ 冲突❌ 冲突❌ 冲突

💡 意向锁的作用:避免加表锁时逐行检查行锁。有了意向锁,只需检查表上的意向锁即可判断冲突。

4.4.4 行级锁(InnoDB 核心)

InnoDB 三种行锁:

1. 记录锁(Record Lock)—— 锁定索引上的单条记录
┌─────┬─────┬─────┬─────┬─────┐
│  5  │ 10  │ 15  │ 20  │ 25  │  ← 索引记录
└─────┴─────┴─────┴─────┴─────┘
              🔒
         锁定 id=15 这条记录

   SELECT * FROM t WHERE id = 15 FOR UPDATE;
   → 只锁 id=15 这一行


2. 间隙锁(Gap Lock)—— 锁定索引记录之间的间隙,防止插入
┌─────┬─────┬─────┬─────┬─────┐
│  5  │ 10  │ 15  │ 20  │ 25  │  ← 索引记录
└─────┴─────┴─────┴─────┴─────┘
         🔒🔒🔒
     锁定 (10, 15) 这个间隙

   → 其他事务无法在 10~15 之间插入新记录
   → 用于防止幻读


3. 临键锁(Next-Key Lock)—— 记录锁 + 间隙锁
┌─────┬─────┬─────┬─────┬─────┐
│  5  │ 10  │ 15  │ 20  │ 25  │  ← 索引记录
└─────┴─────┴─────┴─────┴─────┘
         🔒🔒🔒🔒
     锁定 (10, 15] = 间隙(10,15) + 记录 15

   → InnoDB 在 RR 隔离级别下的默认加锁方式
   → 锁定一个左开右闭的区间
sql
-- ==================== 行锁实战 ====================

-- 准备测试数据
CREATE TABLE t (
    id INT PRIMARY KEY,
    name VARCHAR(20),
    age INT,
    KEY idx_age (age)
) ENGINE=InnoDB;

INSERT INTO t VALUES (5, 'Alice', 20), (10, 'Bob', 25),
                      (15, 'Charlie', 30), (20, 'Diana', 35);

-- 1. 记录锁(等值查询命中索引记录)
-- 会话 A
BEGIN;
SELECT * FROM t WHERE id = 15 FOR UPDATE;
-- → 锁定 id=15 这条记录(Record Lock)

-- 会话 B
UPDATE t SET name = 'test' WHERE id = 15;  -- ❌ 阻塞
UPDATE t SET name = 'test' WHERE id = 10;  -- ✅ 不阻塞

-- 2. 间隙锁(等值查询未命中记录)
-- 会话 A
BEGIN;
SELECT * FROM t WHERE id = 12 FOR UPDATE;
-- → id=12 不存在,锁定间隙 (10, 15)(Gap Lock)

-- 会话 B
INSERT INTO t VALUES (11, 'test', 22);  -- ❌ 阻塞(在间隙内)
INSERT INTO t VALUES (13, 'test', 22);  -- ❌ 阻塞(在间隙内)
UPDATE t SET name = 'test' WHERE id = 10;  -- ✅ 不阻塞
UPDATE t SET name = 'test' WHERE id = 15;  -- ✅ 不阻塞

-- 3. 临键锁(范围查询)
-- 会话 A
BEGIN;
SELECT * FROM t WHERE id >= 10 AND id < 15 FOR UPDATE;
-- → 锁定:
--   Next-Key Lock (5, 10] + Record Lock 10
--   Next-Key Lock (10, 15]
--   实际锁定范围:(5, 15]

-- 会话 B
INSERT INTO t VALUES (8, 'test', 22);   -- ❌ 阻塞
INSERT INTO t VALUES (12, 'test', 22);  -- ❌ 阻塞
UPDATE t SET name = 'test' WHERE id = 5;  -- ✅ 不阻塞

4.4.5 加锁规则总结(RR 隔离级别)

InnoDB 在 RR 隔离级别下的加锁规则:

  ┌──────────────────────────────────────────────────────┐
  │  基本规则:                                           │
  │  1. 加锁的基本单位是 Next-Key Lock(左开右闭)         │
  │  2. 查找过程中访问到的对象才会加锁                     │
  │  3. 唯一索引等值查询,命中记录 → 退化为 Record Lock    │
  │  4. 唯一索引等值查询,未命中 → 退化为 Gap Lock         │
  │  5. 非唯一索引等值查询,最后一个不满足条件的记录 →       │
  │     Next-Key Lock 退化为 Gap Lock                    │
  └──────────────────────────────────────────────────────┘

  示例(索引记录:5, 10, 15, 20, 25):

  ┌──────────────────────────────────────────────────┐
  │ WHERE id = 15(唯一索引,命中)                    │
  │ → Record Lock: id=15                             │
  ├──────────────────────────────────────────────────┤
  │ WHERE id = 12(唯一索引,未命中)                  │
  │ → Gap Lock: (10, 15)                             │
  ├──────────────────────────────────────────────────┤
  │ WHERE age = 25(非唯一索引,命中)                 │
  │ → Next-Key Lock: (20, 25]                        │
  │ → Gap Lock: (25, 30) ← 下一条记录前的间隙         │
  ├──────────────────────────────────────────────────┤
  │ WHERE id >= 10 AND id < 15(范围查询)            │
  │ → Next-Key Lock: (5, 10], (10, 15]               │
  └──────────────────────────────────────────────────┘

4.5 死锁

4.5.1 死锁的产生

死锁:两个或多个事务互相持有对方需要的锁,形成循环等待

  事务 A                        事务 B
    │                             │
    │ 1. 锁定 id=1               │
    │ ┌───────┐                  │
    │ │ 🔒 1  │                  │
    │ └───────┘                  │
    │                             │ 2. 锁定 id=2
    │                  ┌───────┐ │
    │                  │ 🔒 2  │ │
    │                  └───────┘ │
    │                             │
    │ 3. 请求锁定 id=2 → 等待 B   │
    │ ───────────────→ ⏳        │
    │                             │ 4. 请求锁定 id=1 → 等待 A
    │        ⏳ ←─────────────── │
    │                             │
    │  ┌──────────────────────┐  │
    │  │  循环等待 → 死锁!💀  │  │
    │  └──────────────────────┘  │
sql
-- ==================== 模拟死锁 ====================

-- 创建测试表
CREATE TABLE deadlock_test (
    id INT PRIMARY KEY,
    value INT
) ENGINE=InnoDB;

INSERT INTO deadlock_test VALUES (1, 100), (2, 200);

-- 会话 A                          -- 会话 B
BEGIN;                             BEGIN;
UPDATE deadlock_test               UPDATE deadlock_test
SET value = 101                    SET value = 201
WHERE id = 1;                      WHERE id = 2;
-- 锁定 id=1 ✅                    -- 锁定 id=2 ✅

UPDATE deadlock_test               UPDATE deadlock_test
SET value = 201                    SET value = 101
WHERE id = 2;                      WHERE id = 1;
-- 等待 id=2 的锁... ⏳            -- 等待 id=1 的锁... ⏳

-- InnoDB 检测到死锁,自动回滚一个代价较小的事务
-- ERROR 1213 (40001): Deadlock found when trying to get lock;
-- try restarting transaction

4.5.2 死锁排查

sql
-- ==================== 1. 查看最近的死锁信息 ====================
SHOW ENGINE INNODB STATUS\G

-- 关注 LATEST DETECTED DEADLOCK 部分:
-- *** (1) TRANSACTION:    ← 事务1的信息
-- *** (1) WAITING FOR THIS LOCK TO BE GRANTED:
-- *** (2) TRANSACTION:    ← 事务2的信息
-- *** (2) WAITING FOR THIS LOCK TO BE GRANTED:
-- *** WE ROLL BACK TRANSACTION (2)  ← InnoDB 回滚了事务2

-- ==================== 2. 查看当前锁等待 ====================
-- 查看锁等待关系
SELECT * FROM performance_schema.data_lock_waits\G

-- 查看当前所有锁
SELECT * FROM performance_schema.data_locks\G

-- 查看正在执行的事务
SELECT * FROM information_schema.INNODB_TRX\G

-- ==================== 3. 查看锁等待超时 ====================
SHOW VARIABLES LIKE 'innodb_lock_wait_timeout';
-- +----------------------------+-------+
-- | Variable_name              | Value |
-- +----------------------------+-------+
-- | innodb_lock_wait_timeout   | 50    |  ← 默认 50 秒
-- +----------------------------+-------+

-- ==================== 4. 开启死锁检测(默认开启)====================
SHOW VARIABLES LIKE 'innodb_deadlock_detect';
-- +------------------------+-------+
-- | Variable_name          | Value |
-- +------------------------+-------+
-- | innodb_deadlock_detect | ON    |  ← 默认开启
-- +------------------------+-------+

4.5.3 死锁预防策略

避免死锁的最佳实践:

┌────────────────────────────────────────────────────────┐
│  1. 固定加锁顺序                                        │
│     所有事务按相同顺序访问资源(如按 id 从小到大加锁)    │
│                                                        │
│     ❌ 事务A: 锁1→锁2   事务B: 锁2→锁1  → 可能死锁      │
│     ✅ 事务A: 锁1→锁2   事务B: 锁1→锁2  → 不会死锁      │
│                                                        │
│  2. 缩短事务持锁时间                                     │
│     事务尽量短小,快速提交                                │
│     避免在事务中做网络请求、复杂计算                       │
│                                                        │
│  3. 降低隔离级别                                         │
│     RC 隔离级别不使用 Gap Lock,减少锁冲突                │
│                                                        │
│  4. 为表添加合理索引                                     │
│     没有索引 → 表锁 → 更容易死锁                         │
│     有索引 → 行锁 → 冲突范围更小                         │
│                                                        │
│  5. 应用层重试机制                                       │
│     捕获死锁错误(1213),自动重试事务                      │
└────────────────────────────────────────────────────────┘
sql
-- 应用层重试示例(伪代码)
/*
max_retries = 3
for attempt in range(max_retries):
    try:
        begin_transaction()
        -- 业务逻辑
        update_account(1, -200)
        update_account(2, +200)
        commit()
        break  -- 成功则退出
    except DeadlockError:
        rollback()
        if attempt == max_retries - 1:
            raise  -- 重试耗尽,抛出异常
        sleep(0.1 * (attempt + 1))  -- 退避等待
*/

4.6 乐观锁 vs 悲观锁

4.6.1 概念对比

特性悲观锁乐观锁
思想假设冲突会发生,先加锁再操作假设冲突很少发生,提交时才检测
实现数据库行锁(FOR UPDATE版本号 / 时间戳
性能读多写少时较差(锁等待)写冲突少时性能好
适用写冲突频繁读多写少

4.6.2 实现示例

sql
-- ==================== 悲观锁 ====================
-- 通过 FOR UPDATE 加排他锁

START TRANSACTION;

-- 查询并加锁(当前读)
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;
-- → balance = 1000,同时锁定该行

-- 扣减余额
UPDATE accounts SET balance = balance - 200 WHERE id = 1;

COMMIT;
-- 其他事务在 COMMIT 前无法修改 id=1 的行

-- ==================== 乐观锁(版本号方式)====================
-- 表中增加 version 字段

-- 1. 先查询当前版本
SELECT balance, version FROM accounts WHERE id = 1;
-- → balance=1000, version=5

-- 2. 更新时检查版本号
UPDATE accounts
SET balance = balance - 200, version = version + 1
WHERE id = 1 AND version = 5;
-- 如果 affected_rows = 1 → 更新成功
-- 如果 affected_rows = 0 → 被其他事务修改了,需重试

-- ==================== 乐观锁(CAS 方式)====================
-- 不用额外字段,直接用旧值做条件

-- 1. 先查
SELECT balance FROM accounts WHERE id = 1;
-- → balance = 1000

-- 2. CAS 更新
UPDATE accounts
SET balance = 800
WHERE id = 1 AND balance = 1000;
-- 用旧值 1000 做条件,如果被改了就不匹配

4.7 实战练习

实战 Demo 1:模拟不同隔离级别下的并发行为

sql
-- ==================== 准备数据 ====================
CREATE DATABASE IF NOT EXISTS tx_demo;
USE tx_demo;

CREATE TABLE accounts (
    id INT PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(50) NOT NULL,
    balance DECIMAL(10,2) NOT NULL DEFAULT 0.00,
    version INT NOT NULL DEFAULT 0
) ENGINE=InnoDB;

INSERT INTO accounts (name, balance) VALUES
    ('张三', 1000.00),
    ('李四', 2000.00),
    ('王五', 3000.00);

-- ==================== 实验1:READ COMMITTED 下的不可重复读 ====================

-- 终端1:设置 RC 隔离级别
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
BEGIN;
SELECT balance FROM accounts WHERE name = '张三';  -- → 1000.00

-- 终端2:修改并提交
BEGIN;
UPDATE accounts SET balance = 800.00 WHERE name = '张三';
COMMIT;

-- 终端1:再次查询
SELECT balance FROM accounts WHERE name = '张三';  -- → 800.00(不可重复读!)
COMMIT;

-- ==================== 实验2:REPEATABLE READ 下的一致性读 ====================

-- 终端1:设置 RR 隔离级别
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN;
SELECT balance FROM accounts WHERE name = '张三';  -- → 800.00

-- 终端2:修改并提交
BEGIN;
UPDATE accounts SET balance = 500.00 WHERE name = '张三';
COMMIT;

-- 终端1:再次查询
SELECT balance FROM accounts WHERE name = '张三';  -- → 800.00(仍然是 800!MVCC 快照读)
COMMIT;

-- 终端1:事务结束后重新查
SELECT balance FROM accounts WHERE name = '张三';  -- → 500.00(看到最新值)

-- ==================== 实验3:RR 下的幻读场景 ====================

-- 终端1
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN;
SELECT * FROM accounts WHERE balance > 1500;
-- → 李四(2000)、王五(3000),共 2 条

-- 终端2:插入新记录
BEGIN;
INSERT INTO accounts (name, balance) VALUES ('赵六', 2500.00);
COMMIT;

-- 终端1:快照读看不到新插入的记录
SELECT * FROM accounts WHERE balance > 1500;
-- → 仍然 2 条(MVCC 防止了幻读的快照读场景)

-- 终端1:但当前读可以看到!
SELECT * FROM accounts WHERE balance > 1500 FOR UPDATE;
-- → 3 条!(当前读看到最新数据)

COMMIT;

实战 Demo 2:死锁模拟与排查

sql
-- ==================== 准备环境 ====================
USE tx_demo;

CREATE TABLE inventory (
    product_id INT PRIMARY KEY,
    stock INT NOT NULL DEFAULT 0,
    price DECIMAL(10,2) NOT NULL
) ENGINE=InnoDB;

INSERT INTO inventory VALUES (1001, 100, 29.90), (1002, 200, 49.90);

-- ==================== 模拟死锁 ====================

-- 终端1
BEGIN;
UPDATE inventory SET stock = stock - 1 WHERE product_id = 1001;
-- ✅ 锁定 product_id=1001

-- 终端2
BEGIN;
UPDATE inventory SET stock = stock - 1 WHERE product_id = 1002;
-- ✅ 锁定 product_id=1002

-- 终端1(等待终端2释放 1002 的锁)
UPDATE inventory SET stock = stock - 1 WHERE product_id = 1002;
-- ⏳ 等待...

-- 终端2(等待终端1释放 1001 的锁 → 死锁!)
UPDATE inventory SET stock = stock - 1 WHERE product_id = 1001;
-- ERROR 1213 (40001): Deadlock found

-- ==================== 排查死锁 ====================

-- 查看最近一次死锁的详细信息
SHOW ENGINE INNODB STATUS\G

-- 关键输出示例:
-- ========================
-- LATEST DETECTED DEADLOCK
-- ========================
-- *** (1) TRANSACTION:
-- TRANSACTION 12345, ACTIVE 10 sec starting index read
-- *** (1) HOLDS THE LOCK(S):
-- Record lock on `inventory` product_id=1001
-- *** (1) WAITING FOR THIS LOCK TO BE GRANTED:
-- Record lock on `inventory` product_id=1002
--
-- *** (2) TRANSACTION:
-- TRANSACTION 12346, ACTIVE 8 sec starting index read
-- *** (2) HOLDS THE LOCK(S):
-- Record lock on `inventory` product_id=1002
-- *** (2) WAITING FOR THIS LOCK TO BE GRANTED:
-- Record lock on `inventory` product_id=1001
--
-- *** WE ROLL BACK TRANSACTION (2)

-- 查看当前锁信息(MySQL 8.0+)
SELECT
    object_name AS `表名`,
    lock_type AS `锁类型`,
    lock_mode AS `锁模式`,
    lock_data AS `锁定数据`,
    lock_status AS `状态`
FROM performance_schema.data_locks;

-- ==================== 修复:固定加锁顺序 ====================

-- 所有事务按 product_id 从小到大加锁
-- 终端1
BEGIN;
UPDATE inventory SET stock = stock - 1 WHERE product_id = 1001;  -- 先锁小的
UPDATE inventory SET stock = stock - 1 WHERE product_id = 1002;  -- 再锁大的
COMMIT;

-- 终端2
BEGIN;
UPDATE inventory SET stock = stock - 1 WHERE product_id = 1001;  -- 先锁小的(等待终端1释放)
UPDATE inventory SET stock = stock - 1 WHERE product_id = 1002;  -- 再锁大的
COMMIT;

-- → 不会死锁,终端2 等待终端1 完成后顺序执行

4.8 常见问题 QA

Q1:MySQL 默认隔离级别是什么?为什么选择 RR?

A:MySQL 默认隔离级别是 REPEATABLE READ(可重复读)。原因:

  • 历史原因:早期 MySQL 基于 Statement 格式的 Binlog 复制,RC 下可能导致主从数据不一致,RR 配合 Gap Lock 能避免此问题
  • RR 通过 MVCC 提供了一致性读(快照读),在大部分场景下性能和一致性的平衡很好
  • 现在使用 Row 格式 Binlog 后,RC 也是安全的选择,很多公司线上使用 RC

Q2:RR 隔离级别下真的能完全防止幻读吗?

A不能完全防止

  • 快照读(普通 SELECT):通过 MVCC 可以防止幻读
  • 当前读SELECT ... FOR UPDATEINSERTUPDATE):通过 Next-Key Lock(Gap Lock + Record Lock)防止幻读
  • 特殊场景:先快照读再当前读,可能看到不同结果(快照读看不到新插入的行,但当前读能看到)

Q3:MVCC 的 Undo Log 什么时候清理?

A:当没有任何活跃事务需要访问某个版本时,该版本的 Undo Log 才会被 purge 线程清理。所以长事务会导致 Undo Log 不断膨胀,占用大量磁盘空间。建议避免长事务

Q4:共享锁和排他锁的兼容性是什么?

A

共享锁 (S)排他锁 (X)
共享锁 (S)✅ 兼容❌ 冲突
排他锁 (X)❌ 冲突❌ 冲突

简记:读读兼容,读写冲突,写写冲突。

Q5:什么情况下行锁会升级为表锁?

A:InnoDB 不存在锁升级机制。但以下情况等效于表锁:

  • 查询没有走索引 → 全表扫描 → 对所有扫描到的行加锁(相当于表锁)
  • 使用 LOCK TABLES 显式加表锁
  • DDL 操作会加元数据锁(MDL),阻塞所有 DML

Q6:如何减少锁等待和锁冲突?

A

  1. 缩短事务:事务越短,持锁时间越短
  2. 合理使用索引:确保 WHERE 条件走索引,避免全表扫描加锁
  3. 降低隔离级别:RC 比 RR 锁的范围更小(没有 Gap Lock)
  4. 避免大事务:拆分为多个小事务
  5. 固定访问顺序:避免死锁
  6. 使用乐观锁:读多写少场景

Q7:innodb_lock_wait_timeout 和 innodb_deadlock_detect 的区别?

A

  • innodb_lock_wait_timeout:锁等待超时时间(默认 50 秒),超时后返回错误但不回滚事务
  • innodb_deadlock_detect:死锁检测开关(默认 ON),检测到死锁后立即回滚代价最小的事务
  • 高并发场景下死锁检测的 CPU 开销可能很大,可关闭检测,依靠超时机制处理

Q8:FOR UPDATE 和 FOR SHARE(LOCK IN SHARE MODE)的区别?

A

  • FOR UPDATE:加排他锁(X 锁),其他事务不能读(当前读)也不能写
  • FOR SHARE(MySQL 8.0)/ LOCK IN SHARE MODE(5.7):加共享锁(S 锁),其他事务可以加 S 锁读,但不能写
  • 常见用法:FOR UPDATE 用于"查了要改"的场景(如扣库存),FOR SHARE 用于"查了确认存在,但不修改"的场景

Q9:MVCC 下为什么 UPDATE/DELETE 不走快照读?

A:因为 UPDATE/DELETE 需要修改最新数据,如果读旧版本再修改,会导致更新丢失。所以这些操作必须走当前读,读取最新已提交的数据并加锁。

Q10:如何监控当前数据库的锁状态?

A

sql
-- 1. 查看当前所有锁(MySQL 8.0+)
SELECT * FROM performance_schema.data_locks;

-- 2. 查看锁等待关系
SELECT * FROM performance_schema.data_lock_waits;

-- 3. 查看正在运行的事务
SELECT * FROM information_schema.INNODB_TRX;

-- 4. 查看 InnoDB 状态(包含最近死锁信息)
SHOW ENGINE INNODB STATUS\G

-- 5. 查看元数据锁(DDL 阻塞排查)
SELECT * FROM performance_schema.metadata_locks;

4.9 命令速查表

┌───────────────────────────────────────────────────────────────┐
│                   事务与锁 命令速查                             │
├───────────────────────────────────────────────────────────────┤
│  事务控制                                                     │
│  BEGIN / START TRANSACTION     开启事务                       │
│  COMMIT                        提交事务                       │
│  ROLLBACK                      回滚事务                       │
│  SAVEPOINT sp_name             创建保存点                     │
│  ROLLBACK TO SAVEPOINT sp_name 回滚到保存点                   │
│  RELEASE SAVEPOINT sp_name     释放保存点                     │
├───────────────────────────────────────────────────────────────┤
│  隔离级别                                                     │
│  SELECT @@transaction_isolation           查看当前级别        │
│  SET SESSION TRANSACTION ISOLATION        设置会话级别        │
│    LEVEL {READ UNCOMMITTED|READ COMMITTED|                   │
│    REPEATABLE READ|SERIALIZABLE}                             │
│  SET GLOBAL TRANSACTION ISOLATION LEVEL   设置全局级别        │
├───────────────────────────────────────────────────────────────┤
│  加锁语句                                                     │
│  SELECT ... FOR UPDATE          加排他锁(X)                 │
│  SELECT ... FOR SHARE           加共享锁(S,MySQL 8.0)      │
│  SELECT ... LOCK IN SHARE MODE  加共享锁(S,MySQL 5.7)      │
│  LOCK TABLES t READ/WRITE       加表锁                       │
│  UNLOCK TABLES                  释放表锁                      │
│  FLUSH TABLES WITH READ LOCK    加全局读锁                    │
├───────────────────────────────────────────────────────────────┤
│  监控排查                                                     │
│  SHOW ENGINE INNODB STATUS\G              InnoDB 状态+死锁   │
│  SELECT * FROM performance_schema         锁信息             │
│    .data_locks                                               │
│  SELECT * FROM performance_schema         锁等待             │
│    .data_lock_waits                                          │
│  SELECT * FROM information_schema         活跃事务           │
│    .INNODB_TRX                                               │
│  SHOW VARIABLES LIKE 'innodb_lock%'       锁相关变量         │
│  SHOW VARIABLES LIKE 'innodb_deadlock%'   死锁检测配置       │
├───────────────────────────────────────────────────────────────┤
│  自动提交                                                     │
│  SHOW VARIABLES LIKE 'autocommit'    查看自动提交状态        │
│  SET autocommit = 0/1                关闭/开启自动提交       │
└───────────────────────────────────────────────────────────────┘