MySQL数据库运行缓慢如何排查?慢查询、索引与执行计划详解

"数据库慢"是最模糊的故障描述。它可能是某一条 SQL 突然变慢,也可能是整体响应都变长;可能是磁盘满了,也可能是锁住了;甚至可能数据库根本没问题,是应用自己线程池耗尽在排队。如果一上来就重启数据库或者盲目加索引,大概率既没解决问题,又引入了新风险。

这篇文章给一套可复用的排查体系:先用系统指标和会话状态判断"慢在哪一层",再开慢查询日志把问题 SQL 抓出来,用 mysqldumpslow 和 pt-query-digest 做聚合,然后用 EXPLAIN 逐列分析执行计划,最后落到索引优化、SQL 改写和锁等待处理。每一步都配了可以直接复制执行的命令。

一、先定层级:是数据库慢还是别的地方慢

在动数据库之前,先用几分钟确认瓶颈确实在数据库这一层。

看系统资源

top                 # 看 CPU、内存,%wa 高说明在等 IO
iostat -x 1         # 看 %util、await、svctm,%util 接近 100% 说明磁盘饱和
vmstat 1            # r 列持续大于核数说明 CPU 排队
free -h             # available 是否紧张,是否开始用 swap

如果 %wa 高且 %util 接近 100%,瓶颈在磁盘 IO;如果 CPU 用户态打满,可能是大量排序或扫描;如果内存开始用 swap,数据库性能会断崖式下降(因为本该在内存里的页被换出去了)。

看数据库会话状态

SHOW GLOBAL STATUS LIKE 'Threads_running';
SHOW GLOBAL STATUS LIKE 'Slow_queries';
SELECT id, time, state, LEFT(info, 100) FROM information_schema.PROCESSLIST
WHERE command != 'Sleep' ORDER BY time DESC LIMIT 10;

Threads_running 长期高于几十,说明有大量并发在执行;Slow_queries 是慢查询累计计数(需要开了慢日志才有意义)。

一个快速判断

在数据库本机执行一条简单的主键查询并计时:

time mysql -uroot -p -e "SELECT * FROM your_db.your_table WHERE id = 1\G" > /dev/null

如果这条毫秒级的查询也要花几百毫秒,说明是数据库连接或整体资源的问题;如果这条很快但业务 SQL 很慢,那就是 SQL 本身的问题。这一步能帮你省掉很多无用功。

提醒:命令行带密码会被 ps 看到并写入 history。建议配置 ~/.my.cnf 的 [client] 段(权限 600)或用 mysql_config_editor。

二、开启慢查询日志

慢查询日志是排查的起点。它记录执行时间超过阈值的 SQL。

查看当前配置

SHOW GLOBAL VARIABLES LIKE 'slow_query%';
SHOW GLOBAL VARIABLES LIKE 'long_query_time';
SHOW GLOBAL VARIABLES LIKE 'log_queries_not_using_indexes';
SHOW GLOBAL VARIABLES LIKE 'min_examined_row_limit';

几个关键参数:

参数 说明 建议
slow_query_log 是否开启 生产建议常开
slow_query_log_file 日志路径 放在数据盘之外的分区更好
long_query_time 阈值(秒),支持小数 OLTP 建议 0.5 到 1 秒;精细优化时临时设 0.1
log_queries_not_using_indexes 记录未走索引的查询 开启,但会产生大量日志,需配合轮转
min_examined_row_limit 扫描行数低于此值的不记录 设 100 左右,过滤掉扫描很少的
log_slow_admin_statements 记录 ALTER 等管理语句 排查 DDL 影响时可开
log_throttle_queries_not_using_indexes 每分钟最多记多少条未用索引的 开启日志爆炸时用它限流

临时开启(重启失效)

SET GLOBAL slow_query_log = ON;
SET GLOBAL long_query_time = 0.5;
SET GLOBAL log_queries_not_using_indexes = ON;
SET GLOBAL min_examined_row_limit = 100;

注意:long_query_time 和 min_examined_row_limit 是会话级变量,改完全局后需要新连接才生效。改完后自己重连一次客户端再验证。

永久生效(写配置文件)

[mysqld]
slow_query_log                      = 1
slow_query_log_file                 = /var/log/mysql/slow.log
long_query_time                     = 0.5
log_queries_not_using_indexes       = 1
min_examined_row_limit              = 100
log_slow_admin_statements           = 1
log_throttle_queries_not_using_indexes = 100

创建日志目录并授权(路径按你的实际配置调整):

sudo mkdir -p /var/log/mysql
sudo chown mysql:mysql /var/log/mysql

重启生效:

# RHEL/CentOS/Rocky
sudo systemctl restart mysqld
# Debian/Ubuntu
sudo systemctl restart mysql

慢日志要轮转,否则会撑爆磁盘

sudo tee /etc/logrotate.d/mysql-slow <<'EOF'
/var/log/mysql/slow.log {
    daily
    rotate 7
    compress
    delaycompress
    missingok
    notifempty
    create 640 mysql mysql
    postrotate
        /usr/bin/mysqladmin flush-logs 2>/dev/null || true
    endscript
}
EOF

风险提示:开启 log_queries_not_using_indexes 会让日志量急剧增长,尤其是有大表全表扫描的应用。上线前先评估磁盘空间,并配置好轮转。 磁盘写满会导致 MySQL 无法写入甚至崩溃,这是比慢查询严重得多的事故。

另外,慢日志本身有轻微性能开销(主要是写文件),但在高 IO 能力的盘上通常可以忽略。如果实在担心,可以在排查窗口临时开启,查完关掉。

三、分析慢日志:mysqldumpslow 与 pt-query-digest

mysqldumpslow(MySQL 自带)

mysqldumpslow -s t -t 10 /var/log/mysql/slow.log      # 按总耗时排序,取前 10
mysqldumpslow -s c -t 10 /var/log/mysql/slow.log      # 按出现次数排序
mysqldumpslow -s at -t 10 /var/log/mysql/slow.log     # 按平均耗时排序
mysqldumpslow -g 'SELECT' -s t -t 5 /var/log/mysql/slow.log   # 过滤包含 SELECT 的

-s 指定排序依据:t 总时间、at 平均时间、c 次数、r 返回行数、ar 平均返回行数。-t 是取前几条,-g 是正则过滤。

mysqldumpslow 的优点是随 MySQL 自带、零依赖;缺点是输出信息少,没有百分位、没有按模板归类的详细统计。

pt-query-digest(Percona Toolkit,强烈推荐)

# RHEL/CentOS/Rocky
sudo dnf install -y percona-toolkit
# Debian/Ubuntu
sudo apt-get install -y percona-toolkit

pt-query-digest /var/log/mysql/slow.log > slow_report.txt
pt-query-digest --limit 100% --since '2026-01-01 00:00:00' /var/log/mysql/slow.log

报告的核心部分是把相似的 SQL 归成一个"模板"(把具体的参数值替换成占位符),然后给出:

  • Rank、Response time(总耗时与占比)、Calls(执行次数)
  • R/Call(平均每次耗时)
  • V/M(方差与均值的比,越大说明耗时波动越大)
  • 最后是该模板的一个代表性样例与完整统计直方图

怎么看这张表:优先处理"总耗时占比"最高的,而不是"单次最慢"的。 一条 10 秒的 SQL 一天只跑一次,和一条 200 毫秒的 SQL 一天跑 50 万次,显然后者更值得优化——它占用了绝大部分的数据库总时间。

常用变体:

# 只分析最近 12 小时
pt-query-digest --since 12h /var/log/mysql/slow.log

# 分析后写入数据库表便于对比(需先建库)
pt-query-digest --review h=localhost,D=test,t=query_review /var/log/mysql/slow.log

# 直接抓 TCP 流量分析,不依赖慢日志
tcpdump -i any -s 65535 -x -nn -q -tttt 'port 3306' > /tmp/mysql.tcp
pt-query-digest --type tcpdump /tmp/mysql.tcp

--type tcpdump 的方式很有用:不用重启、不用开慢日志,就能看到真实的查询分布。缺点是开销比慢日志大,只适合短时抓取。

四、EXPLAIN:逐列读懂执行计划

拿到问题 SQL 之后,用 EXPLAIN 看优化器打算怎么执行:

EXPLAIN SELECT * FROM orders WHERE user_id = 100 AND status = 1 ORDER BY created_at DESC LIMIT 20\G

对已有连接也可以看真实执行计划(MySQL 8.0 支持 EXPLAIN FOR CONNECTION):

EXPLAIN FOR CONNECTION 12345;

以及带真实统计信息的分析(8.0.18+):

EXPLAIN ANALYZE SELECT * FROM orders WHERE user_id = 100;

各列含义

列 关键取值 说明
id 数字 查询的层级,越大越先执行;相同则自上而下
select_type SIMPLE / PRIMARY / SUBQUERY / DERIVED / UNION 查询类型
table 表名或 访问的表,derived 表示临时派生表
type system > const > eq_ref > ref > range > index > ALL 最重要的列,见下表
possible_keys 索引名列表 理论上可能用到的索引
key 索引名 实际用到的索引,为 NULL 就是没走索引
key_len 字节数 实际使用的索引长度,可推断联合索引用了几列
ref const / 列名 / NULL 与索引比较的是什么
rows 数字 预估要扫描的行数,越小越好
filtered 百分比 经过条件过滤后剩余的比例
Extra 见下文 额外信息,藏着最关键的性能线索

type 从好到坏

type 含义 评价
system 表只有一行 最好
const 主键或唯一索引命中一行 非常好
eq_ref JOIN 时用主键/唯一索引匹配 好
ref 普通非唯一索引匹配 通常可接受
range 索引范围扫描 可接受,注意范围大小
index 全索引扫描(扫整棵索引树) 较差,但比 ALL 好(不回表时另说)
ALL 全表扫描 大表上基本要优化

经验标准:OLTP 业务里,type 至少要达到 range,最好是 ref 或 const。出现 ALL 且 rows 上万,就需要认真对待了。

Extra 里的关键提示

Extra 含义 处理方向
Using index 覆盖索引,无需回表 好现象
Using where 在存储引擎返回后又做了过滤 正常,但如果配合大 rows 要注意
Using filesort 需要额外排序 重点关注,考虑建有序索引
Using temporary 用了临时表 重点关注,优化 GROUP BY / DISTINCT
Using index condition 索引条件下推(ICP) 好现象,减少回表
Using join buffer JOIN 没走索引 检查关联字段是否有索引、类型是否一致
Using MRR 多范围读取优化 通常没问题

Using filesort 并不一定真的在写文件。 它只是表示"需要一次排序操作",数据量小时在内存里完成。真正麻烦的是 Using temporary; Using filesort 同时出现,或者 Copying to tmp table on disk(可以用 SHOW PROCESSLIST 观察到)。

判断排序是否落盘,看临时表配置:

SHOW GLOBAL VARIABLES LIKE 'tmp_table_size';
SHOW GLOBAL VARIABLES LIKE 'max_heap_table_size';
SHOW GLOBAL STATUS LIKE 'Created_tmp%';

Created_tmp_disk_tables / Created_tmp_tables 超过 25% 就偏高。

五、索引失效的典型写法

以下几种写法会让明明有索引的列也走不上索引。

1. 在列上用函数或表达式

-- 失效:对列做了函数运算
SELECT * FROM orders WHERE DATE(created_at) = '2026-01-01';
-- 改写:把函数挪到常量侧,变成范围查询
SELECT * FROM orders WHERE created_at >= '2026-01-01' AND created_at < '2026-01-02';

-- 失效
SELECT * FROM users WHERE YEAR(birthday) = 1990;
-- 改写
SELECT * FROM users WHERE birthday >= '1990-01-01' AND birthday < '1991-01-01';

MySQL 8.0 支持函数索引(在表达式上建索引),可以作为一种兜底:

ALTER TABLE orders ADD INDEX idx_created_date ((DATE(created_at)));

但这通常是次优解,优先改写 SQL。

2. 隐式类型转换

-- user_id 是 VARCHAR,用数字查会导致隐式转换,索引失效
SELECT * FROM users WHERE user_id = 12345;
-- 正确
SELECT * FROM users WHERE user_id = '12345';

反过来,如果列是 INT 而传入字符串,MySQL 会把字符串转成数字,通常还能走索引(因为转换发生在常量侧)。所以真正的雷区是字符串列被当成数字查——MySQL 会对列做 CAST,导致整个列上的函数运算,索引彻底失效。

判断方法:EXPLAIN 里看是否出现 Using where 且 key 为 NULL;也可以用 SHOW WARNINGS 看优化器改写后的 SQL:

EXPLAIN SELECT * FROM users WHERE user_id = 12345;
SHOW WARNINGS;

3. 前导通配符模糊查询

-- 失效:% 开头
SELECT * FROM articles WHERE title LIKE '%mysql%';
-- 可用:前缀匹配,能走索引
SELECT * FROM articles WHERE title LIKE 'mysql%';

B+ 树索引按前缀有序组织,% 在开头就无法定位起始位置。'%xxx' 这种需求要么用全文索引(FULLTEXT),要么接搜索引擎(Elasticsearch 等),不要在 MySQL 里硬扛全表扫描。

4. 不满足最左前缀

有联合索引 (a, b, c):

WHERE a = 1 AND b = 2            -- 用到 a、b
WHERE a = 1 AND b = 2 AND c = 3  -- 用到 a、b、c
WHERE a = 1 AND c = 3            -- 只用到 a(c 被跳过,无法继续用)
WHERE b = 2 AND c = 3            -- 完全用不上(缺最左列 a)
WHERE a > 1 AND b = 2            -- 只用到 a,范围之后的列不再用于索引定位

注意最后一条:范围查询(>、<、BETWEEN、LIKE 前缀)之后的索引列只能作为过滤条件,不能继续用于索引定位。 这是联合索引设计时最容易踩的坑。

5. OR 连接不同列

-- 若 name 和 phone 各自单列索引,OR 可能导致索引合并或全表扫描
SELECT * FROM users WHERE name = 'a' OR phone = '13800138000';
-- 改写为 UNION ALL,各自走各自的索引
SELECT * FROM users WHERE name = 'a'
UNION ALL
SELECT * FROM users WHERE phone = '13800138000' AND name <> 'a';

MySQL 有 index_merge 优化(Extra 里显示 Using union(...)),但它的效果往往不如 UNION 改写稳定,且优化器有时会放弃使用它。

6. 其他常见情况

  • !=、<>、NOT IN、NOT EXISTS 通常选择性差,优化器倾向全表扫描
  • IS NULL 在部分情况下可以用索引,但 IS NOT NULL 效果差
  • 查询结果集占全表比例过高(经验上超过 20% 到 30%)时,优化器会主动放弃索引,因为回表成本高于顺序扫描——这不一定是优化器错了
  • 索引列参与了计算:WHERE price * 2 > 100 应改写成 WHERE price > 50

六、覆盖索引与联合索引顺序

覆盖索引

如果索引已经包含了查询需要的全部列,就不需要回表查主键行,Extra 里会显示 Using index。这能显著减少随机 IO。

-- 有索引 (user_id, status, created_at)
SELECT user_id, status, created_at FROM orders WHERE user_id = 100;
-- 覆盖索引:Extra = Using index

SELECT * FROM orders WHERE user_id = 100;
-- 需要回表:Extra 无 Using index

实践建议:禁止在业务代码里写 SELECT *,只取需要的列。 这不只是规范问题,它直接决定了能不能用上覆盖索引。

联合索引的列顺序

