ARTICLE DETAIL

资讯详情

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

SQL调优实战:从执行计划到索引设计

SQL调优实战:从执行计划到索引设计 做过几年数据库开发和运维的人基本都经历过这种场景一条SQL把线上库拖到CPU飙红连接数瞬间打满应用层报错连成一片。这时候你再去看那条SQL往往就是一个没走索引的全表扫描或者是一次毫无必要的深分页。数据库问题有一个特点它不是“写对就能跑”的逻辑题而是“写完还必须跑得快”的工程题。SQL调优的本质不是背几条优化口诀而是理解数据库到底怎么执行你的语句然后在关键节点上替它把路铺平。这篇文章我就用实际生产中会遇到的情况作为主线把SQL调优从思路到落地完整过一遍。内容包括慢SQL的定位方法、执行计划的解读、索引设计的原则、常见SQL写法的坑以及数据库工程侧的性能兜底手段。适合刚接触数据库调优的后端开发、从传统DBA转向云端数据库运维的同学也适合那些被慢查询折磨过几次、想系统补一补调优思路的人。1. 先把问题看清楚慢SQL到底慢在哪很多人在调优时容易犯一个错误拿到一条慢SQL不看任何证据直接凭感觉加索引。运气好的时候索引加对了问题解决运气不好的时候索引建了一堆SQL还是慢甚至还拖慢了写入。真正靠谱的做法是先搞清楚SQL慢在哪一步再决定怎么改。1.1 执行计划是这个领域的“第一现场”执行计划就是数据库优化器根据SQL语句、表统计信息、索引情况生成的一份“执行方案”。它告诉你数据库打算用哪种方式访问表、先关联哪张表、数据大概有多少行、有没有排序和临时表。改SQL之前先看执行计划就像去医院先做检查再开药顺序反了就容易出大问题。在MySQL里最常用的就是EXPLAIN关键字EXPLAIN SELECT * FROM orders o JOIN users u ON o.user_id u.id WHERE o.status PAID AND o.created_at 2024-01-01 ORDER BY o.created_at DESC LIMIT 20;执行之后你会看到一张表格每一行代表一个步骤。关键字段有这几个type访问类型。从好到差大致是 system const eq_ref ref range index ALL。如果看到ALL就说明是全表扫描这是最需要警惕的信号。key实际用到的索引。如果是NULL说明没有命中任何索引。rows预估扫描的行数。这个数字越大执行时间通常越长。Extra额外信息。这里经常藏着关键线索比如Using filesort代表发生了文件排序Using temporary代表用了临时表。拿到执行计划后第一件事不是改SQL而是先回答三个问题有没有走索引走的索引是否合理扫描行数为什么这么大三连问之后问题的大方向基本就清楚了。1.2 EXPLAIN结果里的几个关键信号我自己的习惯是优先看type和rows这两列。之前在排查一个订单查询接口的慢SQL时EXPLAIN结果显示type是ALLrows有80多万行Extra还出现了Using filesort。这几项凑在一起性能不可能好。再补充一个容易被忽略的点EXPLAIN给的是“估算值”不是真实值。它基于表的统计信息来推算如果表的统计信息过期了估算扫描行数可能和实际差别很大。所以遇到执行计划和实际耗时对不上的情况先执行ANALYZE TABLE更新统计信息再重新看执行计划。MySQL 8.0还支持EXPLAIN ANALYZE可以直接输出真实执行时间和各步骤的实际行数EXPLAIN ANALYZE SELECT * FROM orders o JOIN users u ON o.user_id u.id WHERE o.status PAID AND o.created_at 2024-01-01 ORDER BY o.created_at DESC LIMIT 20;这个命令会真实地执行SQL并统计耗时定位问题比纯EXPLAIN更准。不过需要注意线上大查询最好别直接跑EXPLAIN ANALYZE因为它会真的执行可能把库压出问题。稳妥的做法是在测试环境上执行或者挑业务低峰期来做。1.3 明确调优的目标边界慢SQL的“慢”标准不是拍脑袋定的。通常以一个具体的耗时阈值来定义比如线上接口要求200毫秒内返回那你关注的目标就是那些超过200毫秒的SQL。有些团队会接慢查询日志把超过阈值的SQL自动采集出来做周期性分析。在目标明确之后还要理解一个事实调优不是追求执行计划“看起来很完美”而是追求业务可接受的响应时间和资源消耗。有时候一条SQL虽然走了全表扫描但表本身只有1万行跑起来也就几十毫秒那就没必要为了“用上索引”而强行优化。把优化资源花在真正值得的地方才是工程上的理性选择。2. 索引设计调优里投入产出比最高的一环如果从投入产出比来排名SQL调优里性价比最高的操作就是索引。加好一个索引把查询从全表扫描变成索引查找响应时间可能是从秒级降到毫秒级的差距。但索引也不是万能的设计不好不仅白占存储空间还会拖慢写入。2.1 索引为什么能加速从目录说起可以把索引理解为书的目录。没有目录的书你要找某个关键词只能一页一页翻有了目录你直接查到页码翻到那一页就行。数据库里的索引也是同样的道理它把某一列或某几列的值按顺序组织成一种便于查找的结构查询时通过索引快速定位到目标数据的位置。MySQL InnoDB引擎的索引底层用的是B树。B树的优势在于数据都存放在叶子节点叶子节点之间用指针相连非常适合范围查询和排序操作。这也是为什么InnoDB选择B树而不是其他数据结构。需要特别提醒的是索引不是建得越多越好。每建一个索引写入数据时都要额外维护一份B树结构写入性能会受影响。索引占用的存储空间也会变大。所以索引设计的原则是够用就好精准匹配业务查询模式。2.2 联合索引的顺序问题实际业务里单列索引很难满足复杂查询的需求。更常见的是多条件组合查询这时候就需要联合索引。而联合索引的字段顺序直接决定了它能覆盖哪些查询场景。举个例子假设有张订单表查询条件是SELECT * FROM orders WHERE user_id 123 AND status PAID AND created_at 2024-01-01;如果你建了(status, user_id, created_at)这个联合索引但查询里没带 status 这个条件那这个索引就可能不会被使用。联合索引遵循最左前缀原则只有查询条件里包含了联合索引的最左字段索引才会被考虑使用。所以设计联合索引时要把区分度高、经常作为等值查询条件的字段放在最左边。区分度可以理解为这个字段取值的多样性性别字段只有“男”“女”两个值区分度低用户ID每个用户都不同区分度高。把高区分度的字段放前面能更早地缩小扫描范围。2.3 最左前缀原则的实操解读我用一个更贴近业务的例子来拆解这个原则。假设联合索引是(a, b, c)那么查询条件包含a或a, b或a, b, c都能用到索引查询条件只包含b或c用不到这个联合索引查询条件是a, c能用上索引的只有a这一列c的部分无法走索引还有一种容易忽略的情况范围查询会中断索引的后续使用。比如查询条件包含a ? AND b ? AND c ?那么b之后的部分就没办法继续在索引上精确匹配了因为范围查询之后的字段顺序已经无法保证有序。这是在设计联合索引时特别容易踩的坑。举一个实际例子之前有个订单查询接口条件很多既有等值也有范围。原来联合索引建得比较随意EXPLAIN看的话只用到了部分索引后面字段都是回表查的。后来把等值字段放前面、范围字段放最后扫描行数直接从几万行降到了几百行接口响应时间也从1.2秒降到了80毫秒。2.4 覆盖索引和回表的取舍再说一个提升查询性能的利器覆盖索引。覆盖索引的意思是你要查询的列都包含在索引中不需要回到主表去取其他列。这样做的好处是查询过程只需要扫描索引这棵B树不需要回表IO开销会小很多。用前面的订单表举例SELECT user_id, status, created_at FROM orders WHERE user_id 123 AND status PAID如果索引是(user_id, status, created_at)那这个查询需要的数据都在索引里不需要回表效率非常高。但覆盖索引也有代价索引包含的列越多索引体积越大写入时开销越高。所以只在那些高频、核心的查询上考虑覆盖索引别为了追求覆盖而把整张表的所有列都塞进索引里。我个人判断索引是否需要调整核心标准就一条执行计划里还有没有全表扫描或大范围扫描扫描行数和实际返回行数的差距大不大如果扫描行数远超返回行数说明索引的过滤性不好就该考虑调整。3. 常见SQL写法上的坑与改写思路索引建好了并不代表万事大吉。我见过很多案例索引明明建得没问题但SQL写法触发了隐式类型转换、函数运算或者深分页导致索引失效或者性能骤降。这些写法上的坑有时候比索引缺失更难排查。3.1 隐式类型转换索引失效的隐形杀手最常见的隐式类型转换发生在字符串和数字之间。比如用户表里的手机号字段用的是VARCHAR类型查询时却传了一个数字SELECT * FROM users WHERE phone 13800138000;这个查询在MySQL里会发生隐式类型转换把字符串字段和数字比较时字段值会被转换成数字。一旦字段上发生了函数或转换操作索引就没法正常使用了查询退化成全表扫描。正确的写法应该是SELECT * FROM users WHERE phone 13800138000;这种问题特别隐蔽因为数据量小的时候根本感觉不到差别数据量一大就原形毕露。建议在写SQL时时刻留意字段类型和参数类型是否一致。如果不确定用DESC或SHOW CREATE TABLE查一下表结构再写。3.2 深分页问题与延迟关联深分页是另一个高频问题。很多应用都有分页列表的需求比如“第10000页每页20条”。如果SQL是这样写的SELECT * FROM orders ORDER BY created_at DESC LIMIT 199980, 20;MySQL会先把前200000条数据查出来然后抛弃前199980条只返回最后的20条。问题是前面那199980条数据的查询、排序、传输开销都不会因为“反正要丢弃”而节省完全白忙活一场。解决深分页比较实用的办法是延迟关联先通过覆盖索引快速定位到需要返回的主键ID再用主键去关联原表取完整数据。SELECT o.* FROM orders o JOIN ( SELECT id FROM orders ORDER BY created_at DESC LIMIT 199980, 20 ) tmp ON o.id tmp.id;子查询里只查主键和排序字段走覆盖索引扫描量会小很多。然后再用主键关联回原表拿完整行数据。实测下来这种写法在深分页场景下通常能把查询时间缩短一半以上。如果你的业务场景经常需要翻很多页还有一种替代方案基于游标的分页。客户端传上次查询的最后一条记录的某个唯一字段值比如 id 或 created_at然后用条件过滤而不是LIMIT偏移量。3.3 窗口函数的使用场景窗口函数是分析复杂业务数据的一把利器很多原本需要子查询和临时表才能实现的功能用窗口函数会简洁得多。MySQL从8.0开始支持窗口函数Oracle、SQL Server、PostgreSQL也都有这类语法。举个例子如果想查询每个用户的最近一笔订单没有窗口函数时你可能会写一个批量子查询。有窗口函数后可以这样SELECT user_id, order_id, created_at FROM ( SELECT user_id, order_id, created_at, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC) AS rn FROM orders ) t WHERE rn 1;这里ROW_NUMBER()就是窗口函数PARTITION BY把数据按用户分组ORDER BY在组内排序rn1就代表每个用户的第一行数据也就是最近一笔订单。但要注意窗口函数虽然写法简洁执行时通常需要排序和窗口计算数据量大时开销并不小。用之前最好先EXPLAIN确认一下是否有明显的性能瓶颈避免为了代码整洁牺牲了执行效率。3.4 去重和空值处理的正确姿势去重在SQL里是一个很容易被想当然的需求。最常见的写法是DISTINCT但DISTINCT本质上是排序后去重开销不小。如果只是对某一个字段去重而且这个字段上有索引直接查索引就能去重效果会好很多。另一个常见的需求是去除空值。简单粗暴的做法是用NOT IN或NOT EXISTS但这里有几个隐藏的坑。NOT IN的子查询结果如果含有NULL那整个查询会返回空结果集因为SQL的三值逻辑里NULL参与的比较结果是“未知”。更稳妥的写法是使用NOT EXISTS或者显式加一个IS NOT NULL条件。空值处理还有一个实际问题如果字段本身允许NULL那么在排序、分组、聚合时都要考虑到NULL的行为。比如GROUP BY会把所有NULL分到同一组ORDER BY默认把NULL排在最前面MySQL这些细节在写业务SQL时都需要心里有数。4. 工程侧兜底缓存、批量与连接管理SQL优化做到一定程度后你会发现单条SQL的优化空间是有限的。真正能让系统扛住高并发、大规模数据场景的是工程侧的整体设计。这里我挑几个和SQL调优关系最紧密的环节来讲。4.1 缓存要设计而不是堆砌很多团队把缓存当“万能膏药”SQL慢了就加缓存结果缓存命中率不高数据一致性还出了问题。实际上缓存应该建立在业务访问模式的基础上那些读多写少、热点集中的数据才适合用缓存而频繁更新、每次请求都变化的数据缓存反而会引入不必要的复杂性。缓存的粒度也要想清楚。可以是接口级别的响应缓存也可以是SQL查询结果缓存还可以是更加精细的对象缓存。粒度越细复用性越好但代码复杂度也越高。这个度需要结合业务团队的技术能力来把控。另外缓存架构里最怕的是“缓存雪崩”和“缓存穿透”。缓存雪崩指大量缓存同时过期导致请求直接打到数据库缓存穿透指查询一个一定不存在的数据缓存里没有数据库里也没有请求每次都穿透到DB。处理穿透的常见方法包括缓存空值或者使用布隆过滤器先做一层过滤。4.2 批量操作的合理玩法数据库批量操作有一个常见的矛盾逐条操作太慢但一次性操作太多又可能锁表或产生大事务。生产经验来看批量插入或批量更新要做好分批控制。比如一次插入1万条数据可以拆成每批500条或1000条分批提交。这样既能减少网络往返和事务开销又不会因为单事务过大导致锁持有时间过长。具体批次大小需要压测调整不同数据库、不同表结构的合适批次大小都不太一样。在批量更新场景有一种很好的写法是使用CASE WHEN把多条更新合并成一条SQL。比如要把一批订单改成不同状态可以这样写UPDATE orders SET status CASE id WHEN 1 THEN PAID WHEN 2 THEN SHIPPED WHEN 3 THEN CANCELLED END WHERE id IN (1, 2, 3);这种做法可以减少SQL语句条数从而减少网络往返和日志量。不过要注意如果更新的数据量很大还是要评估事务大小和锁范围。4.3 连接池参数与常见误区应用连接数据库不可能每次请求都新建一个数据库连接那样开销太大了。连接池的作用就是复用一组数据库连接让应用在需要时直接从池里取用完再放回去。连接池的核心参数有两个maximum-pool-size最大连接数和minimum-idle最小空闲连接数。很多人的误区是不断调大最大连接数以为连接越多性能越好。实际上数据库能同时处理的并发事务数是有限的。连接数远大于数据库CPU核数时多余的连接线程基本都在等待锁或IO反而会增加上下文切换开销。一个比较务实的经验是连接池最大连接数不宜超过 (CPU核数 × 2) 磁盘数或者直接用数据库能承受的压力测试出来的最佳并发数。另外要特别关注连接泄漏问题——代码里忘记归还连接会导致连接池连接被耗尽这就是最常见的“数据库连接池爆掉”事故。4.4 从JVM参数调优的角度理解数据库客户端热词里出现了不少JVM参数调优和JVM调优的内容这里也值得顺带讲一下。很多数据库客户端程序是Java写的比如一些数据同步工具、管理平台。JVM参数设置不合理会直接影响这些工具在大量数据进出时的表现。JVM调优有两个方向容易被忽略。一个是堆内存设置。数据抽取工具在拉取大量数据时结果集会占不少堆内存。如果堆设置太小频繁Full GC工具吞吐量就很低但设置太大也可能导致GC暂停时间异常。堆大小要根据数据量和响应时间要求来定不是越大越好。另一个是GC算法的选择。不同GC算法适合不同场景比如面向吞吐量的场景和面向低延迟的场景选型就不同。处理大批量数据的批处理程序往往更关注吞吐量可以接受较长的GC暂停而面向用户的在线服务则更关注暂停时间不能太长。这些Java侧的参数调优思路本质上和数据库连接池调优是相通的先明确目标吞吐量还是延迟再压测验证最后定参数。5. 问题排查实录与优化后效果对比前面把思路和原理讲了不少这一节我用一个完整的案例走一遍从发现问题到解决问题的流程最后再汇总一份排查速查表。这个案例比较典型很多团队都会遇到。5.1 一个典型慢查询的排查过程背景一个电商后台的订单列表接口用户按不同条件筛选订单默认按下单时间倒序。上线初期数据量只有二十万接口很快但跑了大半年后订单量到了五百万级别接口从200毫秒变成了4秒多。排查第一步在慢查询日志里找到具体SQL复制出来看执行计划。EXPLAIN的结果显示type是ALL扫描行数达到了400多万行Extra里有Using filesort。初步判断是查询条件里既没有合理索引排序也全在内存里临时做的。排查第二步分析查询条件。接口支持按订单状态、用户ID、下单时间范围筛选。原本业务表上只有一个单列索引是在status字段上但status字段区分度太低优化器可能认为走索引还不如全表扫描快。排查第三步设计联合索引。根据最左前缀原则把区分度高的user_id放最前面等值的status放中间范围查询的created_at放最后。索引设计为 (user_id, status, created_at)。这样查询条件只要带user_id就能走联合索引再配合created_at覆盖排序字段连排序也能一并解决。排查第四步改写SQL。原来的SQL里好几个条件用了函数运算比如对created_at做了DATE()函数处理SELECT * FROM orders WHERE DATE(created_at) 2024-01-01 AND DATE(created_at) 2024-02-01这种写法让created_at上的索引无法使用。改写方案是把它变成范围查询SELECT * FROM orders WHERE created_at 2024-01-01 00:00:00 AND created_at 2024-02-01 00:00:00优化后的执行计划type变成了ref扫描行数从400多万降到了几千行接口响应时间从4秒降到了80毫秒左右。整个优化过程其实就是三板斧看执行计划、改索引设计、修SQL写法。5.2 常见问题速查表为了方便日常排查我整理了一份数据库和SQL调优方向的常见问题速查表你可以直接保存参考问题现象可能原因排查手段解决思路SQL越来越慢数据量增长索引失效EXPLAIN看扫描行数重建/新增合理索引优化表统计信息索引建了却不用函数导致索引失效/类型不匹配/优化器选择全表EXPLAIN看type避免函数运算对齐字段类型更新统计信息分页越翻越慢深分页导致大偏移量扫描分析LIMIT后面的偏移量延迟关联或游标分页接口响应时快时慢缓存失效/连接池不够/并发冲突监控缓存命中率、连接池占用缓存预热、调连接池参数、锁优化批量写入卡顿单事务过大锁竞争剧烈查看锁等待时间分批提交控制每批大小内存GC频繁JVM堆内存设置不当查看GC日志调整堆参数选择合适GC算法点到接口报错连接耗尽连接泄漏或连接池过小检查连接池监控修复代码中的连接泄漏合理调整池大小这张表没法覆盖所有情况但大多数SQL性能问题都能在这里找到大方向。排查时我的建议是先定位再动手不要一开始就改配置、加缓存、重建索引一起上那样很难找到真正的根因。5.3 关于安装、版本和环境的几个注意点热词里出现了不少数据库安装相关的内容像SQL Server 2008 R2、SQL Server 2022、MySQL安装这类关键词。安装和基础环境配置确实是很多数据库问题的温床尤其是版本选择不当、配置残缺、环境变量没配好后面跑起来各种莫名其妙的问题。一个很典型的教训有同学在生产环境抱着“能用就行”的心态用了很老版本的数据库。老版本在SQL优化器、窗口函数支持、JSON类型支持等方面都比较落后很多新语法跑不了性能优化手段也受限。如果条件允许尽量选择还在官方支持周期内、比较稳定的版本比如MySQL 8.0、PostgreSQL 14以上的版本。安装完成后的基础配置也很重要。字符集、时区、最大连接数、缓冲池大小这些参数最好在初始化和配置阶段就设定好。很多默认值都是为“最小可用”场景设计的生产环境需要根据机器规格和业务特点进行调整。比如MySQL的innodb_buffer_pool_size默认值很小如果线上数据量和并发上来了还不调整性能会受影响。数据库版本升级这事也要谨慎。升级之前一定要在测试环境做兼容性测试特别是SQL语法、存储过程、已有索引结构这些层面。很多升级事故都源于未充分测试就直接上生产最后只能回滚。写在最后的实操心得个人这些年做数据库和SQL调优最大的体会是不要迷信任何一种“银弹”。索引不是越多越好缓存不是万物皆可缓连接池也不是越大越强。真正靠谱的调优逻辑是——先测量再定位最后才动手。拿着执行计划说事比拿着感觉说事靠谱得多。如果你是第一次系统性做SQL调优我的建议是别急着一次性把所有大招都上了。先挑一条影响业务最大的慢SQL按“执行计划 → 索引 → SQL改写 → 工程侧优化”的顺序走一遍记录每一步的效果。这样几轮练习下来你对数据库底层执行逻辑的感觉会建立一个比较扎实的框架。最后分享一个小技巧每次优化结束后把优化前后的执行计划、关键参数、耗时数据都保存下来整理成自己团队的优化记录文档。这些记录既是新人培训的好教材也是以后再遇到类似问题时最快的参考。数据库调优这个领域经验积累比天赋更重要一次次的案例复盘就是你最值钱的资产。
返回列表