MySQL 的锁到底锁住了什么
从两笔同时发生的扣款讲起,说清 MySQL 的读锁和写锁、行锁为什么落在索引上、间隙锁怎么造出死锁,以及用 SHOW ENGINE INNODB STATUS 怎么把死锁看明白。
你写了一句 UPDATE,程序没报错,只是一直不返回。等几十秒,抛出一行 Deadlock found when trying to get lock。代码里没人写过 lock,锁是自己来的。
InnoDB 的锁不止一种,名字也杂。先从一件两笔扣款的事说起。
没有锁,两笔扣款会丢一笔
账户表里有一行,余额 100。两个请求几乎同时进来。A 读到 100,B 也读到 100。A 扣掉 10,写回 90。B 也扣掉 10,写回 90。两笔扣款都成功,账上只少了 10 块。这就是并发写丢更新。
MySQL 不让这件事发生。谁要改一行,得先拿到这行的写锁。写锁一次只发给一个事务,别的事务想改同一行,只能排在后面等。
锁大致分两种。读锁也叫共享锁,写成 S。写锁也叫排他锁,写成 X。同一行的 S 和 S 可以同时存在,几个事务一起读没问题。S 和 X、X 和 X 都会互相挡。
| 已经持有的锁 | 别人要 S | 别人要 X |
|---|---|---|
| S | 可以 | 等 |
| X | 等 | 等 |
表级还有一种意向锁,IS 和 IX。它不锁任何数据,只是提前表明「这个事务接下来要在这张表上加行锁」。别的事务想锁整张表时,看一眼意向锁就知道有没有冲突,不用一行一行去查。
行锁锁在索引上
这是最容易栽的一处。InnoDB 的行锁,锁在索引记录上。你 WHERE 走哪个索引,直接决定它锁哪些记录。
UPDATE account SET balance = balance - 10 WHERE phone = '13800000000';
phone 上没索引,InnoDB 找不到具体记录,只能全表扫。扫过的每一行都要加锁,哪怕最后不满足条件。表越大,被锁住的记录越多,能正常访问的数据越少。给 phone 建上索引,同一条语句就只锁命中的那几条。
WHERE 条件完全用不上索引时,全表的每一行都会进入加锁范围,效果接近锁表。不少慢接口和死锁,根子在这里。
普通 SELECT 默认不加锁
读和读之间基本不用等,因为普通的 SELECT 走的是另一条路。InnoDB 给每行保留多个版本,SELECT 按事务开始时的快照去读,读到的可能是旧版本。读旧版本不需要任何锁。
加了 FOR UPDATE 就不同。这是当前读,读的是最新已提交的数据,同时给这些行加上写锁,持有到事务结束。期间别人想改这些行,得等你。
SELECT * FROM account WHERE id = 1 FOR UPDATE;
FOR SHARE 类似,加的也是读锁。事务不提交,锁就不放,这点比锁本身更容易被忘。
间隙锁,和一种看起来莫名其妙的死锁
REPEATABLE READ 下还有一类锁专门锁记录之间的空隙,叫间隙锁。InnoDB 用它防幻读。你在某个范围加了锁,别的事务往这个范围里插新行会被挡住。
下面这段,两个会话分别执行,会撞在一起。
BEGIN;
SELECT * FROM account WHERE id = 5 FOR UPDATE;
INSERT INTO account (id, balance) VALUES (5, 100);
id 等于 5 的行不存在时,那条 SELECT 拿到的是间隙锁。行还没出现,锁先落在了空隙上。
这段空隙具体是哪一段,看它两边最近的记录。假设主键上已有 1、4、7、10,5 落在 4 和 7 之间,锁住的就是 (4, 7) 这个区间,往这里插 5 或者 6 都会被挡住。5 比所有记录都小时锁的是 infimum 到最小记录那一段,比所有记录都大时锁的是最大记录到 supremum 那一段。查一个存在的值不加间隙锁,只在那行上加记录锁。
间隙锁挂在紧邻的下一条记录上。锁 (4, 7),实际是在记录 7 上打一个标记,意思是它前面这段空隙归这个事务。纯间隙锁不锁两端记录本身,4 和 7 这两行的非索引列照样能被别的事务读和改。
接着说回两个会话。两边都锁住了同一段空隙,接着都要往空隙里插数据。INSERT 前会先申请插入意向锁,意思是「我要往这个位置插一行」。它和别人的插入意向锁不冲突,和别人的间隙锁冲突。于是两边各等对方的间隙锁,环就闭合了,谁都不动。
InnoDB 检测到死锁会自己处理,通常挑改动最少的那个事务回滚,另一个继续。被回滚的那个收到 1213。
REPEATABLE READ 是 MySQL 的默认隔离级别,幻读和间隙锁都跟它绑在一起。把隔离级别降到 READ COMMITTED,间隙锁基本就没了,等锁的范围小很多,代价是同一个事务里两次读可能拿到不同的结果。要不要动这个开关,看业务能不能接受。
InnoDB 是怎么发现死锁的
死锁检测不单独起定时任务。一个事务的锁请求拿不到,准备进等待队列的那一刻,InnoDB 顺手做一次检查。
它把当前状态看成一张等待图。节点是事务,T1 指向 T2 表示 T1 在等 T2 持有的锁。InnoDB 不预先建出整张图,从刚才那个事务开始做深度优先遍历。
遍历顺着锁的等待队列走。先看谁排在前头、谁持有这把锁,再看这些事务各自又在等哪把锁、被谁挡住,一层层递归下去。中途走回起点那个事务,等待就成了一个环,死锁成立。
找到环以后挑一个牺牲者回滚。挑法是比事务的权重,大致是 undo 记录数和持有锁的数量,回滚代价小的那个更可能被选中。发起请求的事务如果是牺牲者,直接收到 1213。换成别人被选中,别人被回滚,它的客户端收到 1213。
遍历有深度上限,代码里的常量叫 LOCK_MAX_DEPTH_IN_DEADLOCK_CHECK。超过上限就按最坏情况处理,免得检查本身把 CPU 拖住。
检测发生在每一次阻塞式的锁获取上,等待链越长越贵。高并发下这笔开销会自己变成一个瓶颈。
开关是 innodb_deadlock_detect,默认打开。关掉以后不再主动检测,死锁只能靠 innodb_lock_wait_timeout 超时,也就是前面那个 1205。
元数据锁走的是另一套等待图,在 MDL 子系统里,DDL 被阻塞时也是它负责检测,检测出来一样报 1213。
这些锁在内部长什么样
锁是纯内存结构,不落盘。
InnoDB 有一个全局的 lock hash,键是 (space_id, page_no),一页挂一条锁链。记录锁的表示有个关键设计,它不给每一行建锁对象。它按页来,一个锁对象带一份覆盖整页的位图,位图上的每一位对应页里的一个 record。一页上锁住几百行,位图也就多出几百个二进制位,换成字节只有一百来个。行锁能便宜,靠的就是这一点。
哪一位对应哪条记录是定死的,1 就一定锁了,0 就一定没锁。它是一份精确的位集合,和布隆过滤器那种允许判错的概率结构不是一回事。
每个锁对象 lock_t 的 type_mode 里编码了几样东西。锁模式分 S 和 X,锁类型分记录锁和表锁。剩下的是标记位,管它是不是间隙锁、next-key、插入意向,或者还在等待。
事务这一侧,自己拿到的锁挂在 trx->lock.locks 链表上,提交或回滚时整体释放。正在等的那把单独记在 trx->lock.wait_lock。
加锁走三个分支。页上还没有任何锁,走快路径,创建一个锁、置个位就完事。页上已经有锁但不冲突,把新位并进已有的位图。冲突了,造一个带等待标记的锁对象,挂到那把锁的等待队列末尾,事务随即挂起,底层是等一个事件。挂起之前会先跑一遍死锁检查,被唤醒后还要确认能不能拿到。
谁和谁冲突,由一张兼容矩阵 lock_compatibility_matrix 决定。间隙锁和插入意向锁有单独的规则,插入位置落在别人的间隙里就要等。
表锁和意向锁不走记录锁的 hash,直接挂在表对象上。意向锁也是表锁的一种,只在表级别排队。
加锁的入口在 row_search_mvcc。SELECT ... FOR UPDATE 把 select_lock_type 设成 X,FOR SHARE 设成 S,扫到哪条记录就给哪条加。命中二级索引时,除了锁二级索引记录,还要回表把对应的聚簇索引记录一起锁上。普通的读不进这套流程,靠 undo 和 read view 出快照。
整个 lock 子系统由 lock_sys 的 latch 保护。MySQL 5.7 是一把全局大锁,8.0 按 page 分片,压力才降下来。元数据锁是另一套独立实现,在 MDL 子系统里,和前面这些没有共用结构。
死锁怎么看见
死锁本身是正常现象,重点在别让它频繁发生,发生了能查。
SHOW ENGINE INNODB STATUS 输出里的 LATEST DETECTED DEADLOCK 一段,记着最近一次死锁的两个事务、各自正在执行什么、持有哪些锁、在等什么锁。读那一段,谁卡住谁,看得很清楚。
想看在途事务,可以查 performance_schema.data_locks 和 data_lock_waits,当前活着的事务各自持有和等待哪些锁,都列在里面。
等锁超过 innodb_lock_wait_timeout,默认 50 秒,会抛 1205。这个错通常说明有事务拿着锁太久。
让撞锁变少的几条
事务开得越短,锁占用的时间越短。事务里的网络调用、文件读写、发消息,全都挪到事务外面去。锁只在事务存在期间有效,事务多活一秒,锁多占一秒。
同一个业务流程,固定按同一个顺序改数据。前面那个死锁,两边改行的顺序反过来,就撞上了。订单和库存之间先扣哪张表,全项目统一。
WHERE 条件上要用的列,索引建好,别让它全表扫。
业务代码对 1213 做重试。重试一两次,大多数死锁都能过去。锁等待超时的重试要谨慎,它往往意味着有别的慢事务堵着,重试只是在排队。
先把「谁在等谁」看清楚。SHOW ENGINE INNODB STATUS 里那段死锁日志,比教程管用。
评论