news 2026/10/14 4:49:11

SQL优化实战:从规范书写到索引设计,避开性能陷阱

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
SQL优化实战:从规范书写到索引设计,避开性能陷阱

好,我梳理一下这篇内容的方向。先说句实在话,做后端开发这几年,我最怕的不是新框架,不是复杂架构,而是接手一套没有任何规范、靠人肉记忆维护的SQL脚本。写代码出了问题你能调试,SQL出了问题有时候连复现都费劲。所以我一直有个习惯,每隔一段时间就把日常用到的SQL技巧和踩过的坑整理一遍,写进团队的技术文档里。

这篇就是其中之一。内容围绕日常开发、运维、报表统计中最常见的SQL使用场景,包括书写习惯、高频函数用法、慢查询优化思路、索引失效案例、以及一些容易让人翻车的边界细节。不追求炫技,只求拿到手能直接用、能少走弯路。下面是正文。

1. 很多SQL问题,根源在书写习惯

1.1 混乱的写法让维护成本成倍增加

最近接手一个旧项目做日常维护,发现里面有一堆让人头疼的SQL。有的语句几百行不带一个换行,有的字段名用拼音简写比如xm、sj,看得人一头雾水,更别提去理解业务逻辑了。

我记得其中有一条统计订单金额的SQL,查了十几张表,条件全靠and一层层往上摞,没有任何缩进和注释。想改一个过滤条件,得先花半天时间去梳理表间关系,稍不留神就改错。这种SQL不是不能跑,但它消耗的是后续每一个接手人的时间,这才是最贵的成本。

后来我做了一个很简单的调整:把每一条线上跑的SQL都按统一风格重排,加注释、拆临时表、规范别名。效果很明显,原来一个同事排查某条日报数据的统计偏差,花了将近两小时;重新整理之后,五分钟就定位到是时间字段边界没包住,漏算了当天0点整的数据。

1.2 先建立几个能救命的书写规范

我总结下来,日常写SQL最值得坚持的规范其实就几条,看着简单,长期收益非常大。

第一条,统一关键字大小写风格。有人习惯全大写,有人习惯全小写,其实都行,但一个团队里必须统一。我个人偏爱关键字大写,字段名小写,这样一眼扫过去就能分清哪些是语法、哪些是业务字段,带着编辑器看长SQL的时候轻松很多。

第二条,字段和表都要写别名。尤其是多表关联的查询,select a.name, b.order_no from t_user a join t_order b on ...这种写法,比直接写字段名好太多——既能避免ambiguous column报错,又能让你知道这个字段来自哪张表。

第三条,复杂SQL一定要做阶段性拆解。如果一个查询超过三层嵌套,或者关联超过五张表,优先考虑用临时表或者CTE把中间结果拆出来。别觉得这样多写了几个步骤,实际上拆出来的每一段都能单独验证,排查问题的时候比在一个巨型SQL里猜要快得多。

第四条,给长SQL写注释。尤其是那些有特殊业务含义的条件,比如where status = 3,后面最好补一句:-- 状态3代表已发货。不写注释的SQL跑几个月之后,连写它的人自己都可能忘记当初为什么这么写。

1.3 规范之后的收益:从半小时排查到三分钟定位

有一次线上反馈某张报表数据不对,凌晨的订单数量少了一些。我打开那条统计SQL,因为之前已经按规范整理过了,结构非常清晰:先是从订单表过滤出当天的数据,再关联用户表和商品表,最后按小时分组统计。

我顺着执行顺序往下看,一眼就发现问题出在时间过滤条件上:

where order_time >= date_format(now(), '%Y-%m-%d 00:00:00')

这个条件意味着它永远只取当天0点前到现在的数据,而凌晨零点整那一秒产生的订单,order_time如果是'2024-05-20 00:00:00',恰好不会被左边>=覆盖到。这类边界问题特别隐蔽,但只要你写SQL时有清晰的注释和分段,定位起来根本不用从头读到尾,直接看条件部分就够了。

这个事儿之后我更加确定:规范不是束缚,是一种能让别人(包括三个月后的自己)少掉头发的习惯。

2. 日常工作中真正高频的SQL技巧

