ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

数据库慢排查实战:先看这5个地方,快速定位性能瓶颈

数据库慢排查实战:先看这5个地方,快速定位性能瓶颈

半夜两点被电话吵醒,值班同事说接口超时报警,数据库 CPU 已经飙到快 100%,页面加载要十几秒——这就是“数据库慢”最典型的线上场景。坦白说,“数据库慢”是一个非常宽泛的形容词,它可能是 SQL 写烂了,可能是索引没建好,可能是有个大查询把资源吃光了,也可能是慢的根本不是数据库而是网络或应用。但线上问题不允许漫无目的地乱试,必须在最短时间内把问题收敛到可处理的范围内。这些年处理过的数据库性能问题少说也有上百次,我养成了相对固定的排查习惯:全套检查项有很多,但总是先看 5 个地方——正在执行的 SQL 和慢日志、锁等待、系统资源、索引使用情况、连接状态。这篇文章把这套线上经验完整写出来,适合刚接触数据库性能排查的后端开发,也适合需要值班处理故障的运维同学参考。

1. 先理清思路:慢数据库排查的优先级,为什么是这五步

1.1 “慢”的表现不同,排查方向完全不同

接到“数据库慢”的反馈时,我做的第一件事不是打开监控平台,而是先弄清楚“慢”到底长什么样。同样是变慢,场景可能完全不同:

  • 查询慢:某个页面加载慢,数据库侧看到 select 语句执行时间很长,或者频繁出现短小但高频的查询。
  • 写入慢:业务上表现为下单、提交订单卡顿,数据库侧看到 insert/update 在执行或提交阶段耗时明显。
  • 整体都慢:所有操作都变慢,页面集体超时,数据库 CPU、IO 或连接数可能已经打满。

判断的方式也很简单:直接问反馈问题的同事“具体是哪个功能慢?什么时间段开始慢?”,然后再去数据库里验证。这一步看起来不起眼,但非常关键。因为“整体都慢”和“单个功能慢”的处理路径差别极大:前者要从连接数、系统资源、参数配置入手,后者要重点排查 SQL、索引、锁等待。如果你一上来就按自己熟悉的套路调参数,十有八九会跑偏。

我之前处理过一个案例,业务方只说“系统特别慢”。结果我查了一圈慢日志、锁等待、连接数,数据库侧干干净净,慢日志里根本没有大查询,连接数也没打满。后来才发现,慢的是应用侧的某个线程池,消息队列里的任务积压导致业务接口排队,数据库完全是“被动承受”。就是因为提前问了“具体是哪个功能慢”,才没有在数据库侧浪费时间。

1.2 我的固定动作:先拿基线,再动手

在开始排查之前,我会先花两分钟记下几组基线数据。这不是强迫症,而是后面确认“问题是否恢复”必须要用到的参照物。最常见的采集动作包括:

# 系统层 top vmstat 1 5 iostat -x 1 5 # MySQL SHOW ENGINE INNODB STATUS; SHOW GLOBAL STATUS LIKE 'Threads%'; SHOW FULL PROCESSLIST;

把输出保存到文件里,并记住当前时间点。后续无论做了修改还是 Kill 了会话,都要拿新的状态和这些基线对比,才能判断操作是否真的有效。我见过不少人改完参数、杀完事务之后凭感觉觉得“好像好了”,结果第二天问题重现,连当时是什么状态都没记录,只能从头再排查。线上故障处理里,一手数据比什么都值钱。

快照采集完之后,我还会顺手做一件事:如果慢查询日志原本没开,就在排查期间临时打开,并把 long_query_time 临时调到一个比较敏感的值(比如 1 秒),这样能捕获更多细节;但排查结束后一定要记得关掉或恢复原样,否则后续会产生大量无用日志占用磁盘。

这也解释了为什么我的检查顺序是固定的:实时会话和慢日志解决“现在正在发生什么”,锁等待解决“为什么大家的操作卡住了”,系统资源解决“物理层有没有被填满”,索引和连接则负责解释“为什么会走到这一步”。

2. 第一个要看的现场:正在执行的 SQL 与慢查询日志

2.1 进入实例的第一步:看看现在正在跑什么

不管是什么数据库,排查慢问题的第一件事都是看“此刻正在执行的会话”。在 MySQL 里是 SHOW FULL PROCESSLIST,在 PostgreSQL 里是 pg_stat_activity。这一步的目的不是找最慢的 SQL,而是快速判断当前数据库的忙碌状态是“正常业务流量”还是“异常堆积”。

