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:23:33 +08:00

291 lines
9.4 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)。这两类查询通常用于统计分析——比如「找出每个部门收入最高的员工」或「统计过去一个月每天的新增订单数」。
```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"`
}
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)
```
> [!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)
> ```
## 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)
```
> [!example] WHERE vs HAVING 的核心区别
| 特性 | WHERE | HAVING |
|------|-------|--------|
| 作用对象 | 单个行记录 | 分组后的聚合结果 |
| 能否用聚合函数 | ❌ 不能 | ✅ 可以 |
| 执行时机 | GROUP BY 之前 | GROUP BY 之后 |
| 性能差异 | 更优(提前过滤减少分组数据量) | 较次(需先完成分组再过滤) |
> [!important] HAVING 的简洁写法
> 如果你只需要最简单的 HAVING 条件(如 `HAVING COUNT(*) > 5`),也可以直接写字符串:
> ```go
> db.Model(&User{}).Group("dept_id").Having("COUNT(*) > ?", 5).Find(&results)
> ```
> 多个 `Having` 会自动用 AND 连接。
## 子查询
GORM 提供了几种方式来构建子查询。
### 派生表子查询(From Subquery)
将子查询作为一个临时表放入 FROM 子句:
```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);
```
### 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 可以让数据库优化器更高效地处理。
### 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 优化器通常能自动选择最优方案,不用太纠结
### 相关子查询(Correlated Subquery)
内层查询引用外层表的列,每一行都重新执行一次内层查询:
```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)
```
> [!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)
> ```
## 实用统计模式
### 按月趋势统计
```go
type DailyOrderStat struct {
Day string `gorm:"column:day"`
OrderNum int64 `gorm:"column:order_num"`
TotalAmt float64 `gorm:"column:total_amt"`
}
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)
```
### TOP N 问题
```go
// 找出消费金额最高的前 10 名用户
var topUsers []struct {
UserID uint
UserName string
TotalSpent float64
}
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)
```
### 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)
```
## 子查询决策图
```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
```
## 常见坑点速查
| 问题 | 原因 | 解决方案 |
|------|------|---------|
| 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 中出现 | 开启 SQL 严格模式或使用 MySQL 8.0+ |
## 关联笔记
- [[03-CRUD 操作]]
- [[04-条件查询]]
- [[05-关联查询]]
- [[06-排序与分页]]
- [[15-性能优化]]