主题
第四阶段:事务与锁机制
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 不会自动提交,需手动 COMMIT4.1.4 隐式提交
某些语句会隐式提交当前事务,需特别注意:
| 类别 | 隐式提交的语句 |
|---|---|
| DDL 语句 | CREATE TABLE、ALTER TABLE、DROP TABLE、TRUNCATE |
| DCL 语句 | GRANT、REVOKE、CREATE USER |
| 锁定语句 | LOCK TABLES、UNLOCK TABLES |
| 其他 | LOAD DATA INFILE、SET 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_id | 6 字节 | 最近修改该行的事务 ID |
roll_pointer | 7 字节 | 指向 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.sql4.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意向锁兼容性矩阵:
| IS | IX | S | X | |
|---|---|---|---|---|
| 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 transaction4.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 UPDATE、INSERT、UPDATE):通过 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:
- 缩短事务:事务越短,持锁时间越短
- 合理使用索引:确保 WHERE 条件走索引,避免全表扫描加锁
- 降低隔离级别:RC 比 RR 锁的范围更小(没有 Gap Lock)
- 避免大事务:拆分为多个小事务
- 固定访问顺序:避免死锁
- 使用乐观锁:读多写少场景
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 关闭/开启自动提交 │
└───────────────────────────────────────────────────────────────┘