Skip to content

09|事务与锁

上一篇讲了 InnoDB 怎么存数据、怎么恢复、怎么回滚。这一篇讲多个事务同时操作时怎么不互相干扰——事务的隔离和锁。

线上经常遇到这种事:某接口突然卡住,一看 MySQL 几十个连接都 UpdatingWaiting,关掉某条 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)或连接池泄漏。

事务的几条原则

  1. 事务尽量短——改几条、提交,别在事务里干别的(查远程接口、等用户输入)
  2. 按固定顺序访问资源——防死锁
  3. 别忘了 commit——开了事务不提交,锁和 undo 都不释放
  4. 大批量更新分批提交——别一个事务改几十万行,锁太多、undo 太大

事务锁之后

知道了事务的隔离、行锁间隙锁、锁等待排查、死锁处理。下一篇讲性能调优——连接数管理、Buffer Pool 调优、sys schema 定位慢查询,把这些机制综合起来看怎么让 MySQL 跑得快。