先说一个让所有开发都头皮发麻的场景
晚上十点半,运营群里突然炸了。着大促刚开始十分钟,订单查询接口的耗时曲线直接拉满,从平常的80ms一路飙到快4秒。监控平台连续弹出慢查询告警,数据库CPU冲到90%以上,一堆本来跑得飞快的接口全部跟着遭殃。我登录线上环境一看,慢查询日志里反复出现同一条SQL,扫描了几百万行数据,执行计划里的type是ALL,没有命中任何索引。
说实话,这种情况下慌是没有用的。慢查询是数据库给我们的“求救信号”,它不会无故出现,每一条慢SQL背后都有一个可以被精确定位的原因。我做了这么多年后端开发和数据库调优,处理过大大小小数不清的慢查询问题,最深的体会是:SQL优化从来不是一个玄学问题,而是一个“日志→定位→执行计划→索引/改写→验证”的标准流程问题。
这篇文章就是要把这套流程完整地拆开讲清楚——慢查询日志怎么开、执行计划怎么看、索引到底怎么建、SQL怎么写才不会把索引废掉、以及在数据量变大之后还有哪些更高级的手段。不管你是刚接触数据库优化的新人,还是写过很多SQL但总感觉性能不够理想的老手,这套方法论都值得收藏。
1. 慢查询是怎么暴露的:从日志到监控的完整链路
1.1 慢查询日志:先让数据库把“凶手”记录下来
很多人上来就聊索引优化,但实际上连慢SQL都没有记录在案,优化根本无从谈起。MySQL的慢查询日志是一切的起点。
在MySQL中开启慢查询日志,核心就两个参数:slow_query_log和long_query_time。默认配置是long_query_time = 10秒,也就是说只有执行超过10秒的SQL才会被记录。这个默认值在绝大多数业务场景下形同虚设——等一条SQL跑10秒,前端早超时了,用户早就流失了。我通常会把它改成1秒,业务激进一点甚至会改成0.5秒甚至0.1秒,然后把所有超过阈值的SQL都记录下来。
# 直接在线修改(无需重启) mysql> SET GLOBAL slow_query_log = ON; mysql> SET GLOBAL long_query_time = 1; mysql> SET GLOBAL log_queries_not_using_indexes = ON; # 持久化到配置文件 my.cnf [mysqld] slow_query_log = ON long_query_time = 1 slow_query_log_file = /var/log/mysql/mysql-slow.log log_queries_not_using_indexes = ON这里有个细节很多人会忽略:log_queries_not_using_indexes这个参数。它会把所有没有利用索引的SQL也记录到慢日志里,哪怕执行时间没有超过阈值。对于排查那些“查得好像挺快但其实在扫全表”的隐患SQL,这个开关特别有用。等业务流量冲上来,这些潜在的全表扫描会直接变成真正的慢查询。
日志开起来之后,接下来要能从海量日志里快速抽出高价值的SQL。裸日志文件通常体量很大,动不动就几GB。这时候有两个工具非常顺手:
mysqldumpslow:MySQL自带的汇总工具,可以按执行次数、耗时排序聚合出Top SQL。pt-query-digest:Percona Toolkit里的明星工具,能输出类似报告格式的汇总结果,把慢查询按指纹归类。
以pt-query-digest为例:
pt-query-digest /var/log/mysql/mysql-slow.log > /tmp/slow_report.txt这份报告会告诉你哪类SQL累计耗时最多、哪类SQL执行次数最多、哪类SQL的平均扫描行数最大。我强烈建议优先关注“累计耗时”这个指标,因为执行1000次每次1秒的SQL,比执行1次每次20秒的SQL更值得先优化——前者在持续消耗数据库资源,后者可能只是某次数据量爆炸的偶发情况。
1.2 别只依赖日志:监控告警才是兜底
慢查询日志是一个被动记录工具,它只能告诉你“哪些SQL慢过”,不能在你还没反应过来的时候就拦住问题。所以成熟的系统里,一定要有主动监控的兜底。
常见的思路有两种:
- 数据库侧监控:使用Prometheus+mysqld_exporter抓取
SHOW GLOBAL STATUS里的Slow_queries计数器,当增长率超过阈值时触达告警。还可以监控QPS、Threads_running、Innodb_row_lock_current_waits等指标。 - 应用侧链路追踪:在业务代码层面给数据库查询加上耗时统计,把单条SQL的耗时上报到链路追踪平台。这种做法能看到“慢到底发生在哪一层”的真相——有时候不是SQL本身慢,而是连接池排队、网络抖动、业务逻辑里多查了几次。
把慢查询日志和监控告警配合起来,才能保证“慢SQL一出现,你的手机就能响”。这一步做不到,后面的优化都无从谈起。
2. EXPLAIN执行计划:慢SQL的第一现场
找到慢SQL之后,下一步就是搞清楚它为什么慢。EXPLAIN是MySQL给我们打开的“第一现场”,一条命令就能看到优化器打算怎么执行这条SQL。
EXPLAIN SELECT order_id, user_id, amount FROM t_order WHERE status = 1 AND create_time >= '2024-01-01' ORDER BY create_time DESC LIMIT 20;这条命令的输出里,最关键的几个字段要逐一读懂:
| 字段 | 含义 | 需要警惕的值 |
|---|---|---|
| type | 访问类型 | 出现ALL或index时要高度警惕 |
| key | 最终选中的索引 | NULL意味着没有可用索引 |
| rows | 估算扫描行数 | 行数越大,代价越高 |
| Extra | 附加信息 | 出现Using filesort或Using temporary往往是瓶颈 |
2.1 type字段:看懂访问路径的“等级”
type字段展示了MySQL访问表数据的方式,从优到劣大致是:
system > const > eq_ref > ref > range > index > ALL
const/eq_ref:通常出现在主键或唯一索引等值查询,速度极快。ref:普通索引等值匹配,速度也不错。range:索引范围扫描,比如BETWEEN、>、IN这类查询,只要扫描区间合理,同样高效。index:虽然用上了索引,但扫描的是整个索引树。这种情况往往比全表扫描好一点点,但依然很慢。ALL:全表扫描,数据量大时基本是灾难。
很多教程会告诉你“出现ALL就一定要加索引”,这句话其实不够准确。我见过不少表总共几千行数据,全表扫描也不过0.01秒,根本不需要索引。判断依据应该是:**rows字段的估算行数 × 单行访问代价 × 查询频率,是否超出了业务容忍范围。**数据量小的时候,全表扫描的成本低到可以忽略,强行建索引反而浪费空间和写入性能。
2.2 Extra字段:被低估的定位神器
Extra字段往往是定位瓶颈的关键。最常见的三种情况:
- Using filesort:意味着MySQL需要额外的排序操作。看到这个要立刻想:查询里的
ORDER BY是不是和索引的排列顺序对不上?如果ORDER BY的字段不在某个可用索引的末尾,优化器就不得不把结果集先捞出来再排序。 - Using temporary:临时表,通常出现在
GROUP BY、DISTINCT或特定类型的JOIN中。临时表会占用内存或磁盘,数据量大时非常拖后腿。 - Using index:好消息,覆盖索引。查询所需的列全部在索引里,不需要回表。能在索引里直接拿到结果,是性能最好的情况之一。
举个真实的例子。我之前排查过一条报表查询,SQL大概是:
SELECT date, SUM(amount) FROM t_payment WHERE user_id = 12345 GROUP BY date;EXPLAIN里出现了Using temporary; Using filesort,意味着先按user_id找到记录,再创建临时表分组排序。最后我的优化方案是建立一个(user_id, date, amount)的联合索引:
ALTER TABLE t_payment ADD INDEX idx_user_date_amount (user_id, date, amount);加完索引之后,Extra变成了Using index,因为(user_id, date)前缀让GROUP BY可以直接按索引顺序扫描,amount存在索引里又避免了回表。这条SQL从800ms降到了15ms左右,效果立竿见影。
2.3 EXPLAIN的局限性:别忘了验证真实耗时
EXPLAIN给的是优化器的估算,不是真实的执行结果。在MySQL 5.7及以后版本可以用EXPLAIN ANALYZE(8.0正式支持)直接执行并输出实际耗时和行数:
EXPLAIN ANALYZE SELECT order_id, user_id, amount FROM t_order WHERE status = 1 AND create_time >= '2024-01-01' ORDER BY create_time DESC LIMIT 20;这个命令会真正执行SQL并返回每一步的actual time、actual rows。这对于判断“优化器选错索引”或者“估算偏差太大”的场景非常重要。注意EXPLAIN ANALYZE会真的执行SQL,线上环境用的时候要小心写操作——用在SELECT上不会有影响,但别拿它去分析INSERT/UPDATE/DELETE这种会改数据的语句。
3. 索引设计:从“建了索引”到“每个索引都算过账”
3.1 为什么索引能加速:B+树结构的直觉理解
索引加速查询的原理,本质上就是用有序结构来换取查找时间。我习惯用一个类比解释:全表扫描就像在一本没有目录的书里找某个名字,只能从第一页翻到最后一页;而索引就是书的目录,你知道名字的拼音开头,可以直接翻到对应的章节页码。
MySQL的InnoDB存储引擎默认使用B+树索引。B+树是一种多路平衡查找树,叶子节点之间通过指针连接,数据在叶子节点上按顺序排列。因为数据有序,所以无论是等值查询、范围查询还是排序,都能借助树结构快速定位。一棵3层的B+树,配合几千甚至上万个扇出(每个节点存几百个键值),可以轻松支撑数百万行数据的索引查找,而定位一条记录只需要几次磁盘IO。
但是索引不是没有代价的:每次插入、更新、删除时,索引树都需要维护,写性能会受影响。索引还占用存储空间。所以建索引不是越多越好,而是要“算账”。
3.2 联合索引的最左前缀:最容易被忽略的规则
联合索引遵循最左前缀原则:索引(a, b, c)可以高效支持a、a+b、a+b+c三种条件的查询,但无法高效支持b或c作为第一个条件的查询。
这条规则由B+树的排序方式直接决定——索引先按第一个字段排序,同第一个字段下再按第二个字段排序,以此类推。查询条件如果不带最左字段,优化器就没法直接在索引树里定位。
知道了这个原理,设计联合索引时要养成“算列顺序”的习惯。两条经验非常实用:
- 把等值查询的字段放在前面。比如
WHERE status = 1 AND create_time > '2024-01-01',status是等值条件,应该放在联合索引更靠前的位置,这样MySQL可以先按照status精确命中一大片数据,再在索引里做范围的二分查找。 - 把排序字段放在合适的位置。如果查询里有
ORDER BY create_time DESC,而索引恰好包含(status, create_time),因为索引本身就是按create_time有序排列的,排序就可以直接利用索引顺序,省掉Using filesort。
要注意的一点是,联合索引在多数数据库里也遵循“索引下推”特性,MySQL 5.6及以后会在索引层就过滤第二个字段的等值和范围条件(Index Condition Pushdown),但这不等于可以无视最左前缀。最左前缀决定的是“能不能用索引定位”,下推决定的是“定位后能不能减少回表”。
3.3 三类典型的索引设计取舍
根据我处理过的慢查询问题,最常见的索引设计场景可以归纳为三类:
第一类:单列等值查询
比如WHERE order_no = 'xxx'。订单号本身唯一,直接建唯一索引就够了。如果业务经常同时用order_no和user_id查询,一个(user_id, order_no)的联合索引往往更划算,但要确认查询条件的顺序真的能匹配最左前缀。
第二类:范围查询+排序
典型如WHERE status = 1 ORDER BY create_time DESC LIMIT 20。这种查询最忌讳只给status建单列索引。因为MySQL找到所有status=1的记录后,还得在内存里对这些记录按create_time排序,走Using filesort,慢是必然的。正确做法是建(status, create_time)联合索引,让排序直接在索引中完成。
第三类:覆盖索引消除回表
如果一个查询只需要读取少数几个列,把这几列都放进索引,EXPLAIN的Extra里就会显示Using index,彻底避免回表访问数据行。这在查询频率极高的场景下收益很大,但也要小心索引膨胀——索引里塞了太多列会让索引变得宽大,写放大严重。
3.4 常见误区:冗余索引与失效索引
在项目里看到过太多“为了优化而优化”的索引设计,最典型的是冗余索引。明明已经有了idx_status_time (status, create_time),又单独建了一个idx_status (status)。后者就是纯冗余——它的所有查询场景都能被前者覆盖,却白白增加了写入的维护成本。
识别冗余索引其实很简单:拿到一个SQL的WHERE条件,问问自己,如果把它对应的联合索引的最左前缀去掉一个列,这个联合索引还能正常工作吗?如果一个索引是另一个索引的“前缀子集”,那它基本就是冗余的。
另一个常见问题是“索引失效”。严格来说,MySQL里存在一些让索引用不上的写法,比如:
- 对索引列做函数运算:
WHERE DATE(create_time) = '2024-01-01',会阻止索引使用,正确写法是WHERE create_time >= '2024-01-01 00:00:00' AND create_time < '2024-01-02 00:00:00'。 - 隐式类型转换:
WHERE phone = 12345678901,当phone是varchar类型时,MySQL会先把列转成数字再比较,索引直接失效。 - 前导通配符:
WHERE name LIKE '%张三%'无法使用索引,但WHERE name LIKE '张三%'可以。
这些失效场景不复杂,但线上碰到时很容易因为“我明明建了索引却不生效”而卡住排查半天。最好的预防办法是,写SQL时就养成“索引列保持原样,不做任何加工”的习惯。
4. SQL改写实战:从改写条件到连接顺序
索引设计得再好,如果SQL本身的写法有问题,索引也可能帮不上忙。SQL改写是慢查询优化里“性价比”最高的一环,有时改几个字符,性能就能天翻地覆。
4.1 大分页优化:把“翻得越深越慢”变成“先找出目标ID”
电商后台的分页列表最爱踩的坑是这样的:
SELECT * FROM t_order WHERE status = 1 ORDER BY create_time DESC LIMIT 100000, 20;这个查询慢到离谱,因为MySQL必须先扫描并丢弃前100000行,才能拿到后面的20行。数据量大之后,LIMIT的偏移量越大,扫描代价越高。
优化手法叫“延迟关联”,核心思想是先用覆盖索引定位目标ID,再回表取完整数据:
SELECT t.* FROM t_order t INNER JOIN ( SELECT id FROM t_order WHERE status = 1 ORDER BY create_time DESC LIMIT 100000, 20 ) tmp ON t.id = tmp.id;子查询里只访问id和create_time,由于这两个字段在联合索引(status, create_time, id)里,MySQL可以用覆盖索引直接完成排序和分页,不需要回表,内层查询的速度会快一个数量级。拿到20个ID后再去主表回表查询,总共只有20次随机IO,代价小得多。
此外还有一个思路是“游标分页”:利用id > last_id条件代替offset。但游标分页的前提是ID有序且连续,不适合需要跨页跳转的常规后台查询。所以最通用的还是延迟关联。
4.2 JOIN的驱动表与被驱动表:小表为什么优先进循环
多表关联查询慢,很多时候不是索引问题,而是驱动表选错了。MySQL的嵌套循环连接(Nested Loop Join)逻辑是:先查驱动表,拿到结果集,再逐行去被驱动表里匹配。驱动表的行数决定了循环次数,被驱动表的每次匹配则必须走索引才快。
所以口诀是:小表驱动大表,被驱动表的连接字段必须有索引。
举例:
SELECT u.name, o.order_no FROM t_user u JOIN t_order o ON u.id = o.user_id WHERE u.level = 1;这里t_user通常是更小的表,u.id = o.user_id里,被驱动表t_order上的user_id必须建索引。如果没建,每一次u的记录都要全表扫描一次t_order,那复杂度就是N乘M,必慢无疑。建上idx_user_id之后,每次匹配走索引,复杂度就降到了接近N加M。
如果你不确定优化器选的驱动表对不对,可以在EXPLAIN输出里看第一行——第一行就是驱动表。如果发现MySQL选错了,可以用STRAIGHT_JOIN强制指定连接顺序,但一般情况下优化器的选择是可信的,除非统计信息过期。
4.3 子查询与IN的隐忧:EXISTS改写并非万能
关于IN和EXISTS的争论在社区里经久不息。我给你一个实操判断方法:MySQL优化器在大多数时候会把IN列表或子查询改写成semi-join(半连接),效率并不差。重点是数据量分布——如果子查询返回的结果集很小,IN完全足够;如果子查询结果很大,嵌套循环的成本会失控,此时改成EXISTS或JOIN往往更合适。
比如:
SELECT * FROM t_order WHERE user_id IN (SELECT id FROM t_user WHERE level = 1);假设level=1的用户有几十万,这个IN列表会非常庞大。改成JOIN写法更稳妥:
SELECT o.* FROM t_order o JOIN t_user u ON o.user_id = u.id WHERE u.level = 1;但要注意JOIN可能会因为重复匹配产生重复行,需要加DISTINCT或者保证user_id唯一。
4.4 聚合查询:先过滤再聚合是铁律
报表类慢查询最常见的错误是先把大量数据搬进临时表,再慢慢聚合。比如:
SELECT region, SUM(amount) FROM t_sales WHERE create_time >= '2024-01-01' GROUP BY region;这种写法本身没问题,前提是create_time字段有索引,而且过滤后的数据量要足够小。如果一张表里有几千万行销售数据,一段SQL先全表扫描再分组,必然慢。
优化方向有两个层面:
- 如果
create_time条件是可以提前过滤的,务必确保条件里带索引,让MySQL只扫描需要处理的分区/区间。 - 如果业务上经常需要按天/按月汇总,可以把聚合结果物化到一张统计表里,定时任务更新,线上查询直接读统计表。这就是“预聚合”思路,后面会展开讲。
写SQL时脑子里始终要有一根弦:**数据从一个大的集合缩小到一个小的集合的操作,越早做越好。**无论是WHERE过滤、JOIN裁剪还是HAVING里的条件,都尽量放在能执行它的最早阶段。
4.5 一个从3800ms到30ms的完整改writing案例
说一个我实际处理的案例,便于你把这些技巧串起来。
线上有一条订单统计SQL,业务是统计某个用户最近30天的订单总额和订单数:
SELECT COUNT(*), SUM(amount) FROM t_order WHERE user_id = 888 AND create_time >= DATE_SUB(NOW(), INTERVAL 30 DAY);刚开始数据量只有几十万的时候这条SQL跑得挺快,后来订单涨到几百万行,慢查询日志里开始反复出现它,EXPLAIN一看:type=ALL,rows=280万,Extra里还有Using where。
我做了三步处理:
- 建联合索引
idx_user_create (user_id, create_time)。等值条件user_id放在前面,范围条件create_time放在后面,这样MySQL可以先定位到该用户的数据块,再做日期范围的二分。 - 再次EXPLAIN验证,type变成ref/range,rows从280万降到几千。
- 由于聚合函数只需要读amount字段,我把索引扩展成
(user_id, create_time, amount),实现覆盖索引,彻底避免回表。
最终这条SQL从最开始的3800ms降到30ms左右。整个优化过程其实只花了不到10分钟,关键就在于日志能找到它、执行计划能读懂它、索引设计能算对列顺序。
5. 并行执行、分区与预聚合:中等规模系统的进阶方案
数据量继续增长之后,单靠“建索引+改SQL”可能就不够用了。这时候要开的武器库里有三样东西:并行SQL、分区表、预聚合。这三者的适用场景和踩坑点要分清楚。
5.1 并行SQL优化:把大查询拆成多个小任务
“并行SQL优化”这个词最近被不少数据库厂商拿出来讲,其实原理很直接:一段SQL如果扫描的数据量大,就让多个CPU核心/多个线程同时处理不同的数据分片,最后把结果汇总。像PostgreSQL、MySQL 8.0(企业版部分功能)、国产数据库大多在并行查询上做了不少优化。
并行查询的收益在大查询上非常明显,比如几亿行的大表做全表聚合。但实际使用时,并行不总是越快:
- 并行会占用更多CPU和IO资源,在OLTP(在线事务处理)高并发场景下,并行查询反而可能把其他普通查询的线程饿死。
- 小查询开启并行,线程调度和结果汇总的开销可能超过收益。
所以正确打开方式是:**只在明确判定为大查询、且当前系统资源空闲时,才允许并行执行。**很多数据库用“并行度阈值”来控制——比如超过100万行才开启并行,并行度限制在4以内。不要一看到慢SQL就把并行度开到16,那是拿整个数据库的性能去赌一条查询。
5.2 分区表:适合时间维度,别滥用
分区表在应对时间维度的归档和清理场景非常有效。比如订单表按create_time做RANGE分区,按月一个分区。好处有几条:
- 查询条件带上月份,可以直接裁剪(partition pruning)到对应分区,减少扫描量。
- 历史数据清理可以直接
DROP PARTITION,比逐条DELETE快几个量级,也几乎不影响在线业务。
但分区表有两个容易让人上当的地方:
- 查询条件必须带上分区键才行。如果不带分区键,优化器需要扫描全部分区,性能不会变好,甚至比普通表更差。
- 分区不会自动替代索引。很多人以为分了区就不用建索引了,这是误区。分区是一层“局部分片”能力,索引是在每个分区内独立维护的,两者是叠加关系,不是替代关系。
我做过的项目里,分区表用得最多的是日志表、流水表这类“只写少查、按时间归档”的场景,效果很好。但业务上的核心订单表如果不是特别需要按月清理历史,真不建议随意分区。分区键没选好,反而给查询和运维增加一堆复杂性。
5.3 物化视图与预聚合报表:以空间换时间
报表统计类的慢查询,与其每次实时算几千万行,不如提前把结果算好存起来。这就是预聚合思想,几种典型实现:
- 定时任务+统计表:应用层在业务低峰期跑聚合任务,把结果写入一张汇总表,线上报表直接查汇总表。
- 物化视图:数据库原生或第三方工具提供的预计算视图。MySQL本身没有内置物化视图(除了视图的某些特性),但可以通过定时刷新+存储过程模拟。
- 列式存储/OLAP引擎:把分析类查询交给ClickHouse、StarRocks等列式存储引擎,实时聚合能力非常强。
这类方案的收益非常直观。我统计过一组数:如果一张订单明细表有5000万行,实时查询“某个月的GMV”平均耗时2.3秒;而用预聚合表把结果按天算好,线上查询耗时基本在50ms以内。省下的这2秒,对报表系统来说就是“能用”和“不能用”的区别。
5.4 缓存算不算SQL优化
很多团队一遇到慢查询,第一反应是上Redis。我理解这个思路,但必须提醒:缓存是“挡子弹”,不是“治病因”。如果一条SQL本身全表扫描4秒,你给它加缓存,缓存命中时确实秒回,但缓存穿透或者第一次查询时,4秒的慢查询还在。更麻烦的是,缓存一旦和数据库数据不一致,业务还会出现脏读问题。
正确的做法是:先用SQL优化把数据库侧的慢查询降到可接受范围,再用缓存扛住高并发重复查询,作为锦上添花。优化SQL是把病根拔出,缓存只是止痛药。
6. 优化上线的最后一公里:压测、灰度与回滚
很多开发在本地跑通一条SQL,看执行计划合适,就直接推到生产。这个习惯非常危险。优化上线同样讲究流程,尤其涉及索引变更和SQL改写,稍不注意就会把线上的写锁或缓存命中率搞崩。
6.1 压测验证:不要只看一条SQL的耗时
单条SQL从4秒优化到30ms,看起来很漂亮,但上线之前至少要回答几个问题:并发到100个请求时,这条SQL还能保持低延迟吗?新增的索引对写入性能影响多大?数据库整体的QPS和CPU有没有异常波动?
压测工具方面,sysbench是MySQL用得最多的基准测试工具,可以模拟大量并发读写。也可以用业务层的JMeter或Locust直接压接口。重点是压低吞吐量、高延迟曲线,看优化后有没有引入新的瓶颈。
我在实际项目中遇到过一种情况:优化SQL后单条查询快了很多,但因为SQL改写引入了更大的临时表(比如某个JOIN的驱动表选错),并发一上来反而把内存打爆了。这种问题只看单条SQL是看不出来的,必须压测。
6.2 索引变更的灰度策略:别在生产直接跑大表ALTER
给一张几千万行的表加索引,如果用ALTER TABLE ... ADD INDEX,在MySQL里会造成锁表或长时间的主从延迟。这里给几个安全的执行方式:
- 用
gh-ost或pt-online-schema-change这类工具做在线DDL,在业务运行期间逐步拷贝数据,不影响写入。 - 或者找业务低峰期执行,配合复制延迟监控,提前准备好若延迟超过阈值就暂停的预案。
- 不要在小表上过度谨慎,但在大表上一定不要图省事直接ALTER。
索引变更上线后,还要在监控里观察一段时间:写延迟、CPU、主从同步状态。如果异常,要能快速回滚——索引变更是可以DROP掉的,删掉就恢复原状,但前提是你知道刚加的是哪个索引。
6.3 团队层面的SQL评审机制
单靠一个人救火,永远救不完火。我在团队里推行过一个很简单的机制,效果出乎意料地好:
- 每次代码评审时,涉及数据库查询的改动必须附带
EXPLAIN结果和预期扫描行数。 - 所有新SQL在合入前,先跑一遍慢查询日志扫描,确保没有任何一条SQL超过预定的耗时阈值。
- 线上慢查询告警按月复盘,把出现频率最高的SQL列进“重点观察清单”,给出优化方案和时间表。
这套机制看起来麻烦,但坚持三个月之后,新上线的慢SQL数量会断崖式下降。SQL优化本质上是一个“习惯养成+流程把控”的工程问题,而不是某个写SQL的人突然灵光一闪。
我在处理慢查询这件事上,最大的体会是:**真正值钱的不是某个技巧,而是把事情一条条做对的耐心。**从开日志、读执行计划、设计索引、改写SQL,到压测、灰度、评审,每一步都有章法。下次再看到慢SQL告警,不用慌,按这个链路一步步走,效率起飞只是时间问题。