十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

SQL查询优化深度指南:从执行计划到索引实战

SQL查询优化深度指南:从执行计划到索引实战 揭秘SQL查询优化从原理到实战的深度指南前几天帮朋友排查一个线上问题他们的订单列表接口上线前压测还正常上线两周后页面越开越慢最夸张的一次直接卡了6秒多。我拉了一条慢日志发现就是一条简单的主订单查询单次执行耗时竟然超过1.5秒。当时后台DBA扫了一眼就说了句连索引都没走还查什么。这种场景在一线业务系统里太常见了。SQL查询优化不是背几条规则、会加索引就算会了真正要解决的是“为什么这条SQL这么慢”、“如何判断索引建得对不对”、“为什么优化后运行几天又变慢”这一连串问题。我接触SQL查询优化这些年踩过很多坑也和很多同行交流过今天把这些原理、排查思路、实战经验一并梳理出来希望能给正在被慢查询折磨的开发者一些真正能落地的帮助。1. 优化器到底在“想”什么SQL执行前的决策链路很多开发者的第一反应是SQL慢就去改写SQL或者看有没有少加索引。但在动手之前先弄明白一件事数据库是怎么决定用哪条路去执行你这条SQL的你不了解优化器的决策逻辑所谓的“优化”就只是在碰运气。1.1 从语法解析到执行计划优化器工作流水线你写一条SQL从敲下回车到拿到结果中间要经过一连串处理。我习惯把整个过程理解成导航软件规划路线你告诉它起点和终点它不会因为你输入顺序就固定路线而是先解析你的需求再结合地图数据统计信息、索引分布算出几条候选路线最后选一个它认为花费最小的。优化器内部的流水线大致是语法解析和语义检查确认SQL语法没写错、表字段存在、用户有权限。逻辑优化这一步不关心数据的物理存储方式它做的是子查询展开、把过滤条件下推到子节点、去掉恒真/恒假条件、把复杂的嵌套JOIN重写成更合理的逻辑结构。物理优化开始考虑真实的数据存储结构和访问路径比如全表扫描要花多少IO走某个索引要回表多少次小表和大表以什么顺序连接更省。生成执行计划从所有候选路径里挑一个代价最低的交执行器真正去跑。我遇到过很多同行一聊到SQL慢就去看执行计划却不知道执行计划是优化器“算”出来的。也就是说执行计划的好坏取决于优化器掌握的统计信息准不准、代价估算模型合不合理。1.2 代价估算模型基数、选择率与直方图代价估算模型里最核心的三个概念是基数Cardinality、选择率Selectivity和直方图Histogram。基数通俗来说就是“这条路走完大概会得到多少行”。它决定了后续每一步要处理多少数据。选择率就是“满足某一条件的数据占全表的比例”。比如一张订单表有100万行status PAID这个条件的选择率如果是20%那估算基数就是20万行。直方图是统计信息里记录的字段值分布情况优化器靠它才知道status字段里有多少个不同值每个值大概占比多少。举个例子你就明白了。有一张100万行的订单表pay_status字段有90万行都是“已支付”占比90%另外两种状态加起来10万行。如果你查询一个只占1%的状态值优化器会倾向于走索引如果你查询的是“已支付”这个占比90%的值优化器算来算去觉得反正要拿回90万行直接全表扫描可能比索引加回表更划算。这就是很多新手搞不懂“为什么我明明建了索引查询却很慢”的原因之一——优化器可能判断走索引反而是亏的。它不是看你有没有索引而是看走索引能不能省钱。1.3 为什么同样的SQL换参数就变慢统计信息与估算偏差SQL优化的坑有很多是“同一个SQL换个参数就翻车”。我给你描述一个很典型的场景业务里有一条按用户判断的查询用来查某用户当天是否已有订单SELECT * FROM t_order WHERE user_id ? AND create_time 2024-01-01 00:00:00;某天运营上报说有个用户点查询的时候等了20多秒。开发用自己本机的账号测毫秒级就返回了压根复现不出来。问题出在哪里统计信息过期加字段值分布极不均匀。表里绝大多数用户只有几行订单但极少数“羊毛党”用户可能有几万行订单。当这个特殊用户来查询时优化器按历史统计信息估算认为这个条件的行数很少选中了索引路径但实际情况是这个用户的订单散落在一万多个数据页上回表次数多到爆炸。我的经验是做SQL优化的前提是先验证统计信息的准确性。MySQL可以在执行EXPLAIN之前先跑一次ANALYZE TABLE刷新统计信息后再看执行计划。如果你发现更新完统计信息之后执行计划变了那就说明之前优化器一直在拿“过期地图”做决策。2. 先定位再说优化慢SQL排查基线怎么建没有数据支撑的优化就是拍脑袋。我之前有过一次很丢脸的经历在一个低频统计SQL上死磕了一天半各种改写SQL、调整索引结果一看日志这条SQL一天才跑了两次——优化得再快业务也没感知。从那之后我养成了习惯第一步永远是定位不做任何变更。2.1 慢查询日志与采集工具排查慢SQL的第一步是开启慢查询日志。以MySQL为例核心配置如下# 开启慢日志 slow_query_log 1 # 慢日志文件路径 slow_query_log_file /var/log/mysql/mysql-slow.log # 超过该耗时算慢SQL单位秒 long_query_time 1 # 记录没有走索引的SQL log_queries_not_using_indexes 0阈值设置上我个人的建议是业务初期可以设置为2秒稳定运行后逐步收窄到1秒甚至0.5秒。如果一开始就设0.1秒慢日志会被各种偶发抖动刷屏反而淹没了真正需要关注的SQL。日志文件是一行行零散记录的直接翻文件不现实。我常用的是pt-query-digest它会自动把相同结构的SQL归一化并聚合统计聚合后你能一眼看到哪一类SQL的累计耗时最高、平均耗时多少、出现在哪个时间段。pt-query-digest /var/log/mysql/mysql-slow.log重点不是看单条SQL而是看“总消耗”。pt-query-digest的报表里有一个“Profile”部分它会把你所有的慢SQL按总执行时间降序排列那个排名靠前的才是你该优化的对象。2.2 一条慢SQL背后是CPU、IO还是锁很多人一看到SQL慢就动手改SQL其实有些慢SQL无论你怎么改都解决不了问题。这里要区分三个层面SQL到底是执行得慢是CPU计算慢还是压根在等待锁释放。我的排查顺序一般是先看是哪种类型CPU型慢执行计划扫描了大量行或者有严重的排序、分组操作把CPU打满了。IO型慢磁盘读写频繁最常见的是深分页查询或随机IO过多。锁等待型慢这个最容易被忽略。SQL本身执行只要50毫秒但它等了2秒才拿到锁看起来也是“慢SQL”。在MySQL里判断CPU/IO型慢可以用EXPLAIN看扫描行数用SHOW PROFILE看执行各阶段耗时判断锁等待型慢查看SHOW ENGINE INNODB STATUS里的“LOCK WAIT”片段可以看到具体等待的是哪一把锁。如果一条慢SQL反复出现锁等待那么需要解决的就不是SQL改写而是移除持锁时间长的事务比如检查是不是有人忘提交了。2.3 优化顺序别在一个低频SQL上死磕定位到几条慢SQL后接下来是排序优先级我的判断标准是“总成本调用频率×单次耗时”。SQL单次耗时调用频率次/天每日总耗时优先级订单列表查询1.8秒2000010小时P0月报聚合查询20秒10200秒P2登录鉴权查询0.8秒5000011小时P0注意看月报聚合查询单次耗时20秒看着吓人但一天才跑10次总消耗200秒订单列表查询单次才1.8秒但一天调用两万次总消耗10小时。如果只优化单次最慢的那条产线整体资源消耗根本降不下来。真正的高手都是先算这笔账再动手。3. 读懂执行计划explain关键信息逐列拆解执行计划是SQL优化绕不开的一步。但很多人对EXPLAIN浮于表面知道有type、rows这些列却不清楚它们在实际调优中怎么组合使用。下面拆几个最关键的维度。3.1 type列从const到ALL的访问路径阶梯EXPLAIN的type列直观反映了访问表的方式性能由好到差大致是const/system按主键或唯一索引查一行速度极快。eq_ref驱动表与被驱动表通过主键/唯一索引关联每行只匹配一行多表JOIN时最理想。ref通过普通索引非唯一索引匹配可能匹配到多行。range索引范围扫描比如IN、BETWEEN、、等操作。index索引全扫描索引树的所有叶子节点都要遍历但比全表扫描好一点。ALL全表扫描唯一能安慰自己的是全表扫描不一定是错的如果表本身只有几百行或者要返回全表大半数据ALL反而可能最优。我经常跟团队说一句话看到ALL不要害怕看到深入ALL却还慢才需要害怕。比如一张1000万行的表typeALL这种肯定得处理但如果是一张只有百十行的字典表typeALL完全没毛病别把时间浪费在建索引上面。3.2 rows与filtered估算值和真实数据怎么对比rows列是优化器估算的需要读取的行数。filtered是经过WHERE条件过滤之后剩余数据量占扫描行数的百分比。两者相乘就能大致估算这步会产出多少行。有个技巧是拿rows和真实返回行数做对比。如果优化器估算要读100万行查询结果只有10行中间差了5个数量级出现这种情况通常有两个原因统计信息过期优化器不知道数据分布已经发生巨大变化。索引选择性太差比如在只有两个枚举值的字段上建了索引优化器一算选择率接近50%就觉得还不如不索引。遇到这种情况我会先执行ANALYZE TABLE刷新统计信息再重新EXPLAIN如果执行计划有变化就确认了统计信息是罪魁祸首。3.3 Extra列filesort、temporary等危险信号Extra列包含的信息量非常大有几个信号是优化红线Extra中的关键字含义处理方向Using filesort无法利用索引完成排序需要额外排序操作考虑让排序字段进入索引或调整排序列顺序Using temporary查询过程使用临时表常见于GROUP BY、DISTINCT、子查询尝试用索引覆盖分组/去重字段Using index condition索引条件下推ICP索引层先过滤再回表通常是好事表示索引利用得不错Using index覆盖索引扫描不需要回表好的信号查询字段全在索引中Using where存储引擎层之后还要用where过滤配合type和rows判断过滤效率低时需优化有一种误区我要特别指出看到Using filesort就加索引但加得不对反而可能让执行计划变坏。比如查询条件是等值过滤WHERE status PAID排序字段是create_time DESC正确做法是建立(status, create_time)联合索引让等值条件和排序条件都嵌入索引如果只给create_time单独建索引排序确实可以用索引但等值过滤的回表成本又会增加整体不一定划算。4. 索引与SQL改写从案例到套路这一节讲实战。我会用几个真实遇到的案例和对应的优化思路来拆解这样比罗列技巧更容易迁移到你的业务场景里。4.1 覆盖索引如何干掉回表有一个典型的业务SQL查用户最近的支付订单SELECT order_id, order_no, pay_status FROM t_order WHERE user_id 127001 ORDER BY create_time DESC LIMIT 20;一开始这张表只有主键索引执行计划显示typeALL, rows800000相当于把全表80万行扫了一遍再排序。优化方式是建立一个覆盖索引ALTER TABLE t_order ADD INDEX idx_user_create (user_id, create_time, order_no, pay_status);这个索引把WHERE过滤字段、排序字段、SELECT返回字段全部纳入了索引。查询时直接在索引树上完成了过滤、排序和取值完全不需要回表慢查询日志里的耗时从1.2秒降到了0.03秒。但覆盖索引有一个前提SELECT的字段必须都能在索引里找到。如果你写的是SELECT *覆盖索引就直接失效因为索引里放不下整行数据优化器还得回表拿其余列。4.2 排序与过滤混合场景的索引设计大家知道“最左前缀”原则但联合索引的列顺序设计是有具体套路的。我的经验排序规则是等值条件字段放最前面WHERE里的等值比较。排序字段紧随其后能让ORDER BY利用索引排序。范围条件字段再往后放、、BETWEEN这种范围之后索引基本就罢工了。最后才放SELECT里需要的普通列考虑是否要做覆盖索引。看一个反例。业务SQLSELECT * FROM t_order WHERE pay_status PAID AND amount 100 ORDER BY create_time DESC;如果建索引(pay_status, amount, create_time)那么第一个等值条件会精确匹配接着是amount 100范围扫描在范围之后create_time就排不上用了依然要Using filesort。倒是把索引改成(pay_status, create_time)amount的范围条件直接在查询层过滤排序却可以完全借助索引完成效果反而更好。4.3 IN、EXISTS、LIMIT深分页等常见改写套路LIMIT深分页问题。经典写法SELECT * FROM t_order ORDER BY create_time DESC LIMIT 100000, 20;数据库要先把前100020行扫出来再把前100000行丢掉前面那些被丢弃的IO全白干了。一个常见改写是用子查询先只取IDSELECT * FROM t_order JOIN ( SELECT id FROM t_order ORDER BY create_time DESC LIMIT 100000, 20 ) AS tmp ON t_order.id tmp.id;子查询里索引覆盖只取主键ID不需要回表外层再按ID回表取20行。注意这里要配合排序字段的索引才有效否则子查询本身也会大排序。NOT IN与NULL陷阱。如果你的子查询结果里有NULLNOT IN会直接返回空集因为NULL参与NOT IN比较时结果既不是真也不是假而是未知最终导致整条SQL查不出数据。排查这类问题通常不是加索引能解决的而是要把NOT IN改成LEFT JOIN ... WHERE col IS NULL的方式语义更明确也不会踩NULL的坑。OR改写UNION ALL的场景。两条独立SQL走不同索引的OR优化器可能合并得很费劲。比如SELECT * FROM t_order WHERE user_id 10086 OR mobile 13800000000;如果user_id和mobile各自有索引数据库要嘛在索引合并上消耗大量CPU要嘛干脆放弃索引走全表。改写为两个分开的查询再用UNION ALL合并执行计划反而可控得多。前提是两边结果集没有重复或者允许重复展示一旦有重复要去重就要评估UNION去重的开销不能无脑套用。5. 执行计划漂移优化效果的“隐形杀手”最后聊一个比较进阶、却很容易让人“翻车”的问题——执行计划漂移。简单说就是你的SQL没变索引没变统计信息也没手动动过但某天执行计划突然变了性能一落千丈。5.1 统计信息失效引发执行计划变化我接过一个真实案例。一个商品维报表SQL半年前优化过一次把原本5分钟的查询压到了30秒当时大家都很满意。半年后报表越来越慢最后直接跑了30分钟还没出结果。开发排查了一圈没发现问题跑ANALYZE TABLE之后SQL竟然恢复到30秒。原因就是半年里这张大表的数据量翻了好几倍旧统计信息暗示优化器“走A索引回表很划算”但实际数据量增长后索引回表的随机IO成本已经翻了10倍而优化器还抱着半年前的地图在规划路线自然就越跑越偏。这个问题没有一劳永逸的解法只能从制度上防范对核心大表设置周期性统计信息更新任务。上线核心SQL前做执行计划回归比对用EXPLAIN记录首选计划后续定期巡检看有没有变化。优化完成后不要立刻删除历史数据留一份优化前后的执行计划和实际耗时存档方便以后回查。5.2 多表连接与并行SQL优化多表连接优化有一个很容易被忽略的判断点哪张表做驱动表。在等值JOIN的情况下通常是小结果集驱动大结果集但MySQL 8.0引入hash join之后部分场景下即使驱动表大一些也能通过哈希匹配弥补所以“小表驱动大表”不再是绝对真理。我的建议是实际去在测试环境跑一下不同顺序的JOIN用数据说话。至于OLAP场景下的并行SQL优化思路和传统的单条SQL改写不太一样。比如你在分析型任务里要把一张上亿行的流水表按用户维度聚合如果数据库是传统单机关系型即便执行计划很完美单线程扫描的物理上限就摆在那。常见的做法是按user_id的哈希值分成多个分片比如user_id % 10每个分片单独跑聚合SQL。多个线程/进程并行执行各自分片最后再把结果汇总。这种“整段SQL拆并行”的方案我在Doris这类分析型数据库上执行过很多次效果立竿见影。但要注意控制并行度不是越大越好磁盘IO和CPU核数是硬约束并行度开过头SQL没慢机器先卡死了。5.3 个人实践中的一些土办法优化做到后期我总结了一套自己的“土办法”不算高深但非常实用第一优化完的SQL一定要换至少三个真实业务参数跑一遍。千万不要只拿自己本地常用的那个参数做验证。就像我开头说的换个参数就翻车的情况本质都是验证不充分。第二MySQL 8.0能用EXPLAIN ANALYZE就直接用。它能给你每个执行步骤的实际耗时、实际行数比EXPLAIN的估算值直观得多。看到哪个节点行数最大、耗时最久就直接奔那个节点去优化。第三别迷信“什么SQL都能优化”。如果一条SQL已经动用了索引、覆盖、改写还是扛不住就考虑业务层面拆分。一次查询改成两次查询、把数据预热到缓存、让报表走独立只读从库这些都是合法的优化手段。技术的最终目的是让业务体验变好不是证明一条SQL能写得多么花哨。最后再分享一个小习惯每次做完SQL优化我都会在工单里附上机器能看懂的对比图优化前执行计划type级别、额外耗时、慢日志记录优化后同样的三项。这样后面接手的人不用从头猜一遍也方便复盘时快速找到当初的决策依据。SQL优化这件事靠的不是一次灵光乍现而是把每一次异常都当成一次系统性问题来对待你积累的排查链路越完整下次踩坑的概率就越低。
返回列表