MySQL 事务与隔离级别:锁、MVCC 与死锁排查
本文目录
事务是数据库区别于"带 SQL 接口的文件"的分水岭,但它的行为在隔离级别这一层开始变得反直觉:同一条 SELECT 在不同级别下看到的数据不一样,两个看似无害的事务可能互相锁死。这篇文章从 ACID 的实现机制讲到四隔离级别、MVCC、间隙锁,最后给出一套死锁排查与预防的操作流程。全部以 InnoDB 为准,示例可直接在本地 MySQL 8.x 复现。
先给出贯穿全文的示例表:
CREATE TABLE orders (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT UNSIGNED NOT NULL,
status TINYINT NOT NULL,
amount DECIMAL(12,2) NOT NULL,
version INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL,
KEY idx_user_status_created (user_id, status, created_at)
) ENGINE=InnoDB;
ACID 的落地:每个性质都有具体组件
| 性质 | 含义 | InnoDB 实现 |
|---|---|---|
| A 原子性 | 全做或全不做 | undo log:回滚时按反向逻辑补偿(insert 的 undo 是 delete) |
| C 一致性 | 约束不被破坏 | 由 A、I、D 共同保障,加上主外键/唯一约束 |
| I 隔离性 | 并发事务互不干扰 | MVCC + 锁(本文主体) |
| D 持久性 | 提交即落盘 | redo log:WAL(先写日志再写数据页),崩溃后重放恢复 |
两个日志的分工值得说透:redo log 保证已提交的丢不了,undo log 保证未提交的可撤销。redo 是顺序写、页修改是随机写,WAL 让提交只等一次顺序刷盘,这是 InnoDB 高吞吐的关键。一个可验证的边界:innodb_flush_log_at_trx_commit = 1(默认)是"每次提交刷盘,宕机不丢已提交事务";调成 2 或 0 是性能换安全,可能丢最近一秒的提交——金融类业务不能碰这个参数。
四个隔离级别与三种读异常
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 说明 |
|---|---|---|---|---|
| READ UNCOMMITTED | 可能 | 可能 | 可能 | 能读到别人未提交的数据,几乎不用 |
| READ COMMITTED(RC) | 不可能 | 可能 | 可能 | Oracle/PostgreSQL 默认,每次读取新快照 |
| REPEATABLE READ(RR) | 不可能 | 不可能 | InnoDB 基本杜绝 | MySQL 默认 |
| SERIALIZABLE | 不可能 | 不可能 | 不可能 | 读也加共享锁,完全串行语义 |
三种读异常用同一个两窗口实验来定义(左列会话 1、右列会话 2,自上而下按时间执行):
-- 会话 1(事务 A) -- 会话 2(事务 B)
BEGIN;
SELECT amount FROM orders WHERE id = 1;
-- 返回 100
BEGIN;
UPDATE orders SET amount = 60 WHERE id = 1;
COMMIT;
SELECT amount FROM orders WHERE id = 1;
-- 第二次读的结果按隔离级别分岔:
-- 脏读(RU):把这条 SELECT 放在 B 提交前执行,也能读到 60(读到未提交数据)
-- 不可重复读(RC):读到 60;RR:仍读到 100(第一次快照)
COMMIT;
-- 幻读是行集合版本的同款问题:
-- B 在两次 SELECT 之间 INSERT 了满足 WHERE 的新行,第二次结果集多出一行
MySQL 选 RR 为默认是有历史原因的(早期基于语句的复制需要确定性读),实际业务里 RC 用得同样多——RC 无间隙锁(大部分场景)、死锁概率更低、且"提交读"语义与多数业务直觉一致。从其他数据库迁移到 MySQL 的团队要注意这个默认差异,跨库行为对齐通常显式声明级别:
-- 查看与设置隔离级别(8.0 的变量名是 transaction_isolation)
SELECT @@transaction_isolation;
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
MVCC:版本链与 ReadView
InnoDB 用多版本并发控制实现"读不阻塞写、写不阻塞读"。每行有两个隐藏列:DB_TRX_ID(最近修改它的事务 ID)和 DB_ROLL_PTR(指向 undo log 里的旧版本)。一行被反复修改后,undo log 里形成一条版本链。
-- 可以直接看到隐藏列(仅调试用)
SELECT id, amount,
DB_TRX_ID, DB_ROLL_PTR
FROM orders WHERE id = 1;
读的时候拿到哪个版本,由 ReadView 的可见性规则决定:
| 判断 | 结果 |
|---|---|
| 行的 trx_id 等于当前事务自己的 ID | 自己改的,可见 |
| trx_id < ReadView 里的最小活跃事务 ID | 改它的事务已提交,可见 |
| trx_id > ReadView 里的最大事务 ID | 是开始快照之后的事务,不可见 |
| trx_id 在活跃列表里 | 还没提交,不可见,顺版本链找旧版本 |
RC 与 RR 的全部区别就一行字:RC 每条语句生成新 ReadView,RR 整个事务用第一次快照。所以 RR 下"开事务后再读,别人提交了你也看不见"——这不是 bug,是快照语义。由此推论一个常见现象:RR 事务里先 SELECT(旧快照),再 UPDATE(当前读看到新行),再 SELECT 又像"幻读"了——update 是当前读,会刷新该行可见性,混用快照读与当前读时行为要以"哪条语句是什么读"来分析。
当前读 vs 快照读:什么时候绕过 MVCC
-- 快照读:走 MVCC,不加锁,读的是快照版本
SELECT * FROM orders WHERE id = 1;
-- 当前读:读最新已提交数据并加锁,以下全是
SELECT * FROM orders WHERE id = 1 FOR UPDATE; -- 排他锁(X 锁)
SELECT * FROM orders WHERE id = 1 LOCK IN SHARE MODE; -- 共享锁(S 锁),8.0 可写 FOR SHARE
UPDATE orders SET status = 1 WHERE id = 1; -- DML 本身就是当前读
DELETE FROM orders WHERE id = 1;
为什么 UPDATE 前的普通 SELECT 锁不住行:它是快照读,读的是旧版本,不加锁。要"读-判-写"原子化只有三条路:SELECT ... FOR UPDATE 先锁、用 UPDATE ... WHERE 乐观条件(affected rows 判断)、或依赖唯一约束冲突。用普通 SELECT 做校验再 UPDATE,是并发下账对不上的经典写法。乐观并发的标准写法:
-- 乐观并发:读时带 version,写时校验 version
SELECT id, status, version FROM orders WHERE id = 10; -- 读到 version = 5
UPDATE orders SET status = 1, version = version + 1
WHERE id = 10 AND version = 5;
-- affected rows = 0:说明别人已改过,业务层决定重试整个读-判-写
读写都短的页面适合乐观锁(冲突少、不用持有数据库锁);冲突激烈的场景才值得用 FOR UPDATE 悲观锁。
间隙锁与临键锁:RR 防幻读的代价
RR 要防住"两次范围读之间插入新行",光锁行不够,还要锁住行之间的间隙:
| 锁 | 范围 | 目标 |
|---|---|---|
| 记录锁(Record Lock) | 单个索引记录 | 锁已有行 |
| 间隙锁(Gap Lock) | 两行之间的开区间 | 阻止区间内插入 |
| 临键锁(Next-Key Lock) | 记录 + 前面的间隙(左开右闭) | InnoDB 行锁默认形态,RR 下范围读加的就是它 |
-- 事务 A(RR 级别)
BEGIN;
SELECT * FROM orders WHERE user_id = 42 AND status = 1 FOR UPDATE;
-- 若命中行是 id=10 和 id=20,next-key 锁住 (前一段间隙,10] 和 (10 到 20 的间隙,20]
-- 事务 B 此时尝试插入同样满足条件的行:
-- INSERT INTO orders (user_id, status, amount, created_at) VALUES (42, 1, 99.00, '2026-10-22 10:00:00');
-- 会被间隙锁挡住(间隙锁之间互相冲突)
两个高代价后果:一是死锁概率显著上升——间隙锁的冲突面比行锁大得多;二是等值查询锁定的间隙范围取决于索引结构,没有合适索引时范围条件会退化为大范围甚至全表加锁。idx_user_status_created 存在时 user_id = 42 只锁相关间隙;索引被函数或隐式转换废掉时,同一句 SQL 的锁范围可能扩大几个数量级。"用上索引"在 RR 下不仅是性能问题,是锁范围问题,这是实践中最容易被忽视的一条。
死锁案例与排查
经典死锁:两个事务以相反顺序锁两行:
-- 事务 1 -- 事务 2
BEGIN; BEGIN;
UPDATE orders SET amount=1
WHERE id = 10; -- 锁 id=10
UPDATE orders SET amount=2
WHERE id = 20; -- 锁 id=20
UPDATE orders SET amount=3
WHERE id = 20; -- 等事务 2 放行 → 阻塞
UPDATE orders SET amount=4
WHERE id = 10; -- 等事务 1 放行 → 死锁
-- InnoDB 检测到等待环:回滚代价小的一方(通常改行少的),另一条继续执行
-- 回滚方收到错误:ERROR 1213 (40001): Deadlock found when trying to get lock
排查的信息全在引擎状态里:
SHOW ENGINE INNODB STATUS\G
-- 找 "LATEST DETECTED DEADLOCK" 段:
-- 两个事务各自正在执行的 SQL、持有的锁(HELD MUTEXES 之后的 TRANSACTION 段)
-- 等待的锁(lock_mode X locks rec but not gap / waiting)
-- 以及 victim 是谁。定期采集进监控(该段只保留最近一次死锁)
配套的等待观测:
SELECT r.trx_id, r.trx_mysql_thread_id AS thread_id,
r.trx_query, r.trx_rows_locked,
b.trx_mysql_thread_id AS blocking_thread_id, b.trx_query AS blocking_query
FROM information_schema.INNODB_TRX r
JOIN performance_schema.data_lock_waits w ON r.trx_id = w.REQUESTING_ENGINE_TRANSACTION_ID
JOIN information_schema.INNODB_TRX b ON b.trx_id = w.BLOCKING_ENGINE_TRANSACTION_ID;
-- 一眼看到:谁在等谁、各自卡在哪条 SQL
死锁的应对分两步:应用侧把 1213 当作可重试错误(指数退避重试 2–3 次,死锁被自动回滚的一侧重跑即可),根因侧用上面的状态段定位顺序颠倒的语句对。只做重试不做根因分析,等于允许死锁率随流量增长线性上升。
避免死锁:顺序、粒度与索引
| 手段 | 原理 |
|---|---|
| 固定加锁顺序 | 批量更新按主键排序:UPDATE ... WHERE id IN (10, 20, 30) 依次锁定,杜绝交叉 |
| 事务短小 | 锁持有时间 = 事务长度,越短冲突窗口越小;把远程调用/发邮件挪出事务 |
| 索引兜底 | 锁范围跟着索引走,失效场景(函数/隐式转换)会把锁放大成大范围 |
| 单语句替代多语句 | UPDATE ... WHERE status = 0 AND ... 一条 SQL 完成抢占,不给交叉留窗口 |
| 乐观并发(version 列) | 读多写少时彻底绕开行锁竞争 |
-- 固定顺序的实例:批量扣减按 id 升序处理
SELECT id FROM orders WHERE id IN (30, 10, 20) FOR UPDATE;
-- 实际按索引顺序加锁(10→20→30),两个事务并发跑也只会排队不会死锁
长事务:undo 膨胀与锁堆积
长事务的两个慢性伤害都不报错,是监控数字慢慢恶化:
- undo log 不能清理:RR 下 ReadView 活着,它可见之前所有版本都不能删。一个跑一下午的事务能让 undo 段膨胀数 GB,其他查询要顺更长的版本链,整体变慢。
- 锁堆积:事务持有的行锁/间隙锁直到提交才放,长事务把冲突面越铺越大,后面的写全在排队。
-- 找出运行最久的事务
SELECT trx_id, trx_started, TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS run_secs,
trx_rows_locked, trx_mysql_thread_id, trx_query
FROM information_schema.INNODB_TRX
ORDER BY trx_started ASC LIMIT 5;
-- run_secs 超过几百秒的事务基本都要人工过问
实践规则:事务里只放数据库操作。事务内调外部 HTTP 接口、等用户确认、处理大文件,等于把锁的持有时间交给第三方网络——连接池耗尽类事故里,十个有九个能追溯到这类"事务里等外部系统"。应用侧还要防止隐式长事务:框架里"打开会话即开事务"的模式,如果路由末尾忘记提交,事务就一直挂着,超时回收靠的是 innodb_lock_wait_timeout 之外的事务空闲超时与连接层心跳,代码评审时专门查事务的关闭路径。
锁等待与事务尺度:配置与批量策略
-- 锁等待超时(默认 50 秒,超时报 1205 ERROR)
SET GLOBAL innodb_lock_wait_timeout = 10;
-- 死锁检测开关(默认开启;极高并发的热点更新场景可评估关闭换死锁监控告警)
-- innodb_deadlock_detect = ON
注意 innodb_lock_wait_timeout 只管"等锁",不管死锁(死锁由检测机制秒级回滚);也不参与 MDL(元数据锁)等待。DDL 时的 MDL 是另一套排队:ALTER TABLE 需要表级 MDL 写锁,它要排在所有拿着 MDL 读锁的长查询后面,后续所有新查询又排在 ALTER 后面——一条慢 SELECT 加一条 ALTER 能把整张表的读写全堵住,SHOW PROCESSLIST 里一列 Waiting for table metadata lock 就是这个现场。上线 DDL 前先确认没有长查询、设置 lock_wait_timeout(MDL 超时)较小值,避免排队雪崩。
8.0 起行锁等待还有两个对批处理非常实用的选项:
-- SKIP LOCKED:跳过正被别人锁住的行,多 worker 抢任务队列互不等待
SELECT id FROM jobs WHERE status = 0 FOR UPDATE SKIP LOCKED;
-- NOWAIT:锁不到立刻报错(错误 3572),不排队,应用侧决定重试还是放弃
SELECT id FROM jobs WHERE id = 7 FOR UPDATE NOWAIT;
批量写入的尺度:
| 策略 | 事务数 | 每事务行数 | 适用 |
|---|---|---|---|
| 单条自动提交 | 极多 | 1 | 低频操作,同步刷新成本高 |
| 中批提交 | 少 | 500–2000 | 批量导入/对账,事务不至于长 |
| 一次性全量 | 1 | 全部 | 反例:undo/锁/主从延迟全部放大 |
批量插入固定每 500–1000 行提交一次:批次太大退化为长事务,批次太小刷盘次数又上不去。同时确认 autocommit 的实际状态——批量场景误开每条自动提交,速度差一个数量级,SELECT @@autocommit 一眼确认。
注意事项与边界
- 本文的锁行为全部指 InnoDB;MyISAM 只有表锁,结论不可迁移。
- 间隙锁只在 RR 级别存在,RC 下等值未命中的查询不加间隙锁——从 RR 切 RC 会减少死锁,但要重新评估业务是否依赖"防幻读"。
SHOW ENGINE INNODB STATUS的死锁段只保留最近一次,频率高的死锁要接监控采集或开innodb_print_all_deadlocks记入错误日志。- 可重复读的"快照"是第一次快照读(或当前读)时建立,
BEGIN后立刻SELECT才能精确定义快照点;START TRANSACTION WITH CONSISTENT SNAPSHOT可以显式化这一点。 - 事务的可见性与锁范围高度依赖是否命中索引:同一条 SQL,索引在不在,锁的可能是几行也可能是整张表,改索引前要连锁行为一起评估。
- 锁信息视图在 8.0 移到了
performance_schema.data_locks/data_lock_waits(5.7 用information_schema.INNODB_LOCKS/INNODB_LOCK_WAITS),网上旧脚本要按版本改。 - 文中秒级、行数等数字为量级示意,真实环境以你自己采集的
INNODB_TRX数据为准。
总结
InnoDB 并发控制的骨架是三件事:WAL 与 undo 支撑的 ACID 基建、MVCC 让读写互不阻塞、锁保证当前读与约束串行。工程上把它落成四条纪律:按业务显式选隔离级别而不是默认继承、把锁顺序与索引质量当作死锁的两道闸门、长事务当事故苗子来监控、批量提交选中等粒度。事务的正确性从来不是配置出来的,是对"这条语句是快照读还是当前读、锁了什么范围、锁多久"三个问题持续给出明确答案。
参考
Live Notes
留言提交后会先进入审核,审核通过后对其他读者公开。