我会重点关注几类状态:

  • Sending data / Copying to tmp table:经常出现在大查询、排序、分组场景。如果大量会话同时处于这个状态,说明有 SQL 在吃资源。
  • Waiting for table metadata lock:这是 DDL 和 DML 冲突的典型标志,后面单独说。
  • Waiting for handler commit:大量写入处于提交等待,通常和磁盘同步、长事务有关。
  • Lock wait timeout exceeded:锁等待超时,会话会直接报错返回。

看的时候还要结合 Time 列。一个跑了 30 分钟的查询虽然显眼,但如果只有一条,可能影响有限;反而是 100 条每条约 1 秒的小查询,在高并发下能把 CPU 慢慢填满,制造出“数据库快死了”的错觉。所以我建议先按“正常/异常”分类,再按“总耗时 = 单条耗时 × 并发量”的维度去评估,而不是单纯盯着最大的那一条。

抓到了可疑会话之后,记下它的 ID、来源 IP、执行时间、SQL 片段,不要急着 Kill。除非你确认这是一条已经完全失控的查询,否则先继续排查,找到它在等什么、卡在哪一步,后面的定位会更完整。

2.2 慢查询日志:从“偶发慢”里找出“稳定的拖后腿者”

数据库慢的时候,如果现场会话没抓到有用的线索,慢查询日志就是下一个突破口。所谓“偶发慢”指的是系统整体正常,只是某个查询偶尔耗时超标;而“稳定的拖后腿者”指的是某个或某几个查询每天稳定地输出大量慢日志,消耗的资源占比一直很高。后者才是性能问题的根源,慢日志的价值就在于帮我们把它找出来。

MySQL 可以用这样的方式确认慢日志状态:

SHOW VARIABLES LIKE 'slow_query_log'; SHOW VARIABLES LIKE 'long_query_time'; SHOW VARIABLES LIKE 'slow_query_log_file';

慢日志没开的话,排查期间可以临时打开,但要把 long_query_time 设得合理一些。我建议先用 2 秒起步,不要一上来就设成 0,否则慢日志表会被刷爆,磁盘空间一满,所有业务都会跟着遭殃。分析慢日志时,我最常用的是 mysqldumpslow 和 pt-query-digest:

mysqldumpslow -s at -t 10 /var/log/mysql/mysql-slow.log pt-query-digest /var/log/mysql/mysql-slow.log

mysqldumpslow 适合快速看个大概,-s at 表示按平均耗时排序,-t 10 只输出前 10 条;pt-query-digest 则会把日志按 SQL 指纹聚合,输出每个指纹的总耗时、平均耗时、出现次数,是线上分析慢日志最顺手的工具。我自己的习惯是先看“总耗时排名”再看“平均耗时排名”,原因很简单:平均耗时长但只出现过一次的 SQL,大概率是一次性任务,风险有限;平均耗时不突出但出现几千次的 SQL,才是日常消耗资源的大户。

举一个我印象很深的例子:有次分析慢日志,第一条是一条跑了 20 秒的冷门导出查询,看起来很吓人,但一天就出现两次。第二条是一条每天出现上万次的“select * from 订单表 where 状态 = ...”,单条只花 0.5 秒,但总耗时是前者的几十倍。我把精力放在第二条上,改完索引之后,整个系统的平均响应时间立刻下来了。如果只盯着“最慢的 10 条”去优化,效果会差很多。

3. 第二个要看的现场:锁等待与阻塞链,先解开“挡路的门”

3.1 锁等待的表现和危害

锁是数据库保证并发正确性的机制,但一旦协调不畅,就会变成性能事故的放大器。锁等待最典型的体感是:功能“卡住”了,不是报错也不是崩溃,就是一直转圈,过一会儿弹出一个Lock wait timeout exceeded; try restarting transaction的报错。

这种问题在高并发下特别隐蔽。比如一个事务里先 update 了一行,又去做了一段耗时的业务逻辑,最后才 commit,这段耗时期间其他要更新同一行的事务全部被堵住。如果一个热门行的更新频率很高,系统很快会进入“大量事务排队等同一个行锁”的状态,整体响应时间直线上升。还有一种更常见也更讨厌的锁,叫 MDL(元数据锁)。MySQL 里执行 DDL 需要获取 MDL 锁,如果之前发起 DDL 的会话迟迟不结束,其他对该表的查询也会全部堵在Waiting for table metadata lock上,表现为“表突然谁都访问不了”。

3.2 快速定位锁等待的实际命令

MySQL 8.0 及以上版本,我一般直接查 sys.innodb_lock_waits:

