--- tags: [MySQL, SELECT, 查询执行顺序, GROUP BY, 分页, 窗口函数, CTE] create time: 2026-05-16 00:00 --- # DQL — SELECT 全解析 ## 概述 SELECT 是 MySQL 中使用频率最高的语句,也是最容易被误解的语句。理解其内部执行顺序是写出高性能查询的第一步。 ## 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; ``` ## 窗口函数 (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; ``` ### 累计计算 ```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; ``` > [!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; > -- 作用:每个用户的最新一条订单记录 > ``` ## 子查询与 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 可在一个语句中多次引用(派生表不行) --- ## 深分页问题 这是 MySQL 最著名的性能陷阱之一。 ```sql -- ❌ 灾难级写法:扫描 100 万行后丢弃前 999,990 行 SELECT * FROM orders LIMIT 999990, 10; -- MySQL 需要先定位到第 999990 行,才返回接下来的 10 行 -- ✅ 方案一:延迟关联(Deferred Join) SELECT o.* FROM orders o INNER JOIN ( SELECT id FROM orders ORDER BY id LIMIT 999990, 10 ) AS tmp ON o.id = tmp.id; -- 子查询只扫主键索引(极紧凑),外层再 JOIN 拿完整数据 ``` ```mermaid flowchart LR subgraph "传统 LIMIT 999990,10" A1["扫描聚簇索引
跳过 999990 行"] --> A2["取出 10 行数据"] end subgraph "延迟关联方案" B1["扫描聚簇索引
跳过 999990 行"] --> B2["仅提取 10 个主键"] B2 --> B3["JOIN 回聚簇索引
精确查找 10 个主键"] B3 --> B4["返回结果"] end A1 --> A2 style B1 fill:#00B6BC,color:#fff style B2 fill:#00D866,color:#fff ``` ### 游标分页(推荐) ```sql -- 上一页最后一条记录的 id = 999985 SELECT * FROM orders WHERE id > 999985 ORDER BY id ASC LIMIT 10; ``` > [!TIP] 为什么游标分页更优? > - `WHERE id > ?` 走索引范围扫描,复杂度 O(log N) 而非 O(N) > - 无论在第几页,查询时间恒定 > - 需要前端传「上一页最后一个 ID」作为下一页的游标 > > **局限性**:不支持跳页(不能直接跳到第 100 页),但这对瀑布流场景足够。 ## 性能小贴士 ```sql -- ❌ 避免 SELECT * SELECT * FROM users WHERE status = 1; -- ✅ 只查需要的列 SELECT id, username, email FROM users WHERE status = 1; -- 好处:减少网络传输、提高 Buffer Pool 命中率、可能触发 Covering Index -- ❌ 函数包裹索引列 SELECT * FROM users WHERE YEAR(created_at) = 2026; -- ✅ 用范围替代函数 SELECT * FROM users WHERE created_at >= '2026-01-01' AND created_at < '2027-01-01'; ``` ## 关联笔记 - [[hhs/MySQL/12-JOIN 原理与优化]] — 深入理解 JOIN 的内部执行机制 - [[hhs/MySQL/13-子查询与派生表]] — 与 SELECT 密切相关的子查询技术 - [[hhs/GORM/06-排序与分页]] — GORM 分页 API 与原生 SQL 的差异