2.1 分页查询的写法与排序陷阱

分页是每个后端开发几乎天天要碰的场景。MySQL用得最多的是limit offset, size,但它的性能问题常常被忽略。

当偏移量很大的时候,比如要查第100万条之后的20条数据,limit 1000000, 20需要先把前100万条记录全部读取出来,再丢弃掉,效率极其低下。这个场景下更合适的写法是延迟关联:

select a.id, a.order_no, a.amount from t_order a inner join ( select id from t_order where create_time >= '2024-01-01' order by id limit 1000000, 20 ) b on a.id = b.id

先通过主键索引拿到目标范围内的20个id,再回表取完整数据。这样前面那一百万次回表操作被省掉了,查询耗时会从几秒直接降到几十毫秒。

分页还有一个经常被忽略的地方:排序字段的选择。如果你对create_time建了索引,order by create_time limit 10可以走索引有序性;但如果排序字段没索引,数据库要先把符合条件的数据全部查出来,再在内存或临时文件里排序,这种filesort在数据量大时非常致命。

另外有个细节,MySQL8.0之后分页如果用order by建议字段唯一性要好,比如order by id, create_time,否则相同的排序值会让分页结果在多次查询间不稳定,翻页时出现数据重复或遗漏。

2.2 去重与聚合:distinct和group by怎么选

distinct和group by都能去重,但使用场景其实有区别。

select distinct col1, col2 from t适合只想去重查看组合值;但你如果想要每个分组里的数量、总额这类统计结果,就必须用group by:

select user_id, count(*) as cnt, sum(amount) as total_amount from t_order where create_time >= '2024-01-01' group by user_id having cnt > 5

这里要提醒一句,having是对分组后的结果做过滤,where是对原始记录做过滤。能用where过滤掉的尽量在where做,别把大量数据先分组再过滤,那样浪费资源。

还有一个经验,就是聚合函数里count(*)和count(1)几乎没有性能差别,但count(某个字段)如果字段里含有NULL,count()会忽略NULL行,这在统计时可能会得到让你意外的结果。比如某张表的remark字段大量为NULL,你用它做计数的时候也会少算,这时候有必要重新确认你到底想数什么。

统计类SQL还有一个常用技巧叫case when条件聚合:

select date(create_time) as order_date, sum(case when status = 1 then amount else 0 end) as pending_amount, sum(case when status = 2 then amount else 0 end) as paid_amount from t_order group by date(create_time);

一条SQL就能把不同状态的金额分成多列,省得查三遍再在内存里拼。

2.3 日期与字符串处理:格式化、截取、转换

日期和字符串处理是我见过日常踩坑最多的领域,函数名记不准、时区没管好、格式不一致,都会导致结果不对。

日期格式化最常见的组合是date_format配合各种格式符,比如%Y-%m-%d、%H:%i:%s。但要注意date_format返回的是字符串,如果你拿它去和时间字段比较,数据库可能做隐式转换,干扰索引使用。更推荐的做法是直接用between加时间字面量:

where create_time >= '2024-05-01 00:00:00' and create_time < '2024-06-01 00:00:00'

字符串处理方面,substring、concat、replace用得最多。有个细节是MySQL里字符串位置从1开始,而不是0,substring(name, 1, 3)取的是前三个字符。如果你不记得这点,很容易在截取时漏一个字符。

另外连接字符串别用+,MySQL里字符串相加会被转成数字,正确姿势是用concat:

select concat(prefix, '-', order_no) as full_no from t_order;

日期、字符串之间转来转去的时候,记住一件事:先搞清楚源数据到底是什么类型,再选择函数。比如str_to_date是把字符串按指定格式解析成日期,而date_format是把日期转成指定格式的字符串,方向别搞反。

3. 慢SQL排查与索引设计思路

3.1 explain查看执行计划,从哪里看起

遇到慢SQL,第一件事不是盲目加索引,而是先看它的执行计划。explain输出里,我最先关注这几列:type、key、rows、Extra。

type表示访问类型,从好到差大致是:system、const、eq_ref、ref、range、index、ALL。平时至少要做到range级别,如果出现大片ALL,基本就是全表扫描了,数据量一大必然慢。

