说实话,干了这么多年开发和数据库运维,我见过太多因为多表查询写得随意而导致的线上事故。要么是少了一个关联条件直接跑出笛卡尔积,要么是 LEFT JOIN 和 INNER JOIN 混用导致数据对不上,更有甚者一张报表 SQL 把生产库 CPU 直接打满。SQL 多表查询是后端开发、数据分析、运维面试都绕不开的硬骨头。这篇文章我把自己多年实战中总结的连接逻辑、去重方案、NULL 陷阱、慢查询排查经验全部梳理出来,配合一个完整的订单查询案例,从原理到实操一步步拆解,不管你是刚入门的新手,还是被线上慢 SQL 折磨过的老手,都能从中找到可以直接用的方案。
1. 多表查询,不只是“多写几个 JOIN”的事
1.1 为什么我们绕不开多表查询
先聊一个最基础的问题:为什么数据库要把数据拆到多张表里,而不是全塞在一张表里?这就要回到关系型数据库的范式化设计。以电商系统为例,用户信息、订单信息、商品信息、支付记录,如果全部放在一张表里,会出现大量冗余。用户买了 100 单,用户的姓名、地址、手机号就要跟着订单重复存储 100 次,不仅浪费存储空间,还会带来更新异常——用户改了手机号,必须把所有相关记录同步更新,只要漏掉一条,数据就变得不一致了。
所以标准的做法是拆表:用户表存用户基本信息,订单表只存 user_id 这个外键。查询的时候,再通过多表查询把分散在不同表里的信息重新“拼”回来。可以这么说,表设计是拆,多表查询是拼,一拆一拼之间,就是关系型数据库的核心玩法。
多表查询也不是随便 join 一下就完事。实际业务里还要考虑:连接字段有没有索引?数据量大不大?连接顺序怎么调整?要不要去重?NULL 值会不会影响结果?这些细节决定了同样的业务需求,你的 SQL 是秒回还是把数据库拖垮。
1.2 工具与版本说明(实操前提)
这篇文章里的建表语句和查询示例,我以 MySQL 8.0 为主,同时会标注 SQL Server 和通用 SQL 的差异点。MySQL 8.0 是当前使用最广的版本,窗口函数、CTE 公共表表达式这些特性都支持,示例代码可以直接跑。如果你用的是 SQL Server 2019 或者更高版本,绝大部分语法也是通用的,只有少数分页写法、字符串拼接函数会不同。为了方便演示,我建了三张简单的表:用户表 users、订单表 orders、商品表 products,后面所有的案例都围绕这三张表展开。
-- 用户表 CREATE TABLE users ( id INT PRIMARY KEY, name VARCHAR(50), city VARCHAR(50) ); -- 商品表 CREATE TABLE products ( id INT PRIMARY KEY, product_name VARCHAR(100), price DECIMAL(10, 2) ); -- 订单表 CREATE TABLE orders ( id INT PRIMARY KEY, user_id INT, product_id INT, quantity INT, order_date DATE );这三张表的关联关系很直观:orders 通过 user_id 关联 users,通过 product_id 关联 products。后面所有案例都是在这个结构上展开的,你完全可以照着建表,边看边跑。
1.3 从业务模型理解“连接”是什么
多表查询的本质,我习惯用一个比喻来解释:笛卡尔积就是“所有人跟所有东西配对”。
假设用户表有 3 条记录,订单表有 5 条记录,这两张表直接 FROM users, orders 不带任何条件,就会得到 3 × 5 = 15 条记录,每条用户记录都会跟每条订单记录组合一次。这个行为叫笛卡尔积,绝大多数情况下它都是灾难。多表连接做的事情,就是先产生笛卡尔积这个“全集”,再用连接条件从里面筛出真正有意义的组合。
理解这一点非常重要。因为后面你遇到莫名其妙的重复数据、数据量暴涨,第一个要怀疑的就是连接条件没写全,导致部分行发生了“交叉配对”。初学阶段我踩过最大的坑就在这里——两个表明明各有 100 条数据,join 之后查出来 1 万条,当时还一脸懵,后来才反应过来这就是笛卡尔积。
2. 核心细节解析与实操要点
2.1 六种连接的适用场景对照
SQL 标准里的连接类型,我整理成了一张对照表,方便你按场景快速选择。
| 连接类型 | 关键字 | 语义 | 典型使用场景 |
|---|---|---|---|
| 内连接 | INNER JOIN | 只返回两表中匹配成功的记录 | 查“有订单的用户”、订单与商品的有效对应关系 |
| 左外连接 | LEFT JOIN | 返回左表全部记录,右表无匹配则补 NULL | 查“所有用户及其订单,没下单的用户也要列出来” |
| 右外连接 | RIGHT JOIN | 返回右表全部记录,左表无匹配则补 NULL | 场景较少,通常可以用 LEFT JOIN 翻转表顺序替代 |
| 全外连接 | FULL OUTER JOIN | 两表全部记录都返回,无匹配补 NULL | 查“两个表的全量差异对比”,MySQL 不直接支持 |
| 交叉连接 | CROSS JOIN | 返回笛卡尔积 | 生成测试数据、排列组合场景 |
| 自连接 | 表自己 JOIN 自己 | 同一张表当作两张表使用 | 查“员工和上级”、“商品分类层级” |
实际开发中用得最多的是 INNER JOIN 和 LEFT JOIN,这两者的区别很多人面试都背过,但一上手就容易混。我自己的判断标准就一句话:看你要不要保留“没有匹配上的那一侧”。
比如查每个用户的订单,如果只想看下过单的人,用 INNER JOIN;如果想看所有用户,包括注册了但从没下过单的人,必须用 LEFT JOIN。RIGHT JOIN 不是不能用,但代码可读性不如 LEFT JOIN,我一般会统一改成 LEFT JOIN 加换表顺序。FULL OUTER JOIN 在 MySQL 8.0 里不直接支持,需要 UNION 实现,等会儿实操环节会讲。
2.2 ON 和 WHERE,写错位置结果差很多
这是多表查询里最容易翻车的地方。LEFT JOIN 的 ON 条件和 WHERE 条件,执行时机完全不同,直接影响最终结果里“左表记录会不会消失”。
ON 是在生成连接结果之前进行匹配条件过滤,WHERE 是在连接完成之后对结果集做过滤。对于 INNER JOIN,两者结果等价;但对于 LEFT JOIN,天差地别。举个实际例子:
-- 查询所有用户及订单,同时只保留上海的订单(错误示范) SELECT u.name, o.id FROM users u LEFT JOIN orders o ON u.id = o.user_id WHERE u.city = '上海'; -- 查询所有用户及订单,同时只保留上海的订单(正确示范) SELECT u.name, o.id FROM users u LEFT JOIN orders o ON u.id = o.user_id AND o.order_date >= '2024-01-01';第一个例子如果 ON 里放的不是过滤订单的条件,而是 im WHERE 里过滤,比如 WHERE o.order_date >= '2024-01-01',那么没有下单的用户会因为右表字段是 NULL 而被整行剔除,LEFT JOIN 就悄悄退化成 INNER JOIN 了。很多新手发现“明明用了 LEFT JOIN,怎么记录还是少了”,十有八九是这个原因。
我的实践习惯是:想限制右表的数据范围,条件写在 ON 里;想对整体结果做过滤,条件写在 WHERE 里。这条规则我踩过好几次坑才固化成肌肉记忆。
2.3 去重:DISTINCT 和 GROUP BY 怎么选
多表查询因为连接会产生重复数据,去重是个高频需求。热词里出现“SQL 去重”“mssql 去重多表查询”,说明这是很多人实际工作里的痛点。
DISTINCT 的作用是对结果集的行做去重,它的逻辑很简单:SELECT DISTINCT user_id FROM orders,就是把订单表里出现过的用户 id 列出来,每个 id 只出现一次。但 DISTINCT 有局限性——一旦 SELECT 里同时查询多个字段,只要这些字段的组合不完全相同,就不会去重。比如 SELECT DISTINCT user_id, product_id FROM orders,同一用户买过不同商品,user_id 会多次出现,因为两行的 product_id 不同。
GROUP BY 则是分组聚合,除了去重,还能配合 COUNT、SUM、MAX 等聚合函数做统计分析。比如要统计每个用户的订单数:
SELECT user_id, COUNT(*) AS order_cnt FROM orders GROUP BY user_id;如果只是单纯的“把这个字段的值列出来,不重复”,用 DISTINCT 更简单直接;如果要“按某个维度分组并统计”,必须用 GROUP BY。
还有一个去重的高级场景是窗口函数 ROW_NUMBER()。比如订单表里同一个用户对同一商品下了多笔订单,我只想保留每个用户最近一笔订单,就可以用 ROW_NUMBER() 配合 PARTITION BY:
SELECT * FROM ( SELECT *, ROW_NUMBER() OVER (PARTITION BY user_id, product_id ORDER BY order_date DESC) AS rn FROM orders ) t WHERE t.rn = 1;这个写法在做“分组取最新一条”时非常实用,也是面试里经常考察的窗口函数考点。关于窗口函数,后面实操章节我会再展开。
2.4 NULL,多表查询里最容易翻车的点
NULL 不是 0,也不是空字符串,它表示“未知、不存在”。多表查询里,尤其 LEFT JOIN 之后,右表没有匹配到的字段就会补 NULL。这个设计很合理,但它带来两个经典问题。
第一个问题:NULL 参与算术运算,结果永远是 NULL。比如要算订单总金额,ORDER 表里如果 quantity 或 price 存在 NULL,quantity * price 算出来就是 NULL,最后 SUM 出来的结果也会被“污染”。解决方法是加 IFNULL 或者 COALESCE 做空值兜底:
SELECT COALESCE(SUM(quantity * price), 0) AS total_amount FROM orders o JOIN products p ON o.product_id = p.id;第二个问题:NULL 无法用等号比较。WHERE user_id = NULL 是永远查不到数据的,必须写成 IS NULL 或者 IS NOT NULL。这个坑在 LEFT JOIN 场景里尤其常见——想查“没有下过单的用户”,正确写法是 WHERE o.id IS NULL,而不是 WHERE o.id = NULL。我见过不少刚入行的同事在这里卡半天,一直想不通为什么条件没问题却查不到数据。
3. 实操过程与核心环节实现
3.1 案例背景与建表脚本
理论讲再多,不如直接跑一遍。现在开始一个完整的实操案例,模拟一个电商平台的订单查询需求。我先准备测试数据,让后面的每个查询都有真实的结果可以验证。
INSERT INTO users (id, name, city) VALUES (1, '张三', '北京'), (2, '李四', '上海'), (3, '王五', '广州'), (4, '赵六', '深圳'); INSERT INTO products (id, product_name, price) VALUES (1, '手机', 4999.00), (2, '电脑', 8999.00), (3, '耳机', 299.00), (4, '键盘', 199.00); INSERT INTO orders (id, user_id, product_id, quantity, order_date) VALUES (1, 1, 1, 1, '2024-01-10'), (2, 1, 3, 2, '2024-01-12'), (3, 2, 2, 1, '2024-01-15'), (4, 3, 1, 1, '2024-02-01'), (5, 3, 4, 1, '2024-02-03'), (6, 2, 3, 1, '2024-02-05'), (7, 4, NULL, NULL, NULL);注意第 7 条订单,我用它来模拟一些边界数据:user_id 是 4,但 product_id 和 quantity 是 NULL。这种数据在实际库中很常见,可能是下单流程异常或者历史数据问题,处理不好会让统计数据出偏差。后面我会专门演示这类脏数据怎么处理。
3.2 基础查询:内连接与左连接怎么写
先看内连接。业务需求:查出所有订单,并显示下单用户姓名和商品名称。
SELECT o.id AS order_id, u.name AS user_name, p.product_name, o.quantity, o.order_date FROM orders o INNER JOIN users u ON o.user_id = u.id INNER JOIN products p ON o.product_id = p.id;执行结果有 6 条订单记录,第 7 条因为 product_id 是 NULL 匹配不上商品表,被过滤掉了。这就是 INNER JOIN 的行为——只要有一端匹配不上,整条记录就丢弃。
再看左连接。业务需求:查出所有用户的订单情况,没下过单的用户也要列出来,订单信息为空就显示 NULL。
SELECT u.id AS user_id, u.name, u.city, o.id AS order_id, p.product_name FROM users u LEFT JOIN orders o ON u.id = o.user_id LEFT JOIN products p ON o.product_id = p.id;结果返回 4 行(4 个用户),其中赵六没有任何有效订单,order_id 和 product_name 都是 NULL。这个查询能回答“有多少用户从未下单”这个问题:
SELECT u.id, u.name FROM users u LEFT JOIN orders o ON u.id = o.user_id WHERE o.id IS NULL;返回结果就是赵六一个人。这套写法是查“左表有但右表没有”的通用模式,一定要熟练掌握。
3.3 进阶查询:子查询与自连接
子查询在多表场景下有两种常见用法:一种是在 WHERE 里用 IN 或 EXISTS 做条件过滤,另一种是在 FROM 里把子查询当作派生表用。业务需求:找出在 2024 年 2 月下过单的用户。
SELECT id, name FROM users WHERE id IN ( SELECT DISTINCT user_id FROM orders WHERE order_date >= '2024-02-01' AND order_date < '2024-03-01' );IN 和 EXISTS 在很多情况下可以互相替换,但数据量大时执行计划可能完全不同。经验上,外部表数据量小、内部表数据量大时,IN 常常表现更好;外部表数据量大、内部子查询结果小,EXISTS 的“短路”特性更占优势。不过现代数据库优化器已经比较聪明,很多时候会自动改写,判断标准还是要看实际执行计划。
自连接是另一类有代表性的多表查询。业务需求:产品分类层级表,比如“电子产品”下面有“手机”“电脑”,“手机”下面还有“手机壳”。我先建一张分类表演示:
CREATE TABLE categories ( id INT PRIMARY KEY, category_name VARCHAR(50), parent_id INT ); INSERT INTO categories VALUES (1, '电子产品', NULL), (2, '手机', 1), (3, '电脑', 1), (4, '手机壳', 2);要查出每个分类及其上级分类,用自连接:
SELECT child.category_name AS child_name, parent.category_name AS parent_name FROM categories child LEFT JOIN categories parent ON child.parent_id = parent.id;这里的关键是把同一张表复制成两张逻辑表,child 表示子级,parent 表示父级。LEFT JOIN 用来保留顶层分类(parent_id 为 NULL 的“电子产品”),它的父级显示 NULL。自连接在组织架构、评论回复、商品多级分类等场景里很常用,掌握了这个写法,遇到树形结构数据不会慌。
3.4 汇总统计:GROUP BY 加多表关联
多表查询加上聚合,是写报表 SQL 最常见的组合。业务需求:统计每个用户的订单数、下单总件数、消费总金额。
SELECT u.id AS user_id, u.name AS user_name, COUNT(o.id) AS order_cnt, COALESCE(SUM(o.quantity), 0) AS total_quantity, COALESCE(SUM(o.quantity * p.price), 0) AS total_amount FROM users u LEFT JOIN orders o ON u.id = o.user_id LEFT JOIN products p ON o.product_id = p.id GROUP BY u.id, u.name;几个细节值得说:
- 用 LEFT JOIN 而不是 INNER JOIN,是为了把一单都没下过的赵六也统计进来,他对应的 COUNT 是 0。
- 用 COALESCE 对 SUM 做兜底。如果该用户订单全是 NULL,SUM 的结果是 NULL,COALESCE 把它转成 0,避免前端展示出现空白。
- GROUP BY 后面把 u.id 和 u.name 都写上。MySQL 在只开启默认 sql_mode 时允许只按主键分组,但更严谨的做法是:你 SELECT 里出现的非聚合列,最好都写进 GROUP BY。MySQL 8.0 默认开启了 ONLY_FULL_GROUP_BY,如果漏了会直接报错。
统计过程中,第 7 条订单因为 product_id 为 NULL,关联 products 后 price 为 NULL,total_amount 计算时这一行不会贡献金额,但 COUNT(o.id) 会把这条记录算进去。这就是脏数据带来的统计口径问题。实际做报表前,一定要先确认好:无效订单到底算不算“订单数”?这个口径问题必须跟业务方对齐,而不是自己拍脑袋。
3.5 窗口函数的引入:让多表查询和分组统计更灵活
GROUP BY 会把多行合并成一行,但有些场景我们既想看聚合结果,又不想丢失明细行的信息,这时候窗口函数就派上用场了。窗口函数也是热词里出现的内容,它是 SQL 进阶的一道坎,但其实原理不复杂。
窗口函数的核心语法是 OVER (PARTITION BY 分组字段 ORDER BY 排序字段)。业务需求:给每个用户的订单按时间排序,标出他的第 1 单、第 2 单、第 3 单。
SELECT u.name, o.order_date, o.product_id, ROW_NUMBER() OVER (PARTITION BY u.id ORDER BY o.order_date) AS row_no, RANK() OVER (PARTITION BY u.id ORDER BY o.order_date) AS rank_no FROM orders o JOIN users u ON o.user_id = u.id;这里 PARTITION BY u.id 的意思是“按用户分组,每个用户内部重新编号”,ORDER BY o.order_date 决定“编号的顺序依据日期从早到晚”。ROW_NUMBER() 和 RANK() 的区别在于遇到并列时的处理:ROW_NUMBER() 永远给出连续不重复的序号;RANK() 遇到相同的排序值会并列,并且下一个号会跳号;还有一个 DENSE_RANK() 并列后不跳号。如果只是纯粹标第几笔订单,ROW_NUMBER() 最合适;如果要算排行榜名次,RANK() 和 DENSE_RANK() 用得多。
窗口函数还有一个常用场景是“分组后取 Top N”:
SELECT * FROM ( SELECT o.user_id, o.product_id, o.order_date, ROW_NUMBER() OVER (PARTITION BY o.user_id ORDER BY o.order_date DESC) AS rn FROM orders o ) t WHERE t.rn <= 2;这个写法返回每个用户最近 2 笔订单。同样的需求,如果不用窗口函数,通常要写复杂的子查询加关联,可读性和性能都不理想。MySQL 8.0 之后窗口函数已经很成熟,建议有条件就尽量用。
3.6 全外连接与分页查询
MySQL 不直接支持 FULL OUTER JOIN,但业务里有时确实需要“把两表的差异都补全”。比如:查所有用户和所有订单的对应情况,不管用户有没有订单、订单是否属于已知用户(比如订单表里有外键约束缺失的脏数据),都要全部显示。用 UNION 实现:
SELECT u.id AS user_id, u.name, o.id AS order_id FROM users u LEFT JOIN orders o ON u.id = o.user_id UNION SELECT u.id AS user_id, u.name, o.id AS order_id FROM orders o LEFT JOIN users u ON o.user_id = u.id;UNION 自带去重,如果想去掉去重的额外开销,数据本身没有重复时可以改成 UNION ALL,性能更好。这个写法是面试里经常被追问的“用 UNION 模拟 FULL OUTER JOIN”,实际业务中遇到数据对齐场景时非常好使。
分页查询也是多表查询的高频配套需求。MySQL 的写法是 LIMIT offset, count,SQL Server 和 Oracle 写法不同。比如每页 10 条,查第 3 页:
-- MySQL SELECT u.name, o.order_date FROM orders o JOIN users u ON o.user_id = u.id ORDER BY o.order_date DESC LIMIT 20, 10;LIMIT 20, 10 表示跳过前 20 条,返回接下来的 10 条。在 SQL Server 里,更通用的写法是用 OFFSET ... FETCH:
SELECT u.name, o.order_date FROM orders o JOIN users u ON o.user_id = u.id ORDER BY o.order_date DESC OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY;分页查询一定要配 ORDER BY,否则每次翻页的顺序可能不一致,用户会看到数据“跳动”。这一点在数据量大的表上尤其明显。
4. 常见问题与排查技巧实录
4.1 多表查询报错与异常结果速查表
实操中遇到的问题,我整理了一个速查表,每一个都是我或同事真实踩过的坑。
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 查询结果行数远超预期,成倍暴涨 | 连接条件缺失或写错,产生笛卡尔积 | 检查 ON 条件,确认关联字段和关联关系 |
| 结果行数缺失,LEFT JOIN 却只剩部分记录 | 右表过滤条件错写在 WHERE 里 | 右表的过滤移到 ON 中,WHERE 只放最终结果过滤 |
| 数据出现重复但数量不等 | 关联字段不唯一(如一对多关联) | 先对右表去重或用 DISTINCT;确认业务口径是否允许重复 |
| 聚合结果 NULL 或金额对不上 | 字段存在 NULL,SUM/算术运算被污染 | 用 COALESCE/IFNULL 兜底;先清洗数据再聚合 |
| ONLY_FULL_GROUP_BY 报错 | SELECT 中字段未全部包含在 GROUP BY 里 | 把非聚合字段全部加入 GROUP BY,或改用聚合函数 |
| 查询没有报错但结果为空 | 比较 NULL 用了等号,实际应使用 IS NULL | 检查 WHERE 条件,NULL 判断用 IS NULL / IS NOT NULL |
| 分页结果顺序乱跳 | 未使用 ORDER BY | 给分页查询统一加上稳定的排序字段 |
这个表格建议截图收藏。我自己带团队时,让组员遇到多表查询的异常结果先对照这个表自查一圈,能省下大量排查时间。
4.2 慢 SQL 的初步定位思路
热词里“慢 SQL 优化”出现频率很高,多表查询是慢 SQL 的重灾区。遇到慢查询,我的排查思路分四步走。
第一步,看是否命中索引。多表连接的关联字段(比如 orders.user_id、orders.product_id),如果没有索引,数据库就得逐行扫描整张表来做匹配,数据量大以后性能直线下降。用 EXPLAIN 查看执行计划(SQL Server 对应的是显示估计的查询计划):
EXPLAIN SELECT u.name, o.order_date FROM orders o JOIN users u ON o.user_id = u.id WHERE o.order_date >= '2024-01-01';关注执行计划里的 type 字段,如果出现 ALL(全表扫描),就要检查关联字段和 WHERE 字段是否建了索引。为 orders 表建复合索引是最常见的优化手段:
CREATE INDEX idx_orders_user_date ON orders(user_id, order_date);第二步,看连接顺序是否需要干预。多表连接的顺序会影响中间结果集大小。数据量小时优化器通常能做对,数据量大时可能跑偏。MySQL 里可以用 STRAIGHT_JOIN 强制指定连接顺序,SQL Server 中通常是调整查询写法或使用查询提示。但我不建议一上来就强制干预,先让优化器按统计信息决策,确认是它的选择有问题再手动调整。
第三步,看是否扫描了多余的行。比如 SELECT * 会把所有字段都捞出来,哪怕业务只用两个字段;ORDER BY 没走索引导致文件排序;子查询在循环里反复执行,等等。尽量把 SELECT * 改成显式字段列表,减少回表和网络传输开销。
第四步,看统计信息是否过期。数据库优化器依赖统计信息来估算行数。表数据发生大幅变化后,统计信息没更新,优化器可能给出糟糕的执行计划。MySQL 里可以用 ANALYZE TABLE 更新,SQL Server 的自动更新统计通常比较及时,但大规模数据变更后手动 UPDATE STATISTICS 也有助于稳定执行计划。
4.3 索引设计的核心原则
索引不是越多越好,每个索引的建立都要考虑“查询到底怎么用”。
多表查询场景下,索引设计的核心原则是:连接字段必须建索引,WHERE 过滤字段选择性高的建索引,ORDER BY 字段尽量让排序走索引。具体到我们的案例,orders.user_id、orders.product_id 是连接字段,必须建索引;orders.order_date 是高频 WHERE 条件,适合加入复合索引。复合索引的字段顺序也有讲究,把等值查询条件的字段放在前面,范围条件的字段放后面。比如经常按 user_id 等值查询再按 order_date 做范围排序,(user_id, order_date) 就比 (order_date, user_id) 更合适。
建索引也要算成本账。索引占磁盘空间,写入时还要维护索引结构。读写比例 10:1 以上的表适合积极建索引,写入频繁的表就要谨慎,避免为了加速查询把一个写场景拖垮。
4.4 多表查询里我最后悔的几个决定
写到最后,分享几个我在实际项目中因为多表查询吃过亏的经验。
第一件后悔的事:早期做统计报表时,我习惯在 JOIN 之后再对结果做 DISTINCT 去重。后来查了执行计划才发现,DISTINCT 会对整个结果集做排序去重,成本和数据量成正比,数据到了百万级就有明显卡顿。数据重复的根因是连接时的一对多关系,真正该做的是先明确业务口径,把连接粒度控制在“一行对应一行”,从源头消灭重复,而不是靠 DISTINCT 事后补救。
第二件后悔的事:在线上环境直接执行 JOIN 大表时,没先在测试环境验证执行计划。有一次在生产库跑一个 3 表关联的统计 SQL,跑之前没仔细看连接字段的索引情况,结果一个全表扫描把数据库 IO 打满了。后来我养成了习惯,凡是超过百万级数据量的多表查询,上线前必先用 EXPLAIN 看执行计划,确认没有全表扫描、没有临时表排序,才敢在线上跑。
第三件后悔的事:盲目追求“一条 SQL 搞定所有需求”。有时候业务逻辑本来就复杂,硬把什么都塞进一条 SQL 里,写出来像天书,维护成本极高。后来我会在两难时选择拆分:先查出主表范围数据,再用 IN 查询关联数据,最后在应用层组装。这样做虽然多一次 IO 交互,但逻辑清晰、每条 SQL 都易于优化,也方便团队其他成员接手。
不管是新接触多表查询,还是已经被各种 JOIN 折磨过,希望这些把原理、实操、排查串起来的经验能帮你少走弯路。数据库优化没有银弹,真正可靠的路径是理解每一个连接的本质,然后在执行计划面前保持敬畏,一条一条验证,一次一次测试。