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/MySQL/21-查询改写技巧.md
T
2026-05-17 00:06:11 +08:00

336 lines
11 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: [MySQL, 查询优化, 重写, 性能调优]
create time: 2026-05-16 00:00
---
# 查询改写技巧
## 概述
同样的业务需求可能有多种 SQL 写法,性能差异可达几十倍。本章总结常用的 SQL 改写模式,帮助你将「能跑的 SQL」变成「跑得快的 SQL」。
## 1. OR → UNION ALL
```sql
-- ❌ 差:两个列各自独立,OR 会导致两边都无法走索引
SELECT * FROM users
WHERE email = 'alice@example.com' OR phone = '13800138000';
-- EXPLAIN: type=ALL, Rows=1000000 → 全表扫描
-- ✅ 好:拆成两次索引查询 + UNION ALL
SELECT * FROM users WHERE email = 'alice@example.com'
UNION ALL
SELECT * FROM users WHERE phone = '13800138000';
-- 两次独立的 ref 查询,各走一个索引
```
> [!TIP] 什么时候不该改?
> - 如果 OR 两边的列在**同一个联合索引**中,原写法可以利用最左前缀
> - 如果结果集非常小(几行),优化器可能自行选择最优方案
## 2. JOIN → EXISTS
```sql
-- ❌ 存在重复行的风险
SELECT DISTINCT u.* FROM users u
JOIN orders o ON u.id = o.user_id
WHERE o.amount > 1000;
-- 一个用户有多张大额订单时会重复,DISTINCT 又带来排序开销
-- ✅ 改用 EXISTS(找到第一条就停止)
SELECT u.* FROM users u
WHERE EXISTS (
SELECT 1 FROM orders o
WHERE o.user_id = u.id AND o.amount > 1000
);
```
> [!NOTE] MySQL Optimizer 的智能
> 现代 MySQL(8.0+)的优化器已经足够聪明,经常能把 JOIN 和 EXISTS 转换为相同的执行计划。但显式使用 EXISTS 语义更清晰,且在某些复杂场景下确实能引导优化器选更好的路径。
## 3. 子查询 → JOIN
```sql
-- ❌ 标量子查询可能导致 N+1
SELECT u.username,
(SELECT SUM(amount) FROM orders WHERE user_id = u.id) AS total_spent
FROM users u;
-- ✅ 换成 GROUP BY + JOIN
SELECT u.username, COALESCE(SUB.total_spent, 0) AS total_spent
FROM users u
LEFT JOIN (
SELECT user_id, SUM(amount) AS total_spent
FROM orders GROUP BY user_id
) SUB ON u.id = SUB.user_id;
```
```mermaid
flowchart TD
subgraph "子查询方案"
A1["10万用户"] --> A2["每个用户查一次 SUM()"]
A2 --> A3["总计 10万次扫描 orders 表"]
end
subgraph "JOIN 方案"
B1["1次 GROUP BY orders"] --> B2["1000个汇总结果"]
B2 --> B3["10万次 LEFT JOIN 1000行<br/>成本极低"]
end
style A3 fill:#EE5A24,color:#fff
style B3 fill:#00D866,color:#fff
```
## 4. 避免函数包裹索引列
```sql
-- ❌ YEAR() / DATE() 包裹索引列 → 索引失效
SELECT * FROM orders
WHERE YEAR(created_at) = 2026 AND MONTH(created_at) = 5;
-- EXPLAIN: type=ALL, Rows=1000000
-- ✅ 改用范围查询 → 走索引
SELECT * FROM orders
WHERE created_at >= '2026-05-01'
AND created_at < '2026-06-01';
```
```mermaid
flowchart LR
Func["WHERE YEAR(col) = 2026"] --> F1["每一行都要计算 YEAR()"]
F1 --> F2["无法利用 B+ Tree 二分查找"]
F2 --> F3["全表扫描 ALL"]
Range["WHERE col >= '2026-01-01' AND col < '2027-01-01'"] --> R1["直接在 B+ Tree 中定位范围"]
R1 --> R2["索引范围扫描 range"]
R2 --> R3["O(log N) 复杂度"]
style F3 fill:#EE5A24,color:#fff
style R3 fill:#00D866,color:#fff
```
## 5. 分页优化:延迟关联 + 游标分页
### 5.1 延迟关联(Deferred Join)
```sql
-- ❌ 深分页灾难
SELECT * FROM articles
WHERE status = 1
ORDER BY created_at DESC
LIMIT 999990, 10;
-- 扫描聚簇索引跳过 999990 行,每行都拉完整数据
-- ✅ 延迟关联:子查询只扫主键,外层精准 JOIN
SELECT a.* FROM articles a
INNER JOIN (
SELECT id FROM articles
WHERE status = 1
ORDER BY created_at DESC
LIMIT 999990, 10
) tmp ON a.id = tmp.id;
-- 子查询只扫主键(极紧凑),外层精准 JOIN 10 条
```
```mermaid
flowchart LR
subgraph "传统方式"
A1["扫描 999990 行完整数据"] --> A2["丢弃 999990 行"]
A2 --> A3["返回 10 行"]
end
subgraph "延迟关联"
B1["子查询扫 999990 行<br/>仅提取主键(8 bytes)"] --> B2["精准 JOIN 10 个主键"]
B2 --> B3["返回 10 行完整数据"]
end
A3 ---|"I/O 减少 90%+"| B3
style A1 fill:#EE5A24,color:#fff
style B1 fill:#00B6BC,color:#fff
style B3 fill:#00D866,color:#fff
```
### 5.2 游标分页(Keyset Pagination)
延迟关联能缓解,但**不是根治方案**。当 offset 极大时仍要遍历大量行。更优解是**基于上一页最后一个 ID 定位下一页起点**。
```sql
-- ❌ offset 越大越慢,且并发翻页可能丢数据
SELECT * FROM articles ORDER BY id DESC LIMIT 100000, 10;
-- ✅ 游标分页:记住上一页最后一条的 id
SELECT * FROM articles
WHERE id < 999980 -- 上一页最后一条的 id
ORDER BY id DESC
LIMIT 10;
```
> [!TIP] Go 应用层写法
> ```go
> // 第一页:无游标
> db.Order("id DESC").Limit(10).Find(&articles)
>
> // 后续页:用最后一条的 ID 做游标
> lastID := articles[len(articles)-1].ID
> db.Where("id < ?", lastID).Order("id DESC").Limit(10).Find(&articles)
> ```
> [!WARNING] 注意事项
> - 要求排序列有**唯一索引**(如自增主键)。如果排序字段不唯一(如 `created_at`),需加辅助条件:`WHERE (created_at, id) < (?, ?)` 利用联合索引最左前缀。
> - 前端无法直接跳到第 N 页——这是合理的 UX 约束。现代平台(Twitter、Instagram)均采用「加载更多」而非页码导航。
## 6. 避免 SELECT * 陷阱
```sql
-- ❌ 拉取不需要的列:浪费网络带宽 + 无法使用 Covering Index
SELECT * FROM orders WHERE user_id = 42;
-- ✅ 只查需要的列 → 可能变成 Covering Index(完全不碰数据行)
SELECT id, amount, status FROM orders WHERE user_id = 42;
```
> [!TIP] 为什么指定列更快?
> - InnoDB 聚簇索引叶子节点存整行数据。如果查询的列都在辅助索引中,MySQL 直接从辅助索引取数(**Using index**),不需要回表。
> - 网络传输的数据量也显著降低——去掉 TEXT/BLOB 列可能从 MB 级降到 KB 级。
## 7. LIKE 与全文搜索
```sql
-- ❌ 左模糊匹配导致全表扫描
SELECT * FROM articles WHERE title LIKE '%MySQL%';
-- EXPLAIN: type=ALL
-- ✅ 前缀模糊走索引
SELECT * FROM articles WHERE title LIKE 'MySQL%';
-- ✅ 大文本搜索用全文索引(FULLTEXT)
ALTER TABLE articles ADD FULLTEXT INDEX ft_title (title);
SELECT * FROM articles
WHERE MATCH(title) AGAINST('MySQL' IN NATURAL LANGUAGE MODE);
```
> [!NOTE] LIKE vs FULLTEXT
> - `LIKE 'prefix%'` 走 B+ Tree 范围扫描,适合精确前缀匹配
> - `FULLTEXT` 支持分词、相关性排序( relevance ranking),适合搜索引擎场景
> - MySQL 的 FULLTEXT 对中文效果有限(空格分隔分词器不适用于中文),中文场景建议对接 Elasticsearch
## 8. 近似聚合:SUM(COL) vs SUM(1)
> [!QUESTION] 下面两行 SQL 有什么区别?
> ```sql
> -- Q1: 这两行结果一样吗?哪个更快?
> SELECT SUM(status = 1) FROM orders;
> SELECT SUM(CASE WHEN status = 1 THEN 1 ELSE 0 END) FROM orders;
> ```
> **答案**: 结果相同,但写法 1 更简洁且执行效率略高。MySQL 将布尔表达式视为 0/1 整数,`SUM(TRUE)` 等价于计数。
```sql
-- ✅ 简洁写法:布尔表达式直接参与算术
SELECT
SUM(status = 'pending') AS pending,
SUM(status = 'shipped') AS shipped,
SUM(status = 'completed') AS completed
FROM orders;
-- 等价于多 CASE WHEN 写法,但更短、可读性更好
```
> [!WARNING] NULL 处理差异
> - `SUM(col = val)` 在条件不满足时返回 0,在列为 NULL 时也返回 NULL(即 0+NULL=NULL)
> - 如果需要对 NULL 安全计数,用 `SUM(CASE WHEN col = val THEN 1 ELSE 0 END)` 或加 `COALESCE`
## 9. COUNT 优化策略
### 9.1 利用索引覆盖计数
```sql
-- ❌ 无索引:全表扫描
SELECT COUNT(*) FROM large_table WHERE status = 1;
-- EXPLAIN: type=ALL → 遍历每一行判断
-- ✅ 建索引后:只扫索引树
ALTER TABLE large_table ADD INDEX idx_status (status);
SELECT COUNT(*) FROM large_table WHERE status = 1;
-- EXPLAIN: type=range, key=idx_status, Extra=Using where; Using index
```
### 9.2 近似计数(可容忍误差)
| 方案 | 精度 | 适用场景 |
|------|------|----------|
| `SHOW TABLE STATUS` | 低(缓存导致偏差) | MyISAM 快速估算 |
| 随机采样 × 膨胀系数 | 中 | 运营后台 Dashboard |
| `EXPLAIN table WHERE ...` | 较高 | 预估行数范围 |
> [!NOTE] InnoDB 的 COUNT(*)
> InnoDB 没有维护表级别行数(因为有 MVCC,不同事务看到的行数可能不同)。`COUNT(*)` 需要实际扫描。对于高并发大表,考虑**异步统计表**或**Redis 计数器**。
## 10. INSERT 批量优化
```sql
-- ❌ 逐条插入(N 次网络往返 + N 次事务提交)
INSERT INTO orders (...) VALUES (...);
INSERT INTO orders (...) VALUES (...);
INSERT INTO orders (...) VALUES (...);
-- ✅ 批量 INSERT(1 次网络往返 + 1 次事务)
INSERT INTO orders (...) VALUES (...), (...), (...);
-- ✅ Go 中分批次提交
for i := 0; i < len(items); i += 500 {
batch := items[i : min(i+500, len(items))]
db.Model(&Order{}).Create(batch)
}
```
## 11. CASE WHEN 代替多次查询
```sql
-- ❌ 应用层多次查询
countPending = db.Where("status='pending'").Count()
countShipped = db.Where("status='shipped'").Count()
countCompleted = db.Where("status='completed'").Count()
-- ✅ 单次查询 + 服务端处理
SELECT
SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) AS pending,
SUM(CASE WHEN status = 'shipped' THEN 1 ELSE 0 END) AS shipped,
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) AS completed
FROM orders;
```
## 改写原则总结
```mermaid
mindmap
root((SQL 改写<br/>核心原则))
早过滤
WHERE 先行
减少中间结果
走索引
避免函数包裹列
OR 拆 UNION ALL
LIKE 前缀匹配
少回表
Covering Index
SELECT 指定列(非*)
分批量
批量 INSERT
延迟关联
游标分页
换思路
EXISTS vs JOIN
CASE WHEN vs 多次查询
子查询转 JOIN
```
## 关联笔记
- [[hhs/MySQL/19-EXPLAIN 完全指南]] — 改写后验证效果的必备工具
- [[hhs/MySQL/20-慢查询日志分析]] — 从慢日志中发现需要改写的 SQL
- [[hhs/MySQL/22-深分页优化]] — 游标分页的专项深入
- [[hhs/MySQL/16-B+Tree 索引原理]] — 理解索引走与否的根本原因
- [[hhs/MySQL/40-常见踩坑]] — 生产环境中的典型错误写法合集
- [[hhs/GORM/15-性能优化]] — GORM 层的批量操作优化