接到这套老系统的时候,压测任务其实挺简单的:把核心下单链路压到200并发,看看性能瓶颈在哪。结果第一次跑JMeteter,50并发就出事了,数据库CPU直接飙到100%,后台一堆连接超时。我跑到数据库里一查,发现有一条统计订单的SQL跑了8秒,几乎把连接池全拖死了。这个场景我相信很多做后端和数据库的人都遇到过——应用层看哪都正常,一压测就暴露出SQL才是真正的隐形炸弹。这轮要解决的就是这类问题:性能测试过程中如何快速定位慢SQL,又怎么从执行计划、索引、SQL改写这些层面把速度真正提上来。
这篇文章我打算按实际排查的顺序来写,先把“怎么定位慢SQL”讲透,再逐步深入到执行计划分析、索引设计、SQL改写、大表场景,最后讲如何用压测来验证优化效果。适合正在做接口压测的后端研发、刚接触SQL优化的测试工程,以及日常要处理慢查询的生产DBA。全文不涉及特定厂商的敏感背景,只聊技术本身。
1. 慢SQL到底是怎么被揪出来的:一套可以照抄的定位链路
很多人的第一反应是“打开慢查询日志不就行了”,但实际压测和生产环境里,等压测发现接口超时才去翻日志,往往已经有点晚了。真正高效的定位链路应该是:先通过压测过程实时抓取,再结合慢日志批量取证,最后用数据库自身视图确认会话状态。三步走完,基本不会漏。
1.1 慢查询日志:先确认“慢”的定义合理不合理
MySQL里慢日志的开关默认是关掉的,我见过不少机器开了也只记录超过10秒的语句,这在大压力测试下几乎等于没开。我一般建议把 long_query_time 调到 1 秒甚至 0.5 秒,压测期间临时打开通用日志也行,但通用日志记录量太大,线上慎开。生产库上我更推荐用 set global slow_query_log = ON 这类动态参数临时调整,压测完再恢复原状。
SQL Server对应的是“服务器端跟踪”或扩展事件,Oracle则要从AWR/ASH里捞Top SQL,国产的达梦数据库也提供了类似的动态性能视图和日志开关。工具名不同,思路完全一样:先定义“慢”的阈值,再让数据库帮你把超过阈值的语句自动筛选出来。
阈值怎么定才合理?我的经验是别拍脑袋。先观察一周正常业务流量的响应时间分布,取TP95的耗时作为基准线,压测场景里把阈值设定为基准线的1.2倍左右。直接设1秒对某些IO密集型系统会刷出一大堆垃圾,设太宽又会漏掉真实问题,没有数据支撑的阈值都是耍流氓。
1.2 压测过程中实时抓取:不要等压完再去翻日志
真正让我效率大增的方法,是在JMeteter压测的同时开一个终端,在数据库里每隔几秒执行一次:
sql复制SHOW FULL PROCESSLIST;
这条命令会列出当前正在执行的所有连接,包括每条SQL的完整文本和运行状态。压测一开始,我基本盯着 time 这一列看,哪个数字在几秒内持续增大,哪个会话的 State 卡在 Sending data 或 Copying to tmp table,那多半就是让数据库变卡的元凶。抓到之后直接 kill 掉,先把连接池释放出来,再回头慢慢分析这条SQL。
如果是MySQL 5.7及以上,我更喜欢直接用 sys.session 视图,它会额外显示每张表的扫描行数、事务等待时间等信息。SQL Server可以用 sys.dm_exec_requests 关联 sys.dm_exec_sql_text 直接看正在执行的SQL文本;Oracle就是查 v$session 和 v$sqlarea 的组合。这套实时监控动作的价值在于:它把“不知道问题在哪”变成了“问题就摆在面前”。
1.3 从接口耗时反查SQL:应对日志没开但又超时的场景
还有一种情况很棘手:慢查询日志没开,权限也受限,只能看到接口调用超时。这时候我会顺着链路反查——在应用日志里把本次请求的流水号捞出来,找到对应的DAO层调用记录,再把Mapper执行的SQL模板和传入参数拼出来,手动在数据库客户端里跑一遍。
很多人问我参数对性能影响那么大吗?大。同一个SQL模板,查一个只返回100行的ID和查一个返回几十万行的IN列表,执行计划可能完全不同。反查的过程中,我习惯把实际参数代入后再跑一次 EXPLAIN,而不是直接分析带占位符的原始SQL。这一步很多人会省略,后面所有优化方向也就跟着偏了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 执行计划才是最诚实的“体检报告”:五种典型危险信号
拿到慢SQL之后不要急着加索引,先看执行计划。优化SQL就像医生看病,执行计划就是体检报告,你不看报告就开药方,运气好能治好,运气不好副作用更大。
2.1 各数据库怎么拿到执行计划
最常用的还是MySQL,在目标SQL前面加 EXPLAIN 就行;需要查看真实执行成本时可以用 EXPLAIN ANALYZE(MySQL 8.0.18+)。Oracle用 DBMS_XPLAN.DISPLAY_CURSOR 能看到真实的执行计划,SQL Server用 SET STATISTICS PROFILE ON 或者直接在SSMS里按快捷键显示估计执行计划,达梦数据库同样支持 EXPLAIN 语法,老版本习惯用 EXPLAIN PLAN FOR 再加查询语句。
我建议压测环境一定要用真实数据量去分析,执行计划和数据量强相关。用几千行数据的开发库看执行计划,和几千万行的生产库看执行计划,走索引的策略可能是相反的。这也是为什么很多人开发环境测得好好的,一上生产就慢得没法看。
2.2 看执行计划只看这几个字段就够
初学者容易把执行计划每个字段都研究一遍,其实定位问题优先看四个点:type、key、rows、Extra。
type 表示访问类型,从好到坏大致是 const、eq_ref、ref、range、index、ALL。见到 ALL(全表扫描)在核心大表上就要警惕;index 也不一定好,如果 Extra 里同时出现 Using index 那是覆盖索引,属于好事,但单纯显示 index 而实际没走索引过滤,同样很危险。
key 表示实际用的索引,rows 表示估计扫描行数。Extra 里最值得关注的几个关键词是 Using filesort、Using temporary 和 Using where。文件排序、临时表都是内存和磁盘的隐形消耗,我见过一条看似简单的统计SQL,Extra里同时出现这三个,耗时3秒以上,优化空间极大。
2.3 一个实际案例:为什么明明有索引却全表扫
之前排查过一条SQL:
sql复制SELECT order_id, user_id, total_amount
FROM orders
WHERE DATE(create_time) >= '2024-01-01';
orders表在 create_time 上明明建了索引,执行计划却显示 ALL,扫了几百万行。原因就是 DATE(create_time) 对索引列做了函数运算,索引无法参与区间匹配。改成 create_time >= '2024-01-01 00:00:00' 之后,type 变成了 range,rows 从几百万降到几十万,耗时从2.3秒降到0.05秒。
这个案例其实想说明一个道理:执行计划不是在骗你,是你用了让优化器没办法走索引的写法。看到全表扫描,不要第一反应是“索引没用上”,而是先问自己“这条SQL的写法是不是主动堵死了索引的路”。
3. 索引设计才是SQL提速的核心杠杆:建索引不是堆数量
很多团队优化SQL的第一反应就是“加个索引试试”。索引确实是性价比最高的手段,但乱加索引的后遗症也很大。我见过一张业务表被加了二十多个索引,最后每次插入都要维护一堆索引树,写入性能烂得一塌糊涂,查询也没快多少。
3.1 组合索引设计:先看等值条件,再看排序条件
有一种写法是:假设查询条件是 WHERE a = ? AND b BETWEEN ? AND ? ORDER BY c,组合索引的最佳顺序一般是 (a, b, c)。
原因很直接:等值条件的字段放最前面,让索引能精确定位;范围条件放中间,MySQL可以利用B+树的天然有序性快速圈定区间;排序字段放最后,因为索引已经有序,查询结果可以直接按顺序返回,避免 filesort。
我见过一些老项目把组合索引建反了,等值字段放后面,范围字段放前面,结果每次查询只能用到索引的一部分,效果大打折扣。建组合索引之前,先拿出业务最高频的几条SQL,统计它们的WHERE条件和ORDER BY字段分布,再决定哪些字段能合并成一个索引,尽可能让一条索引覆盖多个查询场景。
3.2 覆盖索引:让SQL连回表都省掉的“作弊器”
覆盖索引是指在索引本身就能提供查询所需的全部列,MySQL就不需要再通过主键回表查完整行数据。带来的收益不只是少了IO,还减少了随机读。
举个例子:
sql复制SELECT user_id, nickname FROM users WHERE status = 1;
如果 status 上有普通索引,每次查到索引节点后还要回表取 nickname;如果建组合索引 (status, user_id, nickname),那查询的数据全在索引里,Extra 会出现 Using index,速度自然快很多。
但我要提醒一句:覆盖索引的列越多,索引占用的空间就越大,写入维护成本也越高。不能为了追求 Using index 把所有字段都塞进索引。一般只覆盖高频查询需要的字段就够了,而且要注意和“避免宽索引”的原则做平衡。
3.3 哪些“小动作”会让索引失效:逐个踩过的坑
我总结了一下自己在实践中踩过的和见别人踩过的索引失效场景,整理成一张对照表,方便大家参考:
| 写法/场景 | 失效原因 | 改写思路 |
|---|---|---|
WHERE DATE(create_time) = '2024-01-01' |
索引列套函数 | 改为范围条件 create_time >= ... AND create_time < ... |
WHERE phone = 13812345678 |
字符串列接数字,隐式类型转换 | 写成 phone = '13812345678' |
WHERE name LIKE '%张%' |
前导模糊查询,B+树无法走前缀匹配 | 尽量改成 '张%';必要时上全文索引 |
WHERE a = 1 OR b = 2 |
OR导致无法有效利用单列索引 | 改写成UNION ALL,或建组合索引 |
WHERE status != 1 |
不等于无法走常规索引过滤 | 业务层拆成两个等值查询 |
ORDER BY a, b 但索引只有 a |
排序字段不满足最左前缀 | 调整索引顺序为 (a, b) |
这张表不是让你死记硬背,而是要理解背后的原理:索引能加速的原理是B+树的有序性,而任何破坏有序性的操作,比如函数、类型转换、前导模糊、不等于,都会让优化器放弃走索引。
3.4 索引不是越多越好:什么时候该“删索引”
压测优化告一段落后,我会习惯性检查一遍存量索引,特别是联合主键和唯一索引之外的冗余索引。比如在 (a, b) 上有组合索引,又单独在 a 上建了索引,那 a 上的单列索引基本就是冗余的。
判断依据很简单:一条索引如果只服务于某一条低频率SQL,但每次INSERT/UPDATE都要维护它的B+树,那就应该做减法。数据量大的表上,无用的索引不只是占磁盘,还会拖慢所有写入操作,甚至引发死锁概率的上升。生产环境调整索引时也要评估锁表窗口,大表加索引建议用在线DDL工具,避免阻塞业务。
4. SQL改写和表结构设计:压测暴露出的深水区问题
索引优化到一定程度之后,会发现部分SQL再怎么调索引也快不起来了。这时候就要从SQL本身和表结构设计层面找原因。这类问题通常更隐蔽,但一旦修好,收益往往是数量级的。
4.1 深分页优化:LIMIT 1000000, 20 为什么越翻越慢
很多分页接口在页码小的时候飞快,一旦翻到几十页之后就开始卡,原因是 LIMIT offset, size 的偏移量大时,数据库要把前N行全部扫描出来再丢弃。优化方案有很多,我最常用的是“延迟关联”:
sql复制SELECT t1.* FROM orders t1
INNER JOIN (
SELECT id FROM orders
WHERE status = 1
ORDER BY create_time DESC
LIMIT 1000000, 20
) t2 ON t1.id = t2.id;
内层的子查询可以先走 (status, create_time, id) 组合索引,只扫索引树的节点,定位到目标20条ID,再根据ID回表取完整数据。相比直接 LIMIT 两层扫描,这种方式回表次数大幅减少。
如果数据是不断增长的,还可以做成“游标分页”,也就是用上次返回的最后一条ID作为下一页的查询起点:
sql复制SELECT * FROM orders
WHERE id > 上次返回的最大ID
AND status = 1
ORDER BY id ASC
LIMIT 20;
这种方案在千万级大表上的性能远比深分页好,但要注意业务上必须保证ID有序且与显示顺序一致,如果排序字段不是主键,还需要在业务上做额外处理。
4.2 子查询和JOIN改写:别让驱动表选错
优化器大部分时候能自己选对执行计划,但遇到复杂子查询、多表关联时,优化器也可能“犯糊涂”。压测时最容易挖出来的两种问题:一个是相关子查询导致每行都执行一次,另一个是大表驱动小表。
第一种问题的典型写法:
sql复制SELECT u.id, u.name,
(SELECT COUNT(*) FROM orders o WHERE o.user_id = u.id) AS cnt
FROM users u
WHERE u.status = 1;
每次扫描users一行,就要去orders表做一次COUNT统计,执行次数是users行数。优化方式就是改成显式JOIN分组:
sql复制SELECT u.id, u.name, IFNULL(t.cnt, 0) AS cnt
FROM users u
LEFT JOIN (
SELECT user_id, COUNT(*) AS cnt
FROM orders
GROUP BY user_id
) t ON u.id = t.user_id
WHERE u.status = 1;
第二种问题更隐蔽。小表驱动大表这个原则说了很多年,但在实际执行时有些关联写法的驱动顺序会被固定,如果写成 FROM big_table b LEFT JOIN small_table s ON ...,大概率就是从大表开始扫。反过来写成 FROM small_table s JOIN big_table b ...,让优化器有机会先过滤小表数据,再关联大表,扫描行数能差好几个数量级。当然MySQL 8.0的优化器会自己重排JOIN顺序,但SQL Server和Oracle在某些场景仍要依赖SQL写法来保证执行效率。
4.3 大表上的表结构优化:分区、归档与汇总表
慢SQL的根因有时候不在这条SQL,而在表本身已经大到了不适合作业。压测时我发现一张业务流水表两年下来已经3亿行了,任何查询都避不开“扫描范围过大”的问题。这时候再怎么写SQL都是治标。
我一般按下面的顺序来判断处理方案:
- 数据有明确时间范围且有按时间查询的习惯 → 考虑按天/按月分区
- 历史数据很少访问,主要集中在近几个月 → 做数据归档,把老数据迁到历史库
- 查询场景固定,字段也不复杂 → 提前建汇总表/中间表,用定时任务填充计算结果
- 单表数据量持续增长且各种查询都很多 → 考虑分库分表方案
需要特别说清楚的是分区表并不总是“银弹”。分区字段必须和查询条件强相关,否则分区裁剪失效,反而会增加开销。我之前优化过一张订单流水表,分区字段建成年月,但业务查询大都是按用户ID查,结果每个查询都要跨全部分区扫描,比不分表还慢。合理的方案是改成按用户ID哈希分表,或者建立用户ID与时间范围的二级索引。
4.4 事务和锁对SQL性能的隐形影响:慢不一定是查得慢
还有一个非常容易忽略的盲区:有些SQL本身执行只要0.1秒,但它卡在等锁上,最终接口耗时好几秒。压测过程中如果出现大量 Lock wait timeout exceeded,那问题就不再是慢SQL,而是并发事务之间的锁竞争。
我常用 information_schema.innodb_trx、innodb_lock_waits 等视图去查锁等待链,看看是谁持有锁、谁在等待。如果是普通业务更新冲突,需要考虑调整事务隔离级别或减少长事务;如果是某条大事务一次性更新几十万行,那就要把大事务拆成小批量提交,避免长时间持有行锁。
排查锁问题还要特别留意一种情况:死锁。数据库死锁不一定都会抛出异常,有些是被动回滚的。压测时如果不停有事务回滚,同时行锁等待事件飙升,就要检查应用层是不是存在“两个事务以不同顺序更新相同记录”的交叉场景。统一更新顺序、保持事务短小,是降低死锁概率最有效的两招。
5. 用性能测试给SQL优化“上秤”:别靠感觉判断效果
SQL改完之后最难的问题来了:怎么证明它真的变快了?很多人直接在开发环境跑一下,看到响应时间下降就以为大功告成。但在真实业务场景里,这种方式很容易得出错误的优化结论,因为并发、连接池、缓存、资源竞争都会影响最终表现。
5.1 压测场景设计:让测试流量尽可能贴业务
我在做SQL优化验证时,会先用JMeteter搭一套和业务相近的压测场景。核心思路不是那一张单独的SQL,而是把这条SQL嵌入到它所在的接口链路里,模拟真实用户可能混合调用的比例。
一般按这个步骤来:
- 新建线程组,设置合理的并发数(从50开始,逐步加到200、500)
- 添加HTTP请求或JDBC请求,配置好SQL参数化,避免每次压测都用同一批固定值
- 用聚合报告和PerfMon Metrics Collector插件记录响应时间、TPS、CPU、内存、磁盘IO
- 压测前清空慢日志缓存,压测过程中持续捕获慢SQL
如果你用的是LoadRunner,同样思路:设计好集合点模拟并发冲击,利用Analysis分析事务响应时间和数据库资源消耗。无论用哪个工具,原则都一样——要让测试流量尽可能接近生产真实行为,而不是简单地把一条SQL循环执行一万次。
5.2 关注这些指标:别只盯着响应时间
我见很多人优化完就只看平均响应时间,其实这是最不敏感的一个指标。后端数据库变化更值得关注的指标包括:
| 指标 | 说明 | 优化前 | 优化后 |
|---|---|---|---|
| TP95/TP99响应时间 | 避免被平均值骗过的分位耗时 | 3200ms | 210ms |
| TPS | 每秒事务数,反映吞吐能力 | 22 | 186 |
| 数据库CPU使用率 | 压测时的最高值 | 95% | 38% |
| 慢SQL数量 | 压测周期内的慢日志条数 | 143 | 3 |
| 锁等待次数 | 事务等待行锁的次数 | 高频 | 几乎为零 |
为什么强调分位响应时间?因为平均值极容易掩盖长尾问题,一条1秒的慢SQL和一千条1ms的快SQL平均下来可能只有2ms,平均值看起来很正常,但TP99会直接把问题暴露出来。用80%的请求在100ms内、99%的请求在300ms内这类分位指标来衡量优化效果,比平均值可靠得多。
5.3 优化前后的对比要控制变量:一次只动一个变量
我踩过的最大一个坑是:一次性改了SQL写法、加了两条索引、调了连接池参数,压测后性能提升了一大截,但根本说不清到底是谁起的作用。后续再遇到其他线上问题,完全没法复用这套经验。
后来我严格按下面的流程做:
- 每次优化只动一个变量,比如先只加索引,压测一轮记录数据
- 再改SQL写法,压测一轮对比
- 最后调数据库连接池、缓冲池等参数,再压测一轮
- 每一轮都记录当前的分位响应时间、TPS、慢SQL数量,放进同一张对比表
这样不仅知道整体提升了,还能具体说出“这个效果来自索引,那个效果来自SQL改写”,后续出现类似问题直接套经验。
另外还要注意一个现象:优化后第一次压测的性能数据往往虚高。因为数据库的Buffer Pool还缓存了上一轮的数据页,冷热数据混在一起,数据不够真实。我的做法是先跑一次预热流量,压测1-2分钟,让缓存热起来,再开始正式记录数据。或者干脆重启数据库实例来获得冷启动数据,看两种状态下的表现差异。
5.4 灰度与回退:线上优化最后一步的保险策略
优化完成后不是直接推到生产就完事,必须观察线上效果并准备好回退方案。索引变更用在线DDL工具执行时,如果数据库负载特别高,建议先在备库完成,再切换主备;SQL改写上线前可以先在压测环境完整回归一遍,确认不会影响功能。
我还有一个小习惯:在优化后的SQL上写注释,标注清楚优化日期、优化原因和预期效果。这样下次别人看到这条SQL时,就知道这里专门处理过某个性能问题,不会因为“看着别扭”又把它改回去。踩过的坑越多,越发现这些“软细节”对长期维护的价值一点都不比技术本身低。
6. 最后分享一点个人体会
数据库SQL性能优化这件事,做久了你会发现最值钱的不是背多少条优化技巧,而是形成一套稳定的排查节奏:先定位慢SQL,再看执行计划,想清楚索引策略,最后用压测数据验证。整个流程里,压测不只是验证手段,它更像是逼迫数据库暴露真实问题的最佳压力源——没有压测,很多SQL的问题可能在正常业务流量下藏得很好。
另外一个切身体会是:碰到慢SQL时先克制住“马上改”的冲动,先问三个问题——这条SQL的业务含义是什么?它最频繁的参数分布是什么?这个表的数据增长趋势是什么?把这三个问题想清楚,很多时候优化方向自己就出来了。
如果这篇文章对你有帮助,建议把第一节的实时抓取命令和第五节的分位指标记录表收藏起来,下次做接口压测或者处理线上慢查询时直接套用。这套方法我用了很多年,稳定可靠,唯一的代价就是需要你多花一点耐心,把每一步做到位。
