4、查询重写技巧:子查询优化、JOIN优化、OR条件改写、聚合查询优化
各位同学,咱们接着聊。上一章讲了慢查询的定位和基础分析,这一章咱们来点真功夫——查询重写。
说白了,很多时候SQL写得不好,不是数据库不行,是咱们没把意图表达清楚。我见过太多开发同学,上来就是一顿子查询嵌套,看得我头皮发麻。今天我就把压箱底的重写技巧掏出来,一个一个讲透。
4.1 子查询优化:能不嵌套就别嵌套
先说说子查询。很多新手喜欢这么写:
SELECT * FROM orders
WHERE customer_id IN (
SELECT id FROM customers
WHERE status = 'vip'
);
这段代码逻辑上没问题。但性能上呢?MySQL 5.6之前,这种写法会对外层表的每一行都执行一次子查询。你想想看,如果orders表有100万行,子查询就得跑100万次。这不是要命吗?
我个人习惯,遇到这种场景,直接改成JOIN:
SELECT o.*
FROM orders o
JOIN customers c ON o.customer_id = c.id
WHERE c.status = 'vip';
改完之后,数据库只需要做一次关联查询,效率高得多。我在项目中遇到过好几次,改完这个写法,查询时间从3秒降到了0.1秒。
还有一种情况,是关联子查询。比如查每个分类下最新的一条商品:
SELECT * FROM products p1
WHERE p1.created_at = (
SELECT MAX(created_at)
FROM products p2
WHERE p2.category_id = p1.category_id
);
这种写法,每行都要算一次MAX。我建议改成派生表的方式:
SELECT p.*
FROM products p
JOIN (
SELECT category_id, MAX(created_at) as max_time
FROM products
GROUP BY category_id
) t ON p.category_id = t.category_id AND p.created_at = t.max_time;
嗯,这里要注意:派生表会生成临时表,如果数据量特别大,记得给临时表加索引。否则内存撑不住。
4.2 JOIN优化:小表驱动大表,索引要到位
JOIN查询是重灾区。我见过最离谱的,是五张表全连在一起,每张表几百万行,跑一次查询半小时。
JOIN优化的核心原则就一条:小表驱动大表。
什么意思呢?比如你查订单和用户:
SELECT * FROM orders o
JOIN customers c ON o.customer_id = c.id
WHERE o.created_at > '2024-01-01';
如果orders表有100万行,customers表有10万行。那MySQL会先扫描orders表,再逐行去customers表匹配。这就是大表驱动小表,效率低。
更好的做法是,先过滤出小结果集,再去关联大表:
SELECT * FROM (
SELECT * FROM orders WHERE created_at > '2024-01-01'
) o
JOIN customers c ON o.customer_id = c.id;
当然,更直接的办法是确保JOIN字段上有索引。我曾经接手过一个项目,查询慢得不行,一查发现JOIN字段上连索引都没有。加上索引后,查询时间从15秒降到了0.5秒。
| JOIN类型 | 适用场景 | 索引要求 |
|---|---|---|
| INNER JOIN | 两边都有匹配数据 | 两边JOIN字段都要索引 |
| LEFT JOIN | 左表数据必须全保留 | 右表JOIN字段必须索引 |
| STRAIGHT_JOIN | 强制指定驱动表 | 驱动表要小,被驱动表要索引 |
4.3 OR条件改写:别让优化器犯难
OR条件,是优化器最头疼的东西。比如:
SELECT * FROM users
WHERE name = '张三' OR email = 'zhangsan@example.com';
如果name和email上都有索引,MySQL可能会选择全表扫描。为什么?因为OR条件需要合并两个索引的结果集,这个操作成本很高。
我建议改成UNION:
SELECT * FROM users WHERE name = '张三'
UNION
SELECT * FROM users WHERE email = 'zhangsan@example.com';
这样每个查询都能用到索引,然后UNION去重。效率提升很明显。
还有一种情况,是OR条件里包含范围查询:
SELECT * FROM products
WHERE price < 100 OR category = '电子';
这种写法,索引基本废了。我建议拆成两个查询,或者用IN代替OR(如果条件固定的话)。
4.4 聚合查询优化:先过滤,后聚合
聚合查询,最常见的问题是数据量太大。比如统计每个月的订单总额:
SELECT MONTH(created_at), SUM(amount)
FROM orders
GROUP BY MONTH(created_at);
如果orders表有1000万行,这个查询会把所有数据都扫描一遍。你想想看,有必要吗?
我建议先过滤,再聚合。比如只查最近一年的数据:
SELECT MONTH(created_at), SUM(amount)
FROM orders
WHERE created_at > DATE_SUB(NOW(), INTERVAL 1 YEAR)
GROUP BY MONTH(created_at);
这样扫描的数据量大大减少。我在项目中遇到过,一个聚合查询从30秒降到了2秒。
还有一个坑,是GROUP BY的字段没有索引。比如:
SELECT category_id, COUNT(*)
FROM products
GROUP BY category_id;
如果category_id上没有索引,MySQL会先全表扫描,再用临时表做分组。我建议给category_id加上索引,或者用覆盖索引来优化。
另外,HAVING子句也要注意。能用WHERE过滤的,就别用HAVING:
-- 不推荐
SELECT category_id, COUNT(*) as cnt
FROM products
GROUP BY category_id
HAVING cnt > 100;
-- 推荐
SELECT category_id, COUNT(*) as cnt
FROM products
WHERE price > 0
GROUP BY category_id
HAVING cnt > 100;
WHERE在聚合前过滤,HAVING在聚合后过滤。能提前过滤的,一定要提前。
好了,这一章的内容就到这里。下一章咱们聊聊索引设计,那才是慢查询治理的终极武器。到时候我会分享一些实战案例,保证让你大开眼界。