数据库慢排查实战:先看这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-digestmysqldumpslow -s at -t 10 /var/log/mysql/mysql-slow.log pt-query-digest /var/log/mysql/mysql-slow.logmysqldumpslow 适合快速看个大概-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_waitsSELECT * 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 PoolMySQL或 shared_buffersPostgreSQL如果数据量远大于缓冲池的合理范围业务上又有很多全表扫描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运行队列和 waIO 等待具体判断方法很直接如果 load average 很高但 IO 的 %util 很低CPU 也高那就是 CPU 计算瓶颈去 processlist 里抓正在跑的 SQL 即可。如果 %util 接近 100%等待队列明显上涨那就是 IO 瓶颈要重点看是否在全表扫描、是否缓存命中率过低、是否同时有备份和业务高峰叠加。我处理过的一个典型案例某个系统每天晚上固定时段数据库响应时间明显变差白天完全正常。我一开始怀疑是业务夜间的清理任务导致的但看慢日志又没找到明显的高频慢查询。后来 iostat 一看磁盘 %util 在晚上 10 点后直接接近 100%而白天只有 5%。顺着进程列表排查发现是一台应用服务器上部署的数据库备份脚本在同一个时间段跑全量备份备份数据量和业务高峰完全重叠把磁盘 IO 压满了。处理方式不复杂给备份工具加限速参数再把备份时间调整到业务低峰问题立即消失。这件事情给我的教训是数据库慢的时候不要只盯着数据库内部操作系统上的“邻居”进程也可能是元凶。5. 第四个要看的现场索引的使用真相5.1 有索引不等于会走索引查到慢 SQL 之后下一步自然是看它的执行计划。这一步的核心目的是搞清楚这条 SQL 到底是怎么执行出来的走了哪些索引为什么走了全表扫描。MySQL 里最常用的是 EXPLAINPostgreSQL 里是 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 几个我踩过的坑最后把几个实战中容易踩的坑集中写一下都是真实经历过的教训只开慢查询日志但没设置合理的 long_query_time结果一天慢日志表就堆了几个 G反而把磁盘 IO 拖垮。排查完后要记得关掉或者调回阈值。分析慢日志只盯着“单条最慢”。正确的姿势是看“总耗时”和“频率”高频短查询对整体性能的影响往往大于偶发的超长查询。看到锁等待就立刻 Kill。这是风险很高的操作大事务回滚时间可能比你等待恢复的时间还长动手之前一定要确认事务规模和业务影响。一上来就改数据库参数。优化器开关、缓冲池大小这类参数在没有完整数据支撑的情况下乱改即使碰巧能好一阵子也会留下长尾隐患。忽略连接池排队这个指标。连接池问题从数据库侧看往往并不明显真到“Too many connections”才暴露说明已经晚了。日常监控里给应用侧预留一个“连接获取耗时”的监控项很有必要。我在线上处理数据库性能问题时最大的体会是数据库慢并不是某一个单一原因造成的往往是一连串因素叠加的结果。这套“先看 5 个地方”的排查顺序帮我在绝大多数事故里都控制了影响范围也希望大家在遇到类似问题时能够先稳住现场再一步步收敛原因而不是凭感觉乱试。最后多提一句平时一定要保住慢日志和监控数据的留存很多故障不是当时没法查而是事后没有数据可查那才是最被动的局面。