在业务系统里,经常需要把订单流水和用户信息拼在一起,再按城市或者渠道做分组计数、求和。数据量一旦上了规模,原本在测试库跑得很快的语句,在生产环境就会突然变慢,甚至拖垮整个数据库节点。理解数据库优化器如何处理关联与分组,是写出稳定统计SQL的关键。

一、JOIN与GROUP BY的执行顺序误区
很多慢查询并不是因为SQL逻辑错,而是优化器被迫做了昂贵的操作。以MySQL为例,当执行SELECT中含有GROUP BY且涉及多表JOIN时,常见执行路径是:先按驱动表逐行去被驱动表查找匹配行,把所有匹配结果放进临时表,再对临时表按分组列排序或哈希聚合。如果驱动表没有过滤条件,被驱动表关联键又没索引,就会触发全表扫描加文件排序。
我们可以用EXPLAIN观察type列与Extra列。若出现Using temporary; Using filesort,基本意味着分组和排序都没用到索引。此时无论怎么调大缓冲池,效果都有限,因为瓶颈在访问路径。正确思路是让分组所依赖的维度尽量在关联前就被收敛,减少进入临时表的行数。
1.1 驱动表选择的影响
优化器通常选数据量小、过滤性强的表作驱动表。但当你写LEFT JOIN时,左表就是驱动表,无法被自动调换。如果左表是千万级订单,右表是百级地区配置,却用订单左接地区,优化器也只能先扫订单。应改写为先按时间过滤订单成派生表,再JOIN维度表。
如下示例展示反模式与调整方式。反模式中直接大表左联,分组落盘;调整后先用WHERE缩小订单范围,再关联,扫描行数大幅下降。
-- 反模式:大表直接左联后分组 SELECT u.city, COUNT(*) AS cnt FROM orders o LEFT JOIN users u ON o.user_id = u.id GROUP BY u.city; -- 优化:先过滤订单再关联 SELECT u.city, COUNT(*) AS cnt FROM ( SELECT user_id FROM orders WHERE create_time >= '2023-01-01' ) o JOIN users u ON o.user_id = u.id GROUP BY u.city;
二、索引设计决定分组是否落盘
要让GROUP BY不触发临时表排序,分组列必须处在索引的最左前缀中,且关联键也要被索引覆盖。对于跨表分组,理想情况是:驱动表上建立(过滤列, 关联键)联合索引;被驱动表上建立(主键或关联键, 分组列)联合索引,这样关联时能用上索引,分组列也天然有序。
如果分组列来自被驱动表,而该表是按主键组织,但分组列不在索引里,那么每取到一行都要回表读分组值,再排序。此时建立(id, city)的联合索引,虽占用空间,却能让JOIN和GROUP BY一并受益。下面用表结构示例说明。
2.1 联合索引示例
假设用户表原先只有主键id,现在加city做冗余索引。注意联合索引顺序,把高频等值过滤或关联字段放前面。
-- 用户表增加覆盖关联与分组的索引 ALTER TABLE users ADD INDEX idx_id_city (id, city); -- 订单表增加时间过滤与关联索引 ALTER TABLE orders ADD INDEX idx_time_user (create_time, user_id);
加上索引后,再次EXPLAIN会看到Extra里的Using filesort消失,type变为ref或range。这说明数据在引擎层已按序流出,分组线程直接累加即可。
三、三种跨表汇总方案对比
除了改写法与加索引,还可以从架构层减少在线关联。下面用表格列出常用方案的适用场景与代价。
| 方案 | 核心思路 | 优点 | 缺点 |
|---|---|---|---|
| 在线JOIN+索引 | 靠引擎实时关联并分组 | 数据最新鲜,实现简单 | 数据量大时仍占资源 |
| 冗余字段预聚合 | 写入时把city落到订单表 | 免去JOIN,分组极快 | 冗余存储,更新维护复杂 |
| 物化汇总表 | 定时任务算好结果表 | 查询毫秒级,不影响主库 | 有延迟,需调度保障 |
3.1 冗余字段写法示例
若业务允许,在订单表直接冗余user_city,统计时完全不碰用户表。如下代码展示写入时冗余与统计语句。
-- 建表冗余城市 CREATE TABLE orders ( id BIGINT PRIMARY KEY, user_id BIGINT, user_city VARCHAR(32), amount DECIMAL(10,2), create_time DATETIME ); -- 统计直接本地分组 SELECT user_city, SUM(amount) FROM orders WHERE create_time >= '2023-01-01' GROUP BY user_city;
这种方式把跨表问题转化为单表问题,但要求用户城市变更时同步订单,或接受历史订单不变。对报表类查询非常合适。
四、用子查询约束维度提升效率
当维度表本身很大,比如用户表也有几千万,但本次只想看部分城市,可先在维度表上过滤出目标id集合,再让事实表JOIN这个小的派生表。这样优化器能用上更紧凑的索引范围。
下面示例先从用户表取出目标城市用户,再回订单表按这些用户汇总。由于中间结果集很小,关联成本极低。
SELECT u.city, COUNT(o.id) AS order_cnt
FROM (
SELECT id, city FROM users
WHERE city IN ('北京','上海','广州')
) u
JOIN orders o ON o.user_id = u.id
GROUP BY u.city;
这种写法把分组维度控制在前置子集里,避免全维度参与聚合。配合前面说的索引,能在跨表场景下保持线性性能。
五、总结与调优清单
面对跨表分组汇总,先问自己:驱动表能否先过滤?关联键和分组列是否都在索引中?能否把维度收束成小集合?按此顺序排查,多数慢SQL都能找到突破口。
实际调优时建议养成习惯:每次改完语句都跑一遍EXPLAIN,重点看rows和Extra。当Using temporary与Using filesort消失,且rows明显变小,就说明JOIN与GROUP BY的协作已走上高效路径。