资讯详情 SQL子查询全面解析:分类、执行原理与性能优化实战
📅 2026/10/22 15:59:42
SQL子查询是很多人的分水岭。会用它的人能把一个复杂的业务需求拆成几个简单的小问题一层层拼出优雅的查询不会用的人遇到复杂过滤就只有join、join、再join最后结果对不对自己都说不清。子查询最大的价值是把查询的中间结果当成一个独立的逻辑单元来复用——你可以先问哪些用户是重点用户再问这些用户的订单情况如何每一步都清晰可见。这篇文章我打算把这东西彻底讲透分类、写法、执行原理、性能优化、高频报错一次过完。适用于已经能写基础增删改查、但想突破复杂查询瓶颈的人也适合那些被子查询和join该选谁困扰了很久的同学。1. 子查询的三个分类维度先搞清楚你在写哪一种很多人看子查询的教程觉得乱是因为教程一上来就堆十几个例子却没告诉你子查询其实有三个完全独立的分类维度。这三个维度叠在一起才组成了你看到的某某子查询。1.1 按返回结果形状分类这是最基础、也最好理解的分类。子查询本质上也是一条SELECT语句既然是查询它就有返回结果而结果可以被分成四种形状标量子查询返回一行一列只有一个值。它在任何能写表达式的地方都能用比如SELECT后面、WHERE后面。最典型的例子SELECT id, name, (SELECT MAX(total_amount) FROM orders) AS max_order_amount FROM users;注意如果标量子查询返回了多行MySQL会直接报错Subquery returns more than 1 row。所以写标量子查询时要么用聚合函数把它压成一行要么在子查询里加LIMIT 1。行子查询返回一行多列通常配合括号展开的行构造器使用。比如我要找每个订单里金额最高的那一行可以这样表达SELECT * FROM orders WHERE (user_id, total_amount) (SELECT user_id, MAX(total_amount) FROM orders WHERE status paid);这里的(user_id, total_amount)是一个行构造器子查询返回一行某用户的最大支付金额两者逐列比较。行子查询在日常业务里用得不算多但一旦用到能省掉你用两条查询拼接数据的麻烦。列子查询返回一列多行典型搭配是IN。它是最常见的子查询形态之一SELECT id, name FROM users WHERE id IN (SELECT user_id FROM orders WHERE total_amount 1000);表子查询返回多行多列像一张临时表。它通常出现在FROM子句后面也叫派生表。派生表必须要有一个别名否则MySQL直接报错。这是一个新手极高频错误SELECT u.id, t.total_spend FROM users u JOIN ( SELECT user_id, SUM(total_amount) AS total_spend FROM orders WHERE created_at 2024-01-01 GROUP BY user_id ) t ON u.id t.user_id;这四种形状从一个值到一张表能力是逐渐增强的。你在写子查询之前先问自己我需要的中间结果到底是值、是一行、是一列、还是一张完整的表答案清楚了写法自然就有了。1.2 按与外部查询的关联方式分类非关联子查询子查询可以独立运行不依赖外层查询的任何字段。上面举的例子全都是非关联的。优化器处理这种子查询时很舒服因为它可以先执行子查询拿到完整结果再继续处理外层甚至可以把这个结果缓存下来重复使用。关联子查询子查询里出现了外层查询的列。比如SELECT u.id, u.name, (SELECT COUNT(*) FROM orders o WHERE o.user_id u.id) AS order_count FROM users u;内层子查询里的o.user_id u.id用到了外层的u.id这个子查询没法独立运行——它依赖外层正在处理的那一行是谁。理解这个概念很重要因为它的执行逻辑和性能特性和非关联子查询完全不同后面第三章我会专门拆解。1.3 按出现位置分类同一个子查询可以出现在SQL语句的不同位置学起来最乱的就是这里。我用一个表格把它们归置清楚出现位置典型形态主要用途注意点WHEREIN / EXISTS / 比较运算符过滤条件NOT IN谨防NULLFROM派生表构造中间结果集必须有别名SELECT标量子查询动态计算列只能返回一行一列HAVING聚合后过滤组级条件筛选在分组之后执行JOIN ON表子查询关联前的预聚合谨慎处理重复数据UPDATE/DELETE条件子查询批量更新/删除MySQL不允许同表子查询把这三个维度合在一起看你就明白了WHERE id IN (SELECT ...)是非关联的列子查询SELECT (SELECT ... FROM orders WHERE o.user_id u.id)是关联的标量子查询FROM (SELECT ...) t是非关联的表子查询。以后看到任何一段子查询代码你能在脑子里自动把它的分类标出来这就比死记硬背语法高一个段位了。2. 六大应用场景的写法与索引视角2.1 WHERE后面用子查询做筛选这是出现频率最高的场景。它的本质是外层表的一行数据要跟一个动态的集合做匹配。最常见的搭配有IN、EXISTS、以及直接跟比较运算符。-- 查所有下过单的用户 SELECT id, name FROM users WHERE id IN (SELECT user_id FROM orders); -- 查2024年消费超过2000的用户 SELECT id, name FROM users u WHERE EXISTS ( SELECT 1 FROM orders o WHERE o.user_id u.id AND o.total_amount 2000 );关于IN和EXISTS怎么选我后面专门用一章展开讲。这里只提醒一点WHERE子查询的过滤效果最终要看索引能不能被用上。WHERE id IN (SELECT user_id FROM orders)这种写法如果orders.user_id没有索引内层查询必然做全表扫描如果外层表的数据量也很大整体性能会非常难看。在写任何WHERE子查询之前先想清楚内层结果集有多大外层表有多大以及两边的关联字段上有没有索引。2.2 FROM后面用派生表做中间集合派生表的核心应用场景是先聚合、再关联。不要在JOIN里面直接对明细表做聚合那几乎一定会引入笛卡尔积或重复数据。正确思路是把聚合结果先做成一张小表再和主表关联SELECT u.id, u.name, m.total_spend FROM users u JOIN ( SELECT user_id, SUM(total_amount) AS total_spend FROM orders WHERE status paid GROUP BY user_id ) m ON u.id m.user_id WHERE m.total_spend 5000;这里的关键在于MySQL会把派生表先执行、物化为一张临时表然后外层再做JOIN。如果派生表只需要几十行、几百行执行速度会非常快如果派生表本身就要扫几百万行那无论外层怎么优化都救不回来。所以性能瓶颈往往在派生表内部而不是在外层的JOIN上。另外派生表的别名不是可选项是强制项。你没写别名的时候MySQL会报Every derived table must have its own alias这个错误几乎每个人都会遇到一次。2.3 SELECT后面用标量子查询做动态计算列有时候你想在结果集里带出关联数据的一个值不想为此再写一个JOIN就可以用标量子查询。比如每个用户的最近一次下单时间SELECT u.id, u.name, (SELECT MAX(created_at) FROM orders o WHERE o.user_id u.id) AS last_order_time FROM users u;这个写法在数据量小的时候很爽但注意它的执行逻辑外层表每返回一行内层子查询就要执行一次。users表有10万行这个标量子查询就被执行10万次。如果内层子查询能命中索引10万次短查询还扛得住一旦没索引就是10万次全表扫描线上接口不慢才怪。一个常用优化思路是改用LEFT JOIN加分组SELECT u.id, u.name, MAX(o.created_at) AS last_order_time FROM users u LEFT JOIN orders o ON u.id o.user_id GROUP BY u.id, u.name;当然这也要注意GROUP BY的字段要完整否则结果集语义会出问题。SELECT后面的标量子查询适合外层结果集本来就很小、且内层索引稳定命中的场景一旦两张表都是大表优先考虑JOIN聚合。2.4 HAVING里用子查询做组级过滤HAVING里面也可以用子查询但很多人不知道。它的语义是先GROUP BY分组再用子查询结果对组进行过滤。比如我只想看订单数超过全站平均订单数的月份SELECT DATE_FORMAT(created_at, %Y-%m) AS month, COUNT(*) AS order_cnt FROM orders GROUP BY DATE_FORMAT(created_at, %Y-%m) HAVING order_cnt ( SELECT COUNT(*) FROM orders );这个例子可能不太严谨但表达的意思是清楚的HAVING子句后的子查询是对整个分组结果做比较。需要留意的是HAVING子查询的执行时机是在分组之后所以子查询里的数据范围要仔细想清楚是要全表平均值还是要外部分组的平均值这两种语义完全不同。2.5 JOIN ON里用子查询做匹配条件JOIN的ON条件里放子查询本质上是把一个过滤结果集作为关联对象。它和FROM派生表有点像但位置不同FROM派生表是先建好一张表再JOINON子查询是在关联的同时做条件过滤。SELECT o.id, o.total_amount FROM orders o JOIN ( SELECT user_id, MAX(created_at) AS latest_time FROM orders WHERE status paid GROUP BY user_id ) latest ON o.user_id latest.user_id AND o.created_at latest.latest_time;这个例子是找每个用户最近一笔支付订单。这里的派生表先算好了每个用户最近支付时间然后外层订单表和它按用户时间双条件匹配。如果不这么做很自然地就会写出三层嵌套的相关子查询性能要差很多。2.6 UPDATE/DELETE里用子查询做批量条件子查询不是SELECT的专利UPDATE和DELETE的条件里也能用。比如把从未下单用户标记为停用UPDATE users SET status disabled WHERE id NOT IN ( SELECT user_id FROM orders );但这里有一个MySQL特有的坑如果你尝试在同一条UPDATE/DELETE语句里直接对orders表做查询然后再更新orders表比如DELETE FROM orders WHERE id IN ( SELECT id FROM orders WHERE total_amount 0 );MySQL会直接报错You cant specify target table orders for update in FROM clause。原因是MySQL不允许从目标表读取数据的同时修改它。解决办法很简单包一层派生表DELETE FROM orders WHERE id IN ( SELECT id FROM ( SELECT id FROM orders WHERE total_amount 0 ) t );这个套娃技巧很实用很多从旧项目里接手MySQL的人第一次见到这个报错都会有点懵。3. 相关子查询的执行语义为什么它可能慢3.1 逐行执行的底层逻辑要理解相关子查询为什么慢必须理解它的执行模式。对于一个非关联子查询MySQL的执行顺序大致是先执行子查询把结果记在临时结果里再去执行外层查询。就像你进便利店买东西先查好所有商品价格再决定买什么。但关联子查询不一样。它没法独立执行因为内层查询依赖外层当前行的值。它的执行方式更像嵌套循环SELECT u.id, u.name, (SELECT COUNT(*) FROM orders o WHERE o.user_id u.id) AS cnt FROM users u;实际执行时相当于伪代码这样跑对于 users 表中的每一行 u 执行 SELECT COUNT(*) FROM orders o WHERE o.user_id u.id 把结果放到结果集的 cnt 列也就是说外层表有多少行内层查询就被触发多少次。如果users表有5万行内层子查询就被执行5万次。每次都代表一次独立的查询调度、一次索引查找或扫描。这和JOIN的批量执行模式有本质差异。这也是为什么很多新手写出来的看起来很聪明的子查询在百万级数据上跑得比蜗牛还慢。子查询本身没有错但你要清楚地知道自己在用什么执行模型。3.2 EXPLAIN输出里的三种执行策略通过EXPLAIN查看执行计划是定位子查询性能问题的第一步。不同形态的子查询在EXPLAIN里会呈现不同的关键字EXPLAIN关键字含义出现场景SUBQUERY非关联子查询单独执行一次WHERE里的INDEPENDENT SUBQUERY关联子查询需要随外层逐行执行相关子查询DERIVED派生表需要物化为临时表FROM子句中的子查询MATERIALIZED优化器将子查询结果物化IN加上大结果集UNCACHEABLE SUBQUERY每次执行结果不同子查询引用用户变量等传统EXPLAIN输出里面你看到一个DEPENDENT SUBQUERY就要警惕了这个字段本身就说明SQL在走逐行执行的模型。它不是说一定会很慢而是提醒你这个查询的性能取决于外层表行数乘以内层查询单次开销。MySQL 8.0里可以直接用EXPLAIN FORMATTREE看树形结构更直观。执行计划里如果出现Materialize、Initialize这样的节点说明优化器把子查询结果物化成了一张内部临时表这通常不算坏事因为它把逐行执行变成了先算一次再匹配。3.3 相关子查询的提速手段让相关子查询变快核心思路只有一个压缩内层查询单次开销。具体有三种手段。第一给关联字段加索引。相关子查询本质上是反复执行内层查询内层查询如果能命中索引单次开销就从全表扫描变成一次索引查找。上面那个例子如果orders(user_id)没有索引那每次执行都是全表扫描几十万行表你扫几万次整个查询一小时都跑不完加上索引后单次查询可能是0.1毫秒量级整体立刻可用。第二尽量让外层表变小。WHERE条件提前过滤掉不必要的外层行相关子查询的执行次数就直接减少。比如加一个u.status active条件外层只剩活跃用户执行次数降一个数量级效果立竿见影。第三把重复计算提到查询外面。有些所谓相关子查询其实可以改写成非关联的。比如统计每个用户订单数本质上一句GROUP BY就能算出来没必要把聚合函数放进相关子查询里-- 相关子查询写法 SELECT u.id, (SELECT COUNT(*) FROM orders o WHERE o.user_id u.id) AS cnt FROM users u; -- 更高效的思路先聚合再关联 SELECT u.id, COALESCE(t.cnt, 0) AS cnt FROM users u LEFT JOIN ( SELECT user_id, COUNT(*) AS cnt FROM orders GROUP BY user_id ) t ON u.id t.user_id;两者语义在某些场景下略有差别比如0和NULL但性能往往差一个数量级。先聚合再关联聚合结果集只有用户数那么多个组跟全表行数差太远了。4. IN、EXISTS、JOIN的三选一找到性能和语义的平衡点4.1 语义差异去重、NULL与三值逻辑很多人纠结IN和EXISTS到底谁快其实在小数据量上两者几乎没有区别真正要紧的是语义差异。IN子查询意味着外层的某个值是否存在于子查询返回的集合里。这个集合的去重和NULL问题由SQL自己处理。先说去重IN的语义天然是存在即匹配子查询返回重复值不会导致外层行重复所以不用手动DISTINCT。而JOIN没有这个语义如果关联上的数据在右表有多条外层行就会被重复展开造成结果集行数暴增。这是IN改写为JOIN时最容易踩的坑——结果对不上多了一堆重复数据。再说NULL。这里必须引入SQL的三值逻辑SQL里的比较结果不只是TRUE和FALSE还有第三个值UNKNOWN。任何和NULL做比较的表达式结果都是UNKNOWN。UNKNOWN在WHERE里不满足过滤条件所以行会被淘汰。IN实际上是多个等值比较的OR拼接WHERE id IN (1, 2, NULL) -- 等价于 WHERE (id 1) OR (id 2) OR (id NULL)第3个比较id NULL的结果永远是UNKNOWN但它不影响整个OR表达式的真假因为前两个可能已经为TRUE了。NOT IN就危险了它是整个表达式的取反WHERE id NOT IN (1, 2, NULL) -- 等价于 WHERE NOT ((id 1) OR (id 2) OR (id NULL))问题在于只要子查询结果集里出现一个NULLid NULL就是UNKNOWN整个OR表达式里只要有一个UNKNOWN无论其它项是TRUE还是FALSE整个表达式都可能是UNKNOWN或TRUE反正永远不会变成FALSE导致整个查询一行都不返回。用一句话总结只要NOT IN的子查询结果里可能包含NULL就别写NOT IN。要么在建表时给关联字段加NOT NULL约束要么在子查询里提前过滤掉NULLSELECT id, name FROM users WHERE id NOT IN ( SELECT user_id FROM orders WHERE user_id IS NOT NULL );更稳妥的替代方案是用NOT EXISTS它处理NULL的方式完全不同后面会讲。4.2 半连接优化与NOT IN的经典陷阱MySQL 5.6之后引入了半连接优化优化器会把符合条件的IN子查询自动改写为半连接执行逻辑上有点像JOIN但只返回左表匹配的行且不产生重复。这也是为什么现在的MySQL里很多IN子查询并不慢。但半连接优化是有条件的常见不能改写的情况就包括子查询里带LIMIT、带聚合函数、带GROUP BY、带用户变量等。NOT IN的NULL陷阱我已经说过了我还要补充一个实际场景的排查过程。有一次我们在某个报表模块看到一条查询SELECT COUNT(*) FROM users WHERE id NOT IN ( SELECT user_id FROM orders WHERE created_at 2024-01-01 );结果永远是0业务上完全说不通。排查链路是这样的第一步确认orders.user_id字段的默认值。一查表结构果然user_id允许NULL并且历史脏数据里确实有大量NULL值。第二步手工执行SELECT DISTINCT user_id FROM orders WHERE created_at 2024-01-01结果集合里带NULL。第三步用NOT EXISTS改写。第四步结果集立刻从0变成上万。这就是NULL陷阱最经典的呈现方式查询不报错语义上就是错的而且你如果不知道三值逻辑根本猜不到问题出在哪。所以我把这条规律当铁律记着所有NOT IN子查询默认都要假设子查询结果里可能出现NULL然后强制过滤或用NOT EXISTS改写。4.3 不同场景下的改写建议我按自己的经验给出一套实用的选择标准场景推荐写法理由外层小、内层大EXISTS半连接命中即返回不拉全集外层大、内层小IN内层结果集小物化后再匹配很快子查询返回的集合需要去重IN天然去重同时需要右表字段JOIN子查询拿不到右边的列子查询结果可能含NULL且是NOT INNOT EXISTS避免NULL陷阱内层是聚合结果JOIN派生表更清晰可控这里有个常见误区认为EXISTS一定比IN快。实际上MySQL优化器已经做了不少半连接改写在很多场景下IN和EXISTS最终执行计划是一样的。真正决定性能的是两边的数据量、关联字段索引覆盖情况以及子查询内部是否被优化器拦截。NOT EXISTS和NOT IN也有一个容易踩的坑NOT EXISTS关联子查询的关联条件一旦写错比如漏了o.user_id u.id那子查询就是独立查询语义完全改变结果也会错得离谱。所以改写NOT IN时一定要检查关联条件是否完整。5. 实战排查一条慢查询是如何被救活的5.1 现场还原慢在哪里某个业务报表里有一条查询每次跑都要20秒以上日志里频繁刷出慢查询告警。简化后的语句大致是这样SELECT u.id, u.name, u.status, (SELECT COUNT(*) FROM orders o WHERE o.user_id u.id AND o.created_at 2024-01-01) AS order_cnt FROM users u WHERE u.created_at 2023-01-01;users表有约30万行orders表有约800万行。乍一看这是个典型的SELECT后放相关标量子查询的写法外层经过时间过滤后大约还有15万行意味着内层子查询要被触发15万次。orders表上user_id有索引但created_at上没有索引导致每次内层查询还要做一次时间范围过滤。这不是某个特定环境的偶发问题而是这类写法在数据量上来之后的必然结果。相关子查询的逐行执行模型注定了它扛不住大的外层行数。5.2 排查链路与定位手段第一步EXPLAIN看执行计划。输出里能看到关联子查询被标记为DEPENDENT SUBQUERY这直接说明了执行模型是逐行执行的。同时还要看type列如果出现了ALL说明在走全表扫描性能必然崩。第二步拆解单次内层查询的成本。手工执行SELECT COUNT(*) FROM orders o WHERE o.user_id 12345 AND o.created_at 2024-01-01;发现单次执行也要几十毫秒因为created_at上没有索引加时间条件后走的是user_id索引回表再过滤日期。单次几十毫秒乘以15万次就是几十分钟级别。这条查询跑了20秒说明实际外层过滤比预想多或者MySQL做了部分缓存但方向已经很明确了。第三步查看整体数据分布。SELECT COUNT(*), COUNT(DISTINCT user_id) FROM orders确认每个用户平均订单数以及2024年有订单的用户占比这能帮助判断改写方向。5.3 修复方案与验证方案一给orders表加复合索引(user_id, created_at)。这一步能把单次内层查询从几十毫秒压到几毫秒因为索引已经覆盖了关联字段和过滤字段不需要回表。ALTER TABLE orders ADD INDEX idx_user_created (user_id, created_at);方案二把相关子查询改写为先聚合、再关联的非关联查询SELECT u.id, u.name, u.status, COALESCE(t.order_cnt, 0) AS order_cnt FROM users u LEFT JOIN ( SELECT user_id, COUNT(*) AS order_cnt FROM orders WHERE created_at 2024-01-01 GROUP BY user_id ) t ON u.id t.user_id WHERE u.created_at 2023-01-01;改写后的执行模型完全变了子查询只执行一次先按用户分组统计然后外层LEFT JOIN。关键点在于每个用户只对应一行聚合结果所以不会产生重复数据。加不加COALESCE取决于业务上是否区分没有订单和订单数为0。两个方案都验证后查询从20秒降到了0.3秒左右。而且LEFT JOIN改写之后EXPLAIN里看不到DEPENDENT SUBQUERY了出现的是DERIVED。这意味着主查询变成了派生表关联的结构MySQL可以用hash join或索引关联把数据批量处理而不是逐行反复执行子查询。这条排查给我的经验是遇到慢查询先看执行计划确认执行模型再改写法。很多时候不是你写的SQL不对而是它触发了一个不适合当前数据量的执行模型。模型选对了性能立刻就稳定了。6. 子查询的边界限制与高频报错6.1 语法层面的硬限制MySQL对子查询有一些语法边界如果你不知道这些边界报错会来得莫名其妙而且搜索引擎也不一定能直接搜到答案。第一个硬限制是From子句里的派生表不能直接引用外层查询的列。标准SQL里FROM子句的子查询是在外层查询执行前物化的它看不到外层表的字段。如果你试图在派生表里写WHERE o.user_id u.idMySQL会直接报错提示找不到列。这个问题在MySQL 8.0里有了LATERAL关键字可以解决见下一节。第二个硬限制是标量子查询只能返回一行一列。多行报错Subquery returns more than 1 row多列报错Operand should contain 1 column(s)。这两个错几乎每个写子查询的人都会遇到。第三个硬限制在某些版本的MySQL里IN子查询的ORDER BY和LIMIT不生效或者结果不符合预期。MySQL 5.6之前WHERE col IN (SELECT ... ORDER BY ... LIMIT ...)本身就不被优化器保证语义。如果你真想对子查询结果排序后取前几条更稳妥的写法是把子查询放到FROM里做派生表再JOIN。第四个硬限制UPDATE/DELETE不能直接查询目标表前面已经详细说过要包一层派生表。6.2 高频报错的成因与解决我整理了一个高频报错清单按出现频率排序每个都是真实踩过的坑报错信息成因解决方案Every derived table must have its own aliasFROM子查询没写别名给派生表加别名Subquery returns more than 1 row标量子查询返回多行加聚合、加LIMIT、转化为INOperand should contain 1 column(s)行子查询前用了单值比较检查比较运算符两侧列数You cant specify target table ... for update in FROM clause同表UPDATE/DELETE包一层派生表Unknown column u.id in where clause派生表引用外层列改用LATERAL或JOIN改写Illegal reference to a field of the parent query某些旧版本不支持相关子查询的特定引用改写衍生方式遇到报错我的排查顺序是先确认子查询的位置再确认返回结果的形状最后确认是不是引用了不该引用的外层字段。80%的子查询报错都可以靠这三步定位。6.3 LATERAL派生表与MySQL 8.0的新选择MySQL 8.0.14引入了LATERAL派生表它解决了一个长期痛点FROM子句里的派生表无法引用前面表的列。有了LATERAL你可以在派生表里写关联子查询让每行用户都携带最近N条订单这个需求变得非常优雅。SELECT u.id, u.name, r.total_amount, r.created_at FROM users u JOIN LATERAL ( SELECT order_id, total_amount, created_at FROM orders o WHERE o.user_id u.id ORDER BY created_at DESC LIMIT 3 ) r ON TRUE;这个查询对每个用户执行一次LATERAL派生表取最近的三笔订单。注意几点第一LATERAL必须出现在子查询和FROM关键字之间第二子查询里可以引用前面已经出现的表这里是u第三外层用了JOIN LATERAL ... ON TRUE实际上是交叉关联。如果用传统写法你会怎么实现每个用户最近三笔订单先给订单按用户分组排序再想办法取每组前三条。如果不用窗口函数就得写一堆复杂的关联子查询甚至用变量。LATERAL对这种逐行关联聚合的需求是一个很自然的表达方式。当然它的执行模型依然是逐行所以性能上依然要关注内层索引不要觉得用了LATERAL就万事大吉。除了LATERALMySQL 8.0还改善了优化器的物化策略很多子查询会被自动物化后走hash join性能比以前稳定不少。但这也带来一个新问题物化临时表要占内存和磁盘超大子查询结果集会挤压buffer pool。遇到子查询返回几十万行结果的场景还是要反过来想想是不是在业务逻辑上就能先收敛数据量。我现在的习惯是写任何子查询之前先在草稿上画两笔数据量外层大概多少行子查询大概多少行两边字段的索引是什么。数据量小的项目怎么写都行数据量上去了一套固定的先EXPLAIN、再确认执行模型、按需改写的流程远比我记一堆IN快还是EXISTS快的口诀可靠得多。子查询不是洪水猛兽也不是银弹它就是SQL世界里的普通工具用对位置它能让你的查询设计清晰一大截。