搞数据库查询的,十有八九都绕不开连接和子查询这两座大山。不管是刚写SQL的新手,还是做了几年开发的老人,面试也好,日常写报表也好,总会碰到“这数据怎么查不出来”“这个结果怎么多了好多重复行”之类的问题。很多问题的根源,说到底就是内连接、外连接、子查询这几种写法没吃透,脑子里没有一张清晰的执行逻辑图。
这篇文章就围绕MySQL里的内连接、外连接和子查询,从原理讲到实操,再讲到排查问题的方法。我会用大量的示例脚本和结果来演示,尽量把脚本里的每一个细节都说清楚,包括“为什么要这么写”“这个条件放在哪更合适”“为什么这个查出来的数据跟想象的不一样”。无论你是刚接触SQL的初学者,还是刷面试题准备跳槽的开发者,只要跟着这些例子手动敲一遍,相信你对关联查询的认知会有一个质的提升。
1. 关联查询为什么要分这么多写法
1.1 表与表之间是怎么“关联”起来的
先想一个最经典的例子:订单表,用户表。订单表里有user_id,用户表里有id和name。你要查“每个订单对应的用户名”,靠一张表做不到,因为用户名存在用户表里,订单金额存在订单表里。这时候就需要把两张表拼在一起看。
拼表这件事,关系型数据库做了几十年,核心思想就叫“连接”。连接的条件,就是两张表里的某个字段值相等,比如orders.user_id = users.id。这个相等的字段,就是所谓的“关联键”。
但是问题来了:并不是每一条订单都能在用户表里找到对应的用户。比如用户注销了,数据被删了,或者当年录入订单的时候压根就没填user_id。如果我只想查出那些“能对上号”的订单,那就用内连接。如果我想把“所有订单都列出来,没用户名的就显示NULL”,那就得用外连接。这就是两种写法的本质区别:你到底要不要保留那些不匹配的行。
光有这个概念还不够,实际写SQL时还要搞清楚主语是谁。这就引出了“驱动表”和“匹配表”的角色分配。驱动表就是你要保住的那一方数据,匹配表就是拿过来补充信息的。内连接里两边地位平等,外连接里地位就不平等了。后面我会用实际数据说明这个问题。
1.2 连接查询、子查询各自解决什么问题
连接查询处理的是“多张表的行与行之间横向拼接”,一个订单对应一个用户,拼成一行更宽的结果。而子查询处理的是“把一张表的统计结果,当作另一张表的查询条件”,属于纵向的嵌套逻辑。
说出来你可能不信,这俩写法在不少场景下是可以互替的。查“购买过商品的用户列表”,既可以用IN子查询,也可以用内连接加DISTINCT。但在另一些场景下又不能乱换。比如你想查“每个用户的最后一笔订单”,用子查询找max时间,再用连接把整行带出来,这思路很清楚;如果你非要用纯连接硬拼,反而容易把数据搞重复。
所以我的建议是:两种写法都必须掌握,而且要理解它们底层的执行方式。连接本质上是对两张表做“嵌套循环匹配”,子查询在某些优化器里会改写为连接,某些场景又会被当成独立查询先执行一遍。你把原理搞通了,以后看执行计划才不会一头雾水。
2. 内连接:只取两边都能对上的数据
2.1 内连接语法与执行逻辑
内连接的关键字是INNER JOIN,也可以简写成JOIN。标准语法是:
SELECT 字段列表 FROM 表A INNER JOIN 表B ON 表A.关联键 = 表B.关联键 WHERE 其他过滤条件;执行逻辑用一句话概括:遍历表A的每一行,拿着关联键去表B里找匹配的行,找到就拼成一行放进结果集,找不到就丢掉这一行。
这里有一个特别容易搞混的点:内连接的ON条件和WHERE条件,放在一起写还是分开写,结果通常是一样的。但语义上要分清,ON管的是“怎么拼”,WHERE管的是“拼完之后要谁”。你完全可以先JOIN再过滤,也可以先过滤一部分再JOIN,MySQL优化器会自动做等价改写。真正纠结这两个条件顺序的场景,在外连接里才会体现。内连接阶段,你只要记住“结果集只包含两边都匹配的行”就够了。
早年还有一种老写法,直接在FROM后面把表用逗号隔开,再在WHERE里写关联条件:
SELECT * FROM orders, users WHERE orders.user_id = users.id;这种写法就是隐式内连接,现在不推荐了。因为ON条件和过滤条件全混在WHERE里,表多了以后你根本分不清哪句是关联条件,哪句是过滤条件。规范项目里一律用INNER JOIN加ON。
2.2 内连接实操示例
我先建两张简单的表,后面所有例子都靠它们演示。
CREATE TABLE users ( id INT PRIMARY KEY, name VARCHAR(50) ); CREATE TABLE orders ( id INT PRIMARY KEY, user_id INT, amount DECIMAL(10,2) ); INSERT INTO users VALUES (1, '张三'), (2, '李四'), (3, '王五'); INSERT INTO orders VALUES (101, 1, 99.00), (102, 2, 199.00), (103, 5, 299.00);注意看orders表里的第三条记录,user_id是5,但users表里没有id=5的人。这就是典型的“孤儿数据”。
现在执行内连接查询:
SELECT orders.id AS order_id, users.name, orders.amount FROM orders INNER JOIN users ON orders.user_id = users.id;结果只有两条记录:
order_id | name | amount 101 | 张三 | 99.00 102 | 李四 | 199.00订单103因为对不上用户,被内连接直接丢弃了。这在内连接里是正常行为。如果你希望这条记录还是显示出来,就要换外连接,这个下一节说。
另一个常见的用法是三表甚至四表连接。比如再加一张商品表order_items,每个订单包含多个商品,你想查“订单、用户、商品明细”的整合信息。写起来就是在JOIN后面继续JOIN:
SELECT u.name, o.id AS order_id, oi.product_name, oi.quantity FROM orders o INNER JOIN users u ON o.user_id = u.id INNER JOIN order_items oi ON oi.order_id = o.id;这里每个人和订单匹配一次,再和订单里的商品明细匹配一次。如果这个订单有3个商品,最终就会拼出3行。这是正常的,因为连接的结果本来就是“行数 = 左侧匹配行数 × 右侧匹配行数”的笛卡尔积过滤后的产物。
2.3 内连接实战中的三个细节
细节一:字段歧义问题。多张表联查时,如果两张表都有id字段,SELECT里直接写id,数据库不会报错,但容易拿错字段。规范做法是给表起别名,所有字段用别名限定。上面的例子我全用了别名u、o、oi,就是这个原因。
细节二:关联键的类型要一致。users.id是INT,orders.user_id也是INT,匹配很顺利。如果一个是VARCHAR一个是INT,MySQL会做隐式类型转换,转换后如果索引失效,性能就会出问题。我们在建表时保持字段类型一致,省掉后续一堆麻烦。
细节三:内连接结果可能膨胀。如果你关联的右侧表里存在重复数据,结果就会翻倍。比如用户表里id=1的记录有两条,那订单101就会拼出两行完全一样的结果。这不是MySQL的错,是数据本身不规范导致的。定位这种问题时,可以用COUNT(*)看行数,对比一下两张表的唯一键是否存在重复,立刻就能看出端倪。
3. 外连接:我要留住一边的数据
3.1 左连接与右连接的区别
外连接分三种:LEFT JOIN、RIGHT JOIN、FULL JOIN。MySQL原生不支持FULL JOIN,这个后面单独讲。
LEFT JOIN的语义是:左表的每一行都保留,右表能匹配上就拼数据,匹配不上就补NULL。RIGHT JOIN反过来,右表全保留,左表去匹配。实际工作中绝大多数场景都是用LEFT JOIN,因为我们的业务主语一般是“左侧的查询主表”,比如查所有订单时按订单做主语,查所有用户时按用户做主语。RIGHT JOIN理论上能力等价,但写出来不符合自然思维习惯,所以出现了也建议你改成LEFT JOIN再加换边,方便别人阅读。
外连接的执行逻辑,你可以这样记:
- 先拿左表的一行。
- 到右表里找匹配行。
- 找到就拼,找不到就左边数据原样留着,右边所有字段填NULL。
- 一直把左表所有行都遍历完。
说白了,外连接就是在内连接的基础上,“额外加回了左侧没被匹配上的孤行”。
3.2 外连接实操示例
还是用刚才的users和orders表,执行:
SELECT u.id AS user_id, u.name, o.id AS order_id, o.amount FROM users u LEFT JOIN orders o ON o.user_id = u.id;结果是这样的:
user_id | name | order_id | amount 1 | 张三 | 101 | 99.00 2 | 李四 | 102 | 199.00 3 | 王五 | NULL | NULL王五没有订单,但他还是出现在结果里了,order_id和amount都是NULL。这在统计客户时特别好用。比如你查“哪些用户没有下过单”,就可以在LEFT JOIN后面加一句WHERE o.id IS NULL:
SELECT u.id, u.name FROM users u LEFT JOIN orders o ON o.user_id = u.id WHERE o.id IS NULL;这个写法能查出所有没下过单的用户。也许你会说,用NOT EXISTS子查询也能做。没错,两种都行。但LEFT JOIN加NULL判断,在某些数据量下会比NOT EXISTS更直观,执行计划则有可能会把NOT EXISTS优化成反连接,效率更高。具体用哪个,建议先看数据量和执行计划再决定。
再看一版“订单做主语”的左连接:
SELECT o.id, o.amount, u.name FROM orders o LEFT JOIN users u ON o.user_id = u.id;结果是:
id | amount | name 101 | 99.00 | 张三 102 | 199.00 | 李四 103 | 299.00 | NULL订单103是孤儿订单,但依然被保留,用户名显示为NULL。这种“保留孤儿订单”的需求,在财务对账场景里非常常见。
3.3 左右连接变体与FULL JOIN的处理
实际业务里光有LEFT JOIN和RIGHT JOIN还不够。比如你想把“没下过单的用户”和“找不到用户的订单”同时列出来,那就需要FULL JOIN。MySQL没有这个关键字,但可以用UNION把LEFT JOIN和RIGHT JOIN的结果合并在一起:
SELECT u.id AS user_id, u.name, o.id AS order_id, o.amount FROM users u LEFT JOIN orders o ON o.user_id = u.id UNION SELECT u.id, u.name, o.id, o.amount FROM users u RIGHT JOIN orders o ON o.user_id = u.id;重点来了:这两条记录刚好能覆盖三种情形——两表匹配的行、左表独有的行、右表独有的行。UNION会自动去重,所以两边都匹配上的行只会出现一次。
不过说实话,日常开发里FULL JOIN用得极少。你需要它的时候,往往意味着你正在做一个对账或者数据对比任务。如果数据量特别大,用UNION合并两个查询可能效率不高,这时候可以考虑把两张表的完整键集合先查出来,再分别LEFT JOIN,效率会更好。
3.4 外连接查询最容易踩的坑
外连接最大的坑,就是把你以为的“过滤条件”放进ON里或者WHERE里,结果完全不一样。
看这个例子:
SELECT u.id, u.name, o.amount FROM users u LEFT JOIN orders o ON o.user_id = u.id AND o.amount > 100;它表达的意思是:用户表全保留,订单表里只拿金额大于100的订单来匹配。所以张三的99元订单不会显示,但张三本人还是会显示出来,amount是NULL。
改一下,把金额条件挪到WHERE里:
SELECT u.id, u.name, o.amount FROM users u LEFT JOIN orders o ON o.user_id = u.id WHERE o.amount > 100;这句话就变成了:先把所有用户和所有订单匹配好,然后过滤掉金额小于等于100的行。因为金额是NULL的行也不会大于100,所以张三直接被过滤掉了,结果里根本没有张三这个人。
这两种写法,差之一行,失之千里。我见过很多次线上报表数据不一致,最后排查下来就是有人把条件写到了ON里,另一个人写到了WHERE里。记住这个原则:
- ON子句里的额外条件,影响的是“右表拿哪些数据来匹配”。
- WHERE子句里的条件,影响的是“最终结果集保留哪些行”。
只要搞清这一点,外连接就不会再出那种莫名其妙的“数据少了”的问题。
4. 子查询:查询里面再套一层查询
4.1 子查询的三种位置
子查询有三种写法,位置分别对应SELECT后面、FROM后面、WHERE后面。
WHERE后面最常见。典型场景是:查出“下过单的用户”。SQL写出来就是:
SELECT name FROM users WHERE id IN (SELECT DISTINCT user_id FROM orders);这里面子查询先执行还是外层先执行,不同版本MySQL处理不一样。旧版本经常会先把子查询结果集物化,再去外层匹配。新版本优化器常常会把IN子查询改写成半连接,效率提升非常明显。所以你不用太纠结执行顺序,但要知道它可能被优化,别把子查询写得过于复杂,免得优化器改写不出等价语句。
SELECT后面的子查询叫标量子查询,它只能返回一行一列。典型用法是查“每个用户的下单总金额”:
SELECT u.id, u.name, (SELECT SUM(o.amount) FROM orders o WHERE o.user_id = u.id) AS total_amount FROM users u;FROM后面的子查询叫派生表。你把一个查询结果当作一张临时表,再和别的表连接。典型场景:先聚合,再关联明细。比如“先按用户算出总金额,再关联用户基本资料”:
SELECT u.name, t.total_amount FROM ( SELECT user_id, SUM(amount) AS total_amount FROM orders GROUP BY user_id ) t INNER JOIN users u ON t.user_id = u.id;注意派生表必须有别名,上面这个t就是别名。没写别名,MySQL会直接报错。
4.2 相关子查询与非相关子查询
子查询还分相关子查询和非相关子查询,这个概念很多人容易忽略。
非相关子查询:子查询里的条件不依赖外层查询的字段。比如:
SELECT AVG(amount) FROM orders;它独立执行,结果固定。
相关子查询:子查询里的条件引用了外层查询的字段。比如:
SELECT u.id, u.name, (SELECT MAX(o.amount) FROM orders o WHERE o.user_id = u.id) AS max_order FROM users u;子查询里的o.user_id = u.id,引用了外层u表的字段。这意味着外层每一行都要取出来,拿去子查询里执行一次匹配。如果外层有十万用户,子查询就要被评估十万次。性能上往往不如提前用GROUP BY算出结果再JOIN。数据量小时无所谓,数据量一大,相关子查询的执行计划会告诉你什么叫“慢”。
我提供两个衡量方法:
- 用EXPLAIN看有没有DEPENDENT SUBQUERY。看到这个type,就要小心了。
- 用数据量对比测试。比如一张10万用户的表,相关子查询可能跑十几秒,改写为JOIN后用索引,可能几百毫秒就结束。
所以,相关子查询不是不能写,而是你心里要有一根弦:它能解决问题,但可能是性能陷阱。
4.3 子查询性能优化的最佳实践
子查询优化有两条常见路径:
路径一:把WHERE IN子查询改写为JOIN。比如查“下过单的用户”:
SELECT DISTINCT u.id, u.name FROM users u INNER JOIN orders o ON o.user_id = u.id;JOIN的优化空间通常比IN子查询更大,因为优化器可以做更好的连接顺序选择。
路径二:把FROM子查询中的聚合结果先算好,再用JOIN关联。这种思路往往比相关子查询快很多,因为内层聚合只需要执行一次。
但你也不要陷入“子查询一定慢”的误区。MySQL的优化器在8.0版本做了大量改进,很多子查询可以被改写成高效的半连接或物化表。实际项目中,我见过IN子查询跑得比JOIN还快的情况,主要是JOIN把数据行数膨胀了,然后再去重,反而更慢。
所以最优做法永远是根据EXPLAIN结果说话。先写出语义自己最清楚的版本,再测性能,发现慢再改写。不要一开始就为了“性能好”写出晦涩难懂的SQL,那会让后来维护项目的人苦不堪言。
4.4 EXISTS和NOT EXISTS的典型用法
EXISTS和NOT EXISTS专门用来判断“是否存在”,它不关心拿回什么数据,只关心有没有匹配行。典型场景是查“有订单的用户”:
SELECT u.id, u.name FROM users u WHERE EXISTS ( SELECT 1 FROM orders o WHERE o.user_id = u.id );这里子查询里写SELECT 1而不是SELECT *,是一个性能习惯。因为EXISTS只关心行是否存在,写哪个字段都行,写1还能让对方读代码的人知道你没打算用这个结果。
NOT EXISTS则是查“没有订单的用户”:
SELECT u.id, u.name FROM users u WHERE NOT EXISTS ( SELECT 1 FROM orders o WHERE o.user_id = u.id );和NOT IN子查询相比,NOT EXISTS有一个明显优势:如果子查询结果里有NULL值,NOT IN会直接失败,导致查询结果为空。这个坑很多初学者踩过。用NOT EXISTS就完全不用担心NULL问题,因为NULL被视为“没有匹配行”。
5. 连接查询与子查询的实战对比与选型
5.1 同一需求,不同写法的对比
我经常在项目里做的事情,就是把同一个需求用三种写法写出来,然后对比结果和执行计划。这里演示一个经典需求:查每个用户最近一笔订单的金额。
写法一:相关子查询。
SELECT u.id, u.name, (SELECT o.amount FROM orders o WHERE o.user_id = u.id ORDER BY o.id DESC LIMIT 1) AS latest_amount FROM users u;写法二:先查最大订单id再关联。
SELECT u.id, u.name, o.amount FROM users u LEFT JOIN orders o ON o.id = ( SELECT MAX(o2.id) FROM orders o2 WHERE o2.user_id = u.id );写法三:窗口函数(MySQL 8.0及以上)。
SELECT id, name, amount FROM ( SELECT u.id, u.name, o.amount, ROW_NUMBER() OVER (PARTITION BY u.id ORDER BY o.id DESC) AS rn FROM users u LEFT JOIN orders o ON o.user_id = u.id ) t WHERE rn = 1;这三种写法语义都一样,但执行计划和代码可读性差别很大。写法一很直观,但每行用户都要执行一次子查询,数据量大了性能堪忧。写法二用子查询找最大值,再关联,效率相对高。写法三用了窗口函数,逻辑最优雅,但要求数据库版本是8.0以上。
我个人的判断标准是:数据量小,怎么清楚怎么写;数据量大,先看执行计划,再做性能测试。不要只凭经验拍脑袋说某一种一定最快。
5.2 连接查询的效率关键:索引
JOIN查得慢,大部分时候不是SQL写错了,而是关联键上没有索引。拿前面orders和users的连接来举例,users.id是主键,有主键索引,所以内连接时拿orders.user_id去users表回表匹配,非常快。但如果你从users出发LEFT JOIN orders,驱动表是users,匹配表是orders,那么orders.user_id上如果没有索引,每匹配一个用户都要全表扫描一次orders,性能就会直线下降。
解决方法就是给外键字段建索引:
CREATE INDEX idx_orders_user_id ON orders(user_id);建议建完索引再看EXPLAIN,如果看到type=ref而不是type=ALL,说明索引生效了。ALL是全表扫描,ref是通过普通索引等值匹配,这两者性能差距可能达到几个数量级。
索引对子查询同样重要。IN子查询里的子查询如果查的是order.user_id,没索引也会导致扫描大表。总之,关联字段的索引是查询性能的生命线,这是无论多少SQL技巧都绕不过去的基础。
5.3 什么时候用连接,什么时候用子查询
这是一个经常被问到的问题,我把我的选择原则梳理一下。
- 如果目标是补全信息,也就是“A表有的我都要,B表能对上就带出来”,优先用LEFT JOIN。这种场景里子查询实现起来也别扭。
- 如果目标是过滤,也就是“只要在B表出现过/没出现过的人”,优先用EXISTS、NOT EXISTS或者IN,看情况用JOIN加DISTINCT。
- 如果目标是聚合统计,优先用GROUP BY先聚合,再JOIN带明细,避免在子查询里做逐行相关计算。
- 如果目标是取“每X的最近一条/最大一条”,优先用窗口函数或者“先找最大值再关联”。
我还想补一句:看了太多人为了显示自己会子查询,强行在能用JOIN的地方用相关子查询,结果执行计划里全是DEPENDENT SUBQUERY,查询慢得被人投诉。写SQL的第一目标永远是清晰,然后是性能。能把代码写得既清晰又快,才是真的熟练。
6. 常见报错与排查经验速查表
6.1 列名不明确导致的报错
报错信息一般是:Column 'xxx' in where clause is ambiguous。意思是某个字段在多张表里都存在,你没指定到底用哪一张表的。
解决方案就是起别名限定字段。比如在SELECT、WHERE、ON中,所有字段都加上表别名前缀,不要偷懒。这样还能提高代码可读性,两全其美。
6.2 子查询返回多行导致的报错
错误信息是:Subquery returns more than 1 row。这个场景通常发生在标量子查询里。你在SELECT后面写了一个子查询,期待它返回一个值,但实际返回了两行。MySQL没法定夺该显示哪个,只能报错。
解决的办法是:确认业务本来就是“每个外层行只对应一个值”,然后加上GROUP BY或者LIMIT 1,或者改用JOIN + 聚合函数。
6.3 派生表没有别名导致的报错
错误信息是:Every derived table must have its own alias。就是FROM后面的子查询没取别名。记住,派生表后面必须跟一个别名,哪怕你压根用不到它,也要写。哪怕写成t、tmp都行。
6.4 连接查询结果重复问题
排查步骤就三步。
第一步:确认两张表的关联键是否有重复值。如果有,结果自然会翻倍。先用GROUP BY关联键查出重复记录,确认数据。
第二步:确认是不是因为多对多关系导致的重复。订单和商品明细就是典型多对多,一个订单匹配多个商品明细,这属于预期行为,不是Bug。
第三步:确认是不是LEFT JOIN时右表恰好有多条匹配记录,导致左表本来一行结果被撑成多行。这种情况如果不需要明细,考虑用GROUP BY聚合,或者用窗口函数去重。
6.5 外连接与条件位置问题
前面已经详细讲过了,ON和WHERE位置不同会导致结果集差异。排查时,先把查询语句里的表关系画出来,标清哪些条件是“匹配时用”的,哪些是“结果集过滤”的,再决定写在ON还是WHERE。一旦你的SQL风格固定下来,团队内代码Review也会更顺畅。
7. 从使用技巧到查询思维:我的一些经验总结
写了这么多年SQL,我最大的感受是:内连接、外连接、子查询这些东西,单独拎出来讲都不难,难的是把它们放到真实业务里组合运用。一个复杂报表,通常先要用子查询把各层数据算好,再用JOIN把它们拼起来,中间穿插聚合和过滤条件。真正考验人的不是记住语法,而是能不能把一个业务问题拆解成一步步的集合操作。
我平时拿到一个查询需求,不会马上写SELECT,会先干三件事:
第一件事,搞清楚主表是谁。也就是“我要以哪个表的数据为准”。比如“所有用户”就是users表,“所有订单”就是orders表。主语定了,JOIN的方向基本就定了。主表放左边,用LEFT JOIN;两边都要匹配,用INNER JOIN。
第二件事,摸清表和表之间的关系。是一对一、一对多还是多对多。一对多时,连接结果的行数会变多,要提前考虑到聚合或去重。
第三件事,理清过滤条件分别在哪个阶段生效。是JOIN之前过滤,还是JOIN之后过滤。这个决定了条件写在内层子查询还是外层WHERE。如果你在写的时候就能预判到数据的行数和形态,那么最终SQL的准确率会高出不少。
还有一个经验是:宁可多写几个中间步骤,也不要一口气写一个超复杂的SQL。MySQL支持CTE(公用表表达式),8.0版本可以用WITH语句把逻辑拆成一段一段的,可读性提升特别明显:
WITH user_orders AS ( SELECT user_id, COUNT(*) AS cnt, SUM(amount) AS total FROM orders GROUP BY user_id ) SELECT u.id, u.name, COALESCE(uo.cnt, 0) AS order_count, COALESCE(uo.total, 0) AS total_amount FROM users u LEFT JOIN user_orders uo ON uo.user_id = u.id;这段SQL用WITH先算出每个用户的订单统计,再LEFT JOIN到用户表上。逻辑非常清晰,而且CTE在MySQL里可以定义多个,相当于给一段查询起了个名字,后续还能复用。遇到那种动辄七八个表嵌套的报表查询,用CTE来拆解,比让后续维护的人在一堆括号里猜逻辑要友好太多了。
最后再分享一个我在团队里反复强调的习惯:写过SQL以后,一定要EXPLAIN一下,重点看三样东西——type是不是ALL、key有没有用上索引、rows扫描了多少行。type出现ALL通常意味着全表扫描,数据量大时就要警惕了;key是NULL说明索引没生效;rows如果远超预期,说明查询路径绕了远路。EXPLAIN这几列看懂了,大部分查询性能问题都能在发布之前拦住,省得上了线被用户骂“页面转半天”。
内连接、外连接、子查询,说到底就是关系型数据库最核心的三种集合操作思路。把它们练成肌肉记忆,写任何查询都能稳得住。这篇内容覆盖了语法、原理、实操、优化和排查,如果你能照着示例自己建表跑一遍,再试着把我提到的几种等价改写都执行一遍对比结果,你收获的绝对不只是几种写法,而是一套更接近数据库本质的思考方式。