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

资讯详情

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

MySQL多表查询实战:JOIN、子查询与性能优化全解析

MySQL多表查询实战:JOIN、子查询与性能优化全解析 做后端开发的兄弟基本都绕不开MySQL业务一复杂单表查询根本撑不住场面。订单要关联用户商品要关联分类报表要从三四张表里捞数据这时候多表查询就是基本功中的基本功。网上关于多表查询的教程一搜一大把但大多停留在“三种join各抄一遍”的程度真到了生产环境索引怎么建、驱动表怎么选、为什么明明加了条件还是慢照样一堆人栽跟头。这篇东西我按自己的实战经验来写把多表查询的内在逻辑和完整案例都过一遍既给刚入行的同学看也写给写了一段时间SQL但总觉得心里没底的人。1. 先想明白为什么要多表查询单表不香吗很多人刚开始学SQL的时候都有个疑问把数据全放一张表里不是更方便吗非要拆成好几张表查询的时候还得连来连去这不是自己给自己找事吗这个问题的答案其实就是多表查询存在的根本原因。1.1 范式设计把数据拆开查询就得把它们拼回去关系型数据库的核心设计思想之一就是“数据建模”而建模过程中最重要的约束就是范式。简单理解范式就是在教你别把数据重复存。比如第三范式要求“非主键字段之间不能存在依赖关系”翻译成人话就是一张表只描述一件事。拿电商系统举例。一张订单表应该只记录订单本身的信息订单号、下单时间、订单金额、下单用户ID。至于这个用户叫什么名字、手机号是多少那是用户表的事不该出现在订单表里。为什么因为同一个用户的姓名和手机号可能会在几百张订单里重复出现一旦用户改了手机号你就得去更新几百条甚至几千条数据。万一漏了一条数据就矛盾了同一笔订单记录的手机号和用户表里的手机号对不上这种脏数据在业务上是致命的。所以设计阶段大家都会把数据拆开用户表、订单表、商品表、分类表各管一摊。但业务需求是复杂的前台要展示一个订单列表列表里得显示用户名、商品名、订单金额这些字段分布在三张表里怎么办只能靠多表查询把它们重新拼回去。所以说白了多表查询就是数据库世界里的“拼图游戏”范式设计负责把图拆成碎片查询负责把碎片拼成完整的业务视图。1.2 连接的本质笛卡尔积、连接条件、过滤条件理解了“为什么拆”接下来得弄明白“怎么拼”。多表查询底层干的事情叫笛卡尔积这个名词听起来高大上其实特别好理解两张表的数据做全排列组合。拿表A3行去连接表B4行如果没有任何限制条件结果就是3乘4等于12行。每一行A都会去配一遍所有的B。但现实中你根本不需要这种毫无意义的大杂烩。订单表的每一行只应该和它对应的用户行拼在一起这就需要一个“连接条件”告诉MySQL订单表的user_id等于用户表的id时才算是有效匹配。没有连接条件的多表查询要么是刚学SQL还没搞明白的新手写的要么就是真的有跨表全组合的特殊需求。这里有一个非常关键但新手容易混淆的点连接条件ON子句和过滤条件WHERE子句是两码事。连接条件负责定义两张表怎么拼过滤条件负责在拼完之后筛掉不想要的行。虽然有些场景下把连接条件写到WHERE里也能得到一样的结果比如内连接但放到外连接里就会产生完全不同的语义。这个坑后面讲LEFT JOIN的时候还会详细说这里先留个印象。2. 多表查询的三种姿势JOIN、子查询、集合运算MySQL里做多表查询正经路子就三条JOIN连接、子查询、集合运算UNION系列。很多人一提到多表查询就想JOIN其实子查询在某些场景下可读性更好UNION则专门解决“多张结构相同的表纵向拼数据”的问题。下面挨个拆开讲。2.1 JOIN家族INNER、LEFT、RIGHT别再傻傻分不清JOIN家族是使用频率最高的先给个直白的定义INNER JOIN内连接只保留两张表都能匹配上的行匹配不上的两边都不要。LEFT JOIN左连接左表写在LEFT JOIN左边的表的所有行都保留右表只有匹配得上的才拼进来拼不上的用NULL填充。RIGHT JOIN右连接跟LEFT JOIN反过来右表全保留左表只留匹配上的。FULL OUTER JOIN全连接两边都全保留MySQL原生不支持需要用UNION把LEFT JOIN和RIGHT JOIN的结果合起来模拟。举个生活化的例子。你现在有两张表学生表和选课表。学生表有张三、李四、王五选课表记的是张三选了数学、李四选了英语。内连接查出来就是张三和李四这俩有选课记录的人王五直接消失左连接用学生表做左表查出来是张三、李四、王五三个人王五没选课那课的字段就是NULL。这里要特别强调一个经典大坑ON子句里的附加条件和WHERE子句里的条件在LEFT JOIN里结果是完全不一样的。比如你想查所有用户以及他们金额大于100的订单两种写法-- 写法A金额条件放在ON里 SELECT u.name, o.order_id, o.amount FROM users u LEFT JOIN orders o ON o.user_id u.id AND o.amount 100; -- 写法B金额条件放在WHERE里 SELECT u.name, o.order_id, o.amount FROM users u LEFT JOIN orders o ON o.user_id u.id WHERE o.amount 100;写法A会返回所有用户没下过单或者订单金额不足100的用户也会出现订单字段是NULL。写法B就变成了先算出所有用户的订单然后再筛掉金额不大于100的结果里没订单的用户全被过滤了因为它们的订单字段是NULLNULL大于100这个比较是假值。这个现象我第一次踩坑的时候懵了很久后来总结出一个好记的口诀对LEFT JOIN而言ON里的条件是“附带条件”不决定左表的去留WHERE里的条件是“最终筛选”决定一切生死。2.2 子查询嵌套在括号里的另一个世界子查询说白了就是“查询套查询”把一个SELECT的结果当作另一个SELECT的数据来源。它的价值在于可以把复杂的多步查询拆成逻辑清晰的多个层次有时候比连环JOIN好读得多。子查询按返回结果可以粗暴分成三类标量子查询返回单行单列比如“找工资高于平均工资的员工”平均工资就是个标量SELECT emp_name, salary FROM employees WHERE salary (SELECT AVG(salary) FROM employees);这里子查询只执行一次效率很高但要注意如果子查询返回了多行整个语句直接报错。表子查询返回多行多列放在FROM后面当作临时表用。典型场景是“先在一个小范围内算好数据再拿出去跟大表 JOIN”SELECT d.dept_name, t.total_salary FROM departments d JOIN ( SELECT dept_id, SUM(salary) AS total_salary FROM employees GROUP BY dept_id ) t ON t.dept_id d.id;这种写法的好处是逻辑分层很清楚先确定要算“各部门工资总额”再确定“跟部门表拼接”排错的时候一层一层看就行。IN / EXISTS 子查询专门做存在性判断。比如“找出选过课的学生”-- 用 IN SELECT * FROM students WHERE id IN (SELECT student_id FROM course_selection); -- 用 EXISTS SELECT * FROM students s WHERE EXISTS (SELECT 1 FROM course_selection c WHERE c.student_id s.id);IN和EXISTS的选择曾经被当作面试经典题。以前MySQL的优化器对IN支持不太好数据量大的时候IN很慢大家总结出“大表用EXISTS小表用IN”的规律。但现在MySQL 5.6以后优化器做了大量改进IN的性能已经大幅提升。我个人的习惯是优先用IN可读性好只有当子表数据量极其庞大、且子查询的结果集无法被索引覆盖时才考虑EXISTS。不过你可以在EXPLAIN里看一眼执行计划数据不会骗人。2.3 UNION与UNION ALL纵向拼接的正确姿势JOIN和子查询都是横向拼列两张表拼成一张更宽的表。但有时候你需要的恰恰是纵向拼行一张表存了今年上半年的订单一张表存了下半年的订单你想把两个表的数据合并成一个结果集展示这就得靠UNION了。UNION会自动去重UNION ALL不会。去重意味着需要对结果做排序比较数据量大时非常消耗性能。举个我踩过的坑某次导数据统计源库和目标库有两张结构一样的订单表中间有一批重复数据我想当然地用了UNION去重。结果两张表加起来30万行UNION跑了40多秒换成UNION ALL瞬间变成2秒。后来一想业务上两张表的ID本来就用不同的生成策略压根不可能重复白白花了几十秒去重。所以我的原则是能确定不重复或者允许重复就不要用UNION数据量大以后UNION ALL加应用层逻辑去重往往比数据库硬去重划算得多。另外还有个注意点UNION要求每个SELECT出来的列数量必须一致且对应列的数据类型要兼容。这个没注意的话MySQL会直接报错不用猜。3. 实战案例拆解从需求到SQL的完整推演理论说得再多不如让案例落地。下面几个场景都是我在项目里实际写过的从需求出发一步步推演到最终SQL你照着敲一遍基本就能掌握多表查询的核心套路。3.1 电商订单报表用户、订单、订单明细三表联查需求是这样的运营要一张报表展示下单用户的名字、手机号、每个订单的订单号、订单总金额以及每个订单里的商品名和购买数量。涉及三张表usersid、name、phoneordersid、user_id、order_no、total_amountorder_itemsid、order_id、product_name、quantity、price业务关系是一个用户可以有多个订单一个订单可以有多条商品明细。先想一下连接顺序。最自然的思考方式是“从主表出发逐步扩张”。以orders为核心向左连接users拿到用户名和手机号向右连接order_items拿到商品明细SELECT u.name AS 用户姓名, u.phone AS 手机号, o.order_no AS 订单号, o.total_amount AS 订单金额, oi.product_name AS 商品名称, oi.quantity AS 购买数量 FROM orders o JOIN users u ON u.id o.user_id JOIN order_items oi ON oi.order_id o.id;用INNER JOIN是合理的报表场景只需要有完整链路的数据缺了用户或者缺了明细的订单运营也看不上直接过滤掉。但这里有个细节为什么我先JOIN users再JOIN order_items顺序上有没有讲究MySQL优化器在绝大多数情况下会自己决定最优的连接顺序并不一定按你SQL里写的顺序执行所以其实不用太纠结。真正该操心的是让每个连接字段都有索引。这个表里orders.user_id、order_items.order_id都应该建索引否则数据量一上来JOIN会变成灾难。3.2 员工-部门-领导自连接的典型场景员工表里每一行都有一个manager_id指向自己上级的员工ID这种“表中有关联自己”的情况就叫自连接。比如要查“每个员工及其领导的姓名”最直观的做法是对同一张表做两次查询用别名区分SELECT e.emp_name AS 员工姓名, m.emp_name AS 领导姓名 FROM employees e LEFT JOIN employees m ON m.id e.manager_id;这里用的是LEFT JOIN因为老板没有上级用INNER JOIN的话老板这行就没了。自连接的本质就是表的两份拷贝做连接理解这个之后就没什么玄乎的了。你在SQL里看到的employees e和employees m其实是“员工视角的表”和“领导视角的表”两份数据MySQL底层会读两次员工表开销翻倍所以自连接一定要保证连接字段id、manager_id都有索引不然表大一点就会慢得离谱。再扩展一个场景查每个部门里薪水最高的员工。这个需求用窗口函数更简单但早期的MySQL版本5.7及以下不支持只能用自连接加聚合做SELECT d.dept_name, e.emp_name, e.salary FROM employees e JOIN departments d ON d.id e.dept_id WHERE e.salary ( SELECT MAX(salary) FROM employees e2 WHERE e2.dept_id e.dept_id );这个写法的核心就是用关联子查询找到“本部门最高薪水”再在外层做匹配。它的执行逻辑是外层每一行员工都会去子查询里算一遍本部门的最高工资然后对比当前行的工资是否相等。所以这SQL在部门数量多、员工表大的时候会非常吃紧优化方向是给(dept_id, salary)建联合索引让子查询能快速命中。3.3 聚合统计JOIN GROUP BY的组合拳统计报表是业务方最爱提的需求玩法一般就一个套路连表拿到明细再用GROUP BY汇总。比如按分类统计商品销量和销售额SELECT c.category_name AS 分类名称, COUNT(DISTINCT p.id) AS 商品数, SUM(oi.quantity) AS 总销量, SUM(oi.quantity * oi.price) AS 总销售额 FROM categories c LEFT JOIN products p ON p.category_id c.id LEFT JOIN order_items oi ON oi.product_id p.id GROUP BY c.id, c.category_name;这里用LEFT JOIN是故意为之。如果某个分类下没商品或者商品从没有卖出过这个分类依然要出现在报表里而且计数是0。如果用INNER JOIN这种“空分类”会被直接干掉运营看到的就是缺失的行那肯定不行。这个SQL里有两个坑我在这上面栽过不止一次第一GROUP BY后面到底该跟哪些列。只写GROUP BY c.idSELECT里又带了c.category_name这是MySQL特有的“功能依赖”特性严格模式下可以这么写但为了跨数据库兼容性建议GROUP BY里把SELECT出来非聚合的非聚合列都写上也就是c.id, c.category_name。第二COUNT和SUM遇到NULL的坑。LEFT JOIN以后分类没有商品时p.id是NULLoi.quantity也是NULL。COUNT(DISTINCT p.id)会忽略NULL所以商品数显示0没问题。但SUM(oi.quantity)碰到NULL也不会报错返回NULL而NULL在报表里显示出来就是一个空值很多后端代码一拿这个值直接转数字就炸了。稳妥做法是用IFNULL包一层IFNULL(SUM(oi.quantity), 0) AS 总销量。这种细节不写进去接口联调的时候就是事故现场。4. 性能优化多表查询快不快的命门不少开发写多表查询功能上没问题SQL也不复杂但一上线数据量几十万、几百万后直接慢成老牛拉破车。多表查询的性能问题说穿了就三个关键点连接字段有没有索引、驱动表选得对不对、有没有避免不必要的全表扫描。下面一个一个拆。4.1 用EXPLAIN看穿MySQL的执行计划MySQL提供了EXPLAIN命令来展示一条SQL的执行计划这是性能分析的第一入口。用法极其简单直接在SQL前面加EXPLAIN关键字就能看到一张结果表。这张表里的关键字段需要重点关注type连接类型从好到坏依次是system、const、eq_ref、ref、range、index、all。看到all就代表全表扫描多表查询最怕这个。key实际用到的索引名。有可能是NULL说明没走索引。rows预估扫描的行数这个数字越小越好。多表连接时这个数字的乘积大概就是最终的扫描量。Extra如果出现Using temporary和Using filesort就要警惕了说明SQL内部建了临时表或者做了文件排序数据量大时是性能黑洞。举个实际例子。某次线上订单分页查询的SQL某天突然从几十毫秒变成两秒多。我EXPLAIN一看orders表的连接字段user_id那行的type是allrows显示30多万。马上查了用户表的索引发现user_id字段上的索引还在但优化器居然选择了先扫订单表再做连接。核心原因是统计信息过期MySQL以为用索引需要扫描更多的行干脆选择全表扫描。用ANALYZE TABLE orders强制更新统计信息后type变成ref查询时间降到80毫秒。所以遇到SQL突然变慢先别急着加索引EXPLAIN看一眼执行计划很可能只是统计数据太老了。4.2 驱动表小表驱动大表是怎么个道理多表连接时MySQL会选一张表作为“驱动表”先读这张表的数据然后用它的每一行去另一张表里找匹配的数据。驱动表扫描多少行决定了连接的总次数。所以理论上“小表驱动大表”能让扫描次数更少这也是早期开发和DBA们反复强调的优化思路。但现代MySQL优化器已经能做基于成本的智能选择不再需要你手动去暗示“哪张表做驱动”。真正能起到决定性作用的是让被驱动表的连接字段有索引。举个例子A表3万行B表300万行连接条件是A.id B.a_id。如果B.a_id有索引MySQL每拿A的一行去B里查通过索引扫描的代价很低反过来如果没索引那就变成A的每一行都去B表做一次全表扫描3万乘300万这数据量是天文数字。实际操作中我们很少直接干预驱动表但可以通过控制过滤条件让优化器做出更优选择。比如在A表上加一个状态字段过滤把A表扫描的行数从3万降到几千优化器自然会选择这个更小的结果集做驱动。与其纠结驱动表不如先把每张表的过滤条件做足把每张表的连接字段索引建好。4.3 索引设计的常见误区多表查询先给连接字段建索引这基本是常识但实际项目里还有几个容易忽视的细节。误区一从表连接字段没索引主表反而建了。连接的方向是驱动表一行一行去被驱动表匹配所以真正需要索引的是被驱动表的连接字段。拿JOIN users u ON u.id o.user_id来说orders表往往是驱动表users表是被驱动表重点应该保证users.id有主键索引这个天然有。如果换成LEFT JOIN orders o ON o.user_id u.id这种左表是users被驱动表是orders那么orders.user_id一定要建索引否则就是灾难。误区二索引建了但查询没走。最常见的原因是在连接字段上用了函数或者隐式类型转换比如WHERE u.id 123如果u.id是整型MySQL会把字符串转成数字去比较这种隐式转换有时候会导致索引失效最好的做法是保持字段类型统一查询参数类型保持一致。误区三盲目建多列索引。多表查询的GROUP BY、ORDER BY字段如果和连接字段一起建联合索引有时能省掉filesort。但索引也不是越多越好每个索引都会拖慢写入速度。我的习惯是先用EXPLAIN观察确认瓶颈之后再针对性地建绝不为了“可能用到”去建一堆用不上的索引。5. 常见问题与排查实录这一节整理几个在实际开发里高频出现的坑和对应的排查方法。很多问题看起来毫无头绪但其实顺着“连接条件-执行计划-索引”这条线走几分钟内就能定位。5.1 结果集数量不对多了少了的排查思路多表查询结果莫名多出一堆重复行这是新手入职第一周最常被骂的Bug。我见过一个很经典的例子统计每个用户的订单总金额但用户表跟订单表关系是1对N写完下面这段就把数据翻了好几倍SELECT u.id, u.name, SUM(o.total_amount) FROM users u JOIN orders o ON o.user_id u.id GROUP BY u.id, u.name;这个SQL表面上没错但如果orders表里存在“用户和订单是多条关系”的同时你又额外JOIN了一张订单明细表比如SELECT u.id, u.name, SUM(o.total_amount) FROM users u JOIN orders o ON o.user_id u.id JOIN order_items oi ON oi.order_id o.id GROUP BY u.id, u.name;那么问题来了一个订单有多条商品明细JOIN完后订单金额会被明细条数放大。比如订单金额100元有3条商品明细结果是这个订单被算成300元。解决思路是先算明细再合总或者用子查询先聚合订单金额再去关联用户。这类问题的核心教训是多表JOIN产生的行数倍增关系没理清聚合函数算出来的就是错的数据。碰到这种情况先把JOIN去掉单独跑一遍每个表的数据量再带条件看JOIN后的行数变化立刻就能发现是哪里扩散的。5.2 慢查询定位三板斧线上SQL慢的时候别急着优化SQL语句本身先把慢查询日志开起来确认到底哪些SQL是真正吃时间的。MySQL的慢查询日志默认是关闭的可以在my.cnf里配置slow_query_log 1 slow_query_log_file /var/log/mysql/slow.log long_query_time 2设置long_query_time为2秒任何执行超过2秒的查询都会落日志。拿到慢SQL后第一件是EXPLAIN第二件看扫描行数和索引使用情况第三件再考虑改写SQL。我排过最诡异的一个慢查询是这样的一条三表连接的报表SQL在测试环境完全没问题上了生产成了10秒。EXPLAIN一看生产环境的统计信息落后优化器选了一张大表做驱动直接扫了几百万行。解决办法就是执行ANALYZE TABLE刷新统计信息顺便检查是否因为碎片太多导致扫描代价被高估。所以慢SQL优化最忌讳上来就重写SQL一定要先揪出执行计划的异常。5.3 多表查询故障速查表精华版现象可能原因排查方法结果行数暴增多张1对多表直接JOIN产生笛卡尔扩散分别计算每张表的行数一步步还原JOIN过程LEFT JOIN后左表有行丢失WHERE里加了右表的过滤条件把右表过滤条件移到ON子句某个字段显示NULLRIGHT JOIN或LEFT JOIN的补位行为确认业务是否需要该行数据用函数处理NULLSQL突然变慢统计信息过期或索引失效跑EXPLAIN执行ANALYZE TABLE排序字段导致filesortORDER BY字段没索引考虑联合索引覆盖排序字段查询结果重复连接条件不够精确检查连接字段是否唯一考虑用DISTINCT或改写连接逻辑排查问题一定要养成一个习惯手动拿真实数据的子集跑一遍SQL边跑边用EXPLAIN验证自己的每一步猜测。这个习惯救了我很多次不光是多表查询任何数据库问题都适用。写在最后的个人心得多表查询这块我踩过的坑比写对的代码多得多。有一阵子我对各种高级写法特别上瘾凡是能JOIN绝不子查询能嵌套绝不扁平结果同事接我代码的时候一脸痛苦。后来想明白了SQL首先是写给人看的其次才是给机器跑的。一段逻辑清晰、可读性强的多表查询比一段看起来炫技但让人琢磨半天的SQL有价值得多。最后分享一个小技巧写复杂的多表查询可以先在脑子里或者草稿纸上画出表的关联关系图标清哪张表是主表、哪些字段是连接字段、业务上要保留哪些无效行。画清楚之后再去写SQL大概率一遍过而且不容易出现行数翻倍或者数据缺失的经典问题。这个方法和ORM设计时的思路完全一致只是很多人写SQL的时候太着急省了这一步后面花在调试上的时间往往是十倍。
返回列表