MySQL 无索引 UPDATE 会锁全表吗?我用一张 50 万行的表在 8.0.32 上实测了一遍

🔑 关键词:MySQL 间隙锁, next-key lock, performance_schema.data_locks, innodb_lock_wait_timeout, MySQL 8.0 死锁排查

📖 摘要:一次线上锁等待事故的复盘:在 MySQL 8.0.32 上用 50 万行表复现了无索引 UPDATE 的加锁过程,拆解 data_locks 里 X / X,GAP / X,RECORD 三种 LOCK_MODE 的区别,并给出锁等待排查 SQL 和几个反常识的调参建议。

先把结论摆出来,省得你翻到底

图片

网上关于这件事的说法基本是互相抄的:“UPDATE 不带 WHERE、或者 WHERE 没走索引,InnoDB 会锁全表”。这句话我从 2019 年听到现在,自己面试别人的时候也说过。上周我在测试库上把它拆开看了一遍,发现现象是真的,但描述方式至少有三处不准。

不准在哪?InnoDB 在表级别给你加的是 IX 意向锁,不是表级 X 锁;真正被锁住的是扫描到的每一条记录,而且加的是 next-key lock。这两种说法听起来差不多,排查问题时完全是两回事——前者你能在 performance_schema.data_locks 里看到五十万条 RECORD 记录,后者你只会看到一条。

还有一个更隐蔽的坑:data_locks 里的 LOCK_MODE 字段显示规则,官方文档写得非常轻描淡写。next-key lock 显示成光秃秃的 X,纯记录锁显示成 X,RECORD,间隙锁才是 X,GAP。我第一次看到 X 的时候以为是个普通的排他锁,结果它其实是 next-key,包含记录本身加前面的间隙。这一个字母的差别,能让你对着锁表看半天看不出问题。

下面是我做的测试、看到的数字,以及最后怎么解决的。

事情发生在去年双十一前一周

一个做社区团购的朋友半夜找我,说他们下单接口从晚上 9 点开始 P99 从 120ms 涨到 7.8 秒,然后大量报 Lock wait timeout exceeded; try restarting transaction,也就是 ERROR 1205。

图片

环境是 MySQL 8.0.32,主库 16C64G,innodb_buffer_pool_size=40G。上去先看 SHOW ENGINE INNODB STATUS,LATEST DETECTED DEADLOCK 段落里躺着两条 SQL,都是对 t_order_ext 表的更新:

UPDATE t_order_ext SET retry_times = retry_times + 1 WHERE order_no = 'SO20241028xxxx';

看了一眼表结构我就明白了:这张表建表的时候主键用的是自增 id,而业务上一路当成主键用的 order_no 列上——没有索引。当时表里大概 230 万行。

这不是某个人的锅。早期做这块的同事是按“order_no 是唯一的所以数据库会帮我优化”这个直觉建的表,实际上 MySQL 不认这个逻辑,没索引就是全表扫。真正的问题是:全表扫的时候加锁也是一路加过去的,而且两条事务加锁的起点不同,扫描顺序倒过来了,死锁就出现了。

我在测试环境把它完整复现了一遍

回去之后我建了一张干净的表,把行数控制在 500,000,方便看结果:

图片

