1. 为什么我需要回头补基础查询这堂课
说出来有点不好意思,我离职前那个月,生产环境出了一次不大不小的故障:一个后台管理页面的列表接口,用户一点查询按钮,数据库CPU直接飙到90%以上,页面转圈十几秒才出结果。当时我第一反应是"换Redis缓存",结果架构师看了一眼SQL,丢给我一句话:"你先把基础查询搞清楚再来谈缓存。"
这句话挺扎心的,但也点醒了我。很多做了两三年的开发,写SELECT * FROM table WHERE id IN (...)写得很溜,可真要处理慢查询、去重、子查询、多表关联、分页性能这些问题时,往往靠的是网上东抄一段西抄一段,底层逻辑根本不透。等到线上出问题,只能被动的加缓存、加索引,甚至加服务器,真正的病根——SQL写法本身——反而没人管。
这篇博文我尽量把"基础查询"讲透,不局限于某一种数据库,重点覆盖SQL标准语法加上MySQL、Oracle、PostgreSQL里常用到的差异点。内容从最底层的查询逻辑开始,逐步拆解去重、子查询、临时表、删除场景下的查询陷阱、慢查询定位和分页优化,几乎每个点都对应了真实线上踩过的坑。适合刚入行的开发,也适合写了两三年SQL但没系统梳理过查询原理的朋友。
我先说明一点:这篇文章不讲ORM怎么调用,也不讲具体的连接池配置,聚焦的就是一句SQL从写出到跑完的完整链路。你跟着走一遍,把每个环节的"为什么"搞清楚,以后再遇到慢查询或者奇怪的返回结果,第一反应就不会是"换个数据库试试",而是先低头看自己写的SQL。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从SELECT执行顺序说起:WHERE和HAVING为什么会搞混
2.1 一条查询语句的真实执行顺序
很多人学SQL,上来就背语法:SELECT、FROM、WHERE、GROUP BY、HAVING、ORDER BY、LIMIT。语法没问题,但真正跑起来的时候,数据库引擎不是按这个书写顺序执行的。这个逻辑要是没理清楚,很多查询问题根本无从下手。
数据库执行一条查询的逻辑顺序是:
code复制FROM -> ON -> JOIN -> WHERE -> GROUP BY -> HAVING -> SELECT -> DISTINCT -> ORDER BY -> LIMIT
也就是说,FROM先确定数据源,WHERE先做行级过滤,GROUP BY做分组,HAVING对分组后的结果过滤,最后才轮到SELECT投影和ORDER BY排序。
举个例子,你想查"每个部门里工资大于5000的员工人数",新手经常写成:
sql复制SELECT dept_id, COUNT(*)
FROM employee
WHERE salary > 5000
GROUP BY dept_id;
这么写是对的。但如果不小心把salary > 5000放到HAVING里:
sql复制SELECT dept_id, COUNT(*)
FROM employee
GROUP BY dept_id
HAVING salary > 5000;
在MySQL里这能跑通,甚至会返回一个看似合理的结果。为什么?因为MySQL对HAVING子句里的列做了特殊容忍——它允许HAVING引用SELECT列表里的别名,也允许一些非聚合列直接出现在HAVING里。但这个行为在Oracle里直接报ORA-00979,在PostgreSQL里同样报错,因为salary既没有出现在GROUP BY里,也没有被聚合函数包裹。
这就是基础不牢的典型表现:同样的写法在MySQL里没问题,换一个数据库就挂,根子在于没有理解HAVING是"分组后的过滤条件",不是"行级过滤的备胎"。
2.2 WHERE和HAVING的边界在哪里
接着上面的例子,我把这两个子句的边界说清楚:
- WHERE:在分组之前执行,过滤的是FROM之后、GROUP BY之前的原始行。WHERE里不能使用聚合函数,比如
WHERE COUNT(*) > 2直接报错。 - HAVING:在分组之后执行,过滤的是GROUP BY产出的分组结果。HAVING可以使用聚合函数,比如
HAVING COUNT(*) > 2。 - WHERE先执行、HAVING后执行,这意味着能用WHERE过滤掉的,就别放到HAVING里。提前过滤能减少分组计算的数据量,性能差别在千万级表上非常明显。
我见过一个真实案例:有个报表SQL,要统计近30天各渠道的订单量并过滤掉下单量小于10的渠道。有人把时间条件写到了HAVING里:
sql复制SELECT channel, COUNT(*)
FROM orders
GROUP BY channel
HAVING order_time > NOW() - INTERVAL 30 DAY AND COUNT(*) >= 10;
这条SQL在MySQL里能跑,但性能很差。因为HAVING是在全表分组之后才去过滤时间,也就是说2019年以前的历史订单也被拉出来分了一轮组。把order_time条件挪到WHERE之后,同样的数据量,查询时间从4.2秒降到了0.3秒。
**我的实操建议是:HAVING里只放聚合条件,行级过滤永远用WHERE。**别问我为什么MySQL容忍这种写法,兼容性宽松不是写烂SQL的理由。
2.3 SELECT列别名在WHERE里不可用,在ORDER BY里可用
另一个容易踩的坑是别名作用域。在标准SQL里,WHERE子句的执行顺序在SELECT之前,所以WHERE里不能用SELECT中定义的别名。这个很多人知道,但等到实际写的时候还是会犯:
sql复制SELECT user_name AS name, age
FROM user
WHERE name = '张三';
这个在MySQL里会报错:Unknown column 'name' in 'where clause'。但在ORDER BY里,别名就能用:
sql复制SELECT user_name AS name, age
FROM user
ORDER BY name;
因为ORDER BY的执行顺序在SELECT之后。这一点是面试里最常见的送分题,也是实际调SQL时经常卡壳的地方。
3. 去重查询不是只有DISTINCT,GROUP BY和窗口函数怎么选
3.1 DISTINCT的性能隐忧
热搜词里"sql语句去重查询"排得很靠前,说明这是日常需求。但很多人对去重的理解停留在SELECT DISTINCT col FROM table这个层面。
DISTINCT的本质是对结果集做排序去重。如果查询涉及多列,比如SELECT DISTINCT col1, col2 FROM table,它去重的是(col1, col2)组合。这个过程需要把中间结果集放到内存或临时表里做排序,数据量大时非常吃资源。
我做过一次压测:一张2000万行的订单表,SELECT DISTINCT user_id FROM orders跑了7秒。后来改成SELECT user_id FROM orders GROUP BY user_id,跑了3.8秒。原因在于,DISTINCT在MySQL 8.0之前是"先查出来再排序去重",而GROUP BY的聚合语义在优化器里可能走索引去重,或者使用松散索引扫描,路径更优。
当然,这两个SQL的语义有细微差别:DISTINCT是针对结果集整体去重,GROUP BY是分组语义。当只select一个列时,两者基本等价,但优化器选择的执行路径可能不同。建议:单列去重优先用GROUP BY,多列去重且不涉及聚合时,DISTINCT更直观。
3.2 经典错误:只查一列却想得到多列信息
举个例子,你想查"每个用户最近的一笔订单",新手写:
sql复制SELECT user_id, MAX(order_time), order_id
FROM orders
GROUP BY user_id;
这个在MySQL里能跑,返回的order_id可能是任意一条,不是最大order_time对应的那条。在Oracle里直接报ORA-00979,因为order_id没有GROUP BY也没有聚合。这是去重/分组查询里最经典的语义错误。
正确写法有很多种,最推荐窗口函数:
sql复制SELECT user_id, order_time, order_id
FROM (
SELECT user_id, order_time, order_id,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY order_time DESC) AS rn
FROM orders
) t
WHERE rn = 1;
窗口函数在MySQL 8.0、PostgreSQL、Oracle、SQL Server里都是标准能力,用PARTITION BY做组内排序,拿到每组的"第1条",语义清晰,性能也不差。
3.3 如果数据量大到DISTINCT都扛不住
继续上面的话题。如果表有1亿行,你要统计到底有多少个不同的user_id,DISTINCT和GROUP BY都会做一次大排序或者hash聚合。这时候可以考虑分而治之的思路:先按user_id的hash值分桶,比如分成100个桶,对每个桶计数,最后求和:
sql复制SELECT COUNT(*) FROM (
SELECT MOD(CRC32(user_id), 100) AS bucket
FROM orders
GROUP BY bucket
) t;
这个技巧在跑数仓任务时非常实用。当然,如果只是实时性要求不高的场景,物化一个用户维表会更简单——这也是"查询优化不一定在查询里解决"的典型思路。
4. 子查询、IN与临时表:with as 为什么比嵌套子查询好读也更高效
4.1 IN子查询的性能陷阱
热搜词里有"in查询语句报错"和"mysql with as 子查询使用临时表",这两个我一起讲。
先看一个常见的慢查询写法:
sql复制SELECT *
FROM orders
WHERE user_id IN (
SELECT user_id FROM users WHERE vip_level = 5
);
在MySQL 5.x时代,优化器会把IN子查询改写成相关子查询,对orders表的每一行都去执行一次子查询,性能极差。5.6之后优化器引入了半连接优化,性能有所改善,但前提是子查询里的数据量不能太大。如果子查询返回几万行,IN列表膨胀,成本依然很高。
IN列表过长还有一个边界问题:如果IN里包含NULL,结果永远不为TRUE,会导致整条查询返回空集。比如WHERE user_id IN (1, 2, NULL),结果不是返回user_id为1和2的行,而是什么都不返回。这个很多人不知道,经常半夜被这种诡异的空结果集坑到。
4.2 with as 临时表的正确打开方式
CTE(Common Table Expression,也就是WITH AS)在SQL标准里早就有了,但很多人到现在还只把它当成"给子查询起个别名"的语法糖。实际上CTE对可读性的提升是其次,真正的价值在于可以把一段复杂的逻辑拆成多个步骤,每一步都有名字,方便调试和复用。
我处理过一个复杂查询:统计每个品类的销售额、环比增长率和品类占比。嵌套子查询写出来大概五层,调试的时候来回改括号,眼睛都要瞎了。写成CTE之后非常清晰:
sql复制WITH sales_by_category AS (
SELECT category_id, SUM(amount) AS total_amount
FROM orders
WHERE create_time >= '2024-01-01'
GROUP BY category_id
),
total_sales AS (
SELECT SUM(total_amount) AS grand_total FROM sales_by_category
)
SELECT c.category_name,
s.total_amount,
ROUND(s.total_amount / t.grand_total * 100, 2) AS pct
FROM sales_by_category s
CROSS JOIN total_sales t
JOIN category c ON c.id = s.category_id
ORDER BY s.total_amount DESC;
这里有个关键点:CTE不一定物化,MySQL 8.0里普通CTE会被优化器内联展开,也就是说并非"只执行一次"。如果你在一个CTE里查了一张大表,后面又被JOIN了三次,那它可能被执行三次。想强制只执行一次,可以用WITH ... AS MATERIALIZED,这在PostgreSQL里支持。MySQL用户要注意,别以为CTE写了就等于建了临时表,它的语义更接近"命名的子查询片段"。
4.3 子查询和JOIN怎么选
另外一个高频问题:能用JOIN解决的问题,要不要用子查询?
我的经验是:能写成JOIN的,优先JOIN。原因在于,JOIN的优化器选择空间更大——它可以调整连接顺序、选择hash join或nested loop join,而子查询在某些数据库里会被物化成临时表,这个物化过程本身有开销。
但有一个例外:当你需要判断"存在性"的时候,EXISTS往往比IN和JOIN都高效,因为EXISTS只要找到第一条匹配记录就会停下来,不需要扫描完整个结果集。
sql复制-- 查所有下过单的用户
SELECT u.*
FROM users u
WHERE EXISTS (
SELECT 1 FROM orders o WHERE o.user_id = u.id
);
这里的SELECT 1不是随便写的——它表示"不关心返回内容,只关心有没有记录"。写成SELECT *也合法,但会传递错误的信号给读代码的人,性能上在部分数据库里也会有细微差距。
4.4 视图能加快查询速度吗
这个问题也是热搜词里的:"视图可以加快查询速度吗"。答案很直接:普通视图不存储数据,它在查询时会被展开成底层的SQL再执行,所以视图本身不会让查询变快,反而可能因为多了一层解析变得更慢。
能提升性能的是物化视图。MySQL不支持原生物化视图,Oracle和PostgreSQL支持。物化视图会预先计算并存储结果,查询时直接读结果,但代价是数据不是实时刷新的,需要手动或者定时刷新。如果你只是想简化查询写法,用视图没问题;如果你是想提升查询性能,先去看索引和SQL本身,别指望视图兜底。
5. 查询与删除对象纠缠不清的坑:DELETE里不能随便用子查询
5.1 删除对象时最容易被忽略的约束条件
热搜词里出现了两条有意思的内容:"django执行查询-删除对象"和"mybatis plus 查询 禁用逻辑删除"。一个是Python Web框架的ORM,一个是Java的ORM框架,但它们指向同一个痛点:当"查询"和"删除"混在一起时,坑特别多。
先讲SQL层面的经典问题。MySQL里,你不能在子查询中直接从要删除的表里SELECT然后DELETE,比如:
sql复制DELETE FROM orders
WHERE id IN (
SELECT id FROM orders WHERE amount < 0
);
这条SQL会报错:You can't specify target table 'orders' for update in FROM clause。原因很简单:如果允许对同一张表既读又写,那读到的数据可能是删除了一半的中间态,语义无法保证。解决办法是包一层临时表或CTE:
sql复制DELETE FROM orders
WHERE id IN (
SELECT id FROM (
SELECT id FROM orders WHERE amount < 0
) tmp
);
有人会问:多包一层就安全了吗?原理上线包一层子查询之后,MySQL会把内层结果物化成一个派生表,这样就规避了"正在修改的表不能出现在子查询FROM里"的限制。
5.2 逻辑删除字段对查询的隐形影响
再讲ORM层面的坑。MyBatis Plus的逻辑删除功能默认会在所有查询SQL后面自动加上deleted = 0条件——注意,是所有查询,包括你可能手动写的一些复杂SQL。
我踩过的坑是这样的:项目里配置了逻辑删除,@TableLogic注解加在deleted字段上。然后我在一个统计SQL里自己写了WHERE deleted = 1,想专门查已删除的数据。结果MyBatis Plus自动拼了一个deleted = 0上去,两个条件一冲突,查出来永远是空集。排查了半天,最后看了控制台打印的SQL才反应过来。
MyBatis Plus的@TableLogic在查询场景下默认是自动追加条件的,如果你要查含被逻辑删除的数据,不能直接依赖自动条件,得在XML里写原生SQL,或者关闭全局逻辑删除开关,在特定mapper方法上单独控制。
这个问题的本质是:框架帮你做了一件隐式的事,你的"基础查询"功力不够时,根本察觉不到这个隐式条件的存在。
5.3 Django ORM里查询与删除的连锁反应
Django的ORM也有类似的隐藏行为。Model.objects.filter(...).delete()会触发级联删除,而且Django的Collector类会把关联对象也收集起来一起删。如果你的模型里外键没有设置on_delete=CASCADE,那默认是PROTECT,删除时直接抛ProtectedError。
我遇到过一次:删一个用户的记录,结果报ProtectedError,提示"无法删除,因为有订单关联"。我当时的反应是"删用户怎么还把订单扯进来了",后来一看模型:
python复制class Order(models.Model):
user = models.ForeignKey(User, on_delete=models.PROTECT)
这就是ORM的"查询和删除纠缠"的另一个维度:删除操作之前,ORM会自动查询关联对象,这个查询过程你是看不见的。 一旦关联数据量大,删除一条主记录可能要跑出几百条查询,性能直接崩。
经验是:涉及批量删除的场景,先关掉自动事务、用queryset._raw_delete()绕过信号,或者拆成小批量手动处理,比让ORM一把梭靠谱得多。
5.4 慢查询日志里最常见的DELETE模式
在慢查询日志里,DELETE相关的慢SQL往往就两类:一类是删了大量数据的单条DELETE,因为事务要锁住所有涉及的行和索引项;另一类是DELETE里带了复杂子查询,导致每删一行都要执行一次子查询。
第一种的解法是分批删除:
sql复制DELETE FROM orders
WHERE create_time < '2023-01-01'
LIMIT 1000;
循环执行直到影响行数为0。这样做有两个好处:一是单次锁的范围小,不会长时间阻塞其他请求;二是如果中途失败,前排队的事务不会全部回滚,损失可控。
第二种的解法是先把子查询结果导出到临时表,再JOIN删除。但这里有个细节:DELETE的JOIN语法在不同数据库里差别很大。 MySQL支持DELETE t1 FROM table1 t1 JOIN table2 t2 ON ...,Oracle则用DELETE FROM (SELECT ...)或者直接EXISTS子查询。基础查询的功力深浅,在这一步体现得很明显。
6. 慢查询与分页优化:加索引不是万能药
6.1 慢查询日志里到底在告诉我们什么
热搜词里有"慢查询日志"和"分页查询慢怎么用redis优化",这两个放一起讲,因为它们通常是同一个问题的不同阶段。
慢查询日志是排查SQL性能问题的第一入口。MySQL里可以这样开启:
sql复制SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1;
这样所有执行时间超过1秒的SQL都会被记录到日志文件。拿到慢SQL之后,第一步不是加索引,而是用EXPLAIN看执行计划。
sql复制EXPLAIN SELECT * FROM orders WHERE user_id = 123 ORDER BY create_time DESC LIMIT 10;
看执行计划时,重点关注三列:
- type:从好到差依次为system > const > eq_ref > ref > range > index > ALL。看到ALL就是全表扫描,基本是优化对象。
- key:实际用到的索引。如果是NULL,说明没走索引。
- rows:预计扫描行数。这个数越大,SQL一般越慢。
6.2 深分页问题的本质
分页查询慢,大多数人第一反应是加索引。但如果你的SQL长这样:
sql复制SELECT * FROM orders
ORDER BY create_time DESC
LIMIT 100000, 20;
加索引能解决吗?能,但只是缓解。深分页的慢不全在排序,而在LIMIT 100000, 20需要扫描前100020行,然后丢到前100000行。 即使有索引,这前100000行的扫描和丢弃也无法避免。
业界通用的解法有四种:
- 延迟关联:先只查主键,再回表查完整数据。
sql复制SELECT o.*
FROM orders o
JOIN (
SELECT id FROM orders
ORDER BY create_time DESC
LIMIT 100000, 20
) tmp ON o.id = tmp.id;
- 游标分页 / keyset分页:记住上一页最后一条的create_time和id,下一页查询条件变成:
sql复制SELECT * FROM orders
WHERE create_time < '2024-05-10 10:00:00'
OR (create_time = '2024-05-10 10:00:00' AND id < 10086)
ORDER BY create_time DESC, id DESC
LIMIT 20;
这种方式跳过了OFFSET,不扫描已翻过的页,是数据量大时的最优解,但有个限制:排序字段必须唯一或加上id辅助保证稳定顺序,否则翻页数据可能重复或丢失。
-
Redis优化分页:热搜词里提到"分页查询慢怎么用redis优化"。很多人误解了,以为把整张表缓存进Redis再做分页。这在小数据量下可行,但数据量一大就废了。正确做法是用Redis缓存热门页的结果,比如前20页的数据,超过20页的查询直接落到数据库。 因为绝大多数用户只翻前面的页,没人会翻到第500页去——你看到第500页的深度分页慢,说明产品设计本身就出了问题。
-
搜索引擎方案:如果真的要支持任意条件的深度分页,比如后台运营按各种条件筛选,还要看第1000页,那数据库SQL确实不是好工具。这时候上Elasticsearch这类搜索引擎才是正解,但这是另一个话题,不展开。
6.3 一个让我印象深刻的"慢查询优化"失败案例
之前有同事优化一个慢查询,看SQL里有个WHERE status = 1 AND create_time > '2024-01-01',就给status加了个普通索引。结果加了之后查询更慢了。
为什么?因为status字段区分度太低,只有0和1两个值,90%的数据都是status=1。优化器觉得走索引还不如直接全表扫描快,于是INDEX被忽略了,但索引本身要占用写入开销和维护成本。区分度低的列加索引,很多时候只是自我安慰。
正确做法是建联合索引(status, create_time),把区分度高的时间字段放在后面,让索引能直接过滤出小结果集。这种基础知识点,文档里都有,但没踩过坑的人是真记不住。
6.4 视图、索引和查询优化之间的关系
最后把前面提到的视图话题收个尾。视图本身不提升性能,但视图加索引(物化视图)可以。 MySQL原生不支持物化视图,但你可以用"定时任务刷一张汇总表"这种土办法模拟。很多报表系统的"汇总表"就是这么干的——查的时候速度快到飞起,代价是数据可能有几分钟延迟。
如果你对性能有洁癖,纠结"查出来的数据不是实时"这个问题,那我劝你先想清楚产品需求:一个昨天统计的订单总量,延迟5分钟真的会有用户发现吗?在绝大多数场景下,用微小的数据延迟换几十倍的查询性能提升,是划算的买卖。
7. 查询基础排查清单:我在实际定位问题时固定会做的事
这部分算是我个人的固定排查路径。每次接到"查询慢""查询结果不对"的反馈,我基本都是按下面这个清单走一遍,效率比漫无目的地看代码高很多。
第一步,看慢查询日志。 确认SQL有没有进日志,如果没进,看是否超过阈值;如果进了,把SQL原封不动拿出来。
第二步,EXPLAIN看执行计划。 重点看type、key、rows三列。如果是ALL或者key为NULL,先别急着改SQL,想想索引为什么没被用上:是索引失效(比如对索引列用了函数),还是优化器觉得全表扫描更快。
第三步,检查SQL语义。 把WHERE、HAVING、JOIN条件逐条念出来,确认每个条件的执行时机。尤其是"结果集多出重复行"的问题,90%是JOIN条件不完整导致的笛卡尔积,把条件补完整,重复行自然消失。
第四步,确认ORM层有没有隐式条件。 MyBatis Plus的逻辑删除、Django的默认排序和信号、Hibernate的懒加载,可能都在你的查询后面偷偷加东西。控制台打出的SQL一定不能跳过不看。
第五步,分析业务场景。 这个查询是给用户用的,还是给报表用的;实时性要求多高;能不能用缓存、能不能用汇总表。SQL优化永远是为了业务服务的,不要为了优化而优化。
下面这个表格是我日常排查时用的速查表:
| 现象 | 优先怀疑 | 最快的确认方法 |
|---|---|---|
| 查询结果重复行 | JOIN条件不完整 | 去掉一个JOIN表看重复是否消失 |
| 查询结果为空 | IN里含NULL、逻辑删除条件冲突 | 去掉WHERE条件逐步缩小范围 |
| 慢查询 | 全表扫描、深分页 | EXPLAIN看type/rows |
| 换数据库就报错 | 语法依赖了特定数据库特性 | 检查HAVING非聚合列、分页语法 |
| 删除时报错 | 外键约束、ORM级联 | 看完整异常栈和模型定义 |
| 分页数据重复或缺失 | 排序字段不唯一 | 在ORDER BY中补唯一列(通常是id) |
这张表不是银弹,但覆盖了我遇到过的80%以上"查询"问题。剩下20%,基本要靠对具体业务的理解和一点点排查耐心。
写到这里,"基础查询"的核心环节差不多都过了一遍。我在实际项目中最大的体会是:大部分查询性能问题都不是因为没有用高级特性,而是最简单的那几条规则没做到位——WHERE别放聚合条件、区分度低的列别乱加索引、深分页别用OFFSET、ORM的隐式行为要心里有数。 把这些基本功打扎实了,比追着看各种花哨的SQL优化技巧有用得多。如果你能把自己写的每条查询都按上面的执行顺序在脑子里过一遍,大多数查询坑,在部署之前就已经避开了。
