返回文章列表

MySQL 事务与锁深度剖析:从 MVCC 原理到死锁诊断的艺术

M
Moonpeak
| | 50 分钟

一、事务的本质:ACID 的实现内幕

1.1 ACID 不只是概念,更是工程妥协的艺术

事务的 ACID 特性看似简单,但在高并发场景下,一致性(Consistency)隔离性(Isolation)持久性(Durability) 之间存在着深刻的张力。

┌─────────────────────────────────────────────────────────────┐
│ ACID 特性实现机制 │
├─────────────┬───────────────────────────────────────────────┤
│ Atomicity │ undo log + 崩溃恢复(Rollback Segment) │
│ Consistency │ 约束检查 + 触发器 + 存储过程(业务层保障) │
│ Isolation │ MVCC + 锁机制(Locks + Gap Locks) │
│ Durability │ redo log + binlog + force log at commit │
└─────────────┴───────────────────────────────────────────────┘

原子性的真相:

InnoDB 并非真正 “撤销” 已执行的操作,而是通过 undo log 实现逻辑回滚:

-- 假设执行 UPDATE account SET balance = balance - 100 WHERE id = 1;
-- 实际执行流程:
1. 写入 undo log: "将 id=1 的 balance 改回旧值"
┌─────────────────────────────────────┐
│ undo log record │
│ ├─ 表 ID、主键值 (id=1) │
│ ├─ 旧值 (balance=1000) │
│ ├─ 新值 (balance=900) │
│ └─ 回滚指针(指向上一版本) │
└─────────────────────────────────────┘
2. 修改内存中的数据页(balance = 900
3. 如果事务回滚:读取 undo log,执行反向操作
-- 注意:这不是 "恢复",而是 "再执行一次补偿操作"
Important

为什么 InnoDB 选择逻辑回滚而非物理回滚?

  • 物理回滚需要保存完整的页镜像,空间开销巨大
  • 逻辑回滚只需保存变更的列,undo log 更紧凑
  • 支持 MVCC:历史版本可以通过 undo log 链重建

1.2 持久性的代价:两阶段提交的精妙设计

InnoDB 的持久性依赖 redo logbinlog 的双写机制,但这不是简单的 “写两次”。

┌──────────────────────────────────────────────────────────────┐
│ 事务提交的数据流 │
├──────────────────────────────────────────────────────────────┤
│ │
│ 客户端 │
│ │ COMMIT │
│ ▼ │
│ InnoDB 层 ───────────────┐ │
│ │ 1. 写 prepare redo log │ │
│ │ 2. 刷 redo log 到磁盘 │ │
│ ▼ │ │
│ Server 层 ───────────────┤ 两阶段提交(2PC) │
│ │ 3. 写 binlog │ ┌─────────────────────────┐ │
│ │ 4. 刷 binlog 到磁盘 │ │ 崩溃恢复时的协调逻辑 │ │
│ ▼ │ │ │ │
│ InnoDB 层 ───────────────┘ │ redo prepare + binlog 完整│ │
│ │ 5. 写 commit redo log │ → 提交事务 │ │
│ ▼ │ │ │
│ 返回客户端成功 │ redo prepare + binlog 缺失│ │
│ │ → 回滚事务 │ │
│ │ │ │
│ │ redo 已提交 │ │
│ │ → 无需处理(已持久化) │ │
│ └─────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘

为什么需要两阶段提交?

假设没有 2PC,直接分别写 redo log 和 binlog:

崩溃时机redo log 状态binlog 状态问题
写 redo 后,写 binlog 前已提交无记录主库恢复后数据存在,但从库无此记录 → 主从不一致
写 binlog 后,写 redo 前无记录已记录主库恢复后数据不存在,但从库执行了此操作 → 主从不一致
Tip

生产环境参数建议:

# 每次事务提交都刷 redo log(默认)
innodb_flush_log_at_trx_commit = 1
# 每次事务提交都刷 binlog(默认)
sync_binlog = 1
# 此配置下,TPS 会受磁盘 IOPS 限制(约 2000-5000/s)
# 如需更高吞吐,可设为 2(每秒刷盘)但会丢失 1 秒数据

二、隔离级别的底层实现:MVCC 与锁的协奏曲

2.1 MVCC:多版本并发控制的时空魔法

MVCC(Multi-Version Concurrency Control)是 InnoDB 实现高并发的核心,它让 读操作不加锁 成为可能。

┌─────────────────────────────────────────────────────────────┐
│ MVCC 版本链示意 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 事务 100 (活跃) │
│ │ INSERT INTO t (id, name) VALUES (1, 'Alice'); │
│ ▼ │
│ ┌─────────────────────────────────────────┐ │
│ │ 行记录 (id=1) │ │
│ │ ├─ DB_TRX_ID: 100 (创建者事务 ID) │ │
│ │ ├─ DB_ROLL_PTR: ──────┐ │ │
│ │ ├─ name: 'Alice' │ │ │
│ │ └─ ... │ │ │
│ └───────────────────────┼─────────────────┘ │
│ │ │
│ ▼ │
│ 事务 200 UPDATE t SET name = 'Bob' WHERE id = 1; │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ undo log │ │
│ │ ├─ old trx_id│ │
│ │ ├─ old name │ │
│ │ └─ prev ptr │───→ null │
│ └──────────────┘ │
│ │
│ 当前行: DB_TRX_ID=200, name='Bob', DB_ROLL_PTR→undo log │
│ │
└─────────────────────────────────────────────────────────────┘

Read View:事务的快照宇宙

当一个事务启动时,InnoDB 会创建一个 Read View,它决定了事务能看到哪些版本的数据:

// Read View 结构(简化)
struct ReadView {
trx_id_t m_creator_trx_id; // 创建者事务 ID
trx_id_t m_up_limit_id; // 低水位 = 活跃事务最小 ID
trx_id_t m_low_limit_id; // 高水位 = 下一个分配的事务 ID
ids_t m_ids; // 创建时的活跃事务 ID 列表
};
// 判断数据版本可见性的算法
template <typename Index>
bool ReadView::changes_visible(trx_id_t id, const Index& index) {
if (id < m_up_limit_id) {
// 事务 ID 小于低水位:在 Read View 创建前已提交,可见
return true;
}
if (id >= m_low_limit_id) {
// 事务 ID 大于等于高水位:在 Read View 创建后启动,不可见
return false;
}
// 在低水位和高水位之间:检查是否在活跃列表中
return !m_ids.find(id); // 不在活跃列表中 → 已提交,可见
}

不同隔离级别的 Read View 策略:

隔离级别Read View 创建时机特性
READ UNCOMMITTED不创建直接读取最新版本,不管是否提交
READ COMMITTED每条 SELECT 创建新 Read View能看到其他事务已提交的最新数据
REPEATABLE READ事务第一条 SELECT 创建 Read View整个事务看到一致的快照
SERIALIZABLE不依赖 MVCC所有 SELECT 加共享锁
Note

RR 级别下的幻读问题:

-- 事务 A
BEGIN;
SELECT * FROM t WHERE id > 10; -- 创建 Read View,假设返回 5 条
-- 此时事务 B 插入 id=20 并提交
SELECT * FROM t WHERE id > 10; -- 仍是 5 条(MVCC 保证)
UPDATE t SET ... WHERE id = 20; -- 成功!因为记录实际存在
SELECT * FROM t WHERE id > 10; -- 突然变成 6 条!(幻读)

这是 MVCC 的局限性:当前读(UPDATE/DELETE/SELECT FOR UPDATE)会读取最新版本,不受 Read View 限制。

2.2 锁的家族图谱:从表级到行级

InnoDB 的锁是一个多层次、多粒度的体系:

┌─────────────────────────────────────────────────────────────┐
│ InnoDB 锁分类 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┬─────────────────────────────────────────┐ │
│ │ 按粒度分 │ │ │
│ ├─────────────┤ 表级锁 (Table Lock) │ │
│ │ │ ├─ 意向共享锁 (IS) │ │
│ │ │ ├─ 意向排他锁 (IX) │ │
│ │ │ └─ 自增锁 (AUTO-INC) │ │
│ │ │ │ │
│ │ │ 行级锁 (Record Lock) │ │
│ │ │ ├─ 共享锁 (S) / 排他锁 (X) │ │
│ │ │ ├─ 间隙锁 (Gap Lock) │ │
│ │ │ └─ 临键锁 (Next-Key Lock) │ │
│ └─────────────┴─────────────────────────────────────────┘ │
│ │
│ ┌─────────────┬─────────────────────────────────────────┐ │
│ │ 按属性分 │ │ │
│ ├─────────────┤ 共享锁 (S) - SELECT ... LOCK IN SHARE MODE │
│ │ │ 排他锁 (X) - INSERT/UPDATE/DELETE/FOR UPDATE│
│ └─────────────┴─────────────────────────────────────────┘ │
│ │
│ ┌─────────────┬─────────────────────────────────────────┐ │
│ │ 按算法分 │ │ │
│ ├─────────────┤ 记录锁 (Record Lock) - 锁定具体行 │ │
│ │ │ 间隙锁 (Gap Lock) - 锁定范围间隙 │ │
│ │ │ 临键锁 (Next-Key Lock) - Record + Gap │ │
│ │ │ 插入意向锁 (Insert Intention) - 插入时 │ │
│ │ │ 谓词锁 (Predicate Lock) - 空间索引专用 │ │
│ └─────────────┴─────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘

意向锁:表级与行级锁的协调者

意向锁不是为了锁定表,而是为了 快速判断是否可以安全地对表加锁

-- 场景:事务想对表加表级排他锁(如 ALTER TABLE)
-- 需要检查:是否有事务持有该表的行级锁?
-- 无意向锁时:需要遍历所有行,检查是否有锁 → O(n)
-- 有意向锁时:检查表级 IX 锁即可 → O(1)
-- 意向锁的互斥矩阵:
┌─────────┬────────┬────────┬────────┬────────┐
│ │ IS │ IX │ S │ X │
├─────────┼────────┼────────┼────────┼────────┤
IS │ ✓ │ ✓ │ ✓ │ ✗ │
│ IX │ ✓ │ ✓ │ ✗ │ ✗ │
│ S │ ✓ │ ✗ │ ✓ │ ✗ │
│ X │ ✗ │ ✗ │ ✗ │ ✗ │
└─────────┴────────┴────────┴────────┴────────┘

行锁的存储:不是 “锁表”,而是 “锁堆”

InnoDB 不在内存中维护一张 “锁表”,而是直接在数据页的行记录上标记锁信息:

┌─────────────────────────────────────────────────────────────┐
│ 行记录中的锁信息 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 记录头 (Record Header) - 5 bytes │
│ ├─ delete_mask (1 bit): 是否已删除 │
│ ├─ min_rec_mask (1 bit): B+ 树非叶子节点最小记录 │
│ ├─ n_owned (4 bits): 该组拥有的记录数 │
│ ├─ heap_no (13 bits): 堆中位置编号 │
│ ├─ record_type (3 bits): 记录类型 │
│ ├─ next_record (16 bits): 下一条记录偏移 │
│ └─ 【隐式锁标记】← 事务 ID 在这里体现 │
│ │
│ 隐藏列: │
│ ├─ DB_ROW_ID (6 bytes): 隐藏主键 │
│ ├─ DB_TRX_ID (6 bytes): 最后修改的事务 ID ← 判断是否被锁 │
│ └─ DB_ROLL_PTR (7 bytes): 回滚指针 │
│ │
│ 【锁的获取流程】 │
│ 1. 检查行记录的 DB_TRX_ID │
│ 2. 如果 DB_TRX_ID 对应的事务已提交 → 无锁,可以获取 │
│ 3. 如果事务未提交 → 需要等待,创建显式锁结构加入等待队列 │
│ │
└─────────────────────────────────────────────────────────────┘

2.3 临键锁:解决幻读的终极武器

Next-Key Lock 是 InnoDB 在 REPEATABLE READ 级别解决幻读的核心机制:

┌─────────────────────────────────────────────────────────────┐
│ 临键锁锁定范围示意 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 索引记录: [10] [20] [30] [40] │
│ └──┬──┘───┬──┘───┬──┘───┬──┘ │
│ ↓ ↓ ↓ ↓ │
│ 间隙区间: (-∞,10] (10,20] (20,30] (30,40] (40,+∞) │
│ │
│ SELECT * FROM t WHERE id = 20 FOR UPDATE; │
│ → 锁定 (10, 20] 这个临键区间 │
│ │
│ SELECT * FROM t WHERE id > 15 AND id < 25 FOR UPDATE; │
│ → 锁定 (10, 20] 和 (20, 30] 两个临键区间 │
│ = 实际上锁住了 (10, 30] │
│ │
└─────────────────────────────────────────────────────────────┘

临键锁的退化场景:

-- 1. 唯一索引等值查询,记录存在 → 退化为记录锁
SELECT * FROM t WHERE id = 20 FOR UPDATE; -- 只锁 id=20 这一行
-- 2. 唯一索引等值查询,记录不存在 → 退化为间隙锁
SELECT * FROM t WHERE id = 15 FOR UPDATE; -- 锁 (10, 20) 间隙
-- 3. 非唯一索引 → 临键锁
SELECT * FROM t WHERE name = 'Alice' FOR UPDATE; -- 锁间隙+记录
-- 4. 范围查询 → 临键锁
SELECT * FROM t WHERE id > 10 FOR UPDATE; -- 锁 (10, 20], (20, 30], ...

三、死锁:并发世界的死结与破局

3.1 死锁产生的必要条件与经典模式

死锁的发生必须同时满足四个条件,而在数据库中,循环等待 是最常见的诱因:

┌─────────────────────────────────────────────────────────────┐
│ 死锁的四个必要条件 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. 互斥条件 (Mutual Exclusion) │
│ → 锁的独占性,排他锁天然满足 │
│ │
│ 2. 占有且等待 (Hold and Wait) │
│ → 事务持有一个锁,又请求另一个锁 │
│ │
│ 3. 不可抢占 (No Preemption) │
│ → 锁只能由持有者主动释放,不能被强制剥夺 │
│ │
│ 4. 循环等待 (Circular Wait) │
│ → 事务之间形成等待环路 │
│ │
└─────────────────────────────────────────────────────────────┘

经典死锁场景分析:

-- 场景 1: 反向更新顺序(最常见)
-- 表: accounts (id PK, balance)
-- 数据: (1, 1000), (2, 2000)
事务 A 事务 B
BEGIN; BEGIN;
UPDATE accounts UPDATE accounts
SET balance = 900 SET balance = 1900
WHERE id = 1; ──────────────→ WHERE id = 2;
-- 获取 id=2 的 X 锁 ✓
UPDATE accounts UPDATE accounts
SET balance = 2100 SET balance = 800
WHERE id = 2; ─────── 等待 ───→ WHERE id = 1;
-- 等待 B 释放 id=2 -- 等待 A 释放 id=1
-- ↓ 死锁发生 ↓
-- 场景 2: 间隙锁导致的死锁(更隐蔽)
-- 表: t (id PK, c INT, INDEX idx_c(c))
-- 数据: (1, 1), (5, 5), (10, 10)
事务 A 事务 B
BEGIN; BEGIN;
SELECT * FROM t SELECT * FROM t
WHERE c = 3 FOR UPDATE; WHERE c = 4 FOR UPDATE;
-- c=3 不存在,获得 (1, 5) 间隙锁 -- c=4 不存在,获得 (1, 5) 间隙锁
-- 间隙锁不互斥,都成功 ✓
INSERT INTO t(c) VALUES (3); INSERT INTO t(c) VALUES (4);
-- 需要 (1, 5) 的插入意向锁 -- 需要 (1, 5) 的插入意向锁
-- 等待 B 的间隙锁释放 -- 等待 A 的间隙锁释放
-- ↓ 死锁发生 ↓

3.2 死锁检测与超时机制

InnoDB 提供了两种处理死锁的方式:

┌─────────────────────────────────────────────────────────────┐
│ 死锁处理机制对比 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 主动检测 (innodb_deadlock_detect = ON, 默认) │ │
│ ├─────────────────────────────────────────────────────┤ │
│ │ │ │
│ │ 机制: 构建等待图(Wait-for Graph),检测环路 │ │
│ │ │ │
│ │ 等待图示意: │ │
│ │ T1 ──等待──→ R2 ──被持有──→ T2 │ │
│ │ ↑ │ │ │
│ │ └── 持有 R1 ←── 等待 R1 ─────┘ │ │
│ │ │ │
│ │ 检测到环路后,选择 "代价最小" 的事务回滚 │ │
│ │ - 回滚的 undo log 最少 │ │
│ │ - 或事务开始时间较晚 │ │
│ │ │ │
│ │ 代价: 高并发下,检测算法 O(n²),可能成为 CPU 瓶颈 │ │
│ │ │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 超时等待 (innodb_lock_wait_timeout = 50s) │ │
│ ├─────────────────────────────────────────────────────┤ │
│ │ │ │
│ │ 机制: 锁等待超时时,回滚当前事务 │ │
│ │ │ │
│ │ 适用: 关闭 deadlock_detect,纯超时机制 │ │
│ │ 场景: 超高并发写入(如批量导入),避免检测开销 │ │
│ │ │ │
│ │ 风险: 可能误判(非死锁的超时也回滚) │ │
│ │ │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘

生产环境死锁监控:

-- 查看最近一次死锁信息
SHOW ENGINE INNODB STATUS\G
-- ------------------------
-- LATEST DETECTED DEADLOCK
-- ------------------------
-- *** (1) TRANSACTION:
-- TRANSACTION 12345, ACTIVE 12 sec starting index read
-- mysql tables in use 1, locked 1
-- LOCK WAIT 2 lock struct(s), heap size 1136, 1 row lock(s)
-- MySQL thread id 100, OS thread handle 123456789
-- ...
-- MySQL 8.0: 使用 performance_schema 持久化死锁信息
SELECT
THREAD_ID,
EVENT_ID,
OBJECT_SCHEMA,
OBJECT_NAME,
LOCK_TYPE,
LOCK_MODE,
LOCK_STATUS
FROM performance_schema.data_locks
WHERE THREAD_ID IN (
SELECT THREAD_ID
FROM performance_schema.events_transactions_current
WHERE STATE = 'ACTIVE'
);
-- 启用死锁日志记录到错误日志
SET GLOBAL innodb_print_all_deadlocks = ON;

3.3 死锁预防:从架构到代码的实践

┌─────────────────────────────────────────────────────────────┐
│ 死锁预防策略全景 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. 访问顺序规范化(最有效) │
│ └── 所有事务按相同顺序访问资源 │
│ 例: 转账时,先锁 ID 小的账户 │
│ │
│ 2. 减少事务粒度 │
│ └── 将大事务拆分为小事务,减少持有锁时间 │
│ 例: 批量更新改为分批处理 │
│ │
│ 3. 降低隔离级别 │
│ └── RC 级别不使用间隙锁,减少死锁概率 │
│ 注意: 可能引入幻读问题 │
│ │
│ 4. 使用乐观锁替代悲观锁 │
│ └── UPDATE t SET ... WHERE id = 1 AND version = 10; │
│ 通过版本号控制并发,无需加锁 │
│ │
│ 5. 超时重试机制 │
│ └── 应用层捕获死锁异常,自动重试 │
│ 注意: 幂等性设计,避免重复执行 │
│ │
│ 6. 索引优化 │
│ └── 全表扫描会导致锁住更多行 │
│ 确保 WHERE 条件走索引,减少锁范围 │
│ │
└─────────────────────────────────────────────────────────────┘

死锁预防代码示例:

// Java 示例:统一资源访问顺序
public void transfer(Long fromId, Long toId, BigDecimal amount) {
// 统一按 ID 排序,确保所有事务获取锁的顺序一致
Long firstId = Math.min(fromId, toId);
Long secondId = Math.max(fromId, toId);
jdbcTemplate.execute(conn -> {
// 先锁 ID 小的
Account first = accountDao.selectForUpdate(conn, firstId);
// 再锁 ID 大的
Account second = accountDao.selectForUpdate(conn, secondId);
// 执行转账逻辑
if (fromId.equals(firstId)) {
first.debit(amount);
second.credit(amount);
} else {
second.debit(amount);
first.credit(amount);
}
accountDao.update(conn, first);
accountDao.update(conn, second);
return null;
});
}

四、生产环境诊断:从现象到本质的排查艺术

4.1 锁等待分析的完整链路

-- 步骤 1: 查看当前锁等待情况
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 b ON b.trx_id = w.blocking_trx_id
JOIN information_schema.innodb_trx r ON r.trx_id = w.requesting_trx_id;
-- 步骤 2: 查看详细的锁信息(MySQL 8.0)
SELECT
trx.trx_id,
trx.trx_state,
trx.trx_started,
TIMESTAMPDIFF(SECOND, trx.trx_started, NOW()) as trx_seconds,
sql.sql_text
FROM performance_schema.events_transactions_current trx
LEFT JOIN performance_schema.events_statements_current sql
ON trx.thread_id = sql.thread_id
WHERE trx.trx_state = 'ACTIVE';
-- 步骤 3: 查看谁持有了什么锁
SELECT
OBJECT_SCHEMA,
OBJECT_NAME,
OBJECT_INSTANCE_BEGIN,
LOCK_TYPE,
LOCK_MODE,
LOCK_STATUS,
LOCK_DATA
FROM performance_schema.data_locks
WHERE LOCK_STATUS = 'GRANTED';

4.2 慢事务的追踪与优化

-- 查找运行时间超过 10 秒的事务
SELECT
trx_id,
trx_mysql_thread_id,
trx_state,
trx_started,
TIMESTAMPDIFF(SECOND, trx_started, NOW()) as trx_seconds,
LEFT(trx_query, 100) as query_preview
FROM information_schema.innodb_trx
WHERE trx_started < DATE_SUB(NOW(), INTERVAL 10 SECOND)
ORDER BY trx_started;
-- 查找未提交的变更(长事务的危害)
SELECT
trx_id,
trx_mysql_thread_id,
TRX_ISOLATION_LEVEL,
TRX_TABLES_LOCKED,
TRX_ROWS_LOCKED,
TRX_UNDO_LOGS -- undo log 段数量,反映回滚代价
FROM information_schema.innodb_trx
WHERE trx_state = 'RUNNING';
Warning

长事务的危害:

  1. 占用 undo log,导致历史版本无法清理,表空间膨胀
  2. 阻塞 Purge 线程,影响数据页回收
  3. 持有锁不释放,阻塞其他事务
  4. 可能导致复制延迟(从库需执行同样时长的事务)

五、高频面试题深度解析

5.1 事务隔离级别与实现

Q1: MySQL 的默认隔离级别是什么?为什么不是 READ COMMITTED?

参考答案要点:
1. 默认隔离级别是 REPEATABLE READ(可重复读)
2. 历史原因:
- Oracle、SQL Server 默认是 READ COMMITTED
- MySQL 选择 RR 是因为早期 binlog 格式为 STATEMENT 时
RC 级别下主从复制会出现不一致
3. RR 的实现机制:
- MVCC:通过 Read View 保证快照读的一致性
- 临键锁(Next-Key Lock):通过锁定范围防止幻读
4. 现代推荐:
- binlog_format = ROW 时,可以安全使用 RC
- RC 级别减少间隙锁,降低死锁概率
- 但 RC 会出现幻读,需业务层接受或额外处理

Q2: MVCC 能解决幻读吗?

参考答案要点:
1. MVCC 能解决快照读的幻读:
- 同一事务内多次快照读,Read View 不变
- 看不到其他事务插入的新记录
2. MVCC 不能解决当前读的幻读:
- UPDATE、DELETE、SELECT FOR UPDATE 会读取最新版本
- 例:UPDATE t SET x=1 WHERE id=10(id=10 之前不存在)
如果其他事务插入了 id=10,UPDATE 会影响这条记录
3. 当前读的幻读防护:
- RR 级别下使用临键锁(Next-Key Lock)
- 锁定查询范围及间隙,阻止其他事务插入
4. 总结:
- 快照读(普通 SELECT):MVCC 保证无幻读
- 当前读(FOR UPDATE/UPDATE):间隙锁保证无幻读

5.2 锁机制深度追问

Q3: InnoDB 的行锁是如何实现的?

参考答案要点:
1. 行锁不是 "锁表",而是标记在数据页的行记录上
2. 实现机制:
- 行记录包含隐藏列 DB_TRX_ID(6 bytes)
- DB_TRX_ID 标记最后修改该行的事务 ID
- 锁冲突判断通过比较 DB_TRX_ID 实现
3. 锁的内存结构(lock_t):
- 每个锁对象包含:事务信息、锁模式、索引信息、行位图
- 相同页上的多行锁会被合并为一个锁对象(位图表示)
4. 加锁流程:
a. 检查行记录的 DB_TRX_ID
b. 如果对应事务已提交 → 无冲突,获取锁
c. 如果事务未提交 → 创建锁等待,加入等待队列
5. 锁的释放:
- 事务提交或回滚时统一释放
- 非两阶段锁,可以在事务中间释放(但 InnoDB 不这样做)

Q4: 什么是间隙锁(Gap Lock)?什么情况下会用到?

参考答案要点:
1. 间隙锁定义:
- 锁定索引记录之间的 "间隙",而不是记录本身
- 防止幻读,阻止其他事务在间隙中插入数据
2. 使用场景:
- RR 隔离级别下,非唯一索引的等值查询
- RR 隔离级别下,范围查询
- 显式使用 SELECT ... FOR UPDATE 或 LOCK IN SHARE MODE
3. 间隙锁的互斥性:
- 间隙锁之间不互斥(多个事务可同时持有同一间隙的间隙锁)
- 间隙锁与插入意向锁互斥
4. 退化情况:
- 唯一索引等值查询且记录存在 → 退化为记录锁
- 唯一索引等值查询且记录不存在 → 退化为间隙锁
5. 关闭间隙锁:
- 隔离级别降为 RC
- 或设置 innodb_locks_unsafe_for_binlog = 1(已废弃)

5.3 死锁与性能优化

Q5: 如何排查和解决死锁问题?

参考答案要点:
1. 死锁信息收集:
- SHOW ENGINE INNODB STATUS 查看 LATEST DETECTED DEADLOCK
- 开启 innodb_print_all_deadlocks 记录到错误日志
- MySQL 8.0 使用 performance_schema.data_locks
2. 死锁分析步骤:
a. 确定涉及哪些表和索引
b. 分析事务的执行顺序
c. 找出循环等待的资源
d. 理解业务逻辑,找到根源
3. 常见死锁场景及解决:
- 反向更新顺序 → 统一访问顺序
- 间隙锁冲突 → 降低隔离级别或避免范围更新
- 唯一键冲突 → 先查询再插入,或捕获异常重试
- 批量操作 → 分批处理,减少事务粒度
4. 预防策略:
- 事务尽可能短
- 访问顺序统一
- 索引优化,减少锁范围
- 应用层重试机制

Q6: SELECT FOR UPDATE 和 UPDATE 的锁有什么区别?

参考答案要点:
1. 锁类型相同:
- 两者都获取排他锁(X 锁)
- 在 RR 级别下都可能使用临键锁
2. 获取时机不同:
- SELECT FOR UPDATE:执行时立即获取锁
- UPDATE:找到匹配记录时获取锁
3. 行数差异:
- SELECT FOR UPDATE:锁住的行数与扫描行数相关
- UPDATE:只锁住实际更新的行(但扫描过程可能加锁)
4. 使用场景:
- SELECT FOR UPDATE:先读后写,确保读取的是最新值
- UPDATE:直接更新,无需显式加锁
5. 注意事项:
- 无索引的 UPDATE 会升级为表锁
- SELECT FOR UPDATE 在 RC 级别下会锁定不满足条件的行(直到遇到不满足的)

5.4 高级进阶问题

Q7: 什么是隐式锁(Implicit Lock)?

参考答案要点:
1. 隐式锁定义:
- 事务修改记录时,不立即创建锁对象
- 通过行记录的 DB_TRX_ID 标记修改者
2. 工作原理:
- 事务 A 插入/修改记录 → 设置 DB_TRX_ID = A 的事务 ID
- 事务 B 尝试加锁 → 检查 DB_TRX_ID 对应的事务状态
- 如果 A 未提交 → B 创建显式锁等待,A 的隐式锁升级为显式锁
3. 优势:
- 减少锁对象数量,节省内存
- 无冲突时零额外开销
4. 隐式锁升级为显式锁的场景:
- 其他事务尝试加锁时
- 事务自身执行 LOCK IN SHARE MODE 等显式加锁
5. 注意:
- 隐式锁只存在于聚簇索引
- 二级索引的加锁需要通过聚簇索引回查

Q8: 为什么唯一索引插入会出现死锁?

参考答案要点:
1. 唯一性检查机制:
- 插入前需要检查唯一约束
- 检查时会加共享锁(S 锁)或 Next-Key Lock
2. 典型死锁场景:
-- 表 t (id PK, uk INT UNIQUE)
-- 已有数据: (1, 1), (3, 3)
事务 A: INSERT INTO t VALUES (2, 2); -- 申请 (1,3) 的插入意向锁
事务 B: INSERT INTO t VALUES (2, 2); -- 同样申请 (1,3) 的插入意向锁
-- 发现重复值,需要加 S 锁检查
-- 互相等待对方的插入意向锁释放
-- ↓ 死锁
3. 解决方案:
- 应用层先去重
- 使用 INSERT IGNORE 或 ON DUPLICATE KEY UPDATE
- 捕获死锁异常重试
4. 更隐蔽的场景:
- 唯一索引 + 自增主键并发插入
- 自增锁(AUTO-INC Lock)与行锁的冲突

六、架构设计中的事务抉择

6.1 隔离级别的业务选型

┌─────────────────────────────────────────────────────────────┐
│ 隔离级别选型决策树 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 业务是否允许幻读? │
│ │ │
│ ├─ 是(如日志记录、统计报表) │
│ │ └── READ COMMITTED │
│ │ ├─ 优点: 锁少,并发高,死锁少 │
│ │ └─ 注意: 不可重复读,幻读 │
│ │ │
│ └─ 否(如金融交易、库存扣减) │
│ └── REPEATABLE READ │
│ ├─ 优点: 可重复读,防幻读 │
│ └─ 代价: 间隙锁增加死锁风险 │
│ │
│ 极端一致性要求? │
│ │ │
│ └─ 是(如银行核心账务) │
│ └── SERIALIZABLE │
│ └─ 所有 SELECT 加 S 锁,完全串行 │
│ │
└─────────────────────────────────────────────────────────────┘

6.2 分布式事务的抉择

┌─────────────────────────────────────────────────────────────┐
│ 分布式事务方案对比 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ XA 事务 (2PC) │ │
│ ├─────────────────────────────────────────────────────┤ │
│ │ 实现: XA START / XA END / XA PREPARE / XA COMMIT │ │
│ │ 优点: 强一致性,标准协议 │ │
│ │ 缺点: 阻塞协议,单点故障,性能差 │ │
│ │ 适用: 对一致性要求极高的金融场景 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 本地消息表(可靠消息最终一致) │ │
│ ├─────────────────────────────────────────────────────┤ │
│ │ 实现: 业务表 + 消息表,本地事务保证 │ │
│ │ 优点: 非侵入,性能较好 │ │
│ │ 缺点: 需要消息幂等,时效性较差 │ │
│ │ 适用: 电商订单、支付通知 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ TCC (Try-Confirm-Cancel) │ │
│ ├─────────────────────────────────────────────────────┤ │
│ │ 实现: 业务层实现预留、确认、取消三阶段 │ │
│ │ 优点: 性能高,无全局锁 │ │
│ │ 缺点: 业务侵入性强,开发复杂度高 │ │
│ │ 适用: 短事务、高并发场景(如优惠券发放) │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Saga 模式 │ │
│ ├─────────────────────────────────────────────────────┤ │
│ │ 实现: 长事务拆分为本地事务 + 补偿操作 │ │
│ │ 优点: 适合长流程业务(如出行预订) │ │
│ │ 缺点: 最终一致,补偿逻辑复杂 │ │
│ │ 适用: 业务流程长、需要人工介入的场景 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘

七、总结与进阶路线

核心知识点回顾

┌─────────────────────────────────────────────────────────────┐
│ 事务与锁知识图谱 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 基础层 │
│ ├── ACID 的实现原理(undo/redo/binlog) │
│ ├── 两阶段提交的协调逻辑 │
│ └── crash recovery 流程 │
│ │
│ 并发控制层 │
│ ├── 隔离级别与问题(脏读/不可重复读/幻读) │
│ ├── MVCC 原理(Read View + 版本链) │
│ └── 快照读 vs 当前读 │
│ │
│ 锁机制层 │
│ ├── 锁的类型(S/X/IS/IX) │
│ ├── 锁的粒度(表锁/行锁) │
│ ├── 锁的算法(Record/Gap/Next-Key) │
│ └── 锁的存储(隐式锁/显式锁) │
│ │
│ 死锁处理层 │
│ ├── 死锁产生条件与经典场景 │
│ ├── 死锁检测(等待图算法) │
│ └── 死锁预防与诊断 │
│ │
│ 生产实践层 │
│ ├── 慢事务/锁等待分析 │
│ ├── 隔离级别选型 │
│ └── 分布式事务方案 │
│ │
└─────────────────────────────────────────────────────────────┘

面试建议

  1. 理解原理而非背诵概念:能说清楚 MVCC 的 Read View 判断流程,比背诵定义更有说服力

  2. 结合实际场景:举例说明什么情况下会出现幻读、死锁,以及如何排查

  3. 展现工程思维:讨论隔离级别选型时,能权衡一致性与性能的关系

  4. 深入源码加分:了解锁的内存结构、undo log 的物理存储等底层实现


参考资料:

相关文章: