那天凌晨2点17分,我被值班电话叫醒,监控页面上一片深红:订单库的活跃连接数已经超过上限,应用服务开始批量报出 Too many connections。赶到工位后的第一件事不是重启MySQL,而是打开 SHOW PROCESSLIST,发现大量查询都卡在同一个状态上——一条平时根本没人注意的对账SQL,因为一次活动的数据量波动,把一个本应毫秒级的关联查询拖成了几十秒的全表扫描。
这个场景是 MySQL 性能优化在企业应用里最典型的缩影:问题从“连接数打满”这种外观开始,真正的病灶却藏得更深,可能是SQL写错了,可能是索引没建对,也可能是事务把锁拽得太久。我后来总结过一句话:企业库的“慢”,90%不会只有一个原因,它永远是SQL、索引、参数、事务、架构五层问题叠在一起的结果。这篇内容就按我实际排障的顺序展开,适合正在维护线上数据库的后端开发、DBA,也适合准备数据库面试时想建立系统知识框架的朋友。
1. 先说说那次被“连接数打满”逼出来的故障定位
1.1 凌晨报警的真相,往往不是连接数不够
那次的故障链路其实很经典:促销活动结束后,运营后台触发了一大批统计任务,每条统计SQL都要去关联一张几千万行的订单明细表。刚开始只是几条SQL变慢,后来请求越积越多,应用层的连接池先被打满,新的数据库连接请求进不来,最终数据库端抛出了 ERROR 1040: Too many connections。
很多团队到这一步的第一反应是把 max_connections 从 200 调到 2000,我见过不止一次这么做的。调完之后呢?雪崩从十分钟延迟到二十分钟,最后照样炸。为什么?因为 max_connections 只是一个门槛,它控制的是“能同时进多少个请求”,但它回答不了“这些请求来了之后能不能快速走”。真正的问题是每一条连接占用的时间太长,长查询像堵车一样把连接全部堵在路中间。
所以我后来处理这类问题,第一个动作不是调参,而是先确认今天跑的是不是有什么异动任务,再把所有正在执行的SQL抓出来看。调大连接数只能让你晚点死,不能让你活过来。
1.2 SHOW PROCESSLIST是定位慢SQL最快的入口
发现数据库连接异常后,第一现场我的习惯是执行:
sql复制SHOW FULL PROCESSLIST;
这个命令会把当前所有连接、每个连接正在执行的SQL和状态都列出来。我筛选时会重点看三列:
Time:这条SQL已经跑了多少秒,超10秒的基本都需要警惕;State:状态如果是Sending data、Copying to tmp table、Sorting result,通常意味着正在大量读取或排序;Info:正在执行的SQL原文,虽然可能被截断,但足以判断是哪类查询。
那晚抓出来的现场大概长这样:
| Id | Command | Time | State | Info |
|---|---|---|---|---|
| 31826 | Query | 38 | Sending data | SELECT o.order_no, u.name FROM orders o JOIN users u ... |
| 31831 | Query | 27 | Copying to tmp table | SELECT user_id, COUNT(*) FROM order_item GROUP BY user_id ... |
| 31842 | Query | 21 | Sending data | SELECT * FROM orders WHERE status = 1 AND create_time > ... |
定位到具体会话号之后,可以直接用:
sql复制KILL 31826;
先把这些正在吃资源的会话打断,让业务先恢复。注意,不要一上来就重启MySQL实例,如果库里正好有长事务在回滚,重启可能让回滚过程再来一遍,服务恢复反而更慢。这是我在生产环境踩过之后才记住的教训。
1.3 临时止血和根治之间,隔着一个慢查询日志
现场处理完之后,我一般会花整夜去复盘:类似的问题是不是以前也发生过?还有多少条藏在暗处的慢SQL没暴露?这就要靠慢查询日志——它是找出周期性隐患最重要的入口。
之前有个误区我一直提醒自己:排障时别急着开 general_log,它会记录所有SQL,对生产库的性能影响比你想的大很多。慢查询日志只记录超过指定时间的SQL,适合长期开启,是性价比最高的体检工具。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 慢查询日志的正确用法:不是打开就完事
2.1 生产环境建议这样配置
MySQL 里慢查询日志相关的参数组如下,写入配置文件 my.cnf:
ini复制[mysqld]
slow_query_log = ON
slow_query_log_file = /data/mysql/log/mysql-slow.log
long_query_time = 1
log_queries_not_using_indexes = ON
这里的核心是 long_query_time,意思是超过1秒的查询要被记录下来。为什么建议设成1秒而不是别的值?对绝大多数企业应用来说,一条SQL超过1秒已经足以在高峰期引起连接堆积;设成0.1秒会记录太多原本正常的复杂查询,日志增长很快,反而干扰判断。如果业务对延迟要求更严格,比如核心交易链路上的接口要求P99在200ms以内,那可以把 long_query_time 调到0.5秒,作为压测或灰度期间的观察窗口。
log_queries_not_using_indexes 这个开关是把没走索引的查询也记进去。刚打开时量会很大,因为很多全表扫描的查询可能并不慢,但它能帮你在早期发现索引设计的盲区,跑两三周后如果量太夸张,可以关掉或单独过滤。
2.2 慢日志里真正值得关注的四列信息
慢日志打开一段时间后,里面会出现类似这样的内容:
code复制# Query_time: 12.438128 Lock_time: 0.000121 Rows_sent: 500 Rows_examined: 4623896
SET timestamp=1710000000;
SELECT order_no, status FROM orders WHERE user_id = 123456 ORDER BY create_time DESC LIMIT 20;
第一次看慢日志的人容易被 Query_time 吓到,确实它代表这条SQL执行了12秒,但我每次还会认真看后面三列:
Lock_time:锁等待时间,如果这个值明显偏高,说明问题可能不在SQL本身,而在事务和锁竞争;Rows_sent:最终返回了多少行;Rows_examined:这条SQL为了拿到结果,实际扫描了多少行。
一个坏SQL的典型特征就是 Rows_examined 几十万甚至几百万,Rows_sent 只有几十行。说白了,MySQL为这么点结果翻了半天仓库。看到这种组合,基本可以确定是索引缺失或者SQL写法导致索引失效。
2.3 两个聚合工具,帮你从几千条日志里抓重点
线上跑一段时间后,慢日志可能每天几百条。光靠肉眼一条条看是不现实的,我常用的两个工具是:
bash复制# MySQL 自带的 mysqldumpslow,按总耗时排序看前10条
mysqldumpslow -s t -t 10 /data/mysql/log/mysql-slow.log
# Percona Toolkit 里的 pt-query-digest,功能更细
pt-query-digest /data/mysql/log/mysql-slow.log
mysqldumpslow 会把结构相似的SQL抽象成模板,把实际参数折叠成 N,然后告诉你这类SQL一共出现了多少次、平均耗时多少、总耗时多少。pt-query-digest 的输出还会按“总响应时间占比”排序。我拿到报告后,一般优先处理那些“总响应时间占比最高的查询类别”,而不是单次最慢的那一条——单次最慢的可能只出现了一次,修完收益有限;总耗时占比高的,才是每天持续拖累业务的真凶。
bash复制# 一个比较典型的输出片段
Count: 320 Time=3.42s (1095s) Lock=0.00s (0s) Rows=50.0 (16000)
SELECT * FROM orders WHERE user_id = N ORDER BY create_time DESC LIMIT N
从这个摘要能明显看到:这类SQL出现了320次,平均3.42秒,累计跑了1095秒,这就是业务高峰期连接被打满的直接原因。
3. EXPLAIN 执行计划:一眼看穿慢SQL在读什么
3.1 type、rows、Extra 是执行计划里的核心三件套
慢查询日志只能告诉你“谁的锅”,EXPLAIN 才能告诉你“锅为什么黑”。用 EXPLAIN 加在慢SQL前面,MySQL会列出执行计划,我读的时候只盯几个关键字段:
| 字段 | 我关注什么 |
|---|---|
| type | 访问类型,从好到坏大体是 system > const > eq_ref > ref > range > index > ALL |
| key | 优化器实际选择的索引,如果为 NULL,说明这条SQL没走任何索引 |
| rows | 预估要扫描的行数,不是最终实际值,但能直接体现执行路径的代价 |
| Extra | 是否出现 Using filesort、Using temporary、Using index 这些标志 |
ALL 就是全表扫描,index 是指全索引扫描,两者在数据量大时都是灾难。range 表示走索引做了范围扫描,ref 和 eq_ref 在关联查询里属于比较理想的级别,const 是根据主键或唯一索引直接定位。
3.2 用一次真实的关联查询复盘执行计划
假设我们的订单列表查询慢成这样:
sql复制EXPLAIN
SELECT o.order_no, u.nick_name
FROM orders o
JOIN users u ON o.user_id = u.id
WHERE o.status = 1
AND o.create_time >= '2024-06-01'
ORDER BY o.create_time DESC
LIMIT 20;
执行计划出来,如果 orders 表的 type 是 ALL,rows 是几百万,基本就说明优化器选择把整张订单表扫了一遍。常见的原因有两个:一是 status = 1 这个条件可能筛出的是大部分数据,区分度太低,优化器算下来觉得还不如全表扫描;二是 (status, create_time) 上如果没有联合索引,它想用索引做范围过滤和排序也无从下手。
这时候优化的方向是建一个:
sql复制ALTER TABLE orders ADD INDEX idx_status_create_time (status, create_time);
让 create_time 的排序可以直接从索引里拿。但要提醒一句,如果 status = 1 的数据仍然占到全表40%以上,优化器可能还是会选择全表扫描,因为回表成本比顺序扫描还高。遇到这种情况,要么缩小筛选范围,要么结合业务调整查询条件,让扫描行数真正降下来。
3.3 会让索引失效的几种写法,建议背下来
有一类慢SQL,明明表上建了索引,执行计划却显示没走,这通常是写法把索引废了。最常见的几种:
sql复制-- 1. 对索引列做运算
SELECT * FROM orders WHERE total_amount + 5 = 200;
-- 正确改法:把结果算好再比较
SELECT * FROM orders WHERE total_amount = 195;
很多人对第一类不以为然,觉得 total_amount + 5 只是顺手写了个表达式。但MySQL要判断 total_amount + 5 是否等于200,就必须把每一行的 total_amount 取出来算一遍,根本没法用B+树直接定位。哪怕字段是 int 也不例外,这就是为什么我一直建议大家别把常量运算放在索引列那一侧。
sql复制-- 2. 前缀模糊匹配
SELECT * FROM orders WHERE order_no LIKE '%20240601%';
-- 3. 索引列上套函数
SELECT * FROM users WHERE DATE(create_time) = '2024-06-01';
-- 正确改法,写成范围条件
SELECT * FROM users
WHERE create_time >= '2024-06-01 00:00:00'
AND create_time < '2024-06-02 00:00:00';
sql复制-- 4. 隐式类型转换
SELECT * FROM users WHERE phone = 13800138000;
phone 字段如果类型是 varchar,而这里拿数字去比较,MySQL会先把字段转成数字再逐个比较,索引就等于失效了。正确写法是显式加引号:WHERE phone = '13800138000'。
还有一个被问过很多次的点:OR 表达式。有人问“MySQL 的 OR 能去重吗”——其实 OR 只是逻辑条件,它本身不是去重操作,结果重复一般来自 JOIN 或者应用层没过滤。在索引层面要小心的是,假如一条SQL写成 WHERE a = 1 OR b = 2,只有 a 字段有索引,优化器往往没法只用 a 的索引来快速得到 b 的结果集,有时候会退化成全表扫描。遇到这种场景,我一般改写成两个查询的 UNION ALL,或者改造成 WHERE a = 1 UNION ALL WHERE b = 2,每个分支都能独立走索引,让优化器别那么为难。
4. 索引设计不能凭感觉:从回表成本到复合索引顺序
4.1 “加了索引还慢”才是最常见的坑
慢SQL排查到最后,很多人会大刀阔斧地给表加索引。但我要泼一盆冷水:项目里“加了索引还是慢”的情况,远远多于“没索引所以慢”的情况。
原因是 MySQL 的 InnoDB 引擎用的是聚簇索引结构。主键索引的叶子节点直接存整行数据;我们建的普通索引(也叫二级索引)叶子节点只存索引字段和主键值。也就是说,走普通索引查数据时,MySQL 先拿主键值再去主键索引树里找完整行,这个过程叫回表。
回表一次两次还好,如果一条SQL查出5000条满足条件的记录,就要回表5000次,每次都是一次随机磁盘IO。在机械硬盘时代这是灾难,在SSD上也不便宜。所以很多时候MySQL优化器算完成本,觉得回表太多,干脆全表扫描,索引建了也用不上。
4.2 用覆盖索引把列表查询从200ms压到5ms
我之前优化过一个用户查询接口,SQL是这样的:
sql复制SELECT id, user_name, status
FROM users
WHERE user_name = 'zhangsan';
users表在 user_name 上本来有一个唯一索引:uk_user_name(user_name)。执行计划看起来走了索引,但 Extra 里却是空,意味着每条匹配到的记录都得回表取 status。单个查询不明显,但这个接口被多个页面高频调用,并发一上来,回表开销被放大。
优化方式是把它改成覆盖索引:
sql复制ALTER TABLE users
ADD INDEX idx_user_name_status(user_name, status);
索引里已经包含 user_name 和 status,查询需要的字段全部在索引页上能找到,就不需要回表了。此时再看执行计划,Extra 里会出现 Using index,这是比较理想的状态,相当于直接从索引上“抄答案”。
需要注意:别把这种覆盖索引方案套到所有查询上。如果查询要返回的字段有十几个,强行全塞进索引会导致索引体积膨胀,写放大也严重。覆盖索引适合高频且返回列少的查询,核心目标就是减少回表。
4.3 复合索引字段顺序的三条铁律
复合索引的顺序是另一个高频踩坑点。我曾经见过一张表上有 (status, create_time) 和 (create_time, status) 两个几乎重复的索引,就是因为不同开发各建各的,谁也没看谁。设计复合索引时,我通常按以下顺序考虑:
第一条,等值条件放前面,范围条件放后面。例如查询经常写 WHERE user_id = 123 AND create_time > '2024-01-01',那么 (user_id, create_time) 更合适,因为 user_id 的等值条件可以把范围快速缩成一个小集合,再在集合内对 create_time 做范围筛查。
第二条,区分度高的字段放前面。假设有 status 和 order_no 两个条件,order_no 几乎一条一个值,区分度远高于 status,那就把它放前面,先用高区分度字段把数据量砍到底,剩下要处理的自然就少了。
第三条,能让 ORDER BY 走索引就尽量让排序
