作为后端开发,刚接触 MySQL 时,我对 JOIN 简直 “爱不释手”—— 查多表数据不用写多次 SQL,一行代码就能把订单、用户、地址的信息关联出来,当时还觉得 “这就是效率天花板”。
直到 3 次因为 JOIN 在生产环境出问题,从凌晨爬起来排障到被产品追着要优化方案,我才慢慢明白:JOIN 在测试环境的 “方便”,到了生产环境可能变成 “灾难”。今天把这 3 次踩坑经历整理出来,分享给同样在 MySQL 查询上困惑的朋友。
一、第一次踩坑:3 张表 JOIN 查订单,上线后页面卡成 PPT
踩坑场景
去年做电商项目的订单列表页,需要展示 “订单号、支付时间、用户姓名、收货地址”,我用ORDER(订单表)、USER(用户表)、ADDRESS(地址表)三张表做了 LEFT JOIN,SQL 长这样:
SELECT o.order_id, o.pay_time, u.user_name, a.address
FROM `ORDER` o
LEFT JOIN `USER` u ON o.user_id = u.user_id
LEFT JOIN `ADDRESS` a ON o.address_id = a.address_id
WHERE o.create_time > '2024-01-01';
测试环境里,订单表只有几百条数据,执行只要 0.1 秒,我信心满满地上了线。结果上线第三天,订单量突破 1 万,用户反馈 “下单后加载收货地址要等 10 秒”,后台日志一看,这条 JOIN 语句居然跑了 8.3 秒!
为什么会这么慢?
后来跟运维一起查执行计划,才发现两个致命问题:
临时表撑爆了内存:MySQL 执行 3 表 JOIN 时,会先把ORDER和USER表关联的结果存到临时表里,再用这个临时表关联ADDRESS表。当时临时表有 2 万多条数据,超过了 MySQL 默认的tmp_table_size(16M),只能写到磁盘上,IO 速度一下慢了 10 倍;
索引失效成了 “摆设”:ADDRESS表的address_id明明建了索引,但ORDER表过滤后还有 8000 条数据,MySQL 优化器算来算去,觉得 “全表扫描比走索引更快”,直接放弃了索引,把 30 万条地址数据全扫了一遍。
怎么优化的?拆成两次查询,速度快了 10 倍
当时没多想,先把 JOIN 拆成两次单表查询,在代码里拼数据:
-- 第一步:查订单+用户(只关联两张核心表,走索引,0.5秒就出来了)
SELECT o.order_id, o.user_id, o.address_id, o.pay_time, u.user_name
FROM `ORDER` o
LEFT JOIN `USER` u ON o.user_id = u.user_id
WHERE o.create_time > '2024-01-01';
-- 第二步:用第一步拿到的address_id,批量查地址(IN查询走索引,0.3秒)
SELECT address_id, address
FROM `ADDRESS`
WHERE address_id IN (1001, 1002, 1003, ...); -- 这里填第一步获取的address_id列表
最后在代码里用address_id当 key,把地址信息拼到订单数据里,总耗时从 8.3 秒降到 0.8 秒,页面一下就流畅了。
这次踩坑学到的:3 张表以上的 JOIN,尽量别用
后来才知道,3 张表 JOIN 的临时表开销,是两张表的 5 倍以上,尤其是当表数据量超过 1 万条时,临时表很容易触发磁盘写入。不如拆成多次单表查询,虽然代码多写几行,但性能更稳定,也更容易排查问题。
二、第二次踩坑:子查询 + JOIN,60 万数据全表扫
踩坑场景
今年初做财务系统,需要统计 “近 30 天有消费的用户,他们的账单总额”。我先写了个子查询,过滤出近 30 天有支付的用户,再 JOIN 账单表求和,SQL 是这样的:
SELECT u.user_id, SUM(b.bill_amount) AS total_amount
FROM (
-- 子查询:找出近30天有消费的用户,去重
SELECT DISTINCT user_id
FROM `ORDER`
WHERE pay_time > DATE_SUB(NOW(), INTERVAL 30 DAY)
) u
JOIN `BILL` b ON u.user_id = b.user_id -- 子查询结果关联账单表
GROUP BY u.user_id;
我以为子查询能过滤掉大部分用户,结果执行耗时 7.6 秒,财务同事催了好几次,说 “统计报表加载不出来”。查执行计划一看,差点气笑了:BILL表(60 万条数据)的user_id索引完全没生效,MySQL 直接全表扫描了。
为什么索引会失效?
找 DBA 请教才明白,MySQL 对 “子查询结果 JOIN” 的优化特别差 —— 子查询生成的临时表,是没有索引的。相当于用一张 “没索引的临时表”,去关联BILL表,MySQL 优化器算完成本后觉得 “全表扫描比走索引更快”,所以直接放弃了索引。
怎么优化的?去掉子查询,直接两表关联
DBA 建议我去掉子查询,把过滤条件放到 WHERE 里,直接用ORDER表和BILL表关联,SQL 改成这样:
SELECT o.user_id, SUM(b.bill_amount) AS total_amount
FROM `ORDER` o
INNER JOIN `BILL` b ON o.user_id = b.user_id
WHERE o.pay_time > DATE_SUB(NOW(), INTERVAL 30 DAY)
GROUP BY o.user_id; -- 用GROUP BY替代DISTINCT,效率更高
改完后执行,耗时从 7.6 秒降到 0.9 秒,报表一下就出来了。执行计划显示,ORDER表的pay_time索引(过滤近 30 天数据)、BILL表的user_id索引(关联用户)都生效了,原来这么简单。
这次踩坑学到的:子查询 + JOIN,尽量避开
如果需要过滤数据,优先把条件放到 WHERE clause 里,直接用两张表关联,别搞 “子查询 + JOIN” 的组合。多数时候,去掉子查询后,索引会立刻生效,性能提升特别明显。
三、第三次踩坑:大表 JOIN 小表,反而比单表查还慢
踩坑场景
上个月做商品系统,需要统计 “每个分类下有多少件商品”。商品表PRODUCT有 120 万条数据,分类表CATEGORY只有 80 条数据,典型的 “大表 + 小表”。我想 “小表驱动大表” 肯定快,写了这样的 SQL:
SELECT c.category_name, COUNT(p.product_id) AS product_count
FROM `CATEGORY` c
LEFT JOIN `PRODUCT` p ON c.category_id = p.category_id
GROUP BY c.category_id;
结果执行耗时 4.8 秒,而同事用 “先查分类,再循环查商品数” 的方案,只花了 1.2 秒。我当时特别疑惑:小表 JOIN 大表,怎么还这么慢?
慢在哪里?GROUP BY + 磁盘临时表
后来查临时表配置才发现,JOIN 后生成的临时表有 30M,超过了tmp_table_size(16M),自动转成了磁盘临时表。而且 GROUP BY 需要对 120 万条商品数据按分类排序、统计,CPU 和 IO 开销都特别大,速度自然快不起来。
怎么优化的?小表循环 + 大表单查
同事的方案其实很简单:先查所有分类,再循环每个分类查商品数量,在代码里汇总结果:
-- 第一步:查所有分类(小表,0.01秒就够了)
SELECT category_id, category_name FROM `CATEGORY`;
-- 第二步:循环每个category_id,查对应商品数(大表走索引,每个0.02秒)
SELECT COUNT(product_id) AS product_count
FROM `PRODUCT`
WHERE category_id = 101; -- 101是当前循环的分类ID
虽然多了 80 次查询,但每次查询都走PRODUCT表的category_id索引,总耗时 1.2 秒,比 JOIN 快了 4 倍。
这次踩坑学到的:JOIN 后有 GROUP BY,先算拆分成本
如果 JOIN 后需要 GROUP BY,尤其是大表数据量超过 100 万条时,先别着急写 JOIN,算一算 “小表循环查询” 的总耗时。多数时候,拆分方案虽然查询次数多,但每次查询都高效,总性能反而更好。
最后:总结 3 个不用 JOIN 的原则,帮我避开了 90% 的坑
经过这 3 次踩坑,我总结出 3 条 “尽量不用 JOIN” 的原则,现在团队里的 SQL 评审都会参考:
表数量≥3 张:坚决拆:3 张表 JOIN 的临时表开销太大,拆成多次单表查询更可控;
数据量超 1 万条:尽量拆:单表数据过万,JOIN 容易触发全表扫描或磁盘临时表,不如代码拼接;
子查询 + JOIN:必须改:子查询的临时表没索引,会让关联表的索引失效,改成直接关联更稳妥。
当然,不是所有 JOIN 都要禁:比如两张小表(都小于 1000 条数据)关联,像 “用户表 + 角色表”,用 JOIN 反而更方便,效率也不会差。关键是 “看场景”—— 生产环境优先保证性能稳定,别为了省几行代码,埋下线上故障的隐患。
如果你也在 MySQL 查询上踩过坑,或者有更好的优化方案,欢迎在评论区分享,咱们一起交流学习~