用 Performance Schema 追一条被行锁挡住的 MySQL 更新
一条按主键更新单行的 SQL 执行了很久,执行计划却没有明显问题。语句时间只能说明“它花了多久”,不能说明这些时间都用来扫描数据。它可能大部分时间在等待另一个事务放开同一行。
这次用 MySQL 8.4 的三个连接做一次完整排查:A持有行锁,B执行更新,观察连接把B的客户端连接号映射到 Performance Schema 线程,找到谁挡住了它,再把正在执行的语句和完成后的历史记录对上。实验只操作专用表;采集设置也在结束时恢复,不去扩大正式实例的全量采集范围。
先准备一行,不靠大数据制造慢查询
建立专用实验库和表:
CREATE DATABASE IF NOT EXISTS wait42_lab
CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
USE wait42_lab;
CREATE TABLE stock_counter (
id BIGINT UNSIGNED NOT NULL,
quantity INT NOT NULL,
PRIMARY KEY (id)
) ENGINE=InnoDB;
INSERT INTO stock_counter VALUES (1,10);
A、B与观察连接都连接同一实例。观察连接需要读相关 Performance Schema 表;为演练临时启用目标语句采集时,还需要修改指定设置的权限。普通业务连接不应仅为排查拿到这些管理权限。
先在B执行并记下结果:
SELECT CONNECTION_ID();
SET autocommit=1;
连接号用于找到当前会话,不是 Performance Schema 的 THREAD_ID。下面观察连接中的12345必须替换为刚才实际返回的B连接号;不能把二者当作碰巧相等的数字直接关联。
把采集范围与原始配置一起保留下来
在观察连接执行。要求 performance_schema 已在实例启动时启用,语句历史容量大于零,全局与线程级 instrumentation 消费者也已为 YES;不满足时先停在环境配置,不在业务高峰临时尝试重启。
SELECT @@GLOBAL.performance_schema;
SHOW VARIABLES LIKE 'performance_schema_events_statements_history_size';
SELECT NAME,ENABLED FROM performance_schema.setup_consumers
WHERE NAME IN ('global_instrumentation','thread_instrumentation');
SET @worker_connection_id=12345;
SET @worker_thread=NULL;
SELECT THREAD_ID INTO @worker_thread
FROM performance_schema.threads
WHERE PROCESSLIST_ID=@worker_connection_id;
SELECT @worker_thread;
@worker_thread 必须得到一项非空值。随后在同一个观察连接保存设置,临时开启本实验需要的语句当前与历史消费者,并确保目标线程有历史记录资格:
CREATE TEMPORARY TABLE saved_consumers AS
SELECT NAME,ENABLED FROM performance_schema.setup_consumers
WHERE NAME IN ('events_statements_current','events_statements_history');
CREATE TEMPORARY TABLE saved_instrument AS
SELECT NAME,ENABLED,TIMED FROM performance_schema.setup_instruments
WHERE NAME='statement/sql/update';
CREATE TEMPORARY TABLE saved_thread AS
SELECT THREAD_ID,INSTRUMENTED,HISTORY FROM performance_schema.threads
WHERE THREAD_ID=@worker_thread;
UPDATE performance_schema.setup_consumers
SET ENABLED='YES'
WHERE NAME IN ('events_statements_current','events_statements_history');
UPDATE performance_schema.setup_instruments
SET ENABLED='YES',TIMED='YES'
WHERE NAME='statement/sql/update';
UPDATE performance_schema.threads
SET INSTRUMENTED='YES',HISTORY='YES'
WHERE THREAD_ID=@worker_thread;
消费者设置的作用域仍是实例级,本例只在隔离演练环境这样操作,没有把“只观察B”误称为所有配置都变成了单线程开关。全局及线程级采集链还要允许事件产生;若实例上层采集已关闭,应先核对 setup_consumers 中 global_instrumentation、thread_instrumentation 的状态,而不是看到空表就认定语句没有等待。
这里没有清空任何历史表,也没有一口气启用所有 wait 仪器。行锁等待关系有自己的 data_lock_waits 证据,语句耗时有自己的事件记录。先取得能回答问题的数据,再决定是否需要更细粒度的额外采集。
让A持锁,再让B进入等待
连接A:
USE wait42_lab;
START TRANSACTION;
UPDATE stock_counter SET quantity=quantity+1 WHERE id=1;
保持事务未提交。连接B随后执行:
USE wait42_lab;
UPDATE stock_counter SET quantity=quantity+2 WHERE id=1;
B暂时不返回,这正是要观察的阶段。实验没有使用 SELECT SLEEP 伪造等待,因为 sleep 无法替代真实 InnoDB 行锁关系。
观察连接查询请求者、阻塞者及锁对象:
SELECT w.REQUESTING_THREAD_ID,
rt.PROCESSLIST_ID AS waiting_connection,
w.BLOCKING_THREAD_ID,
bt.PROCESSLIST_ID AS blocking_connection,
r.OBJECT_SCHEMA,r.OBJECT_NAME,r.INDEX_NAME,
r.LOCK_TYPE,r.LOCK_MODE,r.LOCK_STATUS,
r.LOCK_DATA,
st.EVENT_ID,st.SQL_TEXT,
ROUND(st.TIMER_WAIT/1000000000000,6) AS statement_seconds
FROM performance_schema.data_lock_waits AS w
JOIN performance_schema.data_locks AS r
ON r.ENGINE=w.ENGINE
AND r.ENGINE_LOCK_ID=w.REQUESTING_ENGINE_LOCK_ID
LEFT JOIN performance_schema.threads AS rt
ON rt.THREAD_ID=w.REQUESTING_THREAD_ID
LEFT JOIN performance_schema.threads AS bt
ON bt.THREAD_ID=w.BLOCKING_THREAD_ID
LEFT JOIN performance_schema.events_statements_current AS st
ON st.THREAD_ID=w.REQUESTING_THREAD_ID
WHERE w.REQUESTING_THREAD_ID=@worker_thread
AND r.OBJECT_SCHEMA='wait42_lab'
AND r.OBJECT_NAME='stock_counter';
应能把B映射为等待连接、A映射为阻塞连接,锁对象指向本实验表与主键相关记录。具体引擎锁标识是即时诊断身份,不适合保存成跨重启仍稳定的业务键。此处也不要求每个版本展示完全一样的 LOCK_DATA 文本。
阻塞连接可能已经没有正在执行的 SQL:A的 UPDATE 早已返回,只是事务还没结束。不要因为它在进程列表里处于空闲状态,就排除它持锁的可能。行锁关系回答“谁在挡住谁”,当前语句表回答“此刻正在做什么”,两张表的含义不同。
在B仍等待时,观察连接再保存事件身份:
SET @statement_event=NULL,@statement_start=NULL;
SELECT EVENT_ID,TIMER_START INTO @statement_event,@statement_start
FROM performance_schema.events_statements_current
WHERE THREAD_ID=@worker_thread;
SELECT @worker_thread,@statement_event,@statement_start;
EVENT_ID只在对应线程内解释,应与THREAD_ID共同保存。此时 TIMER_WAIT 是这次语句当前已累计的时间,不是最终完成耗时;未启用计时或事件不可用时,也不能把NULL强制当零秒。
解除阻塞,再核对同一条历史语句
连接A执行 COMMIT,B的更新应继续并完成。此后先别让B连续执行其他 SQL,避免小容量的每线程历史缓冲很快把目标事件覆盖。
SELECT THREAD_ID,EVENT_ID,SQL_TEXT,
ROUND(TIMER_WAIT/1000000000000,6) AS elapsed_seconds,
ROWS_AFFECTED,MYSQL_ERRNO,
TIMER_START,TIMER_END
FROM performance_schema.events_statements_history
WHERE THREAD_ID=@worker_thread AND EVENT_ID=@statement_event;
观察连接读取这条历史记录,核对 SQL、事件身份、受影响行数及是否成功。TIMER_WAIT等计时字段以皮秒规范化,除以一万亿才是秒;界面若把它当纳秒或毫秒,数量级会完全错开。计时分辨率与仪器计量也有边界,不能从显示了很多小数位推导出同等精度。
再查询业务值:A加一、B加二都提交后,quantity应为13。语句完成记录与最终业务值互相印证,但它们不是同一种证据,缺少其中一个都不应由另一个来猜。
等待关系消失以后,要保留它为什么结束
如果观察查询为空,先确认B真的还在执行。行锁等待超时、死锁受害回滚、连接断开,都会让即时关系消失;它们不等于“更新很快完成”。应结合B实际收到的结果、完成事件里的 MYSQL_ERRNO 与最终业务值判断。若B更新失败而A提交,数量可能只加了一,而不能继续套用13的成功预期。
观察连接的多张表查询也不是把整个实例冻结成一张事务快照。B可能在连接映射、取锁关系与取语句事件之间完成,线程也可能退出。出现无法关联的一部分证据时,先保留采样时点,再判断是否需要重新复现;不要把LEFT JOIN得到的NULL补成一个不存在的连接或语句。
SQL_TEXT还可能受文本长度和采集方式限制。它有助于定位本次实验的短UPDATE,不保证包含所有真实请求的完整文本与业务身份。线上归因通常还需受控的应用请求标识,把日志、连接与语句放到同一时间范围里;报告时也应避免把带敏感参数的整条SQL无筛选地扩散出去。
连接号和线程号都应连同实例与观测时点解释,不能把今天保存的一个数字,直接套到重启后的另一条会话上。当前排查先把正在等待的B锁定,再立即保存事件身份,比事后仅凭“某个连接以前很慢”重新拼关系更可靠。
有语句总时间,还不能做一张百分百相加的等待饼图
如果实例原本已采集相应 wait 事件,可以继续沿线程、计时区间与嵌套身份找细节。先查当前采集状态,再决定历史空白是否有解释意义:
SELECT NAME,ENABLED
FROM performance_schema.setup_consumers
WHERE NAME LIKE 'events_waits%';
SELECT NAME,ENABLED,TIMED
FROM performance_schema.setup_instruments
WHERE NAME LIKE 'wait/%' AND ENABLED='YES';
一个 wait 事件可能嵌套在 stage 中,stage再嵌套在statement中。不能拿某条 wait 的 NESTING_EVENT_ID 直接与 statement EVENT_ID 相等,就断言找到了所属语句;还要看 NESTING_EVENT_TYPE 及中间层。跨线程关联时漏掉THREAD_ID,同样可能把另一条同号事件接进来。
文件I/O、表操作、同步原语与语句本身具有不同观测层次,部分计时可能包含更低层工作。把看到的几列或几层事件全部SUM,再要求等于语句总时间,会产生重复计算或漏算。当前实验能证明B曾被A阻塞,不能仅凭一张即时等待关系表得出“锁恰好占总耗时的某个百分比”。
历史表是有容量的缓冲,也不是长期审计仓库。未采集、未计时、线程未参与、消费者关闭和旧事件被覆盖,都可能让查询返回空集。应先确认观测覆盖,再解释数据;空集不能自动翻译成“没有I/O”“没有锁”或“全部时间都花在CPU”。
结束时恢复设置,别把实验留成实例的新默认
在最初的观察连接恢复保存的配置。临时表属于这个连接,换了连接以后无法再拿它们恢复:
UPDATE performance_schema.setup_consumers AS c
JOIN saved_consumers AS s ON s.NAME=c.NAME
SET c.ENABLED=s.ENABLED;
UPDATE performance_schema.setup_instruments AS i
JOIN saved_instrument AS s ON s.NAME=i.NAME
SET i.ENABLED=s.ENABLED,i.TIMED=s.TIMED;
UPDATE performance_schema.threads AS t
JOIN saved_thread AS s ON s.THREAD_ID=t.THREAD_ID
SET t.INSTRUMENTED=s.INSTRUMENTED,t.HISTORY=s.HISTORY;
如果目标连接已经退出,线程行可能不再存在,恢复语句不会凭空重建线程。保持A、B和观察连接直到检查完成,也能减少身份消失造成的歧义。遇到实验中途失败时,先结束A未完成的事务,再恢复采集状态,别让排查工具本身留下一个持锁会话。
最终应能清楚回答三件事:B是哪条会话与语句、它等待哪一项锁、谁持有那个锁。拿到这条链以后,再去分析索引和I/O才有方向;否则一开始就调整缓冲区,很可能是在优化一条主要时间都用来等事务结束的更新。