16 KiB
tags, create time
| tags | create time | ||||||||
|---|---|---|---|---|---|---|---|---|---|
|
2026-04-28 00:00 |
子查询与分组
概述
当简单的单表查询无法满足需求时,就需要用到子查询(SQL 里嵌套的 SELECT)和分组聚合(GROUP BY + HAVING)。这两类查询通常用于统计分析——比如「找出每个部门收入最高的员工」或「统计过去一个月每天的新增订单数」。
核心要理解的是:GROUP BY 把多行合并成一行,HAVING 从这些合并后的结果中筛出感兴趣的分组,子查询则在查询内部再套一层查询来间接获取数据。
[!question] 思考题
假设你要找「最近一次下单金额超过 500 的用户」——这个需求需要哪种技术?
分析:需要先对每个用户按时间排序找出最后一次下单(可以用相关子查询或
ROW_NUMBER()),然后再过滤金额。这实际上涉及了前面提到的两种技术的组合使用——后面的「实用统计模式」章节会覆盖更多这类复合场景。
flowchart TD
Start["需要复杂查询"] --> Type{"按维度汇总?"}
Type --> |是| GroupBy["GROUP BY + 聚合函数<br/>COUNT / SUM / AVG / MAX / MIN"]
Type --> |过滤聚合结果| Having["HAVING 条件<br/>替代 WHERE"]
Type --> |嵌套查询| SubQ{"子查询类型?"}
SubQ --> |IN/EXISTS 过滤| FilterSub["WHERE field IN (SELECT ...)"]
SubQ --> |关联计算| Correlated["相关子查询<br/>外层行影响内层查询"]
SubQ --> |派生表| DerivedTable["FROM (SELECT ...) AS t<br/>将子查询作为临时表"]
style Start fill:#4FC08D,color:#fff
style GroupBy fill:#3B82F6,color:#fff
style Having fill:#F59E0B,color:#000
style DerivedTable fill:#EC4899,color:#fff
GROUP BY — 分组统计
基本用法
GORM 通过 Select + Group 实现分组:
// 按部门统计人数
type DeptStats struct {
DeptID uint `gorm:"column:dept_id"`
DeptName string `gorm:"column:dept_name"`
Count int64 `gorm:"column:count"`
}
var stats []DeptStats
db.Model(&User{}).
Select("dept_id, dept_name, COUNT(*) as count").
Group("dept_id").
Find(&stats)
// SELECT dept_id, dept_name, COUNT(*) as count
// FROM users GROUP BY dept_id;
多字段分组
// 按部门和月份统计活跃用户数
type MonthlyDeptStats struct {
DeptID uint `gorm:"column:dept_id"`
Month string `gorm:"column:month"`
ActiveCount int64 `gorm:"column:active_count"`
}
var monthlyStats []MonthlyDeptStats
db.Model(&User{}).
Select("dept_id, DATE_FORMAT(created_at, '%Y-%m') as month, COUNT(*) as active_count").
Group("dept_id, DATE_FORMAT(created_at, '%Y-%m')").
Order("month DESC").
Scan(&monthlyStats)
Group 接受逗号分隔的多个表达式,GORM 会原样拼接到 SQL 的 GROUP BY 后面。当分组字段包含函数(如 DATE_FORMAT)时,注意 Select 和 Group 里的函数必须完全一致——否则数据库可能报语法错误或分组不准确。
[!tip] Group 的陷阱 GORM 在调用
Group后默认不再添加任何其他字段到 SELECT——这是 SQL 标准要求。如果需要额外字段,必须在Select中显式声明:// ❌ 错误 —— GORM 可能报错或产生不确定的 SELECT db.Model(&User{}).Group("dept_id").Find(&users) // ✅ 正确 —— 明确指定需要的字段 db.Model(&User{}).Select("dept_id, MAX(age)").Group("dept_id").Scan(&stats)
[!question] Select 和 Group 字段可以不一样吗?
答案:可以不一样,但必须遵守 SQL 规则——GROUP BY 里的每个字段都必须是「函数依赖」于 Group 参数的。换句话说,Group 里的字段可以不出现在 Select 里(你不想展示它),但 Select 里的非聚合字段必须出现在 Group 里。否则 MySQL 5.7+(开启 ONLY_FULL_GROUP_BY)会直接报错。
HAVING — 过滤分组结果
WHERE 过滤的是行级别,HAVING 过滤的是分组后的聚合结果。两者的执行顺序是 WHERE → GROUP BY → HAVING:
// 找平均薪资大于 10000 的部门
type AvgDept struct {
DeptID uint `gorm:"column:dept_id"`
AvgSal float64 `gorm:"column:avg_salary"`
}
db.Model(&User{}).
Select("dept_id, AVG(salary) as avg_salary").
Group("dept_id").
Having("AVG(salary) > ?", 10000).
Scan(&result)
// 组合使用 WHERE 和 HAVING
// 先过滤入职满 1 年的员工,再按部门分组,最后筛选平均薪资超过 10000 的部门
db.Model(&User{}).
Select("dept_id, COUNT(*) as headcount, AVG(salary) as avg_salary").
Where("created_at < ?", time.Now().AddDate(-1, 0, 0)).
Group("dept_id").
Having("COUNT(*) >= 3"). // 至少 3 人
Having("AVG(salary) > ?", 10000). // 且平均薪资超过 10000
Scan(&qualifiedDepts)
[!info] WHERE vs HAVING 的核心区别
| 特性 | WHERE | HAVING |
|---|---|---|
| 作用对象 | 单个行记录 | 分组后的聚合结果 |
| 能否用聚合函数 | ❌ 不能 | ✅ 可以 |
| 执行时机 | GROUP BY 之前 | GROUP BY 之后 |
| 性能差异 | 更优(提前过滤减少分组数据量) | 较次(需先完成分组再过滤) |
[!question] 为什么 WHERE 里不能用 COUNT(*)?
SQL 的执行顺序是
FROM → WHERE → GROUP BY → HAVING → SELECT——当数据库解析到 WHERE 时,分组都还没发生,COUNT(*) 还没有意义。所以像「找出订单数超过 5 个的用户」这种需求,必须拆成两步:先用 WHERE 过滤基础行,GROUP BY 分组统计,最后用 HAVING 过滤聚合结果。
[!important] HAVING 的简洁写法 如果你只需要最简单的 HAVING 条件(如
HAVING COUNT(*) > 5),也可以直接写字符串:db.Model(&User{}).Group("dept_id").Having("COUNT(*) > ?", 5).Find(&results)多个
Having会自动用 AND 连接。
子查询
GORM 提供了几种方式来构建子查询。核心思路是:先创建一个独立的查询对象(subQuery),然后把它作为参数传入主查询的某个位置。
派生表子查询(From Subquery)
将子查询作为一个临时表放入 FROM 子句或 WHERE 条件:
// 找出年龄大于全体员工平均年龄的用户
avgAgeSubQuery := db.Table("users").Select("AVG(age)")
var users []User
db.Where("age > (?)", avgAgeSubQuery).Find(&users)
// SELECT * FROM users WHERE age > (SELECT AVG(age) FROM users);
[!tip]
(?)占位符的用法 这里的(?)是一个占位符——外层括号确保子查询整体被包裹在圆括号中(SQL 语法要求),问号?让 GORM 知道这里要插入的是一个子查询对象,而不是普通参数。
IN 子查询
// 找到有订单的用户
orderSubQuery := db.Model(&Order{}).Select("DISTINCT user_id")
var users []User
db.Where("id IN (?)", orderSubQuery).Find(&users)
// SELECT * FROM users WHERE id IN (SELECT DISTINCT user_id FROM orders);
[!tip] 为什么用 DISTINCT? 一个用户可能有多个订单,如果不加 DISTINCT,子查询会返回重复的 user_id。虽然
IN (...)能自动去重,但加上 DISTINCT 可以让数据库优化器更高效地处理——尤其是在内表数据量很大时。
[!warning] 避免超大 IN 列表 当子查询结果超过数万条时,
IN (1, 2, 3, ...)会导致 SQL 语句超长,可能触发 MySQL 的max_allowed_packet限制。此时应改用 JOIN 或临时表方案。
EXISTS 子查询
// 找到至少有 1 个未支付订单的用户
db.Where("EXISTS (?)",
db.Model(&Order{}).Select("id").
Where("user_id = users.id AND status = 'pending'"),
).Find(&users)
// SELECT * FROM users
// WHERE EXISTS (SELECT id FROM orders WHERE user_id = users.id AND status = 'pending');
[!note] EXISTS vs IN 的选择
- 外表大、内表小 → 用 IN,子查询先查好再用
- 外表小、内表大 → 用 EXISTS,找到一条就停止扫描
- 在实际生产中,MySQL 优化器通常能自动选择最优方案,不用太纠结
- 但要注意:EXISTS 的子查询里不能做聚合(如 COUNT、SUM),而 IN 可以配合子查询中的聚合一起用
- EXISTS 语义是「是否存在」——只要内层查到任意一行就立即返回 true,不做全表扫描;IN 则是把所有结果集加载后再匹配。
相关子查询(Correlated Subquery)
内层查询引用外层表的列,每一行都重新执行一次内层查询。注意这里的 users.id 引用了外层表——数据库无法先执行子查询,必须逐行扫描外层表并重新计算内层。
// 对每个用户,查出他的最新订单日期
var users []User
db.Select(`*,
(SELECT created_at FROM orders
WHERE user_id = users.id
ORDER BY created_at DESC LIMIT 1) as latest_order_date`).
Find(&users)
上面的 SQL 中,(SELECT created_at FROM orders WHERE user_id = users.id ...) 的每一行都会用到外层 users.id 的值来过滤——这就是「相关」的含义:内层和外层相互关联。
[!question] 这个子查询在什么情况下性能最差?
分析:当用户表和订单表都是百万级数据时,每条用户记录都要单独跑一次子查询(n × m 复杂度)。如果用户的最新订单恰好排在订单表的最后面,数据库甚至要做全表扫描。这种情况下应该改用窗口函数
ROW_NUMBER()或 LEFT JOIN + GROUP BY 的方案——这在下文「实用统计模式」中有覆盖。
[!warning] 相关子查询的性能警告 相关子查询每处理一行外层数据就要执行一次内层查询——复杂度接近 O(n × m)。如果数据量大,建议改写为 JOIN + GROUP BY:
// 改写为高效 JOIN: db.Table("users u"). Select("u.*, o.created_at as latest_order_date"). Joins("JOIN orders o ON o.id = (SELECT id FROM orders WHERE user_id = u.id ORDER BY created_at DESC LIMIT 1)"). Find(&users)
实用统计模式
这部分收集了生产中最常遇到的几种查询场景——从简单的按月统计到更复杂的全站 Top N 和条件聚合。每种模式都附带 GORM 的具体实现。
按月趋势统计
type DailyOrderStat struct {
Day string `gorm:"column:day"`
OrderNum int64 `gorm:"column:order_num"`
TotalAmt float64 `gorm:"column:total_amt"`
}
var dailyStats []DailyOrderStat
db.Model(&Order{}).
Select("DATE(created_at) as day, COUNT(*) as order_num, COALESCE(SUM(amount), 0) as total_amt").
Group("DATE(created_at)").
Order("day ASC").
Scan(&dailyStats)
注意 COALESCE(SUM(amount), 0) 的作用:如果某天没有任何订单,SUM(amount) 返回 NULL(而非 0),COALESCE 将其转换为 0,方便前端图表直接渲染。
TOP N 问题
// 找出消费金额最高的前 10 名用户
var topUsers []struct {
UserID uint `json:"user_id"`
UserName string `json:"user_name"`
TotalSpent float64 `json:"total_spent"`
}
db.Table("users u").
Select("u.id as user_id, u.name as user_name, SUM(o.amount) as total_spent").
Joins("JOIN orders o ON o.user_id = u.id").
Group("u.id, u.name").
Order("total_spent DESC").
Limit(10).
Scan(&topUsers)
这里的关键是 Group("u.id, u.name")——MySQL 中非主键字段也需要加入 Group,否则在开启 ONLY_FULL_GROUP_BY 的严格模式下会报错。另外要注意,LIMIT 10 是在 GROUP BY 之后生效的,所以它限制的是「聚合后的组数」,而不是「每个组的行数」。
CASE WHEN 条件聚合(透视表)
// 按状态统计订单数量
type StatusCount struct {
Pending int64 `gorm:"column:pending"`
Completed int64 `gorm:"column:completed"`
Cancelled int64 `gorm:"column:cancelled"`
}
var sc StatusCount
db.Raw(`
SELECT
SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending,
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed,
SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) as cancelled
FROM orders
`).Scan(&sc)
这种模式称为行转列(Pivot)——把原本多行的状态值变成一行中的多个列。适合做仪表盘概览。如果需要同时统计金额,可以在 SUM 内部嵌套条件:
-- 同时统计数量和金额
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed_count,
SUM(CASE WHEN status = 'completed' THEN amount ELSE 0 END) as completed_amount
ROW_NUMBER 取每组最大值
这是最经典的「分组后取第一条」需求——比如「找每个部门收入最高的员工」。虽然这也可以用相关子查询解决,但用窗口函数效率更高:
// 找到每个部门收入最高的员工
type MaxSalaryDept struct {
DeptID uint `gorm:"column:dept_id"`
EmpName string `gorm:"column:emp_name"`
Salary float64 `gorm:"column:salary"`
}
var results []MaxSalaryDept
db.Raw(`
SELECT dept_id, emp_name, salary
FROM (
SELECT dept_id, emp_name, salary,
ROW_NUMBER() OVER (PARTITION BY dept_id ORDER BY salary DESC) as rn
FROM employees
) ranked
WHERE rn = 1
`).Scan(&results)
核心思路:内层用 ROW_NUMBER() 为每个部门的员工按薪资排序并编号(最高薪为 rn=1),外层只取 rn=1 的记录。相比相关子查询,这种方式数据库只需扫描一次 employees 表,性能明显更好。
[!note] MySQL 版本兼容性
- MySQL 8.0+ / PostgreSQL / SQLite 3.25+ 原生支持窗口函数(ROW_NUMBER、RANK 等)
- MySQL 5.7 及以下不支持窗口函数,需要退回相关子查询或自连接方案
子查询决策图
flowchart TD
Start["需要子查询"] --> Pattern{"查询模式"}
Pattern --> |聚合统计| GroupByQ{"是否需要过滤聚合结果"}
GroupByQ --> |不需要| BasicGroup["GROUP BY + Select 聚合函数"]
GroupByQ --> |需要| HavingCheck["GROUP BY + HAVING"]
Pattern --> |条件过滤| FilterType{"IN 还是 EXISTS"}
FilterType --> |精确匹配值集合| InSub["WHERE col IN (SELECT ...)"]
FilterType --> |存在性判断| ExistsSub["WHERE EXISTS (SELECT ...)"]
Pattern --> |数据源嵌套| FromSub["FROM (SELECT ...) AS alias"]
style Start fill:#4FC08D,color:#fff
style HavingCheck fill:#3B82F6,color:#fff
style InSub fill:#F59E0B,color:#000
style FromSub fill:#EC4899,color:#fff
style ExistsSub fill:#3B82F6,color:#fff
常见坑点速查
| 问题 | 原因 | 解决方案 |
|---|---|---|
| Group 后字段丢失 | 未在 Select 中声明所需字段 | 显式列出所有 SELECT 字段,聚合函数也需声明 |
| Having 写了聚合函数但被 WHERE 过滤 | WHERE 在 GROUP BY 之前执行,无法访问聚合 | 把聚合条件移到 Having |
| 子查询导致笛卡尔积 | JOIN 没有正确的关联条件 | 检查外键关联,或用 EXISTS 替代 |
| ORDER BY + GROUP BY 顺序搞反 | SQL 语法要求 GROUP BY 在前 | 按 WHERE → GROUP BY → HAVING → ORDER BY 顺序写 |
| MySQL 5.7 ONLY_FULL_GROUP_BY | 非聚合字段不能在 SELECT 中出现 | 确保 SELECT 中的非聚合字段都在 GROUP BY 里 |
| IN 子查询结果过大 | 内表数据量太大导致 SQL 超长 | 改用临时表、JOIN 或分批查询 |
| Preload 后关联为空 | 关联字段名不匹配 struct 标签 | 检查 foreignKey / 关联名的拼写和大小写 |
| SubQuery 嵌套层级太深 | 数据库优化器对深层嵌套处理不佳 | 拆分为多个简单查询,用 Go 代码组装 |
| COALESCE 返回类型不一致 | SUM 可能返回 NULL,与 int64 字段不兼容 | 始终用 COALESCE(SUM(...), 0) 兜底 |