ARTICLE DETAIL

资讯详情

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

SQL JOIN详解:从底层原理到性能优化实战

SQL JOIN详解:从底层原理到性能优化实战 2. 核心细节解析与实操要点先把这层“为什么”的窗户纸捅破无论哪种JOIN底层做的事只有一件——把两张或更多张表的记录按某个条件做“配对”本质上是笛卡尔积的筛选。说白了就是先让左表的每一行去尝试匹配右表的每一行匹配不上的记录根据JOIN类型决定要不要保留。搞懂这个底层逻辑很多看起来莫名其妙的查询结果就都能解释了。2.1 INNER JOIN只要两边都有的“内层人”INNER JOIN返回的是左表和右表中满足连接条件的交集部分。学生表里有张三成绩表里没有张三的记录那么INNER JOIN的结果里就不会出现张三。SELECT s.id, s.name, sc.score FROM student s INNER JOIN score sc ON s.id sc.student_id;常见误区有些人写INNER JOIN会把过滤条件放在ON后面不放WHERE里。这两种写法对INNER JOIN来说结果是一样的但语义不清晰。ON只负责“怎么连”WHERE负责“连完之后筛什么”分开写代码可读性会高很多。2.2 LEFT JOIN左表是主角右表是配角LEFT JOIN的语义是左表的记录全部保留右表能匹配上就带上右表的字段匹配不上就用NULL填充。这是业务系统里最常用的关联方式但也是坑最多的一种。最大的坑就是“一对多导致数据翻倍”如果右表里有多条记录能匹配上左表的一行那么左表这一行就会被复制成多行返回。比如左表是订单表一笔订单一行右表是订单明细表一笔订单有多条明细LEFT JOIN之后一个订单会变成多行。注意LEFT JOIN的结果行数一定不少于左表行数。如果你发现结果行数比左表多不用怀疑右表存在重复匹配记录。排查思路就是去找右表的关联字段是否有重复值用GROUP BY 右表关联字段 HAVING COUNT(*) 1查一下就知道。2.3 RIGHT JOIN和LEFT JOIN正好反过来RIGHT JOIN以右表为主左表匹配不上用NULL填充。实际工作中RIGHT JOIN基本可以被LEFT JOIN替代——把表的顺序换一下就行。我个人建议统一用LEFT JOIN因为大多数人习惯从左往右读SQLLEFT JOIN在主从关系上更符合阅读直觉也方便后续维护。2.4 CROSS JOIN显式的笛卡尔积CROSS JOIN没有任何连接条件左表的每一行都会跟右表的每一行做组合。如果左表有100行右表有200行结果就是20000行。这种查询在大多数业务场景里都是灾难但有两个场景是它的用武之地一是生成序号表、日期维度表之类的辅助数据。比如你要生成最近30天的日期序列可以把一个数字表和日期起始值做CROSS JOIN。二是某些特定的矩阵计算场景把两组数据做全组合。-- 生成1到10的整数序列 SELECT a.n b.n * 10 1 AS num FROM (SELECT 0 AS n UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9) a CROSS JOIN (SELECT 0 AS n UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9) b WHERE a.n b.n * 10 10;2.5 FULL OUTER JOIN的替代方案MySQL其实没有原生的FULL OUTER JOIN语法PostgreSQL、SQL Server有。但在某些场景下你确实需要“左表和右表的记录都全部出现匹配不上就用NULL填充”的效果比如对比两套数据源的差异。这种情况可以用UNION来凑-- 模拟FULL OUTER JOIN SELECT s.id, s.name, sc.score FROM student s LEFT JOIN score sc ON s.id sc.student_id UNION SELECT s.id, s.name, sc.score FROM student s RIGHT JOIN score sc ON s.id sc.student_id;注意这里要用UNION而不是UNION ALL因为两个表如果都有的记录LEFT JOIN和RIGHT JOIN的结果会出现重复行UNION会去重。3. 实操过程与核心环节实现直接上一套完整的实战案例。假设我们有一个简单的图书管理数据库三张表books图书表、categories分类表、borrow_records借阅记录表。对应的建表语句和测试数据如下CREATE TABLE categories ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL ); CREATE TABLE books ( id INT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(100) NOT NULL, category_id INT, price DECIMAL(10,2), FOREIGN KEY (category_id) REFERENCES categories(id) ); CREATE TABLE borrow_records ( id INT PRIMARY KEY AUTO_INCREMENT, book_id INT NOT NULL, borrow_date DATE NOT NULL, return_date DATE, FOREIGN KEY (book_id) REFERENCES books(id) ); INSERT INTO categories (name) VALUES (编程技术), (文学小说), (历史传记), (没有书籍的分类); INSERT INTO books (title, category_id, price) VALUES (MySQL实战, 1, 89.00), (高性能Java, 1, 99.00), (三体, 2, 68.00), (人类群星闪耀时, 3, 56.00); INSERT INTO borrow_records (book_id, borrow_date, return_date) VALUES (1, 2024-11-01, 2024-11-15), (1, 2024-12-01, NULL), (2, 2024-12-10, 2024-12-20), (4, 2024-12-15, NULL);注意我插入了一条分类表里有、但书籍表里没有对应书籍的记录分类ID为4还插入了一本没有被任何记录借阅的书书ID为3。这样案例才有对比价值。3.1 查询每本书及其分类名称INNER JOIN这是最常见的“多表联查”需求SELECT b.id, b.title, c.name AS category_name FROM books b INNER JOIN categories c ON b.category_id c.id;结果为1 MySQL实战 编程技术2 高性能Java 编程技术3 三体 文学小说4 人类群星闪耀时 历史传记“没有书籍的分类”没有出现在结果里因为INNER JOIN只返回两端匹配成功的记录。3.2 查询所有分类及每个分类下的图书数量LEFT JOIN 聚合这个需求要求“分类全部显示出来包括没有书籍的分类”所以LEFT JOIN很合适SELECT c.id, c.name, COUNT(b.id) AS book_count FROM categories c LEFT JOIN books b ON c.category_id b.category_id GROUP BY c.id, c.name;结果为1 编程技术 22 文学小说 13 历史传记 14 没有书籍的分类 0这里用COUNT(b.id)而不是COUNT(*)因为COUNT(b.id)会忽略NULL值没有书的分类计数为0而不是1。3.3 自连接的经典应用查找同类书籍假设books表本身就能自己跟自己关联——找出同一分类下、但价格不同的书SELECT a.title AS book_a, b.title AS book_b, a.category_id, a.price FROM books a INNER JOIN books b ON a.category_id b.category_id WHERE a.id b.id;结果是编程技术分类下的两本书互相配对展示。这个“通过别名把一张表当成两张表用”的技巧在查找重复数据、树形结构比如员工-上级关系里特别常用。3.4 三表关联查询借阅记录对应的书名和分类这个SQL同时用到了INNER JOIN和LEFT JOINSELECT r.id, b.title, c.name AS category_name, r.borrow_date, r.return_date FROM borrow_records r INNER JOIN books b ON r.book_id b.id LEFT JOIN categories c ON b.category_id c.id;借阅记录里出现过的书ID为1、2、4都能查出完整信息没有借阅记录的书不出现。LEFT JOIN在这里的作用是为了避免书籍的category_id为NULL时丢失记录。3.5 三表JOIN的执行顺序MySQL优化器会自动调整连接顺序不一定会按照你写的顺序执行。但作为开发者理解“先两表关联生成中间结果再和第三张表关联”这个逻辑对排查问题很有帮助。以上面的三表JOIN为例过程大致是先根据连接条件从borrow_records和books找出匹配行生成中间结果集再把中间结果集和categories做LEFT JOIN最后SELECT出最终需要的字段。如果JOIN的中间结果集很大整个查询就会慢。所以优化JOIN查询的第一步永远是想办法把中间结果集缩小。4. JOIN查询的性能优化与索引策略很多人写JOIN只关心结果对不对不关心性能。等数据量到百万级、千万级的时候一条没加索引的JOIN语句能让数据库CPU跑满整个业务卡死。JOIN的性能问题核心就两件事驱动表的选择以及索引的利用。4.1 驱动表怎么选小表驱动大表JOIN的执行逻辑是嵌套循环Nested Loop简单说就是拿外层表的每一行去内层表里做匹配。如果外层表驱动表有100行内层表有10000行且关联字段有索引那么只需要做100次索引查找反过来如果驱动表是10000行内层表是100行就要做10000次查找。所以原则很简单永远让小表做驱动表。MySQL优化器在大多数情况下会自动选择小表驱动大表但有些时候优化器会“犯迷糊”尤其是统计信息不准确的时候。这时候你可以使用STRAIGHT_JOIN强制指定驱动顺序SELECT STRAIGHT_JOIN c.name, COUNT(b.id) FROM categories c LEFT JOIN books b ON c.id b.category_id GROUP BY c.id, c.name;4.2 ON条件上的关联字段必须建索引JOIN的关联字段如果没有索引内层表上的匹配就是全表扫描。数据量一大性能直线下降。判断逻辑很简单LEFT JOIN中驱动表的关联字段有没有索引影响不大被驱动表的关联字段必须建索引。因为每次都是从驱动表取一行然后去被驱动表找匹配行被驱动表如果没有索引就要做全表扫描——这等于驱动表的每行都触发一次全表扫描。用一个具体场景衡量如果驱动表有1000行被驱动表有10万行且无索引总扫描次数是1000乘以10万等于1亿次。这是灾难级别的慢查询。经验值被驱动表的关联字段一定要建索引。主键本身默认有索引外键字段默认也有但普通业务字段需要手动建例如ALTER TABLE books ADD INDEX idx_category_id (category_id);。4.3 用EXPLAIN查看执行计划对于任何JOIN查询写完第一件事就是执行EXPLAIN SELECT ...看执行计划。重点看这几个字段type被驱动表的访问类型至少应该是ref或eq_ref如果出现ALL全表扫描说明被驱动表关联字段没有索引或索引失效。key实际用到的索引名称如果是NULL说明没走索引。rows预估扫描的行数数值越小越好。如果驱动表的rows远大于被驱动表排查是否选错了驱动表。Extra出现Using filesort或Using temporary时要警惕排序和分组产生的临时表开销。4.4 避免在ON和WHERE上使用函数在关联字段上使用函数索引会失效。例如-- 错误示范在category_id上用了函数 SELECT * FROM books b LEFT JOIN categories c ON DATE_FORMAT(c.created_at, %Y-%m-%d) DATE_FORMAT(b.created_at, %Y-%m-%d); -- 正确做法直接比较日期范围 SELECT * FROM books b LEFT JOIN categories c ON c.created_at 2024-01-01 AND c.created_at 2024-01-02;这就像你按拼音首字母查字典的时候规定必须先把每一个字转换成笔画数才能查原有的拼音索引就白建了。4.5 ON和WHERE的过滤时机不同结果可能完全不同这是LEFT JOIN里最容易踩的坑之一。ON条件在JOIN的过程中参与匹配。WHERE条件在JOIN完成之后做最终过滤。上面这行字读一遍绝大多数“LEFT JOIN结果对不上”的问题就解决了。举个最典型的例子-- 需求查所有分类及其书籍但只统计价格高于80元的书 SELECT c.name, COUNT(b.id) AS expensive_book_count FROM categories c LEFT JOIN books b ON c.id b.category_id AND b.price 80 GROUP BY c.id, c.name;如果把AND b.price 80从ON里挪到WHERE里SELECT c.name, COUNT(b.id) AS expensive_book_count FROM categories c LEFT JOIN books b ON c.id b.category_id WHERE b.price 80 GROUP BY c.id, c.name;结果会完全不一样。第一种写法LEFT JOIN先把匹配条件价格80限定住不在这个范围的记录用NULL填充分类全部保留第二种写法先做全表LEFT JOIN然后WHERE把NULL值以及所有不满足价格条件的行都过滤掉了最终结果里“没有书籍的分类”和“有便宜书籍的分类”都会消失。这个微妙区别我见过不止一次在生产环境的报表统计里引发数据对不上的事故。排查思路也很简单把WHERE里涉及右表非空字段的条件挪到ON里试试对比结果行数。5. 常见问题与排查技巧实录把这些年实战中积累的JOIN相关经典问题和对应解法整理成一张速查表现象根本原因排查方法LEFT JOIN结果行数比左表多右表关联字段存在重复值按右表关联字段GROUP BYHAVING COUNT(*) 1 找重复LEFT JOIN结果行数比左表少WHERE里过滤了右表字段为NULL的行检查WHERE中是否包含右表字段的条件把条件移入ON查询结果出现多条一模一样的记录多表JOIN时存在多条匹配路径用DISTINCT去重或检查关联字段是否有重复记录大表JOIN查询特别慢被驱动表关联字段没有索引EXPLAIN看type是否出现ALL给关联字段加索引查出来的数据看起来重复膨胀多表间存在“一对多再对多”的连环关系拆开JOIN先用子查询把一对多的问题折叠成一行用JOIN更新数据时报错目标表出现重复匹配导致无法确定更新哪行先查重复确保关联字段在更新目标侧是唯一的5.1 用JOIN UPDATE误更新多行的坑MySQL允许在UPDATE语句里使用JOIN但这个能力一旦用错后果比SELECT严重得多-- 需求把编程技术分类下的所有书籍价格加10元 UPDATE books b INNER JOIN categories c ON b.category_id c.id SET b.price b.price 10 WHERE c.name 编程技术;这种写法本身没问题但如果categories表里有重复的分类名——理论上不会但现实数据里总有意外——就会导致一个书籍行被更新多次。所以JOIN UPDATE之前先确认连接条件在被更新表的每一行上只对应唯一的一条匹配记录。5.2 多表JOIN时括号与嵌套的问题当JOIN的表超过3张时建议用括号明确分组逻辑虽然MySQL的语法不一定强制要求SELECT ... FROM (books b INNER JOIN categories c ON b.category_id c.id) LEFT JOIN borrow_records r ON b.id r.book_id;这样写的好处是让执行顺序一目了然后续接手的人也能快速理解你的关联逻辑。5.3 JOIN和子查询怎么选先说结论同一场景下优先考虑JOIN。原因有两点第一MySQL对JOIN的优化比子查询更成熟。特别是在IN子查询的场景下查询优化器有时候能把IN转换成半连接semi-join但转换失败时IN子查询会退化成逐行执行的“相关子查询”性能非常差。第二JOIN在语义上更容易配合索引和EXPLAIN排查性能问题。子查询嵌套层次一深EXPLAIN的结果自己都想摔键盘。但有一个场景子查询反而更合适当你在子查询里用到LIMIT或聚合排序时先折叠成小结果集再关联能显著减少中间行数。比如“查每本书的最新一条借阅记录”先在子查询里用窗口函数或GROUP BY取到最新记录再JOIN原表。5.4 关于“join the ripper工具”这个热搜词浏览热搜词的时候发现有不少人搜“join the ripper”这类说法。这个其实是另一个领域的概念了——它是一种密码恢复工具和数据库的JOIN关键字完全不是一回事。大家搜索的时候要注意区分数据库领域的JOIN指的是表连接操作。如果是在MySQL里遇到“密码丢失”的问题通常和数据库账号权限配置有关从安全合规的角度出发正规操作是通过管理员权限重新授权或走官方重置流程。结尾一个实战经验贴士最后分享一个我自己写了几百条JOIN查询之后沉淀下来的习惯每写一条JOIN先在心里默念一遍“主表是谁、附表是谁、连接条件是什么、过滤条件放在哪个阶段”。这四个问题想清楚90%的JOIN相关bug都不会发生。还有一个排查数据的万能套路当你发现JOIN的结果“看起来不太对”的时候不要急着改SQL先把关联字段的分布情况摸一遍——左表每一行在右表里到底匹配了几行。直接执行一条带HAVING的GROUP BY查询看分布通常几分钟内就能定位到问题根源。JOIN本身并不难难的是对数据本身的理解。把数据摸透了JOIN自然就玩顺了。
返回列表