Files
cs-note/hhs/MySQL/02-SQL核心/08-DQL SELECT 全解析.md
2026-05-24 11:42:38 +08:00

439 lines
14 KiB
Markdown
Raw Permalink 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: [MySQL, SELECT, 查询执行顺序, GROUP BY, 分页, 窗口函数, CTE]
create time: 2026-05-16 00:00
---
# DQL — SELECT 全解析
## 概述
SELECT 是 MySQL 中使用频率最高的语句,也是最容易被误解的语句。理解其内部执行顺序是写出**正确且高效**查询的第一步。
本文覆盖 SELECT 从基础语法到进阶优化的所有核心场景:
| 模块 | 内容 | 关键词 |
|------|------|--------|
| 执行顺序 | 书写顺序 vs 执行顺序、完整示例 | `WHERE` / `GROUP BY` / `HAVING` / `ORDER BY` |
| 关键字详解 | DISTINCT、GROUP BY 优化、条件过滤 | 索引利用、聚合 |
| 条件表达式 | CASE 分支、IF 三目运算 | 数据变形、行转列 |
| 窗口函数 | 排名、前后行访问、累计计算、帧子句 | `PARTITION BY` / `ROWS BETWEEN` |
| 子查询与 CTE | 标量子查询、EXISTS、WITH、递归 CTE | 可读性、复用性 |
| 深分页优化 | 延迟关联、游标分页、性能对比 | LIMIT 陷阱 |
| 性能贴士 | 常见反模式与修复方案 | Covering Index、索引失效 |
## SQL 书写顺序 vs 执行顺序
```mermaid
flowchart LR
W["⑥ SELECT"] --> A["① FROM"]
A --> B["② JOIN"]
B --> C["③ ON"]
C --> D["④ WHERE"]
D --> E["⑤ GROUP BY"]
E --> F["⑦ HAVING"]
F --> G["⑧ DISTINCT"]
G --> H["⑨ ORDER BY"]
H --> I["⑩ LIMIT / OFFSET"]
style W fill:#C44569,color:#fff
style A fill:#00B6BC,color:#fff
style I fill:#4FC08D,color:#fff
```
> [!QUESTION] 为什么理解这个很重要?
> 因为 SQL 不是按照你写的顺序执行的——而是按数字顺序!这意味着:
> - `WHERE` 在 `SELECT` 之前执行 → 不能在 WHERE 中使用 SELECT 定义的别名
> - `GROUP BY` 在 `HAVING` 之前 → WHERE 过滤行,HAVING 过滤分组
> - `ORDER BY` 在 `LIMIT` 之前 → 先排序再截断
### 一个完整的例子
```sql
SELECT
DATE(created_at) AS order_date, -- ⑥ 计算列
COUNT(*) AS cnt, -- 聚合函数
SUM(amount) AS total -- 聚合函数
FROM orders -- ① 确定数据源
WHERE status = 'paid' -- ④ 先过滤行
AND created_at >= '2026-01-01' -- 过滤条件
GROUP BY DATE(created_at) -- ⑤ 按日期分组
HAVING COUNT(*) > 5 -- ⑦ 过滤分组
ORDER BY total DESC -- ⑨ 排序
LIMIT 10; -- ⑩ 取前 10 页
```
## SELECT 关键字详解
### DISTINCT
```sql
-- 去重查询
SELECT DISTINCT department FROM employees;
-- DISTINCT 作用于所有选中的列组合
SELECT DISTINCT country, city FROM customers;
-- 返回的是 (country, city) 的唯一组合
```
> [!QUESTION] DISTINCT 一定快吗?
> 不一定。`DISTINCT` 本质上是 `GROUP BY` 的简化版——MySQL 内部可能通过 temporary table + filesort 去重。当数据量大时,它的代价不亚于一次普通的分组聚合。
>
> **替代方案**:如果去重目的是为下拉框提供选项,可以用 `SELECT DISTINCT department FROM employees LIMIT 50;` 限制返回量;更优的做法是在应用层缓存选项列表。
### GROUP BY 优化
```sql
-- ✅ 好:GROUP BY 走索引
-- idx_status_dept = (status, department),可以直接按 department 分组
SELECT department, COUNT(*)
FROM employees
WHERE status = 'active'
GROUP BY department;
-- ❌ 差:GROUP BY 无法利用索引(LIKE 左模糊导致索引失效)
SELECT department, COUNT(*)
FROM employees
WHERE name LIKE '%chen%' -- 左模糊导致索引失效
GROUP BY department;
-- EXPLAIN: Using where; Using temporary
```
### HAVING 与 WHERE 的选择
```sql
-- ✅ WHERE 过滤行(早过滤,减少数据量)
SELECT department, AVG(salary)
FROM employees
WHERE hire_date >= '2024-01-01' -- 先筛选最近入职的人
GROUP BY department
HAVING AVG(salary) > 15000; -- 再过滤平均工资
-- ❌ 把能放 WHERE 的条件放到 HAVING 里
-- 虽然结果一样,但效率更低
SELECT department, AVG(salary)
FROM employees
GROUP BY department
HAVING hire_date >= '2024-01-01' -- 错!HAVING 不能用非聚合列
AND AVG(salary) > 15000;
```
### 条件表达式 — CASE / IF / IFNULL
在 SELECT 中插入"逻辑判断",是做数据变形(Pivot、区间分组)的核心技能。
#### CASE 表达式
SQL 中的 switch-case——标准 SQL 可移植性最好的分支语法:
```sql
-- ✅ CASE WHEN:多路分支
SELECT name, salary,
CASE
WHEN salary >= 20000 THEN 'L5+'
WHEN salary >= 15000 THEN 'L4'
WHEN salary >= 10000 THEN 'L3'
ELSE 'L2-'
END AS level
FROM employees;
-- ✅ CASE WHEN:实现行转列(Pivot)
-- 统计各部门各职级的员工数
SELECT department,
SUM(CASE WHEN level = 'P' THEN 1 ELSE 0 END) AS individual_count,
SUM(CASE WHEN level = 'M' THEN 1 ELSE 0 END) AS manager_count,
SUM(CASE WHEN level = 'D' THEN 1 ELSE 0 END) AS director_count
FROM employees
GROUP BY department;
```
> [!TIP] CASE 位置决定影响范围
> - **WHERE/CASE** → 逐行过滤,走普通索引
> - **HAVING/CASE** → 需要先聚合再过滤,效率较低
> - 能用 WHERE 解决的,不要推到 HAVING
> ```sql
> -- ❌ 把能写在 WHERE 的判断放到 HAVING
> SELECT status, COUNT(*) FROM orders GROUP BY status HAVING status IN ('paid', 'shipped');
> -- ✅ WHERE 先缩小范围,再聚合
> SELECT status, COUNT(*) FROM orders WHERE status IN ('paid', 'shipped') GROUP BY status;
> ```
#### IF / IFNULL / COALESCE
MySQL 专属的快捷函数,适合简单场景:
```sql
-- IF(condition, true_value, false_value) — 三目运算
SELECT name,
IF(status = 'active', '在职', '离职') AS label,
IF(salary IS NULL, 0, salary) AS pay
FROM employees;
-- IFNULL(val, default) — 空值替换
SELECT order_id, IFNULL(comments, '暂无评价') AS review
FROM orders;
-- COALESCE(v1, v2, ..., vn) — 返回第一个非 NULL 值
-- MySQL 8.0.19+ 支持多个参数(之前只支持 2 个)
SELECT name, COALESCE(alias, nickname, name) AS display_name;
-- 优先级:别名 > 昵称 > 真实姓名
```
> [!NOTE] CASE vs IF 的选择
> | 场景 | 推荐 | 原因 |
> |------|------|------|
> | 三路以上分支 | `CASE WHEN` | 可读性好,易扩展 |
> | 二选一简单判断 | `IF()` | 简洁,但仅限 MySQL |
> | 空值兜底 | `COALESCE()` | 标准 SQL,比多个 `IFNULL` 嵌套更优雅 |
## 窗口函数 (WINDOW FUNCTIONS)
窗口函数是 MySQL 8.0+ 引入的分析利器——它能在**不减少行数**的前提下进行聚合计算。
### 排名函数
```sql
-- ROW_NUMBER() / RANK() / DENSE_RANK()
-- 按部门内工资排名(处理并列名次的三种方式)
SELECT name, department, salary,
ROW_NUMBER() OVER(PARTITION BY department ORDER BY salary DESC) AS rn,
RANK() OVER(PARTITION BY department ORDER BY salary DESC) AS rnk,
DENSE_RANK() OVER(PARTITION BY department ORDER BY salary DESC) AS drnk
FROM employees;
-- 结果示例:
-- | name | dept | salary | rn | rnk | drnk |
-- | Alice | sales | 20000 | 1 | 1 | 1 |
-- | Bob | sales | 20000 | 2 | 1 | 1 |
-- | Carol | sales | 18000 | 3 | 3 | 2 |
```
> [!TIP] RANK vs DENSE_RANK 的区别
> - `RANK(20000, 20000, 18000)` → **1, 1, 3**(跳过第二名)
> - `DENSE_RANK(20000, 20000, 18000)` → **1, 1, 2**(不跳号)
> - `ROW_NUMBER` → **1, 2, 3**(永远无并列)
### 前后行访问 — LAG / LEAD
```sql
SELECT order_date, amount,
LAG(amount, 1) OVER(ORDER BY order_date) AS prev_amount, -- 前一天的金额
LEAD(amount, 1) OVER(ORDER BY order_date) AS next_amount -- 后一天的金额
FROM daily_sales;
-- 计算日环比增长率
SELECT order_date, amount,
ROUND((amount - LAG(amount) OVER(ORDER BY order_date)) / LAG(amount) OVER(ORDER BY order_date) * 100, 2) AS growth_pct
FROM daily_sales;
```
### 累计计算 — Running Total / Percentage
```sql
-- 累计求和 (Running Total)
SELECT order_date, amount,
SUM(amount) OVER(ORDER BY order_date) AS running_total
FROM daily_sales;
-- 当前分区内占比
SELECT department, name, salary,
ROUND(salary * 100.0 / SUM(salary) OVER(PARTITION BY department), 2) AS dept_pct
FROM employees;
```
### 窗口函数执行流程
```mermaid
flowchart TD
A["FROM / JOIN<br/>确定数据源"] --> B["WHERE<br/>逐行过滤"]
B --> C["GROUP BY<br/>分组聚合"]
C --> D["HAVING<br/>分组后过滤"]
D --> E["SELECT 列计算<br/>包括窗口函数"]
E --> F["ORDER BY<br/>排序结果"]
F --> G["LIMIT / OFFSET<br/>截断输出"]
style E fill:#00B6BC,color:#fff
linkStyle 4 stroke-width:3px,fill:none,stroke:#00B6BC
```
> [!NOTE] 窗口函数的"隐形"特性
> - 执行顺序在 WHERE、GROUP BY、HAVING **之后**,ORDER BY **之前**
> - 因此不能用 WHERE 直接过滤窗口函数的结果——需要套一层子查询:
> ```sql
> SELECT * FROM (
> SELECT *, ROW_NUMBER() OVER(PARTITION BY user_id ORDER BY created_at DESC) AS rn
> FROM orders
> ) ranked WHERE rn = 1;
> -- 作用:每个用户的最新一条订单记录
> ```
>
> 更详细的帧子句说明见下方「### 帧子句 — ROWS BETWEEN」小节。
### 帧子句 — ROWS BETWEEN
默认帧范围是 `RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW`。用 `ROWS BETWEEN` 可以更精确控制:
```sql
-- 近 7 天滑动窗口均值
SELECT order_date, amount,
ROUND(AVG(amount) OVER(
ORDER BY order_date
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
), 2) AS avg_7day
FROM daily_sales;
```
> [!NOTE] 帧子句速查
> | 语法 | 含义 |
> |------|------|
> | `ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW` | 从分区起点到当前行(**默认**) |
> | `ROWS BETWEEN 6 PRECEDING AND CURRENT ROW` | 当前行及前 6 行,共 7 行 |
> | `ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING` | 整个分区 |
> | `ROWS BETWEEN 2 PRECEDING AND 2 FOLLOWING` | 前后各 2 行的滑动窗口 |
## 子查询与 CTE
### 标量子查询 (Scalar Subquery)
返回单一值,可以像普通列一样使用:
```sql
-- WHERE 中的标量子查询
SELECT name, salary
FROM employees
WHERE salary > (SELECT AVG(salary) FROM employees); -- 工资高于公司平均的人
```
### 行子查询 (Row Subquery / IN)
```sql
-- IN 子查询
SELECT name, department
FROM employees
WHERE department IN (SELECT id FROM departments WHERE region = 'APAC');
-- EXISTS:关注"是否存在"而非具体数据,比 IN 更高效
SELECT d.name
FROM departments d
WHERE EXISTS (SELECT 1 FROM employees e WHERE e.dept_id = d.id);
```
### CTE (Common Table Expression) — WITH 语法
MySQL 8.0+ 推荐用法,比嵌套子查询可读性强很多:
```sql
-- 简单 CTE
WITH dept_stats AS (
SELECT department, COUNT(*) AS emp_count, AVG(salary) AS avg_salary
FROM employees
GROUP BY department
)
SELECT * FROM dept_stats WHERE avg_salary > 15000;
-- 递归 CTE:处理层级数据(组织架构、分类树等)
WITH RECURSIVE org_chart AS (
-- 锚点成员:根节点
SELECT id, name, manager_id, 1 AS level
FROM employees
WHERE manager_id IS NULL
UNION ALL
-- 递归成员:逐层展开
SELECT e.id, e.name, e.manager_id, oc.level + 1
FROM employees e
INNER JOIN org_chart oc ON e.manager_id = oc.id
)
SELECT * FROM org_chart ORDER BY level, name;
```
> [!QUESTION] CTE vs 派生表?
> - **可读性**:CTE 命名清晰,逻辑分层;派生表层层嵌套,括号匹配困难
> - **性能**:MySQL 会将非递归 CTE 优化为临时表或内联展开——多数情况下两者性能一致
> - **复用**:同一个 CTE 可在一个语句中多次引用(派生表不行)
## 深分页陷阱
`LIMIT offset, size` 在 offset 很大时性能急剧下降——MySQL 仍需扫描并跳过前面所有行。
```sql
-- ❌ 灾难级:扫描 100 万行后丢弃前 999,990 行
SELECT * FROM orders LIMIT 999990, 10;
-- ✅ 方案一:延迟关联(扫主键索引,只回表 10 次)
SELECT o.* FROM orders o
INNER JOIN (
SELECT id FROM orders ORDER BY id LIMIT 999990, 10
) AS tmp ON o.id = tmp.id;
-- ✅✅ 方案二:游标分页(推荐,O(log N) 恒定性能)
SELECT * FROM orders
WHERE id > 999985 -- 上一页最后一条的 ID
ORDER BY id ASC
LIMIT 10;
```
> [!TIP] 分页方案选型
> | 方案 | 复杂度 | 支持跳页 | 适用场景 |
> |------|--------|---------|---------|
> | 传统 `LIMIT n, m` | O(N) | ✅ | 小数据量 (< 1 万行) |
> | 延迟关联 | O(log N + m) | ✅ | 大数据量、需要精确页码 |
> | 游标分页 | O(log N) | ❌ | 瀑布流、"加载更多" |
>
> **更完整的分析与调优策略**请见 → [[hhs/MySQL/03-索引与查询优化/18-深分页优化]]
## 性能小贴士
### SELECT * 的反面教材
```sql
-- ❌ 避免 SELECT *
SELECT * FROM users WHERE status = 1;
-- ✅ 只查需要的列
SELECT id, username, email FROM users WHERE status = 1;
-- 好处:减少网络传输、提高 Buffer Pool 命中率、可能触发 Covering Index
```
### 函数包裹索引列
```sql
-- ❌ 函数包裹导致索引失效
SELECT * FROM users WHERE YEAR(created_at) = 2026;
-- ✅ 用范围替代函数(走索引范围扫描)
SELECT * FROM users
WHERE created_at >= '2026-01-01'
AND created_at < '2027-01-01';
```
### ORDER BY 与 Using filesort
```sql
-- ✅ 排序列有索引 → 直接按索引顺序输出,无需额外排序
SELECT * FROM orders ORDER BY user_id;
-- EXPLAIN Extra: NULL (无 filesort)
-- ⚠️ 混合 ASC/DESC → 无法利用普通 B+Tree 索引排序
SELECT * FROM orders ORDER BY user_id ASC, created_at DESC;
-- EXPLAIN Extra: Using filesort — 需要额外的内存/磁盘排序
```
> [!TIP] 覆盖索引 (Covering Index)
> 当 SELECT 的列全部包含在某个索引中时,InnoDB 可以直接从索引树返回结果,**无需回表**。
> ```sql
> -- 创建覆盖索引:id + status + updated_at 都在 idx 中
> CREATE INDEX idx_cover ON users(status, updated_at);
> -- 下面这个查询完全走索引扫描,不回表
> SELECT status, updated_at FROM users WHERE status = 1;
> ```
>
> **验证方法**:看 EXPLAIN 的 `Extra` 列是否出现 `Using index`。
## 关联笔记
- [[hhs/MySQL/02-SQL核心/09-JOIN 原理与优化]] — JOIN 的内部执行机制与驱动表选择
- [[hhs/MySQL/02-SQL核心/10-子查询与派生表]] — EXISTS / IN 子查询与 CTE 的性能对比
- [[hhs/MySQL/03-索引与查询优化/15-EXPLAIN 完全指南]] — 通过执行计划识别查询瓶颈