SELECT * FROM sys.innodb_lock_waits\G

这个视图会直接告诉你谁在等待、谁持有锁、等待了多久。如果没有 sys 库,也可以看:

SHOW ENGINE INNODB STATUS;

在输出的 LATEST DETECTED DEADLOCK 和 TRANSACTIONS 部分能找到持有锁的会话信息,但可读性比较差,需要自己对照 processlist 里的 ID。

讲一个我真实处理过的场景:某天业务方反馈某张核心业务表的所有操作全部超时。我去看 processlist,发现大量会话处于 Waiting for table metadata lock,再往上一查,源头是一个已经执行了很长时间的 ALTER TABLE,而且持有这个 MDL 锁的会话一直没提交。当时 DDL 是从一个图形化客户端发起的,客户端挂着没关,事务一直未结束,于是整张表被“锁死”了。处理方式很明确:先确认这个 DDL 是否业务必须要做的,再联系发起方保存好现场,然后 Kill 掉持有锁的会话,所有等待的会话立刻恢复,业务秒级回归。

这里特别提醒:发现锁等待时,不要无脑 Kill。如果阻塞源头是一个已经跑了几十分钟的大事务,Kill 之后回滚同样需要很长时间,甚至在回滚期间继续占用资源,反而把问题拖得更大。先看它在做什么、事务有多大,再判断是等待还是强制终止。如果阻塞源头是一个只读查询,Kill 的成本就很低;如果是一个正在写大量数据的批处理事务,就要做好业务会中断的准备。

4. 第三个要看的现场:系统资源的天花板(CPU、IO、内存)

4.1 CPU 高和 IO 高是两种完全不同的病

数据库本身也是个程序,跑在哪里都会受到操作系统资源限制。排查完锁,第三步通常是看系统资源。我用一个类比来区分 CPU 和 IO 的问题:CPU 高像是一个人在脑子里疯狂算数,但可能算的是一堆废题;IO 高像是一个人不停从书架搬书出来,但搬的速度跟不上。前者要找到“在算什么”,后者要找到“为什么搬这么多书”。

  • CPU 高的常见原因:执行计划选错导致全表扫描、大范围排序或分组、复杂的字符串处理、函数计算密集的 SQL,还有可能在执行 regexp、JSON 解析这类消耗 CPU 的操作。
  • IO 高的常见原因:数据量和扫描范围过大、缓存命中率过低、查询大量触达磁盘、同时跑着备份或报表任务、慢日志和 binlog 写入量过大。
  • 内存方面,第一眼最应该关注的是 InnoDB Buffer Pool(MySQL)或 shared_buffers(PostgreSQL),如果数据量远大于缓冲池的合理范围,业务上又有很多全表扫描,IO 压力就会居高不下。

这里补充一个实用工具:缓冲池命中率。MySQL 里可以通过状态变量计算:Innodb_buffer_pool_read_requests 表示从缓冲池读的次数,Innodb_buffer_pool_reads 表示从磁盘读的次数。命中率 = 前者 /(前者 + 后者)。如果长期低于 99%,说明热数据频繁被淘汰,要么缓冲池偏小,要么查询扫描的数据量太大根本不缓存——这时候先别急着扩容内存,先看 SQL 的扫描量更实际。

4.2 用系统工具交叉定位瓶颈

我的习惯是一条命令一个窗口,同时观察:

top # 看 CPU、load average、mysqld/数据库进程占用的 CPU iostat -x 1 # 看 %util、await,判断磁盘是否打满 vmstat 1 # 看 r(运行队列)和 wa(IO 等待)

具体判断方法很直接:

  • 如果 load average 很高,但 IO 的 %util 很低,CPU 也高,那就是 CPU 计算瓶颈,去 processlist 里抓正在跑的 SQL 即可。
  • 如果 %util 接近 100%,等待队列明显上涨,那就是 IO 瓶颈,要重点看是否在全表扫描、是否缓存命中率过低、是否同时有备份和业务高峰叠加。

我处理过的一个典型案例:某个系统每天晚上固定时段数据库响应时间明显变差,白天完全正常。我一开始怀疑是业务夜间的清理任务导致的,但看慢日志又没找到明显的高频慢查询。后来 iostat 一看,磁盘 %util 在晚上 10 点后直接接近 100%,而白天只有 5%。顺着进程列表排查,发现是一台应用服务器上部署的数据库备份脚本,在同一个时间段跑全量备份,备份数据量和业务高峰完全重叠,把磁盘 IO 压满了。处理方式不复杂:给备份工具加限速参数,再把备份时间调整到业务低峰,问题立即消失。这件事情给我的教训是:数据库慢的时候,不要只盯着数据库内部,操作系统上的“邻居”进程也可能是元凶。

