数据库常用的SQL查询语句,这篇是真的把能用的场景都过了一遍。很多人觉得SQL嘛,就是select from where,真到写复杂报表或者排查线上慢查询的时候,才发现基础没打好有多吃亏。我整理了一版日常开发里最高频的查询写法,从最基本的单表查询到多表关联、聚合统计、窗口函数,再到执行计划怎么读,把“为什么这么写”和“实际踩过的坑”都放进来,不管是刚入门还是写了两三年SQL的朋友,应该都能从这里找到点有用的东西。
1. 写在前面:为什么SQL查询是绕不开的基本功
结构化查询语言(SQL)是操作关系型数据库的通用语言,市面上主流的关系型数据库——无论是MySQL、PostgreSQL、Oracle还是SQL Server——虽然各有各的方言和细节差异,但核心的SELECT语法体系高度一致。学会了一套写法,换数据库基本只需要改改分页语法、字符串拼接方式这些边角料,主体框架直接平移过去就能用。
也正因为SQL涵盖面广,很多人陷入了一种“好像都见过、真写就卡壳”的状态。单表查询没问题,一旦碰到多表关联、分组统计、去重排序、子查询嵌套、分页取数这些需求,就得现场查文档。更头疼的是,同样的逻辑,写法不同,性能可能差出几十倍。这些坑我在实际项目里基本都踩过一轮,这篇就把常用的查询场景和对应的避坑经验一并写清楚,当作随身手册用。
适合谁来读呢?一类是刚入行的开发新人,对SQL的理解还停留在“能跑就行”的阶段,正好借这篇把查询语法和逻辑理一遍;另一类是写了一段时间SQL但没系统总结过的朋友,可以把这篇当查漏补缺的清单,看看自己是不是漏掉了一些更简洁高效的写法;还有一类是要准备面试的人,SQL查询相关的问题在面试中出现频率极高,这篇覆盖的题型已经够应付大多数场景了。
2. SELECT基础查询:别只会SELECT *,字段挑选有讲究
2.1 SELECT语法结构拆解
任何SQL查询都逃不开SELECT这个关键字,它就像是数据库里的“取货员”,告诉你想要哪张表里的哪些字段,以及按什么条件取。一个完整的查询,内部执行顺序和书写顺序是两码事,这一点初学的时候特别容易混淆。
先看一段标准的查询语句:
SELECT user_id, user_name, create_time FROM user_info WHERE status = 1 AND create_time >= '2024-01-01' GROUP BY user_id HAVING COUNT(*) > 1 ORDER BY create_time DESC LIMIT 10;上面这段SQL的书写顺序是“SELECT -> FROM -> WHERE -> GROUP BY -> HAVING -> ORDER BY -> LIMIT”,但数据库实际执行时,顺序是反着来的:先通过FROM定位到数据表,再用WHERE把不满足条件的行过滤掉,接着用GROUP BY分组、用HAVING过滤分组后的结果,然后才轮到SELECT挑选显示字段,最后ORDER BY排序、LIMIT截取条数。
理解这个执行顺序有个特别实际的意义——调试SQL的时候,如果结果和预期不一致,只要按照“数据来源 -> 行过滤 -> 分组 -> 列挑选 -> 排序”这个顺序一层层排查,很快就能定位问题出在哪个环节。我自己帮同事查过不少“SQL看着没错但结果怪怪的”案例,最后多半都是混淆了WHERE和HAVING的过滤时机,或者忽略了GROUP BY之后SELECT字段的限制。
字段挑选这件事,很多新手不注意,上来就是SELECT *。小表无所谓,一旦表数据到了千万级,SELECT *会把所有列都读出来,尤其是那些TEXT、BLOB类型的大字段,白白增加磁盘IO和网络传输开销。正确的做法是显式列出业务需要的字段,一来减少传输量,二来也能让阅读SQL的人一眼看清取数的范围。
2.2 WHERE过滤的几种典型写法
WHERE子句是过滤行的核心工具,它可以承载几乎所有的比较运算和逻辑运算。日常用得最多的有这几类:
等值与非等值比较。直接使用等于、不等于、大于、小于等操作符,注意不等于在两个主流数据库里的写法不同:MySQL写!=,标准SQL也接受<>,两种都支持,看团队习惯选一种保持一致就好。
范围过滤用BETWEEN AND和IN。BETWEEN表示闭区间,比如age BETWEEN 18 AND 30等价于age >= 18 AND age <= 30,注意边界是包含的,容易被人忽略。IN列表适合枚举值集合,比如city IN ('北京','上海','广州'),如果列表项过多,要考虑列表大小对索引使用的影响,反正超过几百个值的时候我会改用临时表关联或者EXISTS。
模糊匹配用LIKE。LIKE '%王%'表示包含“王”字,LIKE '王%'表示以“王”开头。这里有个性能大坑,后面讲优化时会重点展开:如果通配符%放在关键词开头,比如LIKE '%王',数据库除非做全文索引,否则没法走常规索引,只能全表扫描。对大数据量表,这种查询就是灾难。
多条件组合用AND、OR和NOT。有一点特别容易踩坑:OR两端的条件如果只有一边能走索引,数据库可能直接放弃索引改全表扫描,这种情况可以把OR改写成UNION或者用IN替代。另外,逻辑运算符的优先级也容易出问题——AND的优先级高于OR,所以当你想表达“A或者B,并且C”的时候,一定记得加括号:(A OR B) AND C。
2.3 ORDER BY排序与DISTINCT去重的细节
ORDER BY用于结果排序,默认升序,降序要显式加DESC。多字段排序的时候,顺序很重要,它是从左到右逐级生效的。举个例子,ORDER BY status, create_time DESC的意思是先按status升序,status相同的再按create_time降序;如果你想同时两个字段都降序,必须写成ORDER BY status DESC, create_time DESC,不能只在最后一个字段后面加DESC就指望前一个字段也降序。这个低级错误在真实代码里相当常见。
排序还有一个隐含成本:如果排序的字段没有索引,数据库就需要把结果集先装进临时表或内存排序区,数据量一大,排序时间会成倍增加。所以设计查询时,排序字段尽量和索引字段对齐,或者减少参与排序的行数再排,顺序上可以先去过滤、再排序、最后拿到前N条,而不是把所有数据都排一遍。
DISTINCT用于去重,但它不是万能的。DISTINCT作用于整行数据,也就是说,只有当SELECT出来的所有字段完全重复时才算重复行。如果你想按某一个字段去重,同时又要展示其他字段的信息,DISTINCT就帮不上忙了,这种情况要么使用GROUP BY取一组中的代表记录,要么用窗口函数配合给每行编号,再取编号为1的行。
补充一个细节:
COUNT(DISTINCT column)可以用来统计某列的去重数量,这在写报表SQL的时候很常用,比如统计有下单记录的用户数,直接用COUNT(DISTINCT user_id)就可以,不必先做子查询去重再计数。
3. 多表关联查询:JOIN没搞懂,面试和开发都会吃亏
3.1 JOIN类型:INNER、LEFT、RIGHT、FULL的区别
真实业务里,数据几乎不会躺在一张表里等你去查。用户信息在用户表,订单在订单表,商品在商品表,要回答“每个用户的最近一笔订单是什么”这种问题,必然要把多张表关联起来。JOIN就是干这个的。
四种JOIN的结果差异,用一个具体例子说明最直观。假设有两张表:A表是用户表,有2个用户(A1、A2);B表是订单表,有3笔订单(B1属于A1,B2属于A1,B3属于A3)。
INNER JOIN(内连接)只返回两边都匹配得上的记录。用用户表和订单表做内连接,A2没有订单,不会出现在结果里;A3虽然在订单表里有订单,但A3不在用户表里,也不会出现。结果就是A1的两笔订单。
LEFT JOIN(左连接)返回左表全部记录,右表没匹配上的就用NULL填充。左表是用户表时,A2也会出现,只是订单字段全是NULL。RIGHT JOIN正好反过来,右表全保留,左表匹配不到的用NULL填充。
FULL JOIN(全外连接)把两边的记录都保留下来,匹配不上的一边用NULL填充。MySQL原生不支持FULL JOIN,需要的时候可以用LEFT JOIN UNION RIGHT JOIN模拟,不过这个需求在MySQL业务场景里比较少见。
实际开发里,LEFT JOIN的使用频率最高,因为你经常需要“以某张表为主表,去补其他表的信息”,比如“查出所有用户及其最新的订单信息”——不管用户有没有订单,用户都要出现在结果里,这就是标准的LEFT JOIN。
3.2 关联查询的坑:重复数据、NULL匹配、隐式与显式JOIN
JOIN写起来容易,但结果集经常悄悄出问题,而且问题出得很隐蔽。
第一个坑:一对多关联导致数据翻倍。用户表和订单表关联时,如果某个用户有3笔订单,那么用户的其他字段会在结果里重复出现3次。这不是出错,关联本身就是笛卡尔积的逻辑,只是很多人没意识到。解决方案是看业务到底需要“明细数据、连带用户信息”还是“用户统计数据、附带订单列表”,前者关联没问题,后者就需要先聚合再关联,顺序不能反。
第二个坑:ON条件里对右表字段加WHERE过滤,LEFT JOIN会“悄悄变质”。比如:
SELECT u.user_name, o.order_id FROM user_info u LEFT JOIN order_info o ON u.user_id = o.user_id WHERE o.status = 1;这段SQL的逻辑是想查所有用户及其有效订单,但如果某个用户没有有效订单,那么o表的字段都是NULL,o.status = 1永远不成立,这个用户就被过滤掉了。结果是这个LEFT JOIN和INNER JOIN没有任何区别。想保留主表的全部记录,正确做法是把过滤条件挪到ON子句里:
SELECT u.user_name, o.order_id FROM user_info u LEFT JOIN order_info o ON u.user_id = o.user_id AND o.status = 1;第三个坑:隐式连接和显式连接的混用。老代码里偶尔能看到FROM后面直接跟多张表,用WHERE连接条件的写法,这种叫隐式连接。我强烈建议统一用显式JOIN语法,理由有两个:一是ON和WHERE的职责边界清晰,过滤条件不会混在一起;二是稍不留神漏写了WHERE关联条件,隐式连接直接变成笛卡尔积,数据量一大,查询直接跑死,这种事情在真实案例里出现过太多次了。
3.3 子查询的两种形态:标量子查询与表子查询
子查询是嵌套在另一个查询内部的查询,按返回结果形态分两种。
标量子查询返回单个值,可以嵌在SELECT列表或WHERE条件里。比如“查出每个用户的订单总金额,并和全站平均订单金额做对比”:
SELECT user_id, SUM(amount) AS user_total, (SELECT AVG(amount) FROM order_info) AS avg_amount FROM order_info GROUP BY user_id;标量子查询的好处是直观,缺点是在数据量大的时候,每一行都可能执行一次子查询,性能容易出问题。优化思路通常是改写为JOIN或窗口函数。
表子查询返回一张“临时表”,可以放在FROM后面当作数据源。比如先统计每个用户的订单数,再从这个统计结果里找出订单数大于5的用户:
SELECT user_id, order_cnt FROM ( SELECT user_id, COUNT(*) AS order_cnt FROM order_info GROUP BY user_id ) t WHERE order_cnt > 5;注意MySQL要求派生表必须有别名,也就是上面代码里t,漏了会直接报错。这个别名是随便起的,但建议别用无意义的单字母,时间长了自己也看不懂当年的SQL想干啥。
4. 聚合统计与分组:从计数到窗口函数,报表需求全拿下
4.1 聚合函数的搭配规则
聚合函数是SQL里最常用的一类函数,它们把多行数据变成一个值。常见的有COUNT(计数)、SUM(求和)、AVG(平均)、MAX(最大值)、MIN(最小值)。有几个使用细节值得特别注意。
COUNT和SUM对NULL的处理方式完全不同。COUNT(column)只统计非NULL的行数,COUNT(*)统计所有行数,不管是否为NULL;SUM(column)遇到整列全是NULL时会返回NULL,而不是0,这就意味着在应用层展示报表时需要额外的COALESCE处理。MAX和MIN会忽略NULL,不会把NULL当作最小值。这些细节不搞清楚,统计报表很容易出现对不上数的情况。
聚合函数还能和CASE WHEN搭配做条件聚合,这是报表开发里非常经典的手法。比如统计一张订单表里各状态的数量:
SELECT COUNT(CASE WHEN status = 1 THEN 1 END) AS pending_cnt, COUNT(CASE WHEN status = 2 THEN 1 END) AS paid_cnt, COUNT(CASE WHEN status = 3 THEN 1 END) AS cancelled_cnt FROM order_info;这里的技巧是:CASE WHEN满足条件时返回1,不满足时返回NULL,COUNT只会统计非NULL,所以达到条件计数效果。写法和直接在WHERE里拆成三条SQL比,一次查询就能把多个口径的数值都拿出来,性能和可维护性都好很多。
4.2 GROUP BY + HAVING:先分组还是先过滤,顺序别搞反
聚合函数的作用范围是一整张表,当你需要按某个维度分别统计的时候,就得用GROUP BY分组。分组的本质是“把相同字段值的行归到一堆,再在堆内做聚合运算”。
这里要特别强调GROUP BY和WHERE、HAVING的执行顺序:WHERE是在分组之前过滤行的,HAVING是在分组之后过滤组的。这个区别决定了,想让某个条件参与“聚合前的行筛选”就放在WHERE里,想让某个条件参与“聚合后的组筛选”就放在HAVING里。
举个例子,“统计每个城市的用户数,只保留用户数超过100的城市”:
SELECT city, COUNT(*) AS user_cnt FROM user_info WHERE status = 1 GROUP BY city HAVING COUNT(*) > 100;WHERE先过滤出有效用户,再按城市分组,HAVING过滤掉不足100人的城市。如果把status = 1放到HAVING里,虽然也能运行,但在很多数据库里会报错或性能变差,因为HAVING里最好只用聚合函数和分组字段,别放普通字段条件。
还有一点,GROUP BY之后,SELECT字段不是想选什么就选什么的。SELECT里出现的非聚合字段,必须同时出现在GROUP BY里。这句话在多数据库下已经是标准行为,MySQL以前有宽松模式允许不合法的字段选择,取的是每组随机一行,容易造成数据不确定,现在默认关闭了。写代码的时候还是遵守规则最省心。
4.3 CASE WHEN:行转列与条件统计的实用套路
CASE WHEN本质上就是SQL里的if-else逻辑,它的能力远不止条件计数。最值得掌握的用法是行转列,也就是把一张长表变成宽表。
举个实际例子。有一张学生成绩表,每个学生和课程组合是一条记录,想把每个学生的语数英三科成绩放在一行里展示:
SELECT student_id, MAX(CASE WHEN course = '语文' THEN score END) AS chinese_score, MAX(CASE WHEN course = '数学' THEN score END) AS math_score, MAX(CASE WHEN course = '英语' THEN score END) AS english_score FROM score_table GROUP BY student_id;注意这里用了MAX包裹CASE WHEN,很容易被问为什么。因为某个学生语文成绩只有一条记录,CASE WHEN选出来的是一个单值,单值再用MAX包一层,不会改变结果;但如果不包聚合函数,GROUP BY后选出来的CASE WHEN字段其实不合法,加MAX能让它在分组模式下合理存在。这算是SQL写法上的一个小技巧。
CASE WHEN还能嵌套使用,用于多级分类,比如把订单金额划分成“小额、中额、大额”几个档位,再按档位统计订单数,这种需求在运营报表里很常见,和GROUP BY配合能一次搞定。
5. 高级查询技巧:分页、窗口函数、EXISTS与UNION
5.1 分页查询的方言差异与通用写法
分页是每个写Web应用的人都会遇到的需求。MySQL和PostgreSQL的写法非常接近,用LIMIT配合OFFSET:
SELECT user_id, user_name FROM user_info ORDER BY create_time DESC LIMIT 20 OFFSET 40;意思是跳过40行,取接下来20行,对应“第3页,每页20条”。SQL Server不支持LIMIT,用的是OFFSET FETCH,语法稍有差异;Oracle经典的分页写法是ROWNUM包三层子查询,也有人用FETCH FIRST语法。实际工作中哪个库就用哪个库的写法,不用死记硬背,但要能理解深分页的性能问题。
深分页是真的慢。OFFSET越大,数据库需要扫描并丢弃的行越多。假设OFFSET是100万,即使只需要20条,数据库可能也得从表开头数到100万再取数据。优化方案有两个方向:一个是改成WHERE条件过滤,比如用上一页最后一条记录的create_time和user_id作为游标,再往下取;另一个是延迟关联,也就是先在索引上完成分页定位,再回表取其他字段。
5.2 窗口函数解决“分组Top N”问题
窗口函数是比聚合函数更进阶的工具,它能在不改变行数的情况下,为每一行附加一个聚合值或者排名值。最常见的三个窗口函数是ROW_NUMBER、RANK、DENSE_RANK,区别在于并列排名的处理方式:ROW_NUMBER不管并列,每人一个编号;RANK并列时留出空位;DENSE_RANK并列时不留空位。
“找出每个品类销量排名前10的商品”是窗口函数最经典的场景:
SELECT product_id, category_id, sales_amount, rk FROM ( SELECT product_id, category_id, sales_amount, ROW_NUMBER() OVER (PARTITION BY category_id ORDER BY sales_amount DESC) AS rk FROM product_sales ) t WHERE rk <= 10;PARTITION BY负责分组,ORDER BY负责组内排序,ROW_NUMBER生成组内序号。外层再过滤rk <= 10就拿到了每个品类的前10名。这个逻辑用传统SQL写,不是说不可以,但要各种子查询嵌套、自连接,复杂又容易错。窗口函数把这件难事变成了模板化操作,强烈建议多练习。
窗口函数里还有个常用场景是计算移动平均或累计求和,配合SUM OVER和ROWS BETWEEN就能实现“从当前行往前数7天的累计订单金额”之类的报表需求。
5.3 EXISTS与IN的性能差异
过滤条件里经常遇到“找那些有订单的用户”这种需求,实现方式有IN子查询和EXISTS子查询两种:
-- 方式一:IN SELECT user_id, user_name FROM user_info WHERE user_id IN (SELECT user_id FROM order_info); -- 方式二:EXISTS SELECT u.user_id, u.user_name FROM user_info u WHERE EXISTS (SELECT 1 FROM order_info o WHERE o.user_id = u.user_id);二者逻辑等价,但性能在不同数据库下表现不同。核心差异在于:IN一般先把子查询的结果集物化,再对外层表做匹配;EXISTS是外层表每行去关联一次子查询,操作类型更接近半连接。现代优化器其实已经做了大量改写,两者差距在多数场景下并不明显,但有一个原则性的建议:当子查询结果集很大时,倾向用EXISTS;当外层表很大、子查询结果集很小时,IN通常表现更好。更稳妥的方式是用JOIN改写,直接内连接加DISTINCT,命中索引的情况下效率一般不会差。
5.4 UNION与UNION ALL的区别和适用场景
UNION和UNION ALL都能把多个SELECT的结果合并成一个结果集,区别在于UNION隐含去重,UNION ALL保留所有行。去重意味着排序或哈希运算,比UNION ALL更消耗资源。能用UNION ALL的地方就用UNION ALL,尤其两个结果集本来就不可能有重复时,别白白浪费数据库性能。
举个实际场景。一个应用要同时展示“用户收藏的商品”和“用户最近浏览的商品”,来源是两张不同的表,需要合并展示,且这两个来源可能有交叉,如果想去重就用UNION:
SELECT product_id FROM user_favorites WHERE user_id = 1 UNION SELECT product_id FROM user_view_history WHERE user_id = 1;需要注意UNION/UNION ALL在各分支中字段数量必须一致,字段类型也要兼容,否则数据库直接报错。想给合并结果里每一行标记来源,可以在每个SELECT里增加一个固定的常量字段,比如'收藏' AS source和'浏览' AS source,这个技巧在报表开发中很常用。
6. 查询优化与性能排查:从慢查询到执行计划
6.1 慢查询的第一轮排查:先看索引再看写法
SQL写完了,功能正确,但跑起来慢,这是最让人头疼的情况。排查的第一步,不是去改SQL,而是确认表结构和索引情况。
慢查询最常见的三大原因:没有索引、有索引但没用上、数据量本身过大。用EXPLAIN看执行计划的type列,可以快速定性访问方式:const和eq_ref说明走了主键或唯一索引,性能很好;ref和range说明走了普通索引或索引范围扫描,可接受;ALL表示全表扫描,需要重点关注。正常情况下,核心业务查询不应该出现全表扫描。
索引没被用上的原因里,最常见的是“隐式类型转换”。比如索引字段是varchar类型,查询时却传了数字,数据库会把字段隐式转化为数字,导致索引失效。写SQL时,字符串类型的字段就老老实实带引号。
另一个常见的索引失效原因是函数包裹。在索引字段上套函数,比如DATE(create_time) = '2024-01-01',会让数据库无法直接使用常规的B+树索引。正确写法是create_time >= '2024-01-01' AND create_time < '2024-01-02'。
6.2 执行计划怎么看:重点关注这几个指标
执行计划是数据库优化器生成的“查询路线图”,会明确告诉你数据库打算怎么查。以MySQL的EXPLAIN为例,核心关注这几个字段:
table:这一行针对哪张表。 type:访问类型,从好到差大致是system -> const -> eq_ref -> ref -> range -> index -> ALL。看到ALL就要琢磨怎么优化。 possible_keys:优化器认为可能用到的索引。 key:实际用到的索引。 rows:预估扫描行数。 filtered:过滤比例,结合rows可以估算最终返回行数。 Extra:额外信息,看到Using temporary或Using filesort时要格外留意,分别表示用了临时表排序和文件排序,通常可以进一步优化。
读执行计划时,有一个很容易犯的错误:以为索引建了就一定会被用到。实际上优化器会根据表数据量、索引区分度、查询条件联合决定,某个索引区分度太差,优化器宁可全表扫描也不走该索引,这未必是坏事。如果确信某个查询应该走索引但没走,可以尝试用FORCE INDEX提示来指引,不过这是治标之法,根因还是索引设计不合理。
6.3 常见性能杀手:SELECT *、LIKE前置通配符、OFFSET深翻页
把高频出现的性能问题集中列一下,这些坑只要记住就能绕开。
SELECT *的代价前面提过,大数据量不列字段列表,等于把不需要的数据也读出来送回客户端,浪费IO和带宽。改显式字段列表后,很多慢查询能明显提速。
LIKE前置通配符,也就是LIKE '%关键字'和LIKE '%关键字%',这两类写法常规索引基本无法辅助,只能全表扫描加内存过滤。想要做真正的全文模糊搜索,应该依靠全文索引,或者用专门的搜索引擎,而不是指望普通索引。
OFFSET深翻页的问题前面也详细说了,解决思路有两个:一是改成基于上次位置的分页游标,二是延迟关联。手写SQL的时候我一般优先游标方式,实现简单还不容易出错。
还有一类性能杀手是过多的子查询嵌套。能拆成JOIN的尽量拆,能一层解决的问题不要包三层,既能提升性能,也减少阅读SQL时的理解成本。
7. 常见问题与排查技巧实录
7.1 NULL参与运算的陷阱
NULL在SQL里不是“空字符串”,也不是“0”,它表示“不知道”。一旦字段值为NULL,和它做等值比较,结果既不是真也不是假,而是“不确定”,所以在WHERE里用column = NULL永远查不到数据,正确写法是column IS NULL或者column IS NOT NULL。
NULL参与算术运算时结果必然是NULL,price * quantity如果price是NULL,结果也是NULL。做金额类计算时务必提前用IFNULL或COALESCE做兜底,比如IFNULL(price, 0) * quantity,否则报表上会出现大量空值甚至计算错误。COALESCE更通用,可以传多个参数,返回第一个非NULL值,适配多字段场景,比如“先用折扣价,没有就用原价”这种业务逻辑,COALESCE(discount_price, price)一句话就搞定了。
7.2 多表联查时字段歧义报错
两张表里都有同名字段,比如user_id,直接写WHERE user_id = 1,数据库会报字段歧义错误,因为它不知道该用哪张表的user_id。解决方案是给表起别名,并在所有可能出现歧义的字段前加上别名前缀:
SELECT u.user_id, o.order_id FROM user_info u LEFT JOIN order_info o ON u.user_id = o.user_id;这条SQL里u和o就是别名,一旦使用了别名,所有字段引用都建议带上别名,不只是歧义字段。这样SQL的可读性也更好,别人接手时一眼就能看出字段来源是哪张表。在GROUP BY和ORDER BY里同样要带上别名前缀,否则某些数据库会在执行阶段忽然报错,提前写上能省很多事。
7.3 JOIN条件写错导致数据翻倍
这个坑特别隐蔽,我在实际项目里帮别人排查过不止一次。现象是:明明关联字段没有重复,结果却出现了重复数据,数据量怎么对都差着数。
最常见的原因是把关联条件写得太宽了。比如订单表里的订单可能关联到订单明细表(一对多),再关联到商品表时,如果商品表的关联字段在明细级别也有意义,但你在订单级别直接关联,就会出现一个订单被拆成多个明细再对应多个商品的情况,结果就是查询结果比订单总数多出好几倍。
排查这类问题时,我习惯分步验证:先单独查主表的记录数,再逐步加JOIN看结果数变化,哪一步加完行数暴涨,问题就出在哪一步的关联条件上。另一个辅助手段是给每个JOIN的结果做一个COUNT(*)对比,快速定位是哪个关联步骤引入了重复。
7.4 字符串比较与排序规则差异
字符串字段的比较规则由数据库的排序规则(collation)决定。MySQL的utf8mb4_general_ci和utf8mb4_unicode_ci都不区分大小写,意味着WHERE user_name = 'abc'也能匹配到‘ABC’。如果业务上要求区分大小写,要用BINARY关键字或者选择_bin结尾的排序规则,比如WHERE BINARY user_name = 'abc'。
不同数据库之间这个差异更大,比如PostgreSQL默认区分大小写,同样的字符串查询,换个数据库,结果可能完全不同,代码迁移时千万要注意。还有数字和字符串的比较,前面说过隐式类型转换会导致索引失效,这里再强调一次:查varchar字段时带引号,查int字段时不带引号,保持类型一致比什么都重要。
字符串排序也容易被忽视——中文排序在不同数据库下表现差异很大,有的按拼音,有的按Unicode编码,有的按字节序。如果有明确的中文排序要求,通常需要在SQL里显式指定排序规则,或者在设计表时就把排序规则定好,否则不同环境的同一套代码可能跑出不同顺序。
最后再分享一个小技巧:遇到那种逻辑复杂、算了半天都算不对的记录时,别急着怀疑优化器有问题,先把WHERE条件拆掉,逐步加回去,把每次的结果行数和预期值对比,绝大多数问题都出在某一个过滤条件或关联条件的理解上,而不是数据库本身。写SQL这件事,细心和逻辑感比记一堆语法更重要。