Appearance
09|事务与锁
上一篇讲了 InnoDB 怎么存数据、怎么恢复、怎么回滚。这一篇讲多个事务同时操作时怎么不互相干扰——事务的隔离和锁。
线上经常遇到这种事:某接口突然卡住,一看 MySQL 几十个连接都 Updating 或 Waiting,关掉某条 SQL 就好了。这种"慢"不是真慢,是在等锁。本篇讲事务、隔离级别、锁、锁等待怎么排查。
一、事务:要么全做要么不做
事务是一组操作,要么全部成功提交,要么全部回滚不做。经典的转账:A 扣钱、B 加钱,必须都成功或都失败,不能扣了钱没加上。
sql
START TRANSACTION;
UPDATE accounts SET balance = balance - 100 WHERE user = 'A';
UPDATE accounts SET balance = balance + 100 WHERE user = 'B';
COMMIT; -- 或 ROLLBACK 撤销START TRANSACTION 开事务,COMMIT 提交(确认),ROLLBACK 回滚(撤销)。两条 UPDATE 要么都生效要么都不生效。
事务有 ACID 四个特性:
| 特性 | 含义 | InnoDB 怎么保证 |
|---|---|---|
| 原子性(Atomicity) | 要么全做要么不做 | undo log(回滚) |
| 一致性(Consistency) | 数据从一个正确状态到另一个 | 应用 + 数据库约束 |
| 隔离性(Isolation) | 并发事务互不干扰 | 锁 + MVCC |
| 持久性(Durability) | 提交了就不丢 | redo log |
上一篇讲的 undo log 保原子性、redo log 保持久性。隔离性靠锁和 MVCC,本篇重点。
二、隔离级别
多个事务同时跑,互相能看到什么程度,由隔离级别决定:
| 级别 | 名字 | 问题 |
|---|---|---|
| READ UNCOMMITTED | 读未提交 | 脏读(能读到别人没提交的) |
| READ COMMITTED | 读已提交 | 不可重复读(同一查询两次结果不同) |
| REPEATABLE READ | 可重复读 | 幻读(默认,InnoDB 用间隙锁基本消除) |
| SERIALIZABLE | 串行化 | 性能差,基本不用 |
sql
SHOW VARIABLES LIKE 'transaction_isolation';InnoDB 默认 REPEATABLE READ。这个级别下,一个事务里多次执行同样的 SELECT,结果一致(快照读)。大多业务用默认就行,不用改。
为什么不全用 SERIALIZABLE(最严格)?因为它让事务串行执行,并发性差,性能低。隔离级别是"隔离性 vs 并发性"的取舍——越严格越安全但越慢。
三、锁:行锁和间隙锁
InnoDB 的锁加在行上(行锁),不是锁整张表(表锁)。这让它并发性好——改这一行不影响改另一行。
行锁
两个事务改同一行,后到的等先到的:
sql
-- 事务 A
START TRANSACTION;
UPDATE servers SET status = 0 WHERE id = 1; -- 锁住 id=1 这行
-- 事务 B
START TRANSACTION;
UPDATE servers SET status = 1 WHERE id = 1; -- 等 A 释放锁B 卡住,等 A 提交或回滚后才能改。这就是行锁。
间隙锁
REPEATABLE READ 下,InnoDB 除了锁行还锁"间隙"——防止幻读。比如 WHERE id BETWEEN 10 AND 20,不只锁住这个范围里已有的行,还锁住 10-20 之间没数据的间隙,防止别的事务往这个范围插新行。
间隙锁容易导致意外的锁等待——你以为改不同行不冲突,但因为间隙锁,插数据被挡住。排查锁等待时要考虑到。
四、锁等待怎么排查
接口卡住,怀疑锁等待。看谁在等谁:
sql
SELECT
r.trx_id AS waiting_trx_id,
r.trx_mysql_thread_id AS waiting_thread,
r.trx_query AS waiting_query,
b.trx_id AS blocking_trx_id,
b.trx_mysql_thread_id AS blocking_thread,
b.trx_query AS 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\G输出能看到:线程 A 在等线程 B 释放锁。
排查的关键不是"谁被堵住了",而是"谁堵住了别人"。 堵住别人的往往是一个执行很慢的 UPDATE,或一个忘了提交的事务(长事务一直占着锁)。
KILL 之前确认
找到阻塞源,要 KILL 掉:
sql
KILL 12345;KILL 的是 MySQL 线程 ID,不是 Linux PID。 执行前确认杀的是阻塞源(blocking),不是被阻塞的(waiting)——杀错了问题还在,只是让业务重试一波。
KILL 之后大事务回滚也要时间,不会瞬间释放锁。别以为 kill 完立刻好了。
五、死锁
事务 A 锁了行 1 等行 2,事务 B 锁了行 2 等行 1——两边互相等,谁也不让:
text
事务 A:持有行1的锁,等行2
事务 B:持有行2的锁,等行1看死锁信息:
sql
SHOW ENGINE INNODB STATUS\G搜 LATEST DETECTED DEADLOCK,能看到两个事务的 SQL。
死锁不等于数据库坏了。 InnoDB 会自动检测并回滚其中一个事务(选影响较小的回滚),让另一个继续。应用收到死锁错误做有限次重试即可。
频繁死锁才要看代码逻辑——让多个事务以相同顺序访问资源,死锁概率大幅降低。 比如都先锁行 1 再锁行 2,就不会出现 A 锁 1 等 2、B 锁 2 等 1 的循环。
六、长事务是锁的敌人
一个事务开着一直不提交,它持有的锁一直不放,别人就得一直等。更糟的是 undo log 也不清理(上一篇讲过),拖累性能。
sql
-- 看有没有跑很久的事务
SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id, trx_query
FROM information_schema.innodb_trx
ORDER BY trx_started ASC LIMIT 10;看到跑了几个小时的事务要排查——多半是应用开了连接没提交(忘了 commit)或连接池泄漏。
事务的几条原则
- 事务尽量短——改几条、提交,别在事务里干别的(查远程接口、等用户输入)
- 按固定顺序访问资源——防死锁
- 别忘了 commit——开了事务不提交,锁和 undo 都不释放
- 大批量更新分批提交——别一个事务改几十万行,锁太多、undo 太大
事务锁之后
知道了事务的隔离、行锁间隙锁、锁等待排查、死锁处理。下一篇讲性能调优——连接数管理、Buffer Pool 调优、sys schema 定位慢查询,把这些机制综合起来看怎么让 MySQL 跑得快。