5. 第四个要看的现场:索引的使用真相

5.1 有索引不等于会走索引

查到慢 SQL 之后,下一步自然是看它的执行计划。这一步的核心目的是搞清楚:这条 SQL 到底是怎么执行出来的,走了哪些索引,为什么走了全表扫描。MySQL 里最常用的是 EXPLAIN,PostgreSQL 里是 EXPLAIN ANALYZE。

EXPLAIN 输出里我第一眼看type列:

  • ALL:全表扫描,数据量大时是性能杀手。
  • index:全索引扫描,比 ALL 好,但也不是理想的访问方式。
  • range、ref、eq_ref、const:走索引或主键的合理访问方式,符合预期。

看到 ALL 要警觉,但也要结合表的数据量判断。几万行的小表全表扫描可能毫秒级完成,几千万行的大表全表扫描基本等于灾难。真正要注意的是“执行计划稳定性”——同样的 SQL 有时走索引有时全表扫,这种波动比稳定的慢更麻烦,通常和统计信息、参数改动有关。

常见的索引失效场景,我整理了一下:

  • 条件列上使用函数:WHERE DATE(create_time) = '2024-01-01',数据库只能全表扫描。改成create_time >= '2024-01-01 00:00:00' AND create_time < '2024-01-02 00:00:00'才能用索引。
  • 隐式类型转换:varchar 类型的字段传了数值,比如WHERE user_id = 123但 user_id 是 varchar,优化器可能放弃索引。
  • 前导模糊查询:LIKE '%abc'无法有效利用 B+ 树索引,LIKE 'abc%'可以。
  • OR 条件:如果 OR 两侧没有合适的索引覆盖,经常会导致整体走全表。

5.2 统计信息与基数:两个容易忽略的元凶

即使 SQL 写法正常、索引也存在,优化器也不一定走索引。这背后是统计信息和基数的问题。

统计信息是优化器决策的重要依据。MySQL 的索引统计信息不是实时更新的,如果数据发生大量变更后没有及时更新统计信息,优化器可能会拿着过期的“地图”做选择。我遇到过类似场景:某张业务表的数据量翻了几倍,但统计信息还停留在旧版本,优化器估算走某个索引需要扫很多行,于是选择了全表扫描,导致一个原本稳定的查询突然变慢。处理方式是更新统计信息(MySQL 里是ANALYZE TABLE),重新生成执行计划,问题就消失了。生产环境大批量导数据之后,手动 ANALYZE 一下是成本很低的预防手段。

另一个概念是基数(Cardinality)。如果索引列的重复值特别多,比如一个代表状态的字段只有“待处理”和“已完成”两种值,即使建了索引,优化器也会认为是“低基数”索引,因为它预估走索引后还是要读一半以上的行,不如直接全表扫描。这种情况下,单纯加索引往往没用,需要从查询设计上做文章,或者换一个区分度更高的字段来过滤。

最后分享一个检查索引是否真的在用的方法:在 MySQL 里可以通过 performance_schema 的表 IO 统计去看索引使用情况,也可以对比慢日志优化前后的执行计划变化。更简单的做法是,定期从慢查询日志提取高频 SQL,逐条 EXPLAIN,把 type 列是 ALL 的揪出来,交给开发同事优化。索引不是越多越好,冗余索引不仅浪费空间,还会拖慢写入和 DDL,所以建立索引的原则是“为真实高频查询服务”,而不是“把所有可能用到的列都建一遍”。

6. 第五个要看的现场:连接数与连接池

6.1 连接数打满的表现:业务的“假死”

最后这一个检查点,我把它放在第五位,是因为它经常是前面四项都没查出问题时的“隐形元凶”。数据库连接数打满时,业务的表现是“假死”——应用日志里全是 Too many connections 或者连接超时,但数据库自身 CPU、IO 都不高,甚至很空闲。

这时候先看两个指标:

SHOW VARIABLES LIKE 'max_connections'; SHOW STATUS LIKE 'Threads_connected';

如果 Threads_connected 已经非常接近 max_connections,说明连接数这个“车位”全被占满了,但真正在干活的有多少呢?需要再看活跃线程数。MySQL 里可以通过 performance_schema 的 events_waits_current 或者 processlist 里 Time 列很短的数量来判断。如果大量线程处于 Sleep 状态,说明业务端连上来了但没在用,多半是连接池配置或者应用程序没有正确释放连接。