经验原则:

第一原则:等值条件列在前,范围条件列在后。 因为范围之后的列无法继续走索引。

-- 查询:WHERE user_id = ? AND status = ? AND created_at > ?
-- 索引应该是 (user_id, status, created_at),把范围列放最后
ALTER TABLE orders ADD INDEX idx_uid_st_ct (user_id, status, created_at);

第二原则:区分度高的列尽量靠前(在满足第一原则的前提下)。区分度可以用下面这个查询估算,越接近 1 越好:

SELECT COUNT(DISTINCT user_id) / COUNT(*) AS user_id_sel,
       COUNT(DISTINCT status)  / COUNT(*) AS status_sel
FROM orders;

第三原则:兼顾排序与分组。 如果查询有 ORDER BY created_at,把 created_at 放进索引的最后一位,让索引本身有序,就能避免 Using filesort:

-- WHERE user_id = ? ORDER BY created_at DESC
ALTER TABLE orders ADD INDEX idx_uid_ct (user_id, created_at);

冗余索引要清理

有了 (a, b, c) 之后,(a) 和 (a, b) 基本就是冗余的(除非有单独按 b 查询的需求)。冗余索引会拖慢写入并占用空间。可以查:

SELECT * FROM sys.schema_redundant_indexes LIMIT 20;
SELECT * FROM sys.schema_unused_indexes LIMIT 20;

sys.schema_unused_indexes 依赖 performance_schema 的统计,需要实例跑了一段时间才有参考价值。删除索引前先确认业务确实没用它,并选在低峰操作;索引删除是 DDL,大表上可能耗时很久。

统计信息不准也会导致选错索引

如果 EXPLAIN 的结果明显不合理(明明有更好的索引却选了全表扫描),可能是统计信息过期:

ANALYZE TABLE orders;
SHOW INDEX FROM orders;

SHOW INDEX 的 Cardinality 列反映基数估计。长期没跑过 ANALYZE 的大表、或者数据分布发生剧变后,统计信息会失真。

必要时可以用索引提示强制走某个索引作为临时手段:

SELECT * FROM orders FORCE INDEX (idx_uid_ct) WHERE user_id = 100;

索引提示是双刃剑:它绕过了优化器的判断,数据分布变化后可能变成负优化。只在紧急止血和验证效果时使用,长期方案还是优化统计信息和索引设计。

七、分页深翻优化

深翻页是 classic 的性能杀手:

SELECT * FROM orders WHERE status = 1 ORDER BY created_at DESC LIMIT 100000, 20;

MySQL 需要先扫描并丢弃前 100000 行,再返回 20 行。翻到几百页之后,这个开销是灾难性的。

方案一:延迟关联(子查询先定位主键)

SELECT o.* FROM orders o
INNER JOIN (
  SELECT id FROM orders WHERE status = 1 ORDER BY created_at DESC LIMIT 100000, 20
) t ON o.id = t.id
ORDER BY o.created_at DESC;

子查询只用覆盖索引取主键,避免了大量回表。通常能快几倍到几十倍。

方案二:游标分页(记录上一页的位置)

-- 第一页
SELECT * FROM orders WHERE status = 1 ORDER BY created_at DESC, id DESC LIMIT 20;
-- 后续页,用上一页最后一行的 created_at 和 id 作为游标
SELECT * FROM orders
WHERE status = 1 AND (created_at, id) < ('2026-01-01 10:00:00', 12345)
ORDER BY created_at DESC, id DESC LIMIT 20;

游标分页可以做到任意深度都是常数开销,是游标式无限滚动(信息流、时间线)场景的标准解法。代价是不能跳页,需要产品上接受"上一页/下一页"而不是"第 N 页"。

方案三:业务上限制深度

很多后台系统的深翻页其实是误操作。可以限制最多翻到前 100 页,或者要求先加筛选条件缩小结果集。这是对成本最低的一种"优化"。

八、锁等待与 sys schema

慢并非都是索引问题,也可能是被锁住

