ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

MySQL多表查询优化:JOIN逻辑、索引设计与避坑指南

MySQL多表查询优化:JOIN逻辑、索引设计与避坑指南 MySQL多表查询真没那么难但前提是你得把关联逻辑、索引设计、NULL值陷阱这些底层东西搞清楚。很多人在单表上写SQL溜得飞起一到多表就各种笛卡尔积、慢查询、数据错乱说白了不是不会语法而是没理解JOIN到底在做什么。这篇文章我从实际开发的角度把多表查询拆开揉碎讲一遍。内容包括为什么要拆表、JOIN类型怎么选、关联字段怎么建索引、EXPLAIN怎么读、常见的坑有哪些最后用一个真实业务场景串一遍。无论你是刚学SQL的新手还是被慢查询折磨的进阶选手这篇文章都值得你花十分钟看完。文章不涉及存储过程、主从复制那种偏运维的杂事只看查询这一件事。1. 多表查询的底层逻辑为什么需要JOIN1.1 数据表的拆分与范式设计先说一个最基础的问题为什么好端端的数据要拆成多张表因为冗余。假设你做一个订单系统把所有信息塞进一张表订单号、客户姓名、客户电话、客户地址、商品名称、商品价格、商品分类、下单时间……你会发现同一个客户买了十次东西他的姓名电话地址就重复存了十遍。数据量小的时候无所谓一旦到了千万级这种冗余会直接拖垮存储和性能。所以正规的设计会拆成客户表、商品表、订单表、订单明细表。客户只存客户的信息商品只存商品的信息订单只存订单头和订单项表与表之间用外键或者逻辑外键关联起来。这就是数据库范式设计的基本思想——每个表只负责一件事通过关联字段把数据重新“拼”回来。拆完之后问题就来了业务上要展示“订单列表”的时候你需要同时看到客户叫什么、商品是什么、数量多少、金额多少这些信息分布在三四张表里。这时候就要靠多表查询把拆开的数据重新组合起来。1.2 JOIN的本质是“连接-过滤-投影”三步JOIN听起来很高深本质上就三步先把两张表按关联条件做连接得到一张“超级大表”然后按WHERE条件过滤掉不需要的行最后按SELECT后面指定的列做投影只留下需要的字段。你可以把这个过程想象成两个班级的名单配对A班是订单表B班是客户表关联条件是“订单表的customer_id 客户表的id”。JOIN就是把两个名单并排放在一起凡是customer_id和id对得上的就把两个人的信息写在同一行。对不上的视JOIN类型决定是保留还是丢弃。理解了这个底层过程你就明白为什么多表查询有时候会慢第一步连接产生的结果集可能非常大。两张表各一万行如果关联字段没有索引MySQL就得做一万乘一万的嵌套循环那是一亿次比较。这就是为什么我一直强调多表查询的性能瓶颈往往不在SQL写法本身而在关联字段的索引上。1.3 MySQL执行多表查询的两种基础算法MySQL执行JOIN的底层算法主要有两种8.0版本之后又有Hash Join但这里聊基础。第一种叫Nested-Loop Join嵌套循环连接。MySQL选一张表当驱动表遍历它的每一行然后去被驱动表里找匹配的行。如果被驱动表的关联字段有索引每次查找走索引就是常数级别的开销效率很高。如果没有索引每次查找都得全表扫描整体复杂度就是O(M×N)数据量一大直接完蛋。第二种叫Block Nested-Loop Join块嵌套循环连接。从5.7开始MySQL默认用这个来优化关联字段无索引的情况它把一个驱动表的结果集分成一块一块地缓存起来再去和被驱动表匹配减少了被驱动表的扫描次数。但这只是治标不治本最靠谱的方案依然是把关联字段的索引建好。从MySQL 8.0.18开始引入了Hash Join等值连接场景下性能有很大提升但作为开发者你不需要只依赖这个做“兜底”。索引和合理的SQL写法才是多表查询的性能根基。2. 核心JOIN类型与应用场景选择2.1 INNER JOIN只留交集INNER JOIN叫内连接它只返回两张表中关联条件匹配成功的行。比如查“购买了商品的客户列表”用INNER JOIN就把那些注册了但从没下过单的客户过滤掉了。这个在业务里非常常见凡是要求“必须匹配上”的场景都用INNER JOIN。实际写SQL的时候有两种写法等价-- 显式JOIN写法推荐 SELECT o.order_id, c.customer_name FROM orders o INNER JOIN customers c ON o.customer_id c.id; -- 隐式连接写法老式不推荐 SELECT o.order_id, c.customer_name FROM orders o, customers c WHERE o.customer_id c.id;老式写法虽然也能跑但把连接条件和过滤条件混在WHERE里SQL一复杂就很难读而且容易漏写连接条件导致笛卡尔积。我见过不止一次生产事故就是因为WHERE里忘了写关联条件一张两万行的表和一张五万行的表做笛卡尔积直接把数据库CPU打满。所以我的建议是统一用显式INNER JOIN把连接条件写在ON里过滤条件写在WHERE里各司其职。2.2 LEFT JOIN与RIGHT JOIN保留主表全部行LEFT JOIN叫左连接标准语义是返回左表全部行右表能匹配上就带出右表数据匹配不上右表字段就是NULL。典型的业务场景查“所有客户及其最近一笔订单”。你要让所有客户都出现在结果里哪怕他从没下过单。这时候用LEFT JOIN订单表匹配不上的客户订单信息显示为NULL程序端再处理一下显示成“暂无订单”就行。RIGHT JOIN右连接正好反过来保留右表全部行。但在实际开发中我几乎不用RIGHT JOIN因为把表的顺序换一下RIGHT JOIN就能改写成LEFT JOIN统一用LEFT JOIN可读性反而更好。你可以理解为RIGHT JOIN就是LEFT JOIN的“镜像”但没必要在代码里混用两种写法。-- 查所有客户及其订单信息没有订单的客户也要显示 SELECT c.customer_name, o.order_id, o.amount FROM customers c LEFT JOIN orders o ON o.customer_id c.id;2.3 JOIN的过滤条件写在ON和WHERE里有什么区别这个问题是我面试别人的时候必问的。很多人不知道LEFT JOIN里过滤条件写在ON和WHERE结果可能完全不一样。-- 场景查所有客户以及他们2024年1月的订单 -- 写法A过滤条件写在ON里 SELECT c.customer_name, o.order_id, o.amount FROM customers c LEFT JOIN orders o ON o.customer_id c.id AND o.order_date BETWEEN 2024-01-01 AND 2024-01-31; -- 写法B过滤条件写在WHERE里 SELECT c.customer_name, o.order_id, o.amount FROM customers c LEFT JOIN orders o ON o.customer_id c.id WHERE o.order_date BETWEEN 2024-01-01 AND 2024-01-31;写法A的逻辑是先把订单表按日期范围过滤出一批“候选订单”再去和客户表做LEFT JOIN。这样即使客户没有1月份的订单他仍会出现在结果里订单字段为NULL。写法B的逻辑是先做LEFT JOIN得到全量数据然后按日期字段过滤。这时候问题来了——对于没有订单的客户o.order_date本身就是NULLNULL BETWEEN两个日期是不成立的也就是这些客户会被WHERE直接滤掉。最终结果变成了INNER JOIN的效果。这是多表查询里最典型、也最容易踩的坑。生产环境里出现过这种问题报表里本该有的客户莫名其妙消失了排查半天最后发现是把过滤条件写错了位置。记住一句话LEFT JOIN中要保留左表全部行时对右表的过滤条件必须写在ON里。2.4 交叉连接与自连接的特殊场景交叉连接CROSS JOIN就是没有关联条件的连接返回两表的笛卡尔积。开发中几乎不会主动用它但写SQL时漏掉关联条件就等于是交叉连接这个要特别警惕。自连接用得也不少它本质是“一张表自己跟自己JOIN”。典型场景是查员工和上级的层级关系SELECT e.emp_name AS 员工姓名, m.emp_name AS 上级姓名 FROM employees e LEFT JOIN employees m ON e.manager_id m.id;自连接的技巧在于给同一张表起两个不同的别名让它扮演两个不同的角色。还有更复杂的递归层级比如查所有子孙节点那就要用MySQL 8.0的递归CTE了这里暂时按下不表。3. 多表查询的性能优化核心3.1 关联字段必须建索引多表查询性能的第一原则ON条件里的关联字段一定要建索引。为什么回到第一节说的Nested-Loop Join。驱动表遍历每一行去被驱动表里找匹配项。如果被驱动表的关联字段有索引一次查找只需要沿着B树走几次I/O基本是毫秒级。如果没有索引每次匹配都要全表扫描那等于是拿驱动表的每一行去全表扫一遍被驱动表。还是拿订单表举例-- 假设这个查询每天跑一次 SELECT c.customer_name, COUNT(o.order_id) FROM orders o JOIN customers c ON o.customer_id c.id WHERE o.created_at 2024-01-01 GROUP BY c.customer_name;orders表1000万行customers表100万行。如果o.customer_id没索引这个查询的执行时间可能是几十分钟。建了索引可能就变成几秒钟。索引字段的选择也有讲究字段类型要一致INT对INTVARCHAR对VARCHAR如果一张表是INT另一张表是VARCHAR索引再好也没用因为MySQL要先把类型转换一遍索引直接失效。这个坑我踩过一次订单表的customer_id是bigint客户表的id是varchar(20)两边数据存得一模一样但JOIN就是慢最后发现是隐式类型转换把索引废了。3.2 驱动表的选择与小表驱动大表搞清楚驱动表是谁也很关键。在老版本的MySQL没有Hash Join的情况下驱动表的连接顺序会影响性能。小表驱动大表是经典原则用行数少的表当驱动表去匹配行数多的表。为什么嵌套循环里驱动表每行都要去被驱动表里查一次。如果驱动表有1万行被驱动表有100万行那就需要执行1万次被驱动表的索引查找。反过来如果驱动表是100万行被驱动表是1万行可能需要执行100万次查找。前者明显快得多。当然MySQL的优化器会自动选择它认为最优的表作为驱动表你可以在EXPLAIN结果里看到最上面一行就是驱动表。但在某些复杂的JOIN场景下优化器也会选错这时候可以通过LEFT JOIN优化器不能随便交换驱动表顺序或者使用STRAIGHT_JOIN指定连接顺序来干预。不过这些都是高级玩法开发中大多数场景只要索引建正确优化器选择通常不会有大问题。3.3 字段类型统一与字符集统一我之前提过字段类型要统一这里再补充一个关联字段的重要前提字符集和排序规则也得统一。MySQL的字符集常见的有utf8mb3一般说的utf8其实是utf8mb3、utf8mb4还有可能有人用latin1。如果两张表的关联字段字符集不同JOIN的时候MySQL会尝试做隐式转换导致被驱动表的关联字段索引失效。最典型的坑就是订单表的customer_id用的是utf8mb4客户表的id用的却是latin1结果就是明明两张表数据没问题JOIN却奇慢无比。从5.7开始MySQL默认字符集就是utf8mb4新项目只要统一用utf8mb4就不会踩这个坑。但老项目或者从别人手里接手的项目一定要检查一下两张关联表的字符集和排序规则是否一致。检查语句很简单SHOW TABLE STATUS WHERE Name orders; SHOW FULL COLUMNS FROM customers;或者直接看建表语句里的DEFAULT CHARSET。一旦发现不一致尽早统一别拖到生产环境线上慢查询告警了再去处理。3.4 避免SELECT *与只取必要字段多表JOIN的中间结果集往往比单表大得多。如果不加选择地SELECT *会把两张表所有列都拉出来内存、网络传输、临时表落盘的消耗都随之翻倍。举个例子-- 反例把customer表和order表所有字段都拖出来 SELECT * FROM orders o JOIN customers c ON o.customer_id c.id; -- 正例只取业务需要的字段 SELECT o.order_id, o.amount, c.customer_name, c.phone FROM orders o JOIN customers c ON o.customer_id c.id;尤其是在做报表查询或者分页查询的时候SELECT *会带来无法利用覆盖索引的额外劣势。所谓覆盖索引就是指查询的字段全都在索引里MySQL可以直接读索引返回结果不需要再回表查数据行。而SELECT *几乎必然带回表操作索引的威力就大打折扣。多表查询本身就比单表复杂尽量把需要的字段列出来别图省事。3.5 EXPLAIN解读与慢SQL定位多表查询的性能调优第一步永远是看EXPLAIN。EXPLAIN SELECT o.order_id, c.customer_name FROM orders o LEFT JOIN customers c ON o.customer_id c.id WHERE o.created_at 2024-06-01;输出结果重点关注这几个字段type访问类型。从好到差排列大概是 system const eq_ref ref range index ALL。起码要保证到range级别出现ALL就是全表扫描多半有问题。key实际使用的索引。如果为NULL说明这条查询没用上索引需要检查关联字段和WHERE字段的索引情况。rows预估需要扫描的行数。多表查询时rows的数字能很直观地看出哪一步扫描最多通常那就是性能瓶颈所在。Extra如果出现Using filesort或者Using temporary就要注意了说明查询有不小的排序或临时表开销。实践中我见过很多慢查询不是SQL写得多复杂而是连EXPLAIN都没看过。学会读EXPLAIN你排查慢SQL的效率至少提升一倍。注意EXPLAIN显示的是预估执行计划不是真实的实际执行极少数情况下实际执行路径和预估差异很大那个属于MySQL优化器的问题开发中比较少见这里不展开。4. 多表查询的经典问题与实战技巧4.1 笛卡尔积灾难与防范笛卡尔积就是两张表所有行两两配对。orders表1000行customers表500行JOIN不带关联条件结果就是50万行绝大多数都是没意义的数据。-- 错误示例忘了写ON条件 SELECT * FROM orders o JOIN customers c; -- 也不建议用WHERE替代ON且忘了写关联条件 SELECT * FROM orders o, customers c;防范方法其实很简单第一统一用显式JOIN ON的写法第二SQL评审或者自查的时候看一遍JOIN关键字后面有没有ON。如果是多个表JOIN每两个表之间都要确认有对应的关联条件。养成写完SQL先EXPLAIN看一眼rows的习惯一旦发现rows数字异常大大概率就是笛卡尔积或者索引没建好。4.2 多表JOIN时别忽略NULL值导致的数据丢失前面讲了LEFT JOIN的ON与WHERE区别其实NULL值还有另一个常见的坑聚合函数。看这个例子-- 查询每个客户的总订单金额 SELECT c.customer_name, SUM(o.amount) AS total_amount FROM customers c LEFT JOIN orders o ON o.customer_id c.id GROUP BY c.customer_name;问题在于客户没有订单时o.amount是NULLSUM(NULL)的结果是NULL不是0。报表展示的时候就会很奇怪有的客户总金额是空而不是0。解决方案是用IFNULL或者COALESCE包一层SELECT c.customer_name, COALESCE(SUM(o.amount), 0) AS total_amount FROM customers c LEFT JOIN orders o ON o.customer_id c.id GROUP BY c.customer_name;顺便提一句COUNT也很容易踩坑。COUNT(具体字段)会忽略该字段为NULL的行所以“统计每个客户的订单数量”时用COUNT(o.order_id)是没问题的没有订单就是0。但如果有人图省事写了COUNT(*)那在LEFT JOIN场景下没订单的客户会被计成1这就是数据错误。所以多表LEFT JOIN后的COUNT操作一定要明确自己到底统计的是哪个表的字段。4.3 多表JOIN配合聚合与分组多表JOIN GROUP BY的组合在写统计报表时很常见但也最容易出现逻辑错误。最经典的问题是JOIN之后导致行数变多进而导致SUM翻倍。假设业务是“查每个客户的订单总金额”。我先查订单主表得到每单金额再JOIN订单明细表计算每个订单的实际商品总金额。如果订单主表里有一行明细表里有三行JOIN之后同一笔订单就会出现三行。这时候去SUM主表里的一个金额字段就会把这个订单的金额算三遍。-- 反例JOIN让主表行变多后SUM主表字段 SELECT c.customer_name, SUM(o.total_amount) FROM customers c LEFT JOIN orders o ON o.customer_id c.id LEFT JOIN order_items oi ON oi.order_id o.id GROUP BY c.customer_name;上述SQL如果只为拿到客户主表单据金额就会翻倍。正确做法是先按明细算好聚合再和主表JOIN或者先对明细表做GROUP BY生成一张子表再关联-- 正例先聚合子查询再JOIN SELECT c.customer_name, oi_sum.amount FROM customers c LEFT JOIN ( SELECT o.customer_id, SUM(oi.item_amount) AS amount FROM orders o JOIN order_items oi ON oi.order_id o.id GROUP BY o.customer_id ) oi_sum ON oi_sum.customer_id c.id;这是多表查询里最容易出“看似正确实则错误”的部分我建议所有写统计报表的人把这个案例刻在脑子里。4.4 分页查询中的多表JOIN优化多表JOIN LIMIT分页是另一个性能深坑。原因是MySQL要先把所有JOIN结果算出来排好序然后再LIMIT取前N条。如果数据量很大这个“先算全部再截取”的过程非常浪费。-- 慢JOIN完再排序再LIMIT SELECT c.customer_name, o.amount FROM customers c LEFT JOIN orders o ON o.customer_id c.id ORDER BY o.created_at DESC LIMIT 10 OFFSET 200000;优化方案是子查询分页-- 先取主表的主键再JOIN SELECT c.customer_name, o.amount FROM ( SELECT id FROM customers ORDER BY id LIMIT 10 OFFSET 200000 ) c LEFT JOIN orders o ON o.customer_id c.id;先在外层把需要翻页的那一页主键取出来再回表JOIN需要展示的字段这样能极大减少JOIN的中间数据量。这种优化方式在数据量大时效果立竿见影我做过一次测试同样的分页SQL从7秒优化到0.1秒。如果分页深度深到百万级OFFSET还可以考虑用游标分页WHERE id 上次最大id LIMIT n的方式彻底避开OFFSET的深翻页问题。4.5 利用子查询还是JOIN业务里经常需要在多表场景中做“查一张表中符合条件的数据”。到底用JOIN还是子查询很多人纠结。我的经验建议是能用JOIN尽量用JOIN因为MySQL优化器对JOIN的优化更成熟。但有两个例外——一种是“只取另一个表是否存在标记”的场景用EXISTS子查询通常比JOIN更高效因为EXISTS遇到第一条匹配就会停止扫描另一种是聚合后再关联的场景例如上面那笔订单汇总子查询先GROUP BY再关联性能往往比直接JOIN更好。-- 查所有有订单的客户EXISTS可能比JOIN更快 SELECT c.customer_name FROM customers c WHERE EXISTS ( SELECT 1 FROM orders o WHERE o.customer_id c.id );注意EXISTS和IN的底层处理逻辑也有差别EXISTS适合子查询表很大的场景IN适合子查询结果集很小的场景。这些细节多看看执行计划自然会有感觉。5. 真实业务场景多表查询完整实战5.1 需求描述假设现在有一个电商系统需要开发一个“销售订单报表”页面。页面展示的数据来自四张表客户表customersid, customer_name, phone, level订单主表ordersid, order_no, customer_id, total_amount, created_at订单明细表order_itemsid, order_id, product_name, quantity, price商品分类表categoriesid, category_name报表要求查询2024年1月到3月期间每个客户下过的订单数量、订单总金额、购买商品种类数。并且按订单总金额降序排列只显示前100名客户。5.2 从需求到SQL的推导过程这个需求涉及四个核心信息块客户信息来自customers订单数量和总金额来自orders按customer_id聚合购买商品种类数需要去重统计商品来自order_items要JOIN订单主表确定属于哪个客户时间过滤和排序基于orders表的created_at第一步先把订单维度的聚合做出来。注意订单总金额可以直接取orders.total_amount但商品种类数需要JOIN明细表。这里如果直接把订单主表和明细表JOINSUM(total_amount)会翻倍所以我先分别聚合再合并。SELECT o.customer_id, COUNT(DISTINCT o.id) AS order_count, SUM(o.total_amount) AS total_amount FROM orders o WHERE o.created_at 2024-01-01 AND o.created_at 2024-04-01 GROUP BY o.customer_id;这是订单统计部分。然后计算商品种类数SELECT o.customer_id, COUNT(DISTINCT oi.product_name) AS product_kind_count FROM orders o JOIN order_items oi ON oi.order_id o.id WHERE o.created_at 2024-01-01 AND o.created_at 2024-04-01 GROUP BY o.customer_id;注意这里没有直接取分类表因为商品种类数统计的是“商品名称”去重数量。如果需求是统计“分类数”才需要JOIN categories表。这个细节要和业务确认清楚很多开发在这里吃亏统计完发现口径不对前端报表数据全废了。最后把两张子查询和客户表JOIN完成整个报表SELECT c.customer_name, c.phone, COALESCE(so.order_count, 0) AS order_count, COALESCE(so.total_amount, 0) AS total_amount, COALESCE(sk.product_kind_count, 0) AS product_kind_count FROM customers c LEFT JOIN ( SELECT o.customer_id, COUNT(DISTINCT o.id) AS order_count, SUM(o.total_amount) AS total_amount FROM orders o WHERE o.created_at 2024-01-01 AND o.created_at 2024-04-01 GROUP BY o.customer_id ) so ON so.customer_id c.id LEFT JOIN ( SELECT o.customer_id, COUNT(DISTINCT oi.product_name) AS product_kind_count FROM orders o JOIN order_items oi ON oi.order_id o.id WHERE o.created_at 2024-01-01 AND o.created_at 2024-04-01 GROUP BY o.customer_id ) sk ON sk.customer_id c.id ORDER BY total_amount DESC LIMIT 100;5.3 这个SQL为什么这么写首先用了两个子查询分别做聚合。订单数和金额放一个子查询商品种类数放另一个子查询。避免订单主表和明细表直接JOIN导致total_amount重复计算。其次用LEFT JOIN保留所有客户。需求是“每个客户”那些没下过单的客户也要显示订单数和金额都是0。如果业务上只查有订单的客户那改成INNER JOIN就好但语义会变要提前和产品确认。最后用COALESCE把NULL转成0。前面讲过LEFT JOIN产生的NULL在报表展示时直接露出NULL会很难看程序端还要额外处理。索引建议也很明确orders表的customer_id、created_at必须建联合索引customer_id, created_atorder_items表的order_id必须建索引customers表的id本来就是主键无需额外处理。这个联合索引能同时覆盖子查询里的等值条件customer_id和范围条件created_at。5.4 这个案例暴露的常见误区很多新手会直接这么写-- 反面教材 SELECT c.customer_name, COUNT(DISTINCT o.id) AS order_count, SUM(o.total_amount) AS total_amount, COUNT(DISTINCT oi.product_name) AS product_kind_count FROM customers c LEFT JOIN orders o ON o.customer_id c.id LEFT JOIN order_items oi ON oi.order_id o.id WHERE o.created_at 2024-01-01 AND o.created_at 2024-04-01 GROUP BY c.customer_name;这样写出来的SQL表面看起来没错实际上问题很大一是JOIN明细表后行数膨胀SUM(o.total_amount)会按明细行数翻倍二是WHERE里过滤了o.created_at没有订单的客户全被滤掉LEFT JOIN约等于INNER JOIN。这就是我为什么强调多表查询一定要想清楚每个JOIN会让行数变多还是变少聚合是在JOIN之前还是之后。逻辑搞清楚了SQL怎么写都不会有大问题。6. 多表查询的进阶思考6.1 联合索引与覆盖索引在多表里的应用回到索引这个话题。多表查询中索引不仅要建还要会用联合索引。假设这个查询很常见“查某客户在某段时间内的订单”SELECT * FROM orders WHERE customer_id 123 AND created_at 2024-01-01 AND created_at 2024-02-01;如果你给customer_id和created_at分别建两个单列索引MySQL一般只会选其中一个用另一个索引就浪费了。正确做法是建一个联合索引customer_id, created_at这样等值条件customer_id能快速定位范围条件created_at在联合索引里也是有序的一次索引扫描就拿到所有结果。在多表JOIN场景中被驱动表的JOIN字段如果恰好是联合索引的最左前缀查找效率会非常高。比如上面那个报表SQLorders表的(customer_id, created_at)联合索引对于子查询里GROUP BY customer_id的操作也很有帮助因为GROUP BY本质上和ORDER BY类似有索引就能避免额外的排序和临时表。6.2 哪些写法会让索引失效这里列几个最常见的索引失效场景尤其是多表关联时更要警惕。隐式类型转换是第一杀手。订单表的customer_id是BIGINT你写SQL的时候用了字符串123MySQL会做隐式转换导致索引失效。解决方案是参数类型要和字段类型对齐。对索引列使用函数也很致命。比如WHERE DATE(created_at) 2024-01-01这样写在created_at上套了DATE函数索引就废了。正确写法是写成范围条件created_at 2024-01-01 AND created_at 2024-01-02。前导通配符的LIKE也会失效比如LIKE %abc因为B树索引只能按前缀匹配。但只要改成abc%索引就能用。多表关联的时候被驱动表的WHERE条件如果上面这些情况性能就完全指望不上了。所以写多表SQL时每张表的过滤条件都要检查一遍是否存在隐式转换或函数包裹。6.3 正确使用外连接与内连接的取舍连接类型的选择要回归业务语义。内连接INNER JOIN意味着“我只关心双方都在的数据”。比如订单和客户如果订单表里有孤儿数据customer_id指向不存在的客户那就查不出来。在给财务对账的报表里这种孤儿数据恰恰不能丢否则对不上账。这时候要用LEFT JOIN先把所有订单都查出来再JOIN客户表客户没匹配上就显示为未知客户。反过来如果你只想知道“有订单的客户”用INNER JOIN没问题但如果你还想把没订单的客户也展示出来做用户运营分析那必须LEFT JOIN。这不仅是SQL语法问题更是业务口径问题。开发前花三十秒和产品确认清楚“这个列表到底哪些行该出现”能省掉上线后一大批数据纠错的事故。6.4 理解临时表与排序优化多表查询里出现Using temporary和Using filesort基本意味着性能不会太好。Using temporary出现在GROUP BY或DISTINCT的时候MySQL需要建一张临时表来存中间结果。临时表在内存里还算快一旦数据量超过临时表上限MySQL就会把它落到磁盘上性能断崖式下跌。Using filesort是排序操作无法利用索引需要使用额外的排序算法。在多表JOIN中ORDER BY的字段如果不是某个索引的组成部分几乎必然出现filesort。优化思路有两种一是给ORDER BY和GROUP BY涉及的字段建联合索引让索引天然有序二是把排序和分组改成在子查询里做让中间结果集更小。实在避免不了的场景就接受现实但至少要确保数据量可控别让大数据量的filesort打满内存。6.5 一个容易被忽略的问题JOIN太多张表业务中有人喜欢一张主表JOIN七八张表把所有东西都查出来。这样做方便是方便但副作用也很明显一是SQL可读性差后续维护改起来很痛苦二是JOIN表越多优化器选错执行计划的概率越高三是中间结果集可能会膨胀得很厉害。我个人的经验是一张查询超过三四张表的时候先停下来想想能不能拆成几步比如先查主表数据再通过IN子查询批量查子表数据在业务代码里组装。很多情况下代码里拼一下反而比SQL强行JOIN性能更好、逻辑更清晰。尤其现在ORM框架大行其道很多人习惯关联关系直接懒加载但懒加载在列表页面会有N1查询问题——查一次列表N条数据每条再查一次子表总共N1次SQL。这种场景下多表JOIN一次查出所有数据或者用IN子查询分批加载都比懒加载高效得多。7. 写在最后的几句经验我做MySQL开发和优化这些年踩过最多的坑几乎都集中在多表查询上。这里分享几个我常用的自查清单第一SQL写完先跑EXPLAIN重点看type、key、rows三项出现ALL和NULL就要警觉。第二LEFT JOIN场景里对右表字段的过滤条件一律写在ON里除非你真的想把它变成INNER JOIN。第三多表聚合场景凡是涉及SUM先问自己一句JOIN会不会让行数变多如果会就要先聚合再关联。第四关联字段的类型、字符集、排序规则必须一致不一致先统一否则索引等于白建。第五ORDER BY和GROUP BY字段尽量用索引字段避免filesort和临时表。第六不要把SQL写成“一锅端”JOIN三张表以上还嫌不够的时候想想能不能拆查询、分批加载。多表查询其实不难难的是把执行计划、索引、数据分布、业务语义串起来看。希望这篇长文能帮你把这几条线理顺。有问题欢迎在评论区交流也欢迎补充你日常踩过的多表查询深坑。
返回列表