尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

EXISTS子查询实战:从NOT IN陷阱到SQL性能优化

EXISTS子查询实战:从NOT IN陷阱到SQL性能优化 做了这么多年的数据开发和后端我发现自己写SQL时最怕遇到的不是复杂的窗口函数和存储过程反而是那种“看起来简单一跑就翻车”的存在性判断需求。比如“找出所有没下过单的用户”“筛选出在售且有库存的商品”“判断某张订单在审核前有没有被修改过”——这种需求十有八九会有人在SQL里先写一个NOT IN然后数据对不上或者性能慢到被DBA点名。其实这类“存在还是不存在”的问题正确解法往往就是EXISTS子查询。标题里说的EXISTS看起来只是SQL语法里的一个小关键词但它背后的子查询机制牵扯到关联查询、半连接优化、NULL值陷阱、慢SQL改写等一系列知识点。今天我就从判定逻辑讲到实际场景再配合大量踩坑经验把EXISTS子查询一次讲透。1. EXISTS子查询的工作机制先搞清楚它到底在做什么1.1 一条EXISTS语句的执行顺序很多初学者第一次见到EXISTS的写法都会被它的“返回值”搞糊涂。你问他EXISTS返回了什么他可能会回答“返回了子查询的结果集”。这是最常见的误解。EXISTS不返回任何结果集它只返回TRUE或者FALSE。你可以把EXISTS当成一个布尔开关只要子查询里有任意一行数据满足条件开关就打开外层查询就保留当前这一行如果子查询一条数据都查不出来开关就关闭外层当前行就被丢弃。看一条最基础的写法SELECT customer_id, customer_name FROM customers c WHERE EXISTS ( SELECT 1 FROM orders o WHERE o.customer_id c.customer_id );这段SQL的意图是找出所有下过订单的客户。执行时数据库并不会先把子查询的结果集算出来然后“缓存”起来而是对外层的每一行客户记录单独执行一次子查询判断这个客户在orders表里能不能匹配到至少一条订单。能匹配上这行客户数据就进入结果集匹配不上就不要。我第一次看这个执行逻辑时脑子里浮现的画面是一个流水线工人他手里拿着一个客户名单挨个走到orders表前面翻订单翻到一条就算过关翻完整个表都没有就划掉。这就是EXISTS最底层的运行方式——逐行代入、逐行判断。1.2 关联子查询与非关联子查询刚才的例子用了o.customer_id c.customer_id这个条件把子查询和外层的客户表关联起来了这种写法的学名叫“关联子查询”。关联是EXISTS最常见的用法它的核心逻辑就是外层每一行都要“带着自己的值”去子查询里比对一次。但EXISTS并不是一定要关联外层。再看一个例子SELECT product_name FROM products p WHERE EXISTS ( SELECT 1 FROM inventory i WHERE i.warehouse_id 3 AND i.stock 0 );这段SQL里子查询跟外层products表没有任何关联条件子查询的结果对所有产品都是一样的——如果仓库3有库存则所有商品都会被查询出来如果没有则一律查不出来。这种写法叫“非关联子查询”实际开发中很少单独使用因为它本质上是把外层表整体保留或整体过滤起不到精细筛选的作用。真正体现EXISTS价值的一定是关联子查询。用关联条件把内外两层表绑在一起外层有多少行子查询理论上就会被执行多少次当然优化器会做缓存和优化不至于真的傻傻执行N次每一行都拿到独立的判断结果。我自己的经验是在阅读一条EXISTS子查询时先找到内外层之间的关联条件再看子查询里其他过滤条件最后回到外层看整个查询的意图阅读效率会高很多。1.3 为什么SELECT 1和SELECT *效果一样很多人在写EXISTS的时候喜欢在子查询里写SELECT *我早期也是这么干的后来被同事提醒在EXISTS子查询里SELECT后面的列表写什么根本不重要。因为EXISTS只关心“有没有行”不关心行的内容。不管你是SELECT 1、SELECT NULL、SELECT id还是SELECT *返回一行就判定TRUE返回零行就判定FALSE。数据库优化器在识别出这是EXISTS子查询后甚至不会真正去取SELECT列表里的字段值它只需要判断“是否存在满足WHERE条件的行”即可。所以我在规范团队SQL的时候会要求统一写成SELECT 1。这有两点考虑一是语义清晰明确告诉读代码的人这里只做存在性判断不是要取数据二是能给优化器省点力气尤其在老版本数据库里写SELECT *有可能会触发不必要的回表或全字段解析虽然现代数据库大都做了优化但习惯养好总没错。2. 场景选型EXISTS和IN到底怎么选2.1 语义差异值匹配 vs 行存在性有EXISTS的地方基本就有IN和它对比。很多业务场景用IN也能写比如“找出下过单的客户”SELECT customer_id, customer_name FROM customers WHERE customer_id IN ( SELECT customer_id FROM orders );看起来和EXISTS版本结果差不多但两者语义有本质区别。IN做的是“值的包含关系判断”它先把子查询的结果集一批customer_id计算出来然后判断外层每条记录的customer_id是否在这个集合里EXISTS做的是“行的相关性判断”它判断的是外层每一行和子查询之间是否存在匹配的行。再往深一层说IN的右侧子查询一旦涉及多列就要写成元组形式比如(a, b) IN (SELECT x, y FROM ...)而EXISTS根本不存在“多列匹配”的概念因为它在子查询WHERE里可以用任意复杂的条件来定义“匹配”。有一个非常经典的场景能体现两者差异判断“两张表数据是否完全相同”。同一组业务数据分别存在两个环境里要找出A表有而B表没有的记录我会这样写SELECT * FROM table_a a WHERE NOT EXISTS ( SELECT 1 FROM table_b b WHERE a.id b.id AND a.name b.name AND a.price b.price );这种“按多列同时匹配”的需求用IN写就很别扭而EXISTS因为是在子查询里写关联条件可以随意铺开多个字段、多个逻辑判断写起来顺手得多。2.2 NULL值陷阱NOT IN翻车现场聊到IN就绕不开一个所有人迟早会踩的坑NULL值。先说结论只要子查询返回的结果集里包含NULL那么NOT IN的结果大概率就是“空集”也就是一条数据都查不出来。这不是数据库抽风而是SQL三值逻辑TRUE、FALSE、UNKNOWN的必然结果。举个具体例子。要查“所有没有下过单的客户”SELECT customer_id, customer_name FROM customers WHERE customer_id NOT IN ( SELECT customer_id FROM orders );如果orders表里有一个订单的customer_id是NULL脏数据、手动录入遗漏等情况那么这个NOT IN的结果就是空集。不管customers表里有多少客户统统查不出来。为什么因为customer_id NOT IN (1, 2, NULL)等价于customer_id ! 1 AND customer_id ! 2 AND customer_id ! NULL。三值逻辑里任何值跟NULL做比较结果都是UNKNOWNNULL不是“等于”也不是“不等于”而是一个“未知”。整条AND语句里有一个UNKNOWN整个条件就是UNKNOWN因此所有行都被过滤掉了。这个坑在数据清洗时尤其致命——你本来想找出那些“有问题”的数据去修结果因为目标数据集里混入了一个NULL查询直接给你返回空集而你还在那怀疑数据是不是真的没问题了。改用NOT EXISTS就完全没有这个烦恼SELECT customer_id, customer_name FROM customers c WHERE NOT EXISTS ( SELECT 1 FROM orders o WHERE o.customer_id c.customer_id );NOT EXISTS判断的是“子查询是否能匹配到行”NULL不NULL根本不参与这个判断。如果orders里有一个customer_id为NULL的订单它不会跟任何客户ID匹配上所以不影响最终结果。说了这么多我总结一下我自己的选择原则做“存在性判断”优先用EXISTS/NOT EXISTS做“有限值列表匹配”且能保证列表里没有NULL时可以用IN。遇到子查询来自业务表NULL不可控的情况禁止用NOT IN。2.3 性能维度半连接与提前返回性能上EXISTS被很多人推崇的原因是它的“提前返回”特性。EXISTS子查询只要匹配到一行就会立即停止不需要遍历完全部结果集。拿刚才的例子说orders表里某个客户有100张订单用IN的写法会把100条customer_id全查出来放进集合用EXISTS的写法找到这个客户的第一条订单就完成判断立刻进入外层下一行。数据量一大这个差距就是实打实的IO消耗差距。另外现在的数据库优化器处理EXISTS时通常会把它转换成“半连接”SEMI JOIN。半连接的意思是只关心内层表是否有匹配行有就返回外层行内层表即使有100条匹配也只算一次。这种优化在MySQL、SQL Server、PostgreSQL里都有体现表现为执行计划中出现类似NESTED LOOP SEMI JOIN、HASH SEMI JOIN之类的节点。IN在某些数据库里也会被优化成半连接所以“IN会先把子查询结果全部物化”这个说法在现代数据库里并不绝对但在复杂SQL环境比如子查询里带多个条件、带关联列、带聚合下EXISTS通常能获得更稳定的半连接计划。从我实际调优的经验看遇到性能差的IN子查询第一步就是试试改成EXISTS往往有意想不到的效果。2.4 驱动表选择小表驱动大表的经验谈第一次有人跟我说“EXISTS适合小表驱动大表”的时候我愣了半天。这里说的“小表驱动大表”指的是外层表小、内层表大。因为EXISTS是外层一行一行去内层匹配如果外层表就100条记录即使内层表有1亿条只需要做100次“查找”配合上索引每次查找都是微秒级反过来如果外层有1亿条内层只有100条虽然内层匹配很快但外层1亿行的基数本身就是巨大的成本。所以在EXISTS的世界里正确姿势是行数少的结果集放在外层行数多但有索引关联的表放在子查询里。举个实际例子。我要筛选“近30天有购买记录的高价值用户”如果钻石用户表只有5000条订单流水表有8000万条那我会把用户表放外层订单表放子查询配合user_id和购买时间索引跑起来飞快。SELECT u.user_id, u.user_name FROM vip_users u WHERE EXISTS ( SELECT 1 FROM order_flow f WHERE f.user_id u.user_id AND f.order_time DATEADD(DAY, -30, GETDATE()) );反过来如果把8000万条订单流水放外层让子查询去查5000条的VIP表性能一定糟糕。SQL优化的核心之一就是控制驱动顺序而EXISTS这种“外层驱动、内层匹配”的模型天然适合小表做驱动表的场景。3. 实战案例用EXISTS玩转数据清洗与去重3.1 经典去重场景按某字段分组保留最新记录数据清洗里最常碰到的需求就是去重。比如运营导出的用户标签表里同一个用户因为重复参与活动出现了多行我现在要按user_id去重只保留每人的最新一条记录。这个需求很多人第一反应是ROW_NUMBER窗口函数SELECT t.user_id, t.tag, t.update_time FROM ( SELECT *, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY update_time DESC) AS rn FROM user_tag_raw ) t WHERE t.rn 1;窗口函数当然能解决但它有一个潜在问题它会把每个用户的所有重复行都排序一遍如果重复数据特别多中间结果集会很大。在资源紧张的数据清洗场景里另一种方案是用EXISTS来做“只保留每组最新一条”的判断SELECT u.user_id, u.tag, u.update_time FROM user_tag_raw u WHERE NOT EXISTS ( SELECT 1 FROM user_tag_raw u2 WHERE u2.user_id u.user_id AND u2.update_time u.update_time );这个逻辑翻译成大白话是找出那些“在同一个user_id下不存在另一条记录更新时间比自己晚”的行。因为“比自己晚”的行不存在说明自己就是最新的一条。这个写法的好处是它不需要生成完整的排序结果集只要发现有比当前行更新的记录就立刻把这个当前行淘汰掉配合(user_id, update_time)索引性能非常稳定。3.2 孤儿数据排查NOT EXISTS反连接的实际用法“孤儿数据”是数据质量里特别头疼的问题。比如订单表里的customer_id从业务上应该引用客户表但因为删了客户、程序bug、导数据失误等原因可能出现一些customer_id在客户表里根本查不到的情况。这种数据叫“孤儿数据”会直接导致报表join之后行数变少、对账对不上。用NOT EXISTS查孤儿数据是最顺手的SELECT o.order_id, o.customer_id FROM orders o WHERE NOT EXISTS ( SELECT 1 FROM customers c WHERE c.customer_id o.customer_id );跑出来的每一行都是“在客户表里找不到对应客户”的异常订单。我处理过的最离谱一次是某个接口在写入订单时把customer_id存成了字符串“NULL”查了半天才发现这种脏数据用等值JOIN根本发现不了只有拿着NOT EXISTS的清单逐条核对才暴露出来。NOT EXISTS在数据同步场景里更常见。比如做增量同步我要把主库里有、从库里没有的订单同步过去直接一条NOT EXISTS就能捞出来SELECT o.* FROM master_orders o WHERE NOT EXISTS ( SELECT 1 FROM slave_orders s WHERE s.order_id o.order_id );这种用法本质上就是反连接它比LEFT JOIN WHERE IS NULL的写法执行起来更符合“是否存在”的语义代码也更好理解。3.3 删除操作搭配EXISTS的注意点EXISTS还可以用在DELETE语句里做条件删除。比如清理业务数据时要删除“近一年没有任何订单”的客户常规的DELETE配合NOT EXISTS就是DELETE FROM customers c WHERE NOT EXISTS ( SELECT 1 FROM orders o WHERE o.customer_id c.customer_id AND o.order_time DATEADD(YEAR, -1, GETDATE()) );这里有个特别容易踩的坑某些数据库最典型的就是MySQL不允许在DELETE子查询中直接引用目标表的同一张表会报“You cant specify target table for update in FROM clause”之类的错误。我第一次在MySQL里执行类似语句就被这个错误卡了半天。解决方案有几种。一是包一层派生表把子查询结果先封装成临时表结构再关联删除DELETE FROM customers WHERE customer_id NOT IN ( SELECT customer_id FROM ( SELECT DISTINCT o.customer_id FROM orders o WHERE o.order_time DATEADD(YEAR, -1, GETDATE()) ) tmp );二是先查出要删除的ID列表放到临时表/临时结果集里再用EXISTS关联删除DELETE FROM customers c WHERE EXISTS ( SELECT 1 FROM temp_delete_ids t WHERE t.customer_id c.customer_id );删数据是高风险操作我的铁律是DELETE之前先改成SELECT跑一遍确认结果集数量符合预期再在事务里执行DELETE最后确认影响行数再提交。尤其是带EXISTS/NOT EXISTS子查询的删除逻辑链条长稍不留神就会把不该删的数据一起删了。3.4 空值清洗用EXISTS判断“是否存在有效值”数据清洗里除了去重、找孤儿还有一种常见需求是检查字段的有效性。比如一张CRM表里每个客户可能有多个联系电话但很多电话是空字符串、全空格、NULL或者乱码。业务方要求只保留“至少有一个有效联系电话”的客户记录。这种需求本质上是个存在性判断这个客户名下是否存在一条“联系电话非空且看起来有效”的记录。SELECT c.customer_id, c.customer_name FROM customers c WHERE EXISTS ( SELECT 1 FROM customer_contacts cc WHERE cc.customer_id c.customer_id AND cc.phone IS NOT NULL AND LTRIM(RTRIM(cc.phone)) ! AND LEN(cc.phone) BETWEEN 7 AND 20 );注意这里我没有把“通过正则校验手机号格式”写进去因为不同数据库的正则支持差异很大清洗场景下先用长度和空值过滤就能筛掉大部分问题数据。EXISTS在这种场景的真正价值是它天然处理了“一对多”的关系——只要存在任何一条合格数据就算数不用自己去GROUP BY或者聚合拼接。4. 慢SQL救赎从一条报表SQL到EXISTS改写4.1 事故现场LEFT JOIN GROUP BY的糟糕表现有一年我接手一个报表任务需求是统计“每个产品分类下最近30天有成交记录的供应商数量”。第一版报表SQL长这样SELECT p.category_id, COUNT(DISTINCT s.supplier_id) AS supplier_cnt FROM products p LEFT JOIN suppliers s ON s.supplier_id p.supplier_id LEFT JOIN sales_orders so ON so.product_id p.product_id AND so.order_time DATEADD(DAY, -30, GETDATE()) WHERE p.status 1 GROUP BY p.category_id;这条SQL看起来逻辑没毛病实际跑起来要40多秒。explain一看sales_orders表因为要关联product_id和order_time再加上LEFT JOIN导致的笛卡尔式膨胀中间结果集大得吓人最后还要做COUNT(DISTINCT)去重整个SQL光在临时表排序就耗了大半时间。这个场景里问题的根源在于我们用JOIN去“拼接”一个事实判断。我们需要的信息其实是“这个供应商是否有成交记录”而不是“这个供应商产生了多少行明细”。既然是存在性判断就不应该用JOIN把明细全部拉出来再聚合而应该用EXISTS子查询把判断下推到数据源头。4.2 改写对比一段SQL的进化过程我把上面的SQL改成了EXISTS版本SELECT p.category_id, COUNT(DISTINCT s.supplier_id) AS supplier_cnt FROM products p JOIN suppliers s ON s.supplier_id p.supplier_id WHERE p.status 1 AND EXISTS ( SELECT 1 FROM sales_orders so WHERE so.product_id p.product_id AND so.supplier_id s.supplier_id AND so.order_time DATEADD(DAY, -30, GETDATE()) ) GROUP BY p.category_id;改写后的SQLEXISTS子查询直接关联product_id、supplier_id和order_time三个字段只要在sales_orders表上建立了对应的联合索引数据库就能通过索引快速判断“是否存在这么一条订单记录”。整个查询从40多秒降到了3秒左右。注意这里我把供应商的关联也做了调整先用products JOIN suppliers拿到实际存在的“产品-供应商”组合再用EXISTS去判断最近有没有订单。这样比原来的三表LEFT JOIN要干净得多COUNT(DISTINCT)去重的压力也小了不少。改写过程中我总结的套路是先问自己一句“这里需要明细数据吗”如果不需要只是用JOIN做过滤那就把它替换成EXISTS。JOIN是给结果集添加列的EXISTS是给结果集做过滤的用错工具就是给自己挖坑。4.3 执行计划里的一点门道改了SQL之后看执行计划有助于理解为什么快。MySQL里执行EXPLAIN可以看到EXISTS子查询部分通常会出现DEPENDENT SUBQUERY的标记同时关联字段会使用索引查找。SQL Server里则可能看到NESTED LOOP SEMI JOINPostgreSQL里能看到Semi Join字样。看到SEMI JOIN就要知道数据库已经识别出“只需要判断存在性”这一语义所以对外层每一行它在内层找到第一条匹配行就会停下来不会继续扫描剩余匹配行。这就是EXISTS“提早终止”在优化器层面的落地。当然优化器不是万能的。如果关联字段没有索引EXISTS也会变成内外层全表扫描的嵌套循环性能反而更差。所以改写SQL后一定要确认子查询里关联字段的索引情况。sales_orders表的联合索引建成(product_id, supplier_id, order_time)在这个场景下是最优的因为EXISTS的三个判断条件都能走索引。4.4 别迷信EXISTS索引和统计信息才是根基有句话说得好EXISTS只是给了优化器一个更好的执行方向真正跑得快不快还得看索引和统计信息。我遇到过一条EXISTS子查询越写越慢的情况。原因是子查询的关联字段在原始表上没有索引优化器只能对子查询做全表扫描。EXISTS写得再标准也架不住每次外层代入都去扫一遍1亿行的表。解决方案不是纠结EXISTS和IN的差别而是老老实实在关联字段上建立索引。还有一次是统计信息过期导致的优化器“抽风”。子查询明明有索引可数据库就是不走选择了一个糟糕的嵌套循环。我用UPDATE STATISTICS刷新统计信息之后执行计划才恢复正常。所以面对慢SQL我的排查顺序是先看执行计划判断瓶颈再确认关联字段索引然后才考虑要不要把IN改EXISTS、把JOIN改EXISTS。SQL写法的优化只是其中一环数据基础设施索引、统计信息、硬件资源才是最底层的决定因素。5. 高频报错与排查心得5.1 子查询返回多列报错EXISTS子查询本身很少报“多列”错误但如果你把EXISTS当成IN去写就容易踩这个雷。比如WHERE EXISTS ( SELECT customer_id, order_date FROM orders WHERE orders.customer_id customers.customer_id );这个在绝大多数数据库里没问题因为EXISTS根本不看SELECT列表多列无所谓。真正会报错的是IN的写法WHERE customer_id IN ( SELECT customer_id, order_date FROM orders );这种会直接报“子查询返回的列数不匹配”之类的错误。如果看到类似报错第一反应就是检查子查询SELECT列表列数是否和外层的匹配字段数量一致。另外还有一个容易忽略的点某些数据库对EXISTS子查询里的ORDER BY和GROUP BY也有限制。在Oracle里EXISTS子查询的ORDER BY是无意义的而且可能被优化器直接忽略在SQL Server里子查询带ORDER BY在部分场景会报错。写EXISTS时保持子查询简洁除了WHERE条件外不要塞多余的东西能避免很多低级问题。5.2 IN/NOT IN带来的“假数据”问题前面讲了NULL值陷阱这里再说一个实际开发中经常碰到的“假数据”问题用IN查询出来的结果集和自己手工核对的结果不一致。有一次同事反馈说报表数据比手工算出来的少了几十行。我翻了一下代码发现他的SQL里用了一个NOT IN子查询而子查询里恰好有NULL值。我让他把NOT IN改成NOT EXISTS问题立刻解决。如果你手头也有这类“数据莫名变少”的情况建议先做两件事第一检查子查询结果集里有没有NULL第二用一个临时表把子查询结果单独跑出来看看里面有没有空值。如果确认有NULL别犹豫直接换NOT EXISTS。这个坑我敢说90%的SQL开发都踩过。另外IN子查询对应的列如果在外层表里本身也有NULL同样会产生预期之外的行为。因为NULL IN (1, 2, 3)的结果是UNKNOWN不是FALSE等于在过滤时依然会把该行过滤掉。所以不管是IN还是EXISTS遇到重要查询一定要先搞清楚关联列的空值分布。5.3 EXISTS相关的慢查询排查有人会问我的EXISTS子查询也写了索引也建了为什么还是慢这种时候我会先看执行计划。如果执行计划里EXISTS子查询部分显示的是DEPENDENT SUBQUERY同时外层表扫描行数特别大就要考虑是不是驱动表选错了。EXISTS是外层驱动、内层匹配外层如果是一个全表扫描的百万行大表内层即使走索引也扛不住百万次索引查找。解决思路有两个方向。一是调整外层表的过滤条件先通过WHERE把外层行数压下来二是把EXISTS改写为JOIN配合DISTINCT让优化器换一种执行路径。比如SELECT DISTINCT c.customer_id, c.customer_name FROM customers c JOIN orders o ON o.customer_id c.customer_id WHERE o.order_time DATEADD(DAY, -30, GETDATE());这种写法在部分数据库上可能比EXISTS更快因为它可以让优化器走HASH JOIN而不是嵌套循环。但要注意JOIN会产生重复行必须配DISTINCT而DISTINCT本身可能带来额外的排序开销。到底用哪种最终还是得拿真实数据量去验证。我从不在没有执行计划的情况下拍脑袋断定“EXISTS一定快于JOIN”这句话在不同数据分布下的结果完全可能是反的。5.4 常用场景速查表最后整理一个我自己日常开发中反复使用的EXISTS场景速查建议收藏备用业务需求推荐写法关键注意点查主表在子表有匹配记录EXISTS子查询写SELECT 1关联条件放WHERE查主表在子表无匹配记录NOT EXISTS比NOT IN安全不受NULL影响多列同时匹配判断EXISTSWHERE里同时写多个关联字段条件按分组保留最新一条NOT EXISTS 时间比较关联字段和排序字段建联合索引清理孤儿数据/外键失效数据NOT EXISTS删除前先SELECT核对行数过滤“存在某类业务记录”EXISTS 多个过滤条件过滤字段索引覆盖越全越好判断两张表数据差集NOT EXISTS多列匹配时优势最明显报表计数前的存在性筛选EXISTS COUNT(DISTINCT)注意外层聚合字段的去重逻辑写SQL这十几年我最大的体会是EXISTS子查询看起来简单但它背后关联的是一整套查询语义和优化器行为的理解。它不是一个“高级技巧”而是一个基础能力。每次我碰到“有没有、存不存在、是否至少一条”这种需求脑子里第一个冒出来的就是EXISTS至于IN还是JOIN永远是先考虑数据分布、空值情况、索引状态之后的选择。最后再分享一个我自己坚持的小习惯任何包含EXISTS/NOT EXISTS的生产SQL我都会先在测试库里跑一遍用执行计划确认关联字段走了索引再拿一个预期结果集数量做断言最后才部署到生产。这套流程救过我太多次了如果有人刚开始接触EXISTS建议也养成这个习惯能少走很多弯路。
返回列表