表现是:SQL 本身很简单,EXPLAIN 也走索引,但就是卡住不动,SHOW PROCESSLIST 里显示 Waiting for table metadata lock 或者长时间无状态推进。

SHOW ENGINE INNODB STATUS\G

重点看这几个段:

  • TRANSACTIONS:列出当前活跃事务,包含事务开始时间、持有的锁、等待的锁
  • LATEST DETECTED DEADLOCK:最近一次死锁的详细信息,包含两个事务各自持有的锁和等待的锁、以及被回滚的是哪一个
  • SEMAPHORES:大量等待通常说明锁争用严重
  • BUFFER POOL AND MEMORY、FILE I/O:辅助看 IO 状态

注意 SHOW ENGINE INNODB STATUS 输出的是最近一次死锁,不是全部。要看更完整的死锁信息,可以开启 innodb_print_all_deadlocks,把死锁写进错误日志。

查当前锁等待(MySQL 8.0)

SELECT waiting_pid, waiting_query, blocking_pid, blocking_query,
       wait_age, sql_kill_blocking_query
FROM sys.innodb_lock_waits;

这个视图直接给出了"谁在等、被谁挡住、杀掉阻塞者的语句"。执行 sql_kill_blocking_query 前务必确认阻塞事务的业务含义——它可能是一个正在跑的重要批量任务。

查长时间未提交的事务

SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id,
       trx_rows_locked, trx_rows_modified, LEFT(trx_query, 100) AS q
FROM information_schema.innodb_trx
ORDER BY trx_started
LIMIT 10;

一个开了几分钟还没提交的事务,很可能就是元数据锁的源头。事务要短,这是 InnoDB 使用的基本原则之一:不要在事务里做远程调用、不要等用户输入、批量操作要分批提交。

sys schema 常用视图

-- 全表扫描的语句
SELECT * FROM sys.statements_with_full_table_scans LIMIT 10;

-- 总耗时最高的语句模板
SELECT * FROM sys.statements_with_runtimes_in_95th_percentile LIMIT 10;

-- 临时表/排序最多的语句
SELECT * FROM sys.statements_with_temp_tables LIMIT 10;
SELECT * FROM sys.statements_with_sorting LIMIT 10;

-- 错误与警告
SELECT * FROM sys.statements_with_errors_or_warnings LIMIT 10;

-- 表维度的 IO 统计
SELECT * FROM sys.schema_table_statistics_with_buffer LIMIT 10;

-- 从未被使用过的索引
SELECT * FROM sys.schema_unused_indexes LIMIT 20;

sys schema 本质上是对 performance_schema 的封装,输出更友好。如果查询报错说 performance_schema 未启用,需要在配置文件里开启 performance_schema = ON 并重启。

九、排查流程小结

一个可复用的顺序:

# 1. 系统层:CPU / IO / 内存
top; iostat -x 1; free -h

# 2. 数据库层:活跃线程、当前长查询
mysql -e "SHOW GLOBAL STATUS LIKE 'Threads_running'"
mysql -e "SELECT id,time,state,LEFT(info,120) FROM information_schema.PROCESSLIST WHERE command!='Sleep' ORDER BY time DESC LIMIT 10"

# 3. 开慢日志(如未开),跑一个业务周期
mysql -e "SET GLOBAL slow_query_log=ON; SET GLOBAL long_query_time=0.5"

# 4. 聚合分析,找总耗时占比最高的模板
pt-query-digest /var/log/mysql/slow.log > /tmp/report.txt

# 5. 对 Top SQL 做执行计划分析
mysql -e "EXPLAIN \G"

# 6. 改索引 / 改 SQL / 改业务形态,然后对比验证

验证环节不能省:改完索引后重新跑一遍 EXPLAIN 确认 type 和 rows 的变化,再用 pt-query-digest 对比前后同一时间窗口的耗时。凭感觉说"应该快了"是不合格的。

常见问题

开了慢日志但没有记录,为什么?