CREATE TABLE t_stock (
  id     BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  sku_id INT NOT NULL,
  num    INT NOT NULL DEFAULT 0,
  PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

注意,这里故意没建 sku_id 的索引。用存储过程灌了 50 万行,隔离级别保持默认的 REPEATABLE READ。然后在 session A 里:

BEGIN;
UPDATE t_stock SET num = num + 1 WHERE sku_id = 12345;
-- 不提交

session B 里查加锁情况:

SELECT INDEX_NAME, LOCK_TYPE, LOCK_MODE, LOCK_STATUS, LOCK_DATA
FROM performance_schema.data_locks
WHERE OBJECT_SCHEMA = 'test' AND OBJECT_NAME = 't_stock';

结果:1 条 TABLE 级别的 IX,500,001 条 RECORD 级别的锁,LOCK_MODE 全是 X,其中最后一条的 LOCK_DATA 是 supremum pseudo-record。这条查询本身花了 1.9 秒才返回——查锁的语句自己被锁表拖慢了,这个体验挺魔幻的。

图片

然后我在 session B 里执行同样一条 UPDATE,它开始等。等了 50.0 秒整,报 1205。这个 50 秒就是 innodb_lock_wait_timeout 的默认值。

为了对照,我又在 sku_id 上加了索引重建了一遍,同样一条 SQL,data_locks 里只剩 2 条 RECORD 锁。是的,从 500,001 降到 2。差距就是这么离谱。

几个我原本理解错的地方

第一个是 LOCK_MODE 的显示。加了索引以后,命中唯一索引等值查询时你看到的是 X,RECORD;但如果是普通二级索引等值查询(比如这里的 idx_sku),你看到的是 X,也就是 next-key lock,同时还会有一条主键索引上的 X,RECORD,因为要回表。所以“锁了几条”这件事不能光数行数,得看 INDEX_NAME 那一列。

第二个是 supremum pseudo-record。测试时我一度以为这是 Bug,因为表里根本没有这么一行数据。它其实是 InnoDB 造出来的一个“比所有记录都大”的虚拟记录,用来锁住最后一个间隙。全表扫描结束时一定会锁到它,所以你看到这一条不用慌,它是正常的。

第三个更反常识:在 READ COMMITTED 下,同样的无索引 UPDATE 加锁数量会显著减少,因为 RC 有 semi-consistent read,MySQL 会在判断完 WHERE 条件后释放掉不匹配记录的锁。很多团队就是为了这个把隔离级别从 RR 降到 RC 的。我不建议这么干——不是为了“政治正确”,而是因为 RC 下必须保证 binlog_format=ROW,一旦有老代码或者中间件偷偷把格式改成 STATEMENT,主从数据就会悄悄不一致,而且这种不一致往往几周后才被发现。用降隔离级别来遮锁问题,本质是把一个显性的报错换成了一个隐性的数据问题。

图片

具体的排查步骤和参数

如果你现在正被锁等待折磨,按这个顺序来:

第一步,找到谁在等谁。 MySQL 8.0 里 information_schema.innodb_locks 已经被移除了,别再去查它。直接用:

SELECT
  r.trx_mysql_thread_id AS waiting_thread,
  r.trx_query           AS waiting_query,
  b.trx_mysql_thread_id AS blocking_thread,
  b.trx_query           AS blocking_query
FROM performance_schema.data_lock_waits w
JOIN information_schema.innodb_trx b
  ON b.trx_id = w.BLOCKING_ENGINE_TRANSACTION_ID
JOIN information_schema.innodb_trx r
  ON r.trx_id = w.REQUESTING_ENGINE_TRANSACTION_ID;

或者更省事,直接 SELECT * FROM sys.innodb_lock_waits\G。

第二步,看这个事务跑了多久。 在 information_schema.innodb_trx 里关注 trx_started 和 trx_rows_locked、trx_rows_modified 两个字段。我那次事故里,trx_rows_locked 显示 230 万——基本上就是全表。这个数字比任何解释都直接。

图片

第三步,把 innodb_lock_wait_timeout 从 50 调下来。 我一般建议线上设成 5 到 10 秒。这听起来是在降低系统的容忍度,但锁等待这种事,等 50 秒和等 5 秒的结果是一样的,都是失败;区别在于等 50 秒会占着连接、拖垮连接池、把上游一起带崩。快速失败加应用层重试,整体可用性反而更高。如果你们有跑批任务走同一个实例,记得用 SET SESSION innodb_lock_wait_timeout = 50 单独给那个连接放宽,别全局一刀切。

第四步,把没走索引的 SQL 揪出来。 打开 log_queries_not_using_indexes 之前先想清楚,流量大的库上这个开关会让慢日志每小时涨到几个 G。更好的做法是开 slow_query_log 配合 long_query_time=0.5,再拿 pt-query-digest 过一遍。顺带说一句,Rows_examined 这个指标在 RR 和 RC 下的含义有细微差别,别只盯着它判断,配合 Handler_read_next 看更靠谱。

最后说点我自己的看法

这次事故之后,我在团队里推了一条规则:死锁排查第一步不是看锁范围,是看两个事务的加锁顺序。

加索引把锁从 50 万行缩到 2 行,这解决的是“影响面”。但只要两个事务按相反的次序去锁同样两行数据,哪怕只有两行,照样死锁。真正治本的做法是在业务层统一访问顺序,比如所有涉及库存和订单的更新,都先锁库存再锁订单,或者干脆按主键排序后依次处理。这个约束听起来很土,但它比任何数据库参数都管用。

还有一点,我不太喜欢“无索引会锁全表”这种口诀式表达。口诀的好处是记得快,坏处是它把 InnoDB 的加锁逻辑压缩成了一个黑箱,等你真去查 data_locks 的时候,看到 IX、X、X,GAP 三种东西摆在一起,反而不知道该信谁。数据库这东西,口诀只适合面试,不适合凌晨两点排查线上问题。

🏷️ 标签: