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

资讯详情

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

MySQL复合查询彻底讲透:从笛卡尔积、自连接到子查询优化

MySQL复合查询彻底讲透:从笛卡尔积、自连接到子查询优化 刚把一条线上报表的慢查询优化完从十几秒压到了几十毫秒背后其实就是复合查询里子查询和连接的改写问题。群里也经常看到有人问“为什么我多表查询出来一堆重复数据”“自连接到底什么时候用”正好借这篇把MySQL复合查询这块彻底讲透从笛卡尔积到自连接再到子查询的三种位置和exists的执行逻辑一次性的。不管你是刚学SQL的初学者还是写了两三年业务代码想补一下查询功底的开发这篇都能给你一些实际能用的东西。1. 复合查询到底在解决什么问题1.1 为什么单表查询远远不够真实业务里数据几乎不可能只存在一张表里。比如一个最简单的电商系统用户信息在user表订单在order表订单里的商品明细在order_item表。你要查“某个用户最近买了什么”至少要关联user和order两张表要查“某个商品被哪些用户买过”要关联三张表。如果你只会单表查询就只能先查user表拿到用户id再拿这个id去order表里查再拿order_id去order_item表里查。这种在代码里循环查数据库的方式就是经典的“N1查询问题”。每多一层数据就多一次网络往返数据量一上来接口就直接卡死。复合查询的意义就在这里用一条SQL把多张表的数据按照关联条件组合起来让数据库一次性把结果算完返回给你。这不仅减少了应用和数据库之间的交互次数更重要的是优化器可以统一调整执行计划走索引还是做hash join由数据库帮你决策。另外一个很常见的场景是报表统计。比如运营要一张表“每个部门的平均工资以及该部门工资最高的员工姓名”这种需求往往会把聚合查询和子查询组合在一起单表根本写不出来。1.2 笛卡尔积多表查询的第一个坑多表查询最基础的概念是笛卡尔积。拿两张表举例A表3条记录B表4条记录直接select * from A, B结果就是3×412条记录这种没有任何关联条件的两表相乘就是笛卡尔积。实际业务中笛卡尔积基本没有意义但是理解它特别重要因为所有join的本质都是先产生笛卡尔积再通过关联条件筛选出有效数据。举一个我实际遇到过的例子一张订单表5万条一张订单明细表8万条开发直接写了from order o, order_item i忘了加where o.id i.order_id结果查出来40亿行数据库直接打满临时磁盘空间整个库被拖垮。这个案例说明两件事写多表查询永远要在where或on后面带全关联条件。线上环境一定要配置sql_safe_updates和查询超时避免一条烂SQL把数据库拖死。多表查询有两种主流写法SQL92语法用逗号分隔多张表关联条件放在where里。SQL99语法用join ... on ...显式指定关联条件。我强烈推荐你用SQL99语法。原因不只是清晰更重要的是外连接只能用SQL99表达。SQL92里要模拟左外连接得写()Oracle里才有MySQL根本不支持。所以直接养成用join的习惯后面切各种数据库都通用。2. 自连接一张表也能玩出花2.1 自连接的本质是“表跟自己连”很多人第一次看到“自连接”这三个字会觉得特别抽象脑子里想的是“一张表怎么和自己连接”。其实一点都不神秘自连接的本质就是给同一张物理表起了两个不同的别名然后当作两张独立的逻辑表来做关联查询。这里的关键点在于在SQL语句的上下文中一个表名只要出现在from子句两次就必须给它起别名否则数据库没法区分你要引用的是哪一份。起了别名之后from emp e1, emp e2实际上就是把同一份物理数据加载了两次在逻辑上变成了两张完全独立的表。打个比方学校里的学生名单就这么一份但是你可以把这份名单同时当作“学生信息”和“班长信息”来用。查“每个学生的班长叫什么名字”本质上就是拿学生表里的benban_id字段去关联同一份名单里对应的学生id。这就是自连接的日常应用。2.2 案例实操员工表和上级领导的经典查询最经典的自连接案例是员工表。假设一张emp表字段如下create table emp ( empno int primary key comment 员工编号, ename varchar(20) comment 员工姓名, job varchar(20) comment 岗位, mgr int comment 上级领导编号指向empno, hiredate date comment 入职日期, sal decimal(10, 2) comment 工资, deptno int comment 部门编号 );插入几条测试数据insert into emp values (1001, 张三, 开发工程师, 1006, 2020-03-15, 15000, 10), (1002, 李四, 测试工程师, 1006, 2021-07-01, 12000, 10), (1003, 王五, 产品经理, 1007, 2019-11-20, 18000, 20), (1004, 赵六, UI设计师, 1007, 2022-01-10, 10000, 20), (1005, 钱七, 运维工程师, 1008, 2020-09-05, 14000, 30), (1006, 孙八, 技术总监, 1009, 2015-06-01, 35000, 10), (1007, 周九, 产品总监, 1009, 2016-04-12, 33000, 20), (1008, 吴十, 运维总监, 1009, 2014-08-19, 30000, 30), (1009, 郑壹, CEO, null, 2010-01-01, 80000, null);现在要查“每个员工的姓名以及他的上级领导姓名”。员工姓名直接从emp表取上级领导也在这张表里因为mgr字段的值指向的是empno。如果不做自连接你只能先查一次员工列表然后拿着mgr的编号再在代码里循环查员工表效率极低。自连接的SQL写法如下select e1.ename as 员工姓名, e2.ename as 领导姓名 from emp e1 join emp e2 on e1.mgr e2.empno;这条语句的逻辑拆开来看非常清晰把emp表加载两份一份命名为e1当作员工表。另一份命名为e2当作领导表。关联条件e1.mgr e2.empno意思是e1这一行的mgr值要在e2里找到对应的empno。输出e1里的员工姓名和e2里的领导姓名。执行结果如下员工姓名领导姓名张三孙八李四孙八王五周九赵六周九钱七吴十孙八郑壹周九郑壹吴十郑壹注意CEO郑壹的记录没有出现在结果里因为他的mgr是null没有任何人的empno等于null。这时候如果需要把CEO也查出来就要用左外连接保左表全部记录select e1.ename as 员工姓名, ifnull(e2.ename, 无上级) as 领导姓名 from emp e1 left join emp e2 on e1.mgr e2.empno;加了left join之后左表e1的所有记录都会保留匹配不上就补null。实际开发中“找出没有上级的人”或者“找出所有员工的状态即便他暂时没被分配领导”这种需求很常见左外连接就是为此设计的。2.3 什么时候该想到用自连接从上面的案例能总结出自连接的适用特征同一张表里某一行和另一行之间存在上下级、父子、前后关系但要查的结果需要同时出现“父”和“子”的信息。碰到的典型场景大多长这样组织架构表员工与其汇报上级都在同一张员工表查成员的汇报链路。商品分类表分类有父分类idparent_id指向自己的category_id查“二级分类属于哪个一级分类”。评论回复表评论和回复都放一张表reply_id指向comment_id查评论内容与其回复内容。地铁线路表站点表里记录prev_station和next_station用自连接还原线路顺序。血缘关系树一个人和父母的记录都在同一张people表里。我这里拿商品分类表举个例子。假设category表有id、name、parent_id三个字段根分类的parent_id为0。要查“所有二级分类以及它们所属的一级分类名称”SQL可以写成select c2.name as 二级分类, c1.name as 一级分类 from category c1 join category c2 on c1.id c2.parent_id where c1.parent_id 0;这条语句本质是拿一级分类作为父表c1二级分类作为子表c2关联条件等于c2的parent_id指向上级c1的id。如果组织架构有更多层级还可以连续自连接多次一层层往上翻。虽然层数多了性能会下降但数据量小的时候非常直观。自连接还有一个高频变体同一张表的非等值连接。比如要查“工资比自己上级还高的员工”。因为比较的是同一张表里e1的sal和e2的sal但关联条件不是相等关系而是存在“超过”的关系select e1.ename as 员工, e1.sal as 员工工资, e2.ename as 领导, e2.sal as 领导工资 from emp e1 join emp e2 on e1.mgr e2.empno where e1.sal e2.sal;这种“非等值自连接”在查排行榜差值、时间间隔、区间重叠等问题时特别有用。比如一张停车记录表要算每辆车相邻两次停车的时间间隔就需要把一张停车表按车辆编号分成两份一份作为前一次一份作为后一次然后on a.car_id b.car_id and a.in_time b.in_time再通过分组取最小值来筛选出相邻记录。3. 子查询嵌套在SQL里的SQL3.1 子查询的三种位置与语义差异子查询通俗说就是嵌套在查询里面的查询。外层查询拿到内层查询的结果再继续做筛选或计算。虽然很多子查询可以用join改写但有些场景用子查询表达逻辑比多表连接要自然得多。根据子查询在SQL语句中出现的位置可以分成三类位置写法特征典型用途where子查询where 字段 操作符 (select ...)过滤条件依赖另一张表的计算结果from子查询from (select ...) as 别名把子查询结果当作临时表继续关联或聚合select子查询select (select ...) from ...输出列需要从其他表里取一个标量值为什么要分位置讲因为执行顺序完全不同性能差异也很大。where子查询往往是逐行判断from子查询通常先把子查询结果算出来物化成派生表再执行外层select子查询则是外层每返回一行就执行一次。理解这些执行特征写出来的SQL才可控。3.2 where子查询单行与多行判断where子查询里最简单的场景是返回单个值的标量子查询。比如查“工资比全公司平均工资高的员工有哪些”。这里内层先算出平均工资外层拿着这个值去比较select ename, sal from emp where sal (select avg(sal) from emp);这种单行子查询会用、、这类普通比较运算符。要注意的是如果子查询返回了多行直接加会报错Subquery returns more than 1 row。想看清这个底层逻辑就记住一条普通比较符期望子查询只返回一行一列像个常量一样参与运算。当子查询返回多行时需要用多行比较操作符in、any、all。in表示匹配集合中任意一个。比如查“和张三、李四在同一个部门的员工”select ename, deptno from emp where deptno in (select deptno from emp where ename in (张三, 李四));这里的内层子查询可能返回10、20两个部门编号外层用in匹配任意一个即可。实际业务里“找出那些下过订单的用户”“找出没有买过任何商品的用户”本质上都是通过这种in/not in子查询实现的。any和all配合比较运算符使用语义容易混淆我直接给对照表表达式含义sal any(子查询)大于子查询结果里的任意一个等价于大于最小值sal all(子查询)大于子查询结果里的所有值等价于大于最大值sal any(子查询)小于子查询结果里的任意一个等价于小于最大值sal all(子查询)小于子查询结果里的所有值等价于小于最小值举个例子查“工资比部门10里任意一个员工高但不是部门10的员工”select ename, sal from emp where deptno ! 10 and sal any(select sal from emp where deptno 10);这个查询在做“横向对比”时非常顺手。但注意MySQL里any和some是同一个意思都表示任意一个。如果你更习惯in的写法牢记“in适合穷举匹配any配合比较符做范围判定”。3.3 from子查询派生表解决问题的思路from子查询是把内层查询的结果当成一张临时表必须起别名外层可以像操作普通表一样继续查它。当你的查询需要经过多步计算而每一步都必须依赖上一步的聚合结果时这个模式就非常有用。举一个典型场景查“各部门平均工资最高的那个部门的部门编号和平均工资”。如果直接一条SQL写需要对emp分组求平均值再从这些平均值里挑最大值。可以先分组算平均把结果作为派生表再对派生表做一次聚合select deptno, avg_sal from ( select deptno, avg(sal) as avg_sal from emp group by deptno ) as t order by avg_sal desc limit 1;这里内层子查询的结果是一张两列的临时表tdeptno和avg_sal外层直接基于它查询。用from子查询的好处是思路清晰分步计算每一层只干一件事。实际工作中的报表SQL经常是这种三层甚至四层嵌套的结构先明细层过滤再分组层汇总最后外层排个序或者做个占比计算。4. 相关子查询与exists逐行判断的艺术4.1 什么是相关子查询前面讲到的where子查询中内层子查询是独立执行的和外层没有任何字段引用这种叫非相关子查询。数据库可以先执行完子查询拿到一个固定的结果集合然后外层再执行。但还有一类子查询内层引用了外层的字段。最经典的场景是查“每个部门中工资最高的员工”。如果只做非相关子查询先查所有部门的最高工资却不知道这条最高工资属于哪个部门无法和外层员工精确关联。相关子查询的写法如下select ename, sal, deptno from emp e1 where sal ( select max(sal) from emp e2 where e2.deptno e1.deptno );注意内层子查询里出现了e1.deptno这个字段来自外层emp表的别名e1。所以数据库不能先执行子查询只能外层取一行把这一行的deptno带入内层去计算一次然后判断当前行的sal是否等于计算结果。这种逐行关联执行的子查询就是相关子查询。相关子查询的逻辑非常好理解面试也常考但很多人容易忽略它的执行代价如果外层有1万行内层子查询每次执行都在扫描全表最坏情况就是1万次全表扫描性能堪忧。所以实际使用中一定要保证内层子查询的where条件字段有索引。以这个例子来说emp表的deptno字段上要有索引内层子查询才能快速找到对应部门的数据。4.2 exists与in的真实性能差异exists谓词是相关子查询的一种特殊形式。它的作用不是返回具体数据而是判断子查询里有没有记录存在。哪怕子查询返回100万行exists只看是否存在返回true后就停了。经典面试题是查“有下属的员工”也就是“哪些人是领导”。用in实现的写法select ename from emp where empno in (select mgr from emp);这个SQL的逻辑是先执行内层子查询拿出所有mgr编号的集合会有重复值然后外层每一行去判断empno是否在这个集合里。用exists实现的写法select e1.ename from emp e1 where exists ( select 1 from emp e2 where e2.mgr e1.empno );这个SQL的逻辑是外层每一行拿它的empno去内层匹配有没有e2.mgr等于它只要有一条记录就成立不需要统计具体条数。在MySQL的优化器里in和exists并不存在绝对的性能优劣最终取决于数据分布、索引和优化器成本估算。如果子查询的结果集很小in通常更快如果外层表小、内层表大而且内层关联字段有索引exists往往表现更好。从语义和可读性角度我个人遇到“存在性判断”时更倾向于exists因为它的逻辑表达更贴近业务语言“只要有就成立”。还有一点常被忽略not in与null的坑。如果子查询结果集里包含null值not in可能返回空结果因为SQL里null参与的比较结果都是unknown所有行的不匹配条件都无法成立。而not exists不会受null影响因为它是逐行做存在性判断。所以查“没有下属的员工”时直接用not in配合子查询可能出现诡异结果。例如select ename from emp where empno not in (select mgr from emp);emp表里mgr字段包含null值吗这里mgr有nullCEO没有上级但not in要判断的是empno是否等于mgr这个集合里的值mgr集合里其实都是非空的编号正常不会出问题。但在实际生产表里如果子查询返回的列本身有null行比如select mgr from emp where job 临时工查出来一个null那么not in就大概率给不出期望结果。这一点在排查“为什么not in查出来的记录少了”的时候是最优先要检查的方向。5. 非等值连接与多表连接的综合应用5.1 非等值连接从区间匹配到业务规则前面聊过非等值连接可以用于自连接的场景这里单独展开一下。非等值连接的意思是关联条件不一定是也可以是、、between等。比如有一张工资等级表salgradecreate table salgrade ( grade int comment 等级, losal decimal(10, 2) comment 最低工资, hisal decimal(10, 2) comment 最高工资 ); insert into salgrade values (1, 0, 10000), (2, 10001, 20000), (3, 20001, 40000);现在要查每个员工的工资等级select e.ename, e.sal, s.grade from emp e join salgrade s on e.sal between s.losal and s.hisal;这里没有等值关联字段靠的是工资落在等级区间里。这种表结构在计费系统里也非常常见流量套餐档次表、优惠券门槛表、会员成长值等级表都是区间匹配业务。相比在应用代码里写一串if-else判断工资属于哪个等级让数据库用between join去做区间匹配写法更简洁也不太容易漏边界值。另外要注意非等值连接容易产生比等值连接更多的结果行。如果用错关联条件会莫名多出来很多数据。排查思路就是先单独查两张表的行数然后观察join结果行数是否等于两张表行数的乘积如果接近笛卡尔积就说明关联条件有问题。5.2 三表及以上的多表连接执行思路多表连接并不是简单地“两两相乘”。比如这样一个需求查每个员工的姓名、部门名称和工资等级。需要关联的字段员工的deptno关联部门的deptno员工的sal关联工资等级表的losal/hisal区间。SQL可以写成select e.ename, d.dname, s.grade from emp e join dept d on e.deptno d.deptno join salgrade s on e.sal between s.losal and s.hisal;这里的执行思路可以这么理解先从emp和dept的连接结果中拿出一行再拿这一行去和salgrade做区间匹配。多表连接的重点是搞清楚表与表之间的关联路径即通过哪个字段把一张表和另一张表接上而不是机械地去罗列join条件。实际项目里常见的是业务表、配置表、日志表之间三表连接比如订单表关联用户表取用户名再关联订单状态字典表取状态描述。写多了之后会发现只要保证每张表join时都有明确的关联字段并且关联字段有索引多表连接的可控性其实很高。5.3 join与子查询的改写抉择很多查询既可以用join表达又可以用子查询表达。比如查“每个部门里工资最高的员工”用相关子查询可以用join加聚合也可以select e.ename, e.sal, e.deptno from emp e join ( select deptno, max(sal) as max_sal from emp group by deptno ) t on e.deptno t.deptno and e.sal t.max_sal;这个写法先把每个部门的最高工资算成一张临时表再和emp等值连接筛选出工资等于最高工资的员工。两种写法各有取舍相关子查询的可读性好但外层行数多时执行次数也多。join派生表的写法规整如果派生表的group by字段有索引可以先快速聚合出结果外层再做连接。我用一个简单的对比表帮你理清取舍对比维度join改写相关子查询可读性逻辑分步容易理解贴近业务语义但嵌套深了容易晕执行方式一次性连接计算外层逐行执行内层逻辑典型瓶颈大表连接内存占用内层无索引导致逐行全表扫描适用场景结果集需要多表字段单纯存在性判断或关联比较从我踩过的坑来说最稳妥的做法是先写语义最清晰的版本如果数据量大再用explain看执行计划。技术方案没有银弹只有在你自己的数据量和索引结构下实测过才能判断哪个写法更优。网上很多“一定不要用子查询”的论断都过于极端。MySQL 8.0的优化器已经具备子查询解嵌套的能力很多子查询会被自动改写成semi join和手动join不一定有性能差异。关键是让人看懂的代码优先遇到瓶颈再针对性地做改写。6. 实操中高频翻车点与排查技巧6.1 容易踩的五个坑光看语法和概念容易觉得简单真正上线写报表SQL的时候总能碰到几个莫名其妙的问题。帮大家总结一下我日常排查最多的几类问题。第一个坑join条件漏写导致结果爆炸。有一次看一条线上SQL开发在from里写了三张表但where里只写了一个关联条件剩下两张表等于做了笛卡尔积查出来的行数直接膨胀了几十万倍。排查技巧很直接先分别select count(*)看三张表的行数然后把SQL拆成多个两表连接每步验证行数是否符合预期最后再拼起来。第二个坑自连接忘记起别名。很多人第一次写自连接会写成from emp, emp然后MySQL直接报错提示表名重复。记住同一张表出现两次必须起别名。哪怕你用join语法也要给两边分别起别名否则后面的on没法写。第三个坑not in遇到null值结果诡异。前面已经重点提过not in的子查询结果集里只要混入null值查询结果就会变少甚至为空。排查方法是把子查询单独跑一遍select distinct 字段 from ...看有没有null有的话可以用not exists替代或者给null做处理。第四个坑相关子查询无视索引嵌套查询慢到怀疑人生。相关子查询的内层通常要带外层传入的字段做等值匹配如果这个字段没有索引外层有多少行内层就全表扫描多少次。通过explain能看到内层子查询的type是ALL那就是在扫全表。解决办法是给关联字段比如mgr、deptno加索引。第五个坑group by与select字段不兼容。many-to-many关联后做group by输出非聚合字段时MySQL只返回分组后碰巧遇到的第一条记录。如果是关掉only_full_group_by模式的旧版本结果会带上随机性。我建议尽量兼容严格模式SQL写规范一点。6.2 快速定位SQL问题的通用排查顺序排查一个复合查询的问题我一般按这个顺序来单独确认每个子查询或每张基础表的数据是否正确。拆开执行逐段验证。检查join类型是否合理。如果业务需求需要保留某张表的全部记录确认用的是left join而不是inner join。用explain看执行计划。重点关注type字段是从index还是ALL走possible_keys有没有用上rows估算的行数是否合理。对比结果行数。inner join结果行数通常不会超过驱动表行数乘以匹配倍数如果结果行数异常膨胀优先检查关联条件。检查查询涉及字段的字符集和排序规则。跨表关联时如果两张表字段的字符集不同MySQL可能无法使用索引出现隐式转换关联性能急剧下降。6.3 复合查询性能优化清单除了上述翻车点日常调优时最值得关注的方向有以下几点优化方向具体做法索引设计join关联字段、where过滤字段、order by字段尽量建组合索引减少回表查询字段控制在索引覆盖范围内尽量避免select *子查询改写数据量大时把相关子查询改写成join或派生表减少逐行执行分页优化深分页时先用子查询拿到起始id再join取数据临时表空间大结果集group by、order by容易写临时文件注意监控tmpdir空间查询拆分单条SQL如果过于复杂拆成多条SQL在应用层聚合反而更快更可控以上几条里最容易被忽视的是“隐式类型转换”。举个例子如果emp表的deptno是varchar类型而dept表的deptno是int类型那么on e.deptno d.deptno会导致MySQL把varchar转成数字再去比较一旦字段上有索引索引会失效。开发环境数据量小看不出问题一到生产环境就疯狂慢查询。排查方法是用explain看key字段是否为空或rows是否异常大。7. 我的一些实操经验和建议最后分享一点个人经验。对于复合查询首先要建立一个意识能用一条SQL表达清楚的数据需求不要拆成多条在应用层做但反过来也要意识到不是所有逻辑都适合塞进一条SQL里。判断标准很简单看SQL的可维护性和执行计划是否可控。如果一条SQL嵌套了五六层子查询连你自己过一个月回来看都很难理解那不如拆成两步查询在应用层做合并。代码的可读性、可调试性和执行性能一样重要。再送大家一个小技巧在编写比较复杂的复合查询时先把业务需求画成一张简单的表格左侧列出需要输出的字段右侧列出这些字段分别来自哪张表中间用箭头画出关联关系。画完之后你会发现SQL结构其实已经出来了比对着代码盲写高效得多。多表查询、自连接、子查询这些知识虽然单独讲每个点都挺简单但组合起来才贴近真实业务。希望这篇对你有帮助有问题欢迎在评论区交流。
返回列表