这次要说的事,得从一句吐槽开始:有次帮朋友看一个生产库的问题,他们刚花了二十多万买了台顶配服务器,两路CPU、512G内存、全NVMe磁盘阵列,结果业务方和老板天天投诉——后台一条分页查询要跑三秒,导一张月报表能把接口拖到超时。朋友气得拍了张服务器照片发群里,配文是:拿着顶级服务器跑慢查询,就像开着法拉利送外卖。
这句话其实点破了大量团队的真相。慢查询的锅,绝大多数时候不在服务器,而在查询本身、数据库设计、索引结构,甚至是业务需求写得太过贪婪。硬件堆得再高,被几条糟糕的SQL架着,照样能跑出二十年前老主机的效果。这篇文章就围绕服务器与慢查询这条线,从几个真实排查案例聊起,讲讲这类问题要怎么定位、怎么拆解、怎么优化,以及怎么避免“硬件越买越贵、接口越来越慢”的怪圈。适合正在被SQL性能问题折磨的开发、DBA、运维朋友,也适合那些一遇到慢就准备掏钱加服务器的团队。
1. 法拉利卡在巷子里:为什么顶级服务器也会被慢查询拖垮
1.1 三个真实的“高配低能”现场
先看三个我实际遇到过的场景,都是典型的硬件资源极其充裕、查询却慢得离谱。
第一个场景,某后台管理系统的列表页,表里大概两百万行数据。页面做了一个筛选功能,按创建时间倒序、分页拉数据。SQL大概是这样的:
sql复制SELECT * FROM trade_order
WHERE order_status = 1
ORDER BY create_time DESC
LIMIT 100000, 20;
这条查询在测试环境跑得还行,一到生产就卡。为什么呢?因为测试环境数据量五万行,生产有两百万行。深分页要扫描并排序之前十万条记录,再扔掉,只留最后二十条。服务器CPU再强,也架不住每页都做一次大排序大丢弃。实际上很多线上慢查询都有一个共同点:资源高配,但执行路径选择了最笨的办法。
第二个场景,某报表统计功能,每晚定时任务跑一次,要统计某个客户一年内的订单金额、退款金额、客单价等十几个指标。开发同学图省事,写了一个大联合查询,从六张表里LEFT JOIN出来,每张表还套了子查询。单条SQL涉及的数据量不大,但表的连接顺序完全错误,优化器选了一个笛卡尔积的中间结果,跑一次要十几分钟,直接把服务器IO打满。
第三个场景更隐蔽。一个对外查询接口,响应时间从原本的80毫秒慢慢涨到800毫秒,最后变成两秒。数据库CPU一直不低,但也不是特别高。后来抓慢查询日志发现,某些查询没有走索引,在做全表扫描。原因是表字段上的字符集和排序规则跟查询参数不一致,导致即使有索引也用不上。这类问题,不抓SQL和执行计划,根本不可能通过换硬件解决。
1.2 慢查询的本质不是“计算慢”,而是“路径差”
很多人都搞混了一个概念,以为服务器跑慢查询,瓶颈在“算力不够”。实际上,你打开一条SQL的执行计划看,绝大多数慢查询消耗的时间,不在于CPU计算那几毫秒,而在于磁盘读取、行扫描、排序、临时表、网络传输这些环节。
打个比方。法拉利发动机马力再大,也改变不了“外卖箱里塞了五十单,需要跑五十个不同小区”的现实。每个小区就是一次磁盘随机读,街道路线就是SQL的执行计划。路线绕、要跑的小区多,换再大马力的发动机都没用。
数据库的行扫描和磁盘随机IO,是所有慢查询最大的敌人。MySQL的InnoDB默认页大小16KB,数据以B+树组织,走索引查询时,理想情况是沿着索引树往下走,访问几个页就能拿到数据。但全表扫描时,可能要读几万个页。IO次数差了一万倍,服务器处理能力再强也填不平这个坑。
这就是为什么在做慢查询优化时,第一看执行计划、第二看扫描行数、第三才看服务器资源指标。方向错了,后面全部白费。
1.3 先分锅:硬件、数据库配置、SQL与架构各占多少
遇到慢查询,先别急着给硬件“定罪”。我的习惯是做一个三分钟快速分锅:
| 维度 | 常见表现 | 高频原因 |
|---|---|---|
| 服务器硬件 | CPU打满、内存不足、磁盘IO等待高 | 连接数爆了、临时表落盘、慢SQL并发拖垮资源 |
| 数据库配置 | 连接池不够、缓冲区过小、参数非默认被改坏 | buffer pool过小、日志刷盘策略不当、连接数上限过低 |
| SQL与索引 | 单条SQL非常慢,但整体资源不高 | 索引缺失、LIKE前置通配、函数包裹列、隐式类型转换 |
| 业务架构 | 单表数据量过大、缓存策略缺失、接口循环查询 | 表结构设计不合理、一次请求查N次库、未做读写分离 |
大多数时候,锅在第三行和第四行,但团队愿意在服务器上花钱。原因很简单:加硬件不需要改代码,也不用承担上线风险,尤其在“老板催、业务急”的时候,掏钱买机器是最短路径。可治标不治本,过两个月数据涨了,又得再买。
我的建议是严格按顺序排查:先看是不是SQL和索引的问题,再看配置,最后才看硬件。一旦确认SQL有问题,花几小时改写带来的收益,远超加一台服务器。毕竟法拉利外卖箱里那五十单外卖,换更好的发动机只会送得更快,但你不把箱子里的单数减下来,一样会迟到。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 一次慢查询定位的完整复盘:从日志到执行计划到根因
纸上谈兵没用,这里完整复盘一次我处理过的慢查询定位过程,供大家复现参考。
2.1 慢查询日志怎么开、阈值怎么定
那次接到业务反馈,某个查询接口在每天下午3点左右开始响应变慢,持续半小时后恢复。接口调用一个比较复杂的报表查询,数据库是MySQL 8.0。我登录服务器后干的第一件事,不是看CPU,而是看慢查询日志有没有开、阈值多少。
确认慢查询日志相关的全局参数:
sql复制SHOW VARIABLES LIKE 'slow_query_log%';
SHOW VARIABLES LIKE 'long_query_time';
当时long_query_time设置的是10秒,等于说10秒以上的SQL才记日志。对一个面向用户的接口来说,这阈值太宽松了,很多1秒甚至3秒的查询根本没被记录。生产环境我一般建议设为1秒,初始化时直接改成0.5秒都不过分。日志会占一点磁盘,但排查问题的价值远超这些开销。
开启方法:
sql复制SET GLOBAL slow_query_log = ON;
SET GLOBAL long_query_time = 1;
注意,long_query_time修改后对新连接生效,老连接需要重连。所以我当时直接改了配置文件,顺便设置了一个变量,把没走索引的查询也记录下来,避免隐藏问题继续漏掉:
sql复制SET GLOBAL log_queries_not_using_indexes = ON;
2.2 EXPLAIN里的“赔钱货”:type、key、rows怎么读
抓到日志后,找到那条被投诉最多的SQL,我做了两件事:先单独跑一遍确认执行时间,然后前面加EXPLAIN看执行计划。
执行计划里重点看三列:
- type:访问类型,从好到差大致是const、eq_ref、ref、range、index、ALL。ALL代表全表扫描,基本可以断定优化方向就是让它不要全扫。
- key:实际用到的索引。为NULL或与预期不符,要往下查原因。
- rows:估算扫描的行数。单条查询扫描几十万行,响应时间不可能快。
当时那条SQL的执行计划显示:主查询的type是ALL,扫描行数约180万,Extra里还写着Using filesort。这意味着数据库先把整张表扫了一遍,再把结果放到文件里排序,这个成本能不大吗?
再配合EXPLAIN ANALYZE看真实执行时间分布(MySQL 8.0支持),发现绝大部分时间耗在排序和读取回表数据上。这是非常典型的深分页问题。
2.3 资源画像:CPU不高不代表SQL没问题
这里要说一个新手很容易误解的点。当时我打开监控面板,数据库CPU只有12%,磁盘IO也不高,内存占用正常。业务方就说了:“你看服务器资源占用那么低,不应该是数据库的问题吧?”
我反问了一句:“那你是希望CPU打满才叫问题?”低资源占用配慢查询,恰恰说明SQL根本没有把服务器资源用起来。如果查询全表扫描,很多情况下数据库是顺序读,IO不高,CPU也不高,因为真正的时间耗在“从头到尾读很多用不上的数据”上面。像一个人翻一本1000页的书找一句话,翻书这个动作很慢,但翻书不耗费多大脑力,也不消耗多大体力,可它就是费时间。
所以资源画像的意义在于排除法。CPU高,可能是并发太高或者有大计算;IO高,可能是查了很多页或者临时表落盘;两者都不高但查询慢,则大概率是执行路径设计有问题,比如索引失效、排序太重、锁等待、网络往返太多。那次情况就属于后者。
3. 枪毙现场:三个慢SQL的真实优化过程
复盘完定位链路,再来看看几个典型的慢SQL是怎么被一步步优化的。每个案例都给出前后对比,方便“抄作业”。
3.1 深分页:limit 100000,20是怎么把服务器打趴的
案例一就是前面提到的两百万行交易订单表,SQL长这样:
sql复制SELECT * FROM trade_order
WHERE order_status = 1
ORDER BY create_time DESC
LIMIT 100000, 20;
这条SQL的慢点,在于LIMIT后的大偏移量。数据库必须找出排序后前100020行,把前100000行丢掉,才把最后20行返回给用户。而排序本身是在一个大集合上做的,create_time字段虽然有索引,但WHERE条件还要过滤order_status,优化器一旦判断走status索引过滤后的数据量更小,就会弃用create_time索引,结果全表数据都要进排序。
当时我的优化方案是延迟关联。先在一个小集合里定位需要返回的ID,再用这些ID回原表取完整数据:
sql复制SELECT t.*
FROM trade_order t
INNER JOIN (
SELECT id
FROM trade_order
WHERE order_status = 1
ORDER BY create_time DESC
LIMIT 100000, 20
) tmp ON t.id = tmp.id
ORDER BY t.create_time DESC;
子查询里只查主键id和排序字段,InnoDB的二级索引本身就能覆盖这个查询,不需要回表读完整行。所以子查询在索引上完成排序和丢弃后,总共只取出20个id,再回表取20行完整数据。这个改动之后,查询从三秒多降到了50毫秒左右。
如果业务上允许,还有更狠的方案——禁止深分页。前端最多只给用户翻100页,超过100页就必须用筛选条件缩小范围。或者用“下一页游标”的写法:记住上一页最后一条记录的create_time和id,下一页只查比它小的数据。
sql复制SELECT * FROM trade_order
WHERE order_status = 1
AND (create_time, id) < ('2025-06-01 12:00:00', 10086)
ORDER BY create_time DESC, id DESC
LIMIT 20;
这种写法无论翻到多深,扫描范围都只是紧邻的几行,性能稳定得一匹。对于“上一页/下一页”型的分页体验,体验也不差。
3.2 IN查询与索引失效:内存规划远比想象中复杂
第二个案例是开发反馈的:“为什么一条IN查询,条件里只有50个ID,却要把整张表的索引都扫一遍?”这条SQL简化后长这样:
sql复制SELECT * FROM user_account
WHERE status = 1
AND user_id IN (
SELECT user_id FROM user_extra WHERE tag = 'vip'
);
开发的本意是先查出所有VIP用户ID,再回到主表取账号信息。但MySQL优化器对IN子查询的处理方式,在不同版本和不同数据分布下可能完全不同。那条SQL实际执行时,外层user_account表做了全表扫描,对于每一行都去判断user_id是否在子查询结果集里。也就是说,不是用50个ID去索引里快速找50个点,而是拿着整张表180万行,一行行去“对答案”。
优化方式有两类。第一类最简单,把子查询先查出来作为临时表,再改写成JOIN:
sql复制SELECT ua.*
FROM user_account ua
INNER JOIN (
SELECT DISTINCT user_id FROM user_extra WHERE tag = 'vip'
) t ON ua.user_id = t.user_id
WHERE ua.status = 1;
但注意,JOIN也要看驱动顺序。如果user_extra里tag='vip'的用户有上百万,这个临时表还是很大,驱动表如果选错,依然会慢。所以第二类优化是直接给user_extra.tag建索引,然后调整写法,先缩小user_extra的结果集再JOIN user_account。
当时实测下来,IN列表数量本身也值得关注。列表里只有几个ID,用IN通常没问题;一旦IN列表有几万甚至几十万个值,MySQL要把这些值塞进内存做哈希查找,列多的情况下反而比JOIN更慢。不同数据库对IN数量的处理机制差异很大——这个在后面达梦数据库那段会继续提。
这类问题的通用排查方法,还是EXPLAIN看执行计划。重点看驱动表是哪个、有没有出现Using where和全表扫描,然后通过改写成JOIN或者加索引,让优化器走理想路径。
3.3 缓存真能救慢查询吗?Redis方案的一致性与边界
第三个案例,有人问“分页查询很慢,能不能用Redis缓存优化”。我的答案分两层:能用,但一定要想清楚缓存什么、缓存多久、何时失效。
之前遇到过一个查询热销商品列表的接口,QPS上去之后数据库压力很大。很多人喜欢把整个列表页响应缓存到Redis,key是请求参数的哈希,过期时间五分钟。这种做法对小规模、变化不频繁的业务有效,但问题也很明显——商品下架或价格变动,最多五分钟后才生效,用户看到下架商品还能点进去,体验很差。
更合理的方案是分层缓存。第一层,把高维汇总数据(如商品总数、总页数)缓存起来,五分钟过期没问题;第二层,对列表的ID集合做缓存,过期时间缩短到一两分钟;第三层,用户真正访问的商品详情数据,使用单独的key缓存,修改时主动删除。层与层之间互相独立,数据不一致的窗口小得多。
还有缓存穿透、缓存雪崩和缓存击穿这三个问题要处理。穿透是查了一个不存在的ID,每次都打到数据库,解决办法是用布隆过滤器或者缓存空值;雪崩是大量key同时过期,压力瞬时灌到数据库,解决办法是过期时间加随机值;击穿是某一个热点key恰好过期,高并发同时来查,解决办法是使用互斥锁重建缓存或者热点key永不过期、后台异步刷新。框架性的方案谁都能背,但在实际项目里把这些边界条件处理干净,才叫真的会用。
不过要记住一点:Redis是数据库前的一道缓冲,不是慢SQL的遮羞布。如果一条深分页SQL本身就要跑三秒,缓存击穿后用户请求还是会打到这条慢SQL上,数据库照样被拖垮。所以正确的姿势是:先把SQL优化到足够快,再用缓存挡高频读。两层都做好,系统才稳。
4. 出了MySQL的地界:达梦、PostgreSQL和其他隐藏慢点
MySQL之外,国产数据库和PostgreSQL在近年的项目里越来越常见。很多人把MySQL那套经验直接搬过去,结果发现水土不服。这里聊聊达梦和PostgreSQL场景下常见的慢查询坑,以及一些和服务器状态相关的“隐藏慢点”。
4.1 达梦IN查询慢:统计信息与内存参数调优
有一个典型的线上问题:应用从MySQL迁移到达梦之后,原来跑100毫秒的IN查询,变成跑10秒以上。开发一开始怀疑是达梦性能不行,后来查下来根本不是。达梦的两大类问题,在迁移场景里几乎必然遇到。
第一类,统计信息没更新。MySQL里很多团队根本没养成手动更新统计信息的习惯,依赖自动采样。达梦也有自动更新,但在数据量剧烈变化或批量导入后,统计信息经常滞后。优化器拿到的“情报”不准,自然会选错执行计划。解决方法是定期执行:
sql复制DBMS_STATS.GATHER_SCHEMA_STATS('业务账号', OPTIONS 'GATHER AUTO');
对大批量数据导入后,也要立刻更新相关表的统计信息,很多迁移后变慢的问题,跑完这行命令就好了。
第二类,内存参数设置不合理。达梦的很多性能参数默认值偏保守,例如BUFFER、SORT_BUF_SIZE等与排序和缓冲区相关的参数没调到位。一次大批量IN查询或者排序操作时,如果内存排序区不够,数据库会把中间结果落盘,性能就断崖式下跌。排查时先看:
sql复制SELECT * FROM V$PARAMETER WHERE NAME LIKE '%SORT%';
SELECT * FROM V$PARAMETER WHERE NAME LIKE '%BUFFER%';
结合服务器物理内存去调整。达梦的参数调整对新手不友好,建议参照官方运维手册的推荐值,结合监控逐步改。但换数据库之后,最怕的其实是团队拿着MySQL的执行计划经验直接套用。达梦的执行计划查看方式是EXPLAIN SQL,虽然长得接近,但细节差异不少,遇到性能问题一定先在新库上重新看一遍执行计划。
4.2 PostgreSQL的慢查询台账:pg_stat_statements与连接问题
PostgreSQL的慢查询排查思路和MySQL类似,但工具和方法不同。很多人刚接触PG时,根本不知道慢查询日志文件名是什么、怎么开,其实PG有两个核心手段。
第一,开启慢查询日志。PG的配置项是:
conf复制log_min_duration_statement = 1000
log_directory = 'log'
log_filename = 'postgresql-%Y-%m-%d.log'
第二,安装pg_stat_statements扩展。这个扩展相当于PG的慢SQL台账,可以直接按总耗时、平均耗时、调用次数排序,快速找出“最大的麻烦”:
sql复制CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
SELECT query, calls, total_exec_time, mean_exec_time
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;
很多人反馈“pgadmin4无法联接服务器”,遇到这种问题,常见原因按概率排序是:pg_hba.conf里没允许客户端IP网段、postgresql.conf里listen_addresses默认只监听localhost、防火墙没放行5432端口、密码认证方式不对。这些看着像连接问题,但有时候其实是服务器负载太高、连接数被占满导致的。用pg_stat_activity查一下:
sql复制SELECT count(*), state FROM pg_stat_activity GROUP BY state;
如果大量连接卡在active状态并且都在执行同一条SQL,那根因还是慢查询,连接工具只是一个报错窗口。
4.3 时区与时钟同步:一个隐蔽的伪慢查询源
接着说一个和“服务器”这个词关系更直接、但很少有人第一时间想到的慢点:时钟漂移和时区配置。
项目里如果用了多台服务器组成集群,数据库和应用服务器分布在不同的机器上。只要某台服务器的系统时间和数据库服务器差了几秒甚至几分钟,很多需要时间戳校验的逻辑就会出现诡异现象:连接被断开、事务反复重试、监控误报超时。有一个案例,某团队排查一个接口偶发慢请求,查到最后发现是应用服务器和数据库服务器的时钟差了接近一分钟,定时任务触发时间不一致,导致每天特定时段出现重复任务抢锁,数据库锁等待时间飙升。
更隐蔽的是数据库时区与会话时区不一致导致的索引失效。MySQL里,如果字段是DATETIME类型,应用传入的字符串带时区转换函数,比如CONVERT_TZ(create_time, '+00:00', '+08:00') > ?,这个函数包住了列,索引直接失效,查询变成全表扫描。优化方法有几种:统一应用和数据库的时区设置,尽量不对列做函数运算,或者在设计阶段直接用TIMESTAMP类型存UTC时间,展示层再转换。
时钟同步层面,建议所有服务器都用NTP或chrony做时间同步,并定期检查。Linux服务器上可以用:
bash复制chronyc tracking
timedatectl
ntpq -p
如果时间服务器地址配置错了或者123端口被防火墙挡住,时间同步就会失败。这个问题平时注意不到,一旦集群节点之间存在时间差,各种奇怪问题都会冒出来。性能排查时,先确认“所有机器时间是一致的”,省的排查了半天,结果是两个钟没对齐。
5. 把法拉利当通勤车的治理观:别让钱和SQL一起跑偏
聊到最后,说点比单条SQL优化更重要的东西:慢查询治理的价值观和落地机制。上面那些案例,本质都是一回事——团队把慢查询当成了硬件问题,而没当成工程问题。
5.1 改一行SQL与加一台服务器的成本对比
算笔经济账。一台像样的数据库服务器,加上集群、存储、网络设备,往少了说也要十几万。一次慢查询优化,假设一个熟手DBA或后端工程师花半天定位并改写,人力成本按几千块算。前者是十几万的一次性投入,而且通常半年后随着数据量增长又要再投;后者是几千块的改造成本,带来的性能提升往往能撑到业务量翻几倍。
要是把慢查询改成索引优化,成本更低。一条几十个字符的索引,就能覆盖几个高频查询,收益甚至高过所有硬件投入。我在做技术方案评审时经常说一句话:先让SQL体面地跑起来,再谈服务器够不够。体面的意思是执行计划合理、没有多余的扫描和排序、扫描行数和返回行数一个量级。
当然,这不代表硬件不重要。如果优化完SQL、调整完配置,CPU和IO在业务高峰还是逼近极限,那说明业务量真的上来了,该加服务器加服务器,该上读写分离上读写分离。区别在于:硬件加在“瓶颈确认以后”,而不是加在“懒得排查的时候”。
5.2 运维侧能落地的监测体系:日志、告警、例行巡检
慢查询治理得靠机制,不能靠偶尔的“灵光一现”。团队里建议把这四件事落地:
- 统一开启慢查询日志,阈值设成和生产要求匹配的值,一般1秒,核心接口0.5秒,日志集中采集。
- 配置告警规则,比如“单条慢SQL执行超过5秒”“每分钟慢查询超过N次”“某类SQL今天比昨天平均慢30%”,直接推到钉钉或企微群。
- 每半个月用工具或脚本扫一次慢SQL的增量,按总耗时排序,挑Top 5安排优化,形成迭代节奏。
- 对执行计划基线做版本管理。SQL上线前强制EXPLAIN,确认没有全表扫描和大排序,发布后再观察一段时间。
Linux服务器上的日志采集,可以用rsyslog把各业务服务器的日志统一集中到一台日志服务器上,方便跨服务器检索。没有日志平台的团队,至少要把慢查询日志集中归档,不然出了事每台机器都上去翻文件,效率非常低。
5.3 需求侧的减法:业务真的需要一次查一年吗
最后一个角度,是“需求治理”。很多慢查询的产生,根源不在SQL写法,而在业务要的东西本身就是“法拉利外卖箱里塞了五十单”。
曾有运营提需求,要“支持查看上线以来所有订单明细,并且点击每页都要能无限往下翻”。数据量几千万行,任何分页方案在无限深翻页场景下都很难做。这种需求的合理形态是:默认只查最近三个月,更早的数据走异步导出,导出超大文件时拆包下载。跟运营对齐之后,80%的慢查询消失。
SQL不可能解决所有业务欲望。技术人有必要从数据量和业务频率的角度去审视需求:这个查询真的需要查全量吗?真正的用户场景里,有多少人会翻到第1000页?报表一定要实时跑吗,能不能离线预计算?如果一开始就把需求收敛到合理范围,后端和数据库会轻松很多。
再回到“开着法拉利送外卖”这个隐喻。法拉利可以送外卖,但送外卖的正确解法从来不是买更快的车,而是优化路线、合理接单、减少回头路。服务器同样是这个道理。跑慢查询的顶级机器,缺的不是马力,而是一条好的执行计划、一份合理的索引设计,和一套能防患于未然的监测机制。把这三件事做好,你手里的法拉利才真正值回票价,而不是每天在数据库里干着三轮车的活。