rows是个预估值,表示要扫描多少行。明明加了索引但rows还是特别大,说明索引没用上,或者用的索引选择度太低。

Extra里出现Using temporary、Using filesort通常意味着排序或去重操作没充分用好索引,这块值得多花时间抠一下。

举个例子,有张订单表查询长这样:

select id, order_no from t_order where user_id = 1001 order by create_time desc limit 10;

最初的索引只有idx_user_id(user_id),执行计划里能看到Using filesort,因为索引只帮你过滤了用户,排序还得额外去做。后来我把索引改成idx_user_create(user_id, create_time),状态立刻变成Using index,排序走索引有序性,查询时间降了一个数量级。

3.2 覆盖索引让查询提速的原理

覆盖索引是优化查询里成本最低、见效最快的手段之一。它的核心逻辑很简单:查询需要的字段全部都包含在某一个索引中,那就没必要再回表查完整行数据。

比如这样一条SQL:

select user_id, status, count(*) from t_order where user_id = 1001 group by status;

如果表上有联合索引(user_id, status),这条查询只要扫索引就能拿到所有数据,不需要回表,整体IO会大幅降低。在超高并发场景下,这个优化非常关键。

创建一个覆盖索引不需要多复杂:

alter table t_order add index idx_user_status(user_id, status);

但别为了覆盖而覆盖,索引不是越多越好。每个索引在写入、更新时都要维护成本,所以覆盖索引更适合加在核心查询路径、查询频率非常高的字段组合上。

3.3 常见让索引失效的写法

我见过太多人加了索引之后发现查询还是很慢,一问才知道,索引其实根本没有生效。最容易让索引失效的几类写法,必须先刻在脑子里。

第一类,在索引字段上做函数操作。比如where date(create_time) = '2024-05-20',哪怕create_time有索引,加上date()处理后索引就废了。应该改写成范围查询:

where create_time >= '2024-05-20 00:00:00' and create_time < '2024-05-21 00:00:00'

第二类,隐式类型转换。字段是字符串类型,你传了个数字进去,或者反过来,数据库会先把字段转成另一种类型再比较,索引跟着失效。这个在实际环境里非常常见,比如统计接口里从前端拿到的参数都是字符串,直接拼进SQL导致字段类型不匹配。

第三类,前导模糊匹配。like '%关键字%'因为不确定开头是什么字符,B+树没法按顺序定位,自然走不了索引。如果业务上确实需要,可以考虑全文索引或者搜索引擎,而不是硬扛。

第四类,OR条件使用不当。MySQL优化器对有些不带索引的OR条件会选择全表扫描。如果多个OR条件里有一个字段没有索引,整个查询就很容易变成ALL,这一点非常值得注意。

4. 一次真实排查:从接口变慢到索引重建的完整链路

4.1 现象:某报表模块的查询一天比一天慢

有个报表查询接口,线上功能一直正常运行,但一到月底数据量较大时就会明显变慢。最初也就几百毫秒,后来涨到几秒,甚至偶尔出现超时。

这个报表核心是统计某段时间内每个销售区域的订单总额和订单量,主表是一张订单流水表,已经积累了上千万条数据。接口的SQL结构类似:

select region_id, status, count(*), sum(amount) from t_order where create_time between '2024-04-01 00:00:00' and '2024-04-30 23:59:59' and is_deleted = 0 group by region_id, status;

第一反应当然是想办法加索引,但说实话,光这条SQL本身create_time上其实已经建了索引,按说范围查询之后再去分组,不至于慢到这个程度。问题显然不止出在单表扫描上。

4.2 排查过程:逐步缩小嫌疑范围

我先把这条SQL单独拿出来跑了一遍explain。结果发现key列确实用了idx_create_time,但type是ref,rows估算有几十万行,Extra里还出现了Using temporary和Using filesort。

看到这里,我的判断是:分组字段和过滤字段没有走同一个索引,导致分组操作必须在临时表里完成。MySQL扫描出几十万行符合时间条件的数据之后,再在临时表里按region_id, status做分组和排序,这显然非常耗费性能。