几个常见原因:改了 long_query_time 但没重连会话(它是会话级变量);slow_query_log_file 指向的路径 MySQL 进程无权限写;min_examined_row_limit 设得太大把查询过滤掉了;或者查询确实都比阈值快。先用 SHOW GLOBAL VARIABLES 确认实际生效值,再用 SELECT SLEEP(2) 造一条慢查询验证。

long_query_time 设多少合适?

日常生产建议 0.5 到 1 秒。做精细优化时可以临时设到 0.1 秒抓更多样本,但要注意日志量会暴涨、且磁盘压力上升,抓完记得调回去。不要长期设成 0,那等于把所有查询都记下来,开销不可接受。

为什么明明有索引却不走?

最常见的是三种:查询列上加了函数或发生了隐式类型转换;结果集占全表比例太高(优化器认为全表扫描更便宜);联合索引不满足最左前缀。用 EXPLAIN 看 possible_keys 和 key,如果 possible_keys 有值但 key 是 NULL,基本就是优化器的成本判断,可以先 ANALYZE TABLE 刷新统计信息再试。

Using filesort 一定要消除吗?

不一定。小结果集的内存排序开销很小,为了消除它而加索引会增加写入成本和空间。判断标准是:这条 SQL 的总耗时占比高不高,排序的数据量大不大。优先处理 Top SQL,而不是把所有 filesort 都消灭掉。

索引加得越多越好吗?

不是。每个索引都会让 INSERT、UPDATE、DELETE 变慢(要维护多棵 B+ 树),占用更多磁盘和 buffer pool 空间,还会让优化器的选择变复杂。经验上一个表的索引数控制在 5 个以内比较健康,定期用 sys.schema_unused_indexes 清理没人用的。

大表加索引会不会锁表?

MySQL 5.6 之后 InnoDB 支持 Online DDL,加索引通常不会全程锁表,但仍有准备阶段和最后应用增量日志阶段的短暂锁,且大表操作可能耗时很久、产生大量 IO 和主从延迟。生产环境建议用 pt-online-schema-change 或 gh-ost 这类在线改表工具,并先在从库或从库快照上验证耗时。 操作前必须有可用备份。

总结

  • 先定层级再动手:用 top、iostat -x 1、free -h 区分 CPU、IO、内存瓶颈,再用 Threads_running 和 PROCESSLIST 看是否真有长查询。在本机跑一条主键点查计时,能快速判断是数据库慢还是应用侧慢。
  • 慢日志要常开且要轮转:slow_query_log=1、long_query_time 设 0.5 到 1 秒、log_queries_not_using_indexes 配合 min_examined_row_limit 和 logrotate 使用,避免日志撑爆磁盘——磁盘满比慢查询严重得多。
  • 分析看总耗时占比而不是单次最慢:pt-query-digest 的聚合报告里,总耗时占比最高的模板才是优化收益最大的目标;mysqldumpslow -s t -t 10 是零依赖的备选。
  • EXPLAIN 重点看四列:type 至少要到 range(OLTP 最好 ref/const)、key 是否为 NULL、rows 预估扫描行数、Extra 里的 Using filesort 与 Using temporary。MySQL 8.0 的 EXPLAIN ANALYZE 能看到真实耗时。
  • 索引失效有六种典型写法:列上用函数、隐式类型转换、% 开头模糊匹配、不满足最左前缀、OR 连接不同列、以及范围条件后的列无法继续索引定位。改写 SQL 优先于加函数索引,索引提示只用于临时止血。
  • 深翻页用延迟关联或游标分页:LIMIT 100000, 20 的开销来自丢弃前 10 万行;游标分页能做到常数开销但不支持跳页,需要产品形态配合。
  • 慢也可能是锁:SHOW ENGINE INNODB STATUS 看事务与死锁段,sys.innodb_lock_waits 直接给出阻塞关系。事务要短,杀阻塞会话前必须确认它的业务含义。所有索引变更和 DDL 前先确认有可用备份,并在低峰执行。