我常用的一个进阶判断是:把当前连接按来源 IP 或用户名分组统计,看看是哪个应用占用了绝大部分连接。如果某个应用自己就占了总连接数的七八成,而且大部分处于 Sleep,基本可以断定是连接池回收慢或者连接泄漏,而不是数据库出了问题。这种问题会出现在典型的高峰期之后,连接数只增不减,最终把 max_connections 打满。

6.2 连接池配置决定数据库能扛多少压力

连接池参数里,我最看重这几个:maximumPoolSize(最大连接数)、minimumIdle(最小空闲数)、connectionTimeout(连接超时)、maxLifetime(连接最大存活时间)。很多人对连接池有个误解:以为设置越大越好。实际上连接池最大连接数设置得过大会带来两个问题:第一,数据库能同时承受的活动事务是有限的,连接再多也服务不过来,反而是排队;第二,过高的连接数会占用数据库的内存和上下文开销,把本来就不宽裕的资源进一步挤占。

我见过一个案例:某应用把连接池最大连接数从 100 调到 500,以为能应对大促,结果数据库连接被全部占满,数据库 CPU 因为上下文切换大幅飙升,业务响应反而更慢。后来把连接数调回 200,再配合对慢 SQL 的治理,才算恢复稳定。反过来也有一个典型场景:连接池最大值设置过小,比如只有 10,但业务高峰期并发请求几百个,连接池里的连接全部被占用,后面的请求在连接池外排队等待。表现为接口超时,但数据库 CPU 很低。

也有另一种情况是活跃线程数很高,但连接数没打满,说明 SQL 本身慢,占用了大量活跃连接,此时优化 SQL 才是重点。判断的口诀是:数据库资源打满,问题可能在数据库;数据库资源很闲,业务还是慢,先去查连接池和代理层。我的建议是:不要迷信单一参数值,同一套配置在不同机型、不同业务量下表现完全不同,最好结合压测结果来定。线上没有压测条件的话,从连接池排队耗时和数据库活跃线程数两个指标反推,逐步调整。

7. 最后分享几个实战中的经验和容易忽略的坑

7.1 排查顺序背后的逻辑

写到这里,回头把这五个观察点串起来看,其实是一套收敛问题边界的顺序:先确认“现在发生了什么”(实时会话、慢日志),再确认“为什么卡住”(锁等待),再确认“物理资源够不够”(CPU、IO、内存),再确认“执行路径对不对”(索引和执行计划),最后确认“流量入口是不是堵了”(连接数和连接池)。每一步都在缩小问题的可能范围,前面的结果会影响后面怎么查。

比如慢日志抓到了大查询,紧接着就应该看它的执行计划和锁情况;如果 CPU 打满,就要去看是哪个会话导致的,而不是直接去调缓冲池大小。这套顺序不是死的,但它能帮你避免那种最常见的问题:花了半小时在数据库里翻来翻去,最后发现 root cause 在别的系统。

7.2 几个我踩过的坑

最后把几个实战中容易踩的坑集中写一下,都是真实经历过的教训:

  1. 只开慢查询日志,但没设置合理的 long_query_time,结果一天慢日志表就堆了几个 G,反而把磁盘 IO 拖垮。排查完后要记得关掉或者调回阈值。
  2. 分析慢日志只盯着“单条最慢”。正确的姿势是看“总耗时”和“频率”,高频短查询对整体性能的影响往往大于偶发的超长查询。
  3. 看到锁等待就立刻 Kill。这是风险很高的操作,大事务回滚时间可能比你等待恢复的时间还长,动手之前一定要确认事务规模和业务影响。
  4. 一上来就改数据库参数。优化器开关、缓冲池大小这类参数,在没有完整数据支撑的情况下乱改,即使碰巧能好一阵子,也会留下长尾隐患。
  5. 忽略连接池排队这个指标。连接池问题从数据库侧看往往并不明显,真到“Too many connections”才暴露,说明已经晚了。日常监控里给应用侧预留一个“连接获取耗时”的监控项很有必要。

我在线上处理数据库性能问题时,最大的体会是:数据库慢并不是某一个单一原因造成的,往往是一连串因素叠加的结果。这套“先看 5 个地方”的排查顺序帮我在绝大多数事故里都控制了影响范围,也希望大家在遇到类似问题时,能够先稳住现场,再一步步收敛原因,而不是凭感觉乱试。最后多提一句,平时一定要保住慢日志和监控数据的留存,很多故障不是当时没法查,而是事后没有数据可查,那才是最被动的局面。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进