接下来我又看了表的索引情况,发现region_id和status虽然分别有索引,但没有一个联合索引能把create_time跟它们组合在一起使用。优化器能做的最多就是先用时间索引把范围缩小,然后剩下的活儿全得靠内存临时表硬扛。

这时候也顺手检查了一下is_deleted = 0这个条件的影响。绝大多数记录is_deleted都是0,所以这个条件几乎不产生过滤效果,但它会干扰索引选择。在某些情况下,优化器宁可全表扫描也不索引时间字段,就是因为它觉得过滤完了数据量还是很大。

4.3 根因与修复方案

根因已经清楚了:索引设计与查询条件不匹配。修复方式并不复杂,直接把联合索引建起来,把过滤、分组所需的关键字段都包含进去:

alter table t_order add index idx_time_region_status(create_time, region_id, status);

这里把create_time放在联合索引最左侧,是为了让范围查询先按时间定位;region_id、status紧随其后,正好覆盖分组需求。加完索引后再看explain,Using temporary和Using filesort都消失了,分组操作直接走索引顺序,性能提升非常明显。

修完之后我又做了一个对照测试:把同一份数据分别用旧查询和新索引跑了一遍,旧方案平均耗时3.2秒,新方案耗时约0.18秒,差距接近18倍。月底报表再跑起来,接口响应时间一路稳定在几百毫秒以内。

这次排查给我留下的经验是:索引不是越多越好,而是要做“查询条件 + 分组/排序字段”的整体匹配设计。只给每个字段分别建单列索引,碰到多条件过滤加分组的场景,依然会猛烈。

5. 容易翻车的边界细节与误用场景

5.1 NULL值的那些坑

NULL可以说是SQL里最容易制造“假数据”的元凶。很多人刚学SQL时没太在意,等到统计数字对不上,才意识到NULL参与计算会产生一堆莫名其妙的结果。

举个例子,订单表里有个discount_amount字段,如果某些行是NULL,执行sum(discount_amount)时数据库会自动跳过NULL,并不会报错。但这种“自动跳过”有时候会掩盖数据问题——你以为总额就是所有订单的折扣合计,实际却漏掉了那些没填折扣的订单,导致汇总口径不统一。

判断NULL不能写= null,必须用is null或者is not null。我见过同事排查了半天的问题,最后发现是where column = null这种写法导致条件永远不成立,查出来的是全表数据,跟没过滤一样。

函数参数为NULL的情况也值得注意。比如concat(name, '-', addr)如果addr是NULL,整个结果都可能变成NULL,这会导致展示层发生奇怪的现象。一般情况下要记得用ifnull或者coalesce兜底:

select concat(name, '-', coalesce(addr, '未填写')) from t_user;

5.2 隐式类型转换引发性能问题的小案例

有一张用户表,手机号字段mobile是varchar类型,上面建了索引。某次查询条件传入的是一个数字类型的参数,SQL长这样:

select * from t_user where mobile = 18712345678

这个数字常量会被MySQL转换成字符串再去匹配字段,但转换是发生在字段层面的,结果会导致索引失效,进而全表扫描。如果用户表数据量不小,这个查询的响应时间会非常感人。

解决方式说来也简单,传参时在程序里统一转成字符串,或者SQL里写成where mobile = '18712345678'。但这类问题最麻烦的地方在于线上参数来源很多,有的是前端传的,有的是不同服务之间调用传的,类型很难完全收敛。所以排查慢SQL时,如果发现字段类型是字符串、参数是数字,第一个检查点就该是隐式转换。

同理还有日期字符串之间的比较,字符串日期与date类型比较时也可能触发隐式类型转换,碰到类似慢SQL别忘了往这个方向上排查。

5.3 连接查询中容易被忽略的笛卡尔积风险

多表连接如果没有写全关联条件,结果集会变成笛卡尔积,即两表行数相乘,这个数据量增长是非常恐怖的。

有一回同事统计渠道转化数据,两个表各有一万行左右,因为关联条件里少写了一个渠道编号字段,查询结果直接多了一万倍。下游报表看到异常大的数据量时,第一反应还以为是渠道流量暴涨,排查半天才发现是关联条件漏了。

所以写连接查询时,我给自己定了一条规矩:join之后立即检查两件事——第一,关联条件是否完整、是否有多表都存在的同名字段被漏掉;第二,连接的字段是否有索引。关联字段无索引的话,嵌套循环连接会逐行扫描被驱动表,性能可能退化到非常难看的程度。

如果两个表关联时有多个条件,尽量把它们都写进on里,不要只留一个条件然后其他全塞到where。虽然结果可能一样,但执行计划的可读性和优化空间完全不同,后续维护的人看到on里的条件就能还原出表之间完整的关系逻辑。

另外连接查询里别顺手select *。只需要几个字段就明确列出字段列表,一来减少传输数据量,二来方便走覆盖索引,对性能和可维护性都有帮助。

最后再分享一个我自己一直保留的习惯:每一条要上线跑的SQL,不管多简单,先在测试库跑一遍执行计划,确认type和rows的预估成本可接受后再放出去。这步不会花太多时间,但它能把很多压测阶段根本发现不了的隐患提前挡下来。等线上数据膨胀到一定程度再回头优化,代价就完全不是一个量级了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/14 4:49:09

聊天记录导出指南:网页端、手机端与本地部署方案详解

我看着屏幕上密密麻麻的对话&#xff0c;第一反应是“完了&#xff0c;这几万字要是手动复制粘贴&#xff0c;怕是得复制一晚上”。当时我在做一个本地知识库整理&#xff0c;想把和某个对话机器人聊过的几十轮内容全部归档进自己的笔记系统&#xff0c;结果发现这个看似简单的…

作者头像 李华
网站建设 2026/10/14 4:49:09

HDFS机架感知:原理、配置与副本放置策略解析

1. 机架感知到底解决什么问题1.1 从一个数据副本丢失说起先说一个真实场景。某公司机房里有几十台机器&#xff0c;网络架构是典型的两层&#xff1a;核心交换机下面挂着几台接入交换机&#xff0c;每台接入交换机带一个机柜里的十几台服务器。集群刚上线时&#xff0c;HDFS默认…

作者头像 李华
网站建设 2026/10/14 4:49:06

2026 年北京外包服务市场扫描:人力外包与项目外包的选型维度与实践路径

一、北京人力资源外包服务市场的发展现状与需求特征北京作为全国人力资源服务业的核心集聚区域&#xff0c;外包服务市场在 2026 年呈现出结构化升级的发展态势。从需求侧观察&#xff0c;政府机关、事业单位、国央企以及科技制造、金融能源、文教卫生等领域的组织&#xff0c;…

作者头像 李华
网站建设 2026/10/14 4:48:44

Claude Code工作流:本地化AI编程协作的工程实践指南

1. 这不是AI编程助手&#xff0c;而是一套可落地的代码协作新工作流“Claude Code”这个名称在最近几个月频繁出现在开发者社区的讨论帖、技术播客的片尾字幕&#xff0c;以及某高校计算机系导师给研究生布置的实验任务清单里。它不是某个独立发布的桌面软件&#xff0c;也不是…

作者头像 李华
网站建设 2026/10/14 4:48:25

Seedance 2.0提示词工程实战:从时序解析到API批量生成

简介&#xff1a;Seedance 2.0提示词指南可运行源码包&#xff0c;面向短视频创作者、AI视频爱好者及希望系统掌握即梦平台提示词工程的中高阶用户。内容涵盖从新手到AI导演的完整进阶路径&#xff0c;以五要素导演法为核心&#xff0c;分别从主体/角色、动作/剧情、镜头/运镜、…

作者头像 李华
网站建设 2026/10/14 4:47:44

语雀知识库批量导出工具YuQue-BdT:备份与迁移实践

说到知识库批量导出&#xff0c;做过内容备份的人应该都有同感&#xff1a;文档一多&#xff0c;手动复制粘贴能把手点抽筋。尤其是团队的知识库沉淀到几百上千篇之后&#xff0c;语雀自带的手动导出又只支持单篇操作&#xff0c;今天导几篇、明天导几篇&#xff0c;断断续续&a…

作者头像 李华