好,我梳理一下这篇内容的方向。先说句实在话,做后端开发这几年,我最怕的不是新框架,不是复杂架构,而是接手一套没有任何规范、靠人肉记忆维护的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的预估成本可接受后再放出去。这步不会花太多时间,但它能把很多压测阶段根本发现不了的隐患提前挡下来。等线上数据膨胀到一定程度再回头优化,代价就完全不是一个量级了。