This repository has been archived on 2026-05-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
all-in-kingsoft/hhs/GORM/07-子查询与分组.md
T
2026-04-28 20:56:51 +08:00

374 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: [GORM, Go, ORM, 子查询, Group, Having, SubQuery, 聚合]
create time: 2026-04-28 00:00
---
# 子查询与分组
## 概述
当简单的单表查询无法满足需求时,就需要用到**子查询**(SQL 里嵌套的 SELECT)和**分组聚合**(GROUP BY + HAVING)。这两类查询通常用于统计分析——比如「找出每个部门收入最高的员工」或「统计过去一个月每天的新增订单数」。
核心要理解的是:**GROUP BY 把多行合并成一行,HAVING 从这些合并后的结果中筛出感兴趣的分组,子查询则在查询内部再套一层查询来间接获取数据。**
> [!question] 思考题
>
> 假设你要找「最近一次下单金额超过 500 的用户」——这个需求需要哪种技术?
>
> > **分析**:需要先对每个用户按时间排序找出最后一次下单(可以用相关子查询或 `ROW_NUMBER()`),然后再过滤金额。这实际上涉及了前面提到的两种技术的组合使用——后面的「实用统计模式」章节会覆盖更多这类复合场景。
```mermaid
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` 实现分组:
```go
// 按部门统计人数
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;
```
### 多字段分组
```go
// 按部门和月份统计活跃用户数
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` 中显式声明:
> ```go
> // ❌ 错误 —— 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`:
```go
// 找平均薪资大于 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`),也可以直接写字符串:
> ```go
> db.Model(&User{}).Group("dept_id").Having("COUNT(*) > ?", 5).Find(&results)
> ```
> 多个 `Having` 会自动用 AND 连接。
## 子查询
GORM 提供了几种方式来构建子查询。核心思路是:**先创建一个独立的查询对象(subQuery),然后把它作为参数传入主查询的某个位置**。
### 派生表子查询(From Subquery)
将子查询作为一个临时表放入 FROM 子句或 WHERE 条件:
```go
// 找出年龄大于全体员工平均年龄的用户
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 子查询
```go
// 找到有订单的用户
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 子查询
```go
// 找到至少有 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` 引用了外层表——数据库无法先执行子查询,必须**逐行扫描外层表并重新计算内层**。
```go
// 对每个用户,查出他的最新订单日期
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:
> ```go
> // 改写为高效 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 的具体实现。
### 按月趋势统计
```go
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 问题
```go
// 找出消费金额最高的前 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 条件聚合(透视表)
```go
// 按状态统计订单数量
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 内部嵌套条件:
```sql
-- 同时统计数量和金额
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 取每组最大值
这是最经典的「分组后取第一条」需求——比如「找每个部门收入最高的员工」。虽然这也可以用相关子查询解决,但用窗口函数效率更高:
```go
// 找到每个部门收入最高的员工
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 及以下**不支持窗口函数,需要退回相关子查询或自连接方案
## 子查询决策图
```mermaid
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)` 兜底 |
## 关联笔记
- [[03-CRUD 操作]]
- [[04-条件查询]]
- [[05-关联查询]]
- [[06-排序与分页]]
- [[15-性能优化]]