MySQL 调优
慢 SQL、索引、分页、锁等待和连接池调优。
查找慢 SQL
先从慢日志和请求追踪找到耗时较高的 SQL。
按总耗时和调用次数寻找候选语句
SELECT SCHEMA_NAME, DIGEST_TEXT, COUNT_STAR,
ROUND(SUM_TIMER_WAIT / 1e12, 3) AS total_s,
ROUND(AVG_TIMER_WAIT / 1e9, 3) AS avg_ms,
SUM_ROWS_EXAMINED, SUM_ROWS_SENT
FROM performance_schema.events_statements_summary_by_digest
WHERE SCHEMA_NAME = DATABASE() AND DIGEST IS NOT NULL
ORDER BY SUM_TIMER_WAIT DESC
LIMIT 10;选中业务库后查询 Performance Schema。计时单位为皮秒,统计值为累计值,观察当前负载时取两次快照的增量。
结合慢日志取回参数和调用次数。相同 SQL 在不同租户上的数据量可能相差很大,执行很快但调用频繁的 SQL 也值得检查。
建立可复现的订单测试数据
SELECT VERSION(), @@session.time_zone, @@transaction_isolation;
-- 以下表结构由下载的初始化脚本创建,不要重复执行
CREATE TABLE tuning_orders (
id BIGINT NOT NULL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
customer_id BIGINT NOT NULL,
status TINYINT NOT NULL,
created_at DATETIME NOT NULL,
amount DECIMAL(12,2) NOT NULL,
note VARCHAR(500) NOT NULL
) ENGINE=InnoDB;初始化脚本创建十万条订单、十个租户和一万条客户记录。订单表初始只有主键,客户表主键为 (tenant_id, id)。同一租户有重复的创建时间,用于检查分页。
在空测试库中导入初始化脚本,再执行对照脚本或逐条操作。实验使用 MySQL 8.0.45、1 个 CPU、1 GiB 容器内存和 128 MiB buffer pool。
同时记录计划、实际工作量与结果
EXPLAIN SELECT id, created_at
FROM tuning_orders
WHERE tenant_id = 7 AND status = 1
ORDER BY created_at DESC, id DESC
LIMIT 20;
EXPLAIN ANALYZE SELECT id, created_at
FROM tuning_orders
WHERE tenant_id = 7 AND status = 1
ORDER BY created_at DESC, id DESC
LIMIT 20;EXPLAIN 查看估计计划,EXPLAIN ANALYZE 执行查询并显示实际结果。重点看 rows、loops 和耗时;多次循环节点的 rows 与时间是每次循环的平均值。
下面对比执行计划中的实际读取行数。
联合索引
按租户和状态筛选订单,再按创建时间倒序取 20 条。
检查过滤与排序之前读了多少行
只有主键时,计划先扫描 100000 行,筛出租户 7、状态 1 的 3333 行,再排序取 20 行。
调整前的实际节点(省略 cost 与时间):
Limit rows=20 loops=1
Sort rows=20 loops=1
Filter rows=3333 loops=1
Table scan rows=100000 loops=1Sort 输出 20 行,输入却有 3333 行。优化时要减少筛选和排序之前读取的记录。
为等值条件与排序建立联合索引
ALTER TABLE tuning_orders
ADD INDEX idx_tenant_status_time (tenant_id, status, created_at, id),
ALGORITHM=INPLACE, LOCK=NONE;tenant_id 和 status 固定后,索引按 created_at、id 排列,倒序查询可以反向扫描。id 用于区分创建时间相同的记录。
id、created_at 都在索引中,可以覆盖查询。增加 amount 等返回列后,会产生回表访问。
复跑同一查询并检查写入代价
调整后的实际节点:
Limit rows=20 loops=1
Covering index lookup (reverse) rows=20 loops=1
index: idx_tenant_status_time
lookup: tenant_id=7, status=1建索引后,实际读取从 100000 行降到 20 行,显式排序也被消除。这个索引按 status 分组,不带 status 的查询需要另选索引。
日期范围查询
在索引列上调用 DATE,会让查询先定位租户,再逐行计算日期。
建立时间索引并观察原查询
ALTER TABLE tuning_orders
ADD INDEX idx_tenant_time (tenant_id, created_at, id),
ALGORITHM=INPLACE, LOCK=NONE;
EXPLAIN ANALYZE SELECT COUNT(*)
FROM tuning_orders
WHERE tenant_id = 7 AND DATE(created_at) = '2026-09-14';idx_tenant_time 读取该租户的 10000 行,再用 DATE 筛出 144 行。
改成左闭右开的时间范围
EXPLAIN ANALYZE SELECT COUNT(*)
FROM tuning_orders
WHERE tenant_id = 7
AND created_at >= '2026-09-14 00:00:00'
AND created_at < '2026-09-15 00:00:00';直接比较时间上下界,使用大于等于当天零点、小于次日零点的范围。TIMESTAMP 或跨时区日期需要先换算业务时区。
核对结果数量与扫描范围
| 对照项 | DATE 条件 | 时间范围条件 |
|---|---|---|
| COUNT(*) 结果 | 144 | 144 |
| 实际索引节点读取行数 | 10000 | 144 |
| 访问方式 | 按租户查找后过滤 | 租户与时间的覆盖范围扫描 |
COUNT(*) 仍需读取范围内符合条件的索引记录。
游标分页
LIMIT offset, size 会扫描并跳过前面的记录,偏移越大,读取越多。
观察大偏移量实际丢弃的记录
EXPLAIN ANALYZE SELECT id, created_at
FROM tuning_orders
WHERE tenant_id = 7
ORDER BY created_at DESC, id DESC
LIMIT 8000, 20;返回 20 行,索引实际读取 8020 行,前 8000 行被跳过。
用上一页末条记录作为下一页边界
保存上一页最后一条的 created_at 和 id,作为下一页起点。下面使用排序后第 8000 条的值,与 OFFSET 查询对照。
EXPLAIN ANALYZE SELECT id, created_at
FROM tuning_orders
WHERE tenant_id = 7
AND (
created_at < '2026-08-14 21:00:00'
OR (created_at = '2026-08-14 21:00:00' AND id < 20007)
)
ORDER BY created_at DESC, id DESC
LIMIT 20;时间相同时继续比较 id,避免漏掉同一时刻的记录。
验证页边界及产品语义
两种写法返回相同的 20 条记录,游标查询读取 20 行,OFFSET 读取 8020 行。
游标适合连续翻页。筛选条件和排序改变后,需要重新开始翻页。
批量查询与关联
先取 20 条订单,再逐条查询客户,会产生 21 次数据库调用。
从请求追踪确认逐条查询
-- 第一次:取一页订单
SELECT id, customer_id, created_at
FROM tuning_orders
WHERE tenant_id = 7
ORDER BY created_at DESC, id DESC
LIMIT 20;
-- 随后应用循环执行 20 次;这里的 334 是客户 ID 示例
SELECT name FROM tuning_customers
WHERE tenant_id = 7 AND id = 334;从请求追踪中检查重复 SQL 和调用次数。
先确定订单页,再一次关联客户
EXPLAIN ANALYZE
SELECT o.id, o.created_at, c.name
FROM (
SELECT id, tenant_id, customer_id, created_at
FROM tuning_orders
WHERE tenant_id = 7
ORDER BY created_at DESC, id DESC
LIMIT 20
) AS o
LEFT JOIN tuning_customers AS c
ON c.tenant_id = o.tenant_id AND c.id = o.customer_id
ORDER BY o.created_at DESC, o.id DESC;先取订单页,再按 tenant_id、id 关联客户。LEFT JOIN 保留没有客户记录的订单,客户主键保证每条订单最多匹配一位客户。
也可以收集这页的客户 ID,用 tenant_id 和 IN 一次查出,再在应用中映射。
同时检查调用次数与关联循环
关联计划查客户主键时 loops=20、rows=1,数据库内部仍访问 20 次索引,应用往返从 21 次降到 1 次。
比较接口总耗时、SQL 次数和返回数据。
锁等待
按主键更新仍然卡住时,检查是否有其他事务持锁。
在测试库用两个会话复现锁等待
-- 会话 A:持有测试订单的行锁,暂不结束事务
START TRANSACTION;
SELECT id FROM tuning_orders WHERE id = 7 FOR UPDATE;
-- 完成会话 B 的观察后,在会话 A 执行 ROLLBACK;-- 会话 B:独立连接;将本次测试的锁等待上限设为 5 秒
SET SESSION innodb_lock_wait_timeout = 5;
UPDATE tuning_orders SET amount = amount + 1 WHERE id = 7;A 未释放锁时,B 会等待。观察后在 A 执行 ROLLBACK。
找到等待者与阻塞者
-- 在第三个观察连接中,于 B 等待期间执行
SELECT wait_started, wait_age, locked_table,
waiting_pid, blocking_pid, waiting_query, blocking_query
FROM sys.innodb_lock_waits;
SELECT trx_mysql_thread_id, trx_started, trx_state, trx_query
FROM information_schema.innodb_trx
ORDER BY trx_started;阻塞连接可能空闲,blocking_query 为 NULL,事务却仍持有锁。根据连接和事务时间找到对应请求,提交或回滚事务。元数据锁另查 metadata_locks。
缩短事务并用条件更新表达状态转换
SELECT FOR UPDATE 后调用远程接口,会在网络等待期间一直持锁。将可提前完成的计算移出事务,简单状态转换可以用条件更新。
-- 独立示例:在完整业务事务设计中使用
UPDATE tuning_orders
SET status = 1
WHERE id = 7 AND tenant_id = 7 AND status = 0;
SELECT ROW_COUNT() AS changed_rows;影响 1 行表示完成 0 → 1 转换,0 行表示记录不存在或状态已变化。
复测并发冲突与回滚路径
比较锁持有时间和等待数量。事务按相同顺序访问记录可减少死锁;死锁后有限次重试整个事务,锁等待超时则先结束失败事务。
连接池与缓冲池
高峰期排队时,检查连接池等待、活动连接和物理读取。
在同一观察窗口采集状态增量
SHOW GLOBAL STATUS WHERE Variable_name IN (
'Threads_connected', 'Threads_running', 'Connections',
'Max_used_connections', 'Aborted_connects',
'Innodb_buffer_pool_read_requests', 'Innodb_buffer_pool_reads'
);
SHOW GLOBAL VARIABLES WHERE Variable_name IN (
'max_connections', 'innodb_buffer_pool_size'
);Threads_connected、Threads_running 是当前值,Max_used_connections 是峰值。读取次数取同一时间窗口的增量,Δreads / Δread_requests 可估算缓冲池未命中比例。
先控制连接预算,再评估内存调整
四个应用实例各开 40 个连接,总上限为 160。总预算为 80 时,每个实例可分配 20 个,还需给管理任务和其他服务留出连接。
调整后比较连接池等待、超时率、Threads_running 和吞吐量,同时检查连接泄漏与长事务。
物理读取较多且内存有余量时,可以调整 buffer pool。先记录原值,便于恢复。
-- 仅用于本笔记的独立测试实例,需要系统变量管理权限
SET @original_pool_bytes = @@global.innodb_buffer_pool_size;
SET GLOBAL innodb_buffer_pool_size = 268435456;
SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_resize_status';
SELECT @@global.innodb_buffer_pool_size;
-- 等待调整完成,在相同负载下采集指标后恢复
SET GLOBAL innodb_buffer_pool_size = @original_pool_bytes;比较整体表现而非单个命中率
检查实际生效的容量。缓存预热后再比较,SET GLOBAL 的修改需要另行持久化。
内存还要供连接、排序、临时表和操作系统使用。
其他
估计行数与实际行数偏差较大
SHOW INDEX FROM tuning_orders;
ANALYZE TABLE tuning_orders;先检查参数、数据倾斜和统计信息,再用 ANALYZE TABLE 更新统计并重跑查询。它会访问表并获取相应锁。
执行计划字段的快速对照
| 字段或标记 | 看什么 |
|---|---|
| type / key | 访问方式与实际索引;index 是全索引扫描,key 非空不代表读得少 |
| rows / filtered | 估计读取量与过滤比例,结合连接顺序及实际计划 |
| actual rows / loops | 节点每次循环的平均输出行数与循环次数,向下查看真实读取节点 |
| Using index | 覆盖访问;不要与仍可能回表的 Using index condition 混淆 |
| Using filesort / Using temporary | 额外排序或临时表,结合输入规模与是否落盘评价 |
评估索引上线、回退与写入影响
ALGORITHM=INPLACE、LOCK=NONE 在不支持相应方式时会报错。创建索引仍有元数据锁和资源开销,上线前检查长事务、磁盘空间和复制延迟。
将疑似冗余索引设为不可见,观察查询变化,再恢复可见进行对照。
ALTER TABLE tuning_orders ALTER INDEX idx_tenant_status_time INVISIBLE;
-- 复测相关查询;完成后恢复
ALTER TABLE tuning_orders ALTER INDEX idx_tenant_status_time VISIBLE;不可见索引仍会随写入维护。评估删除后的写入收益,需要在测试库中实际删除并比较。