336 lines
11 KiB
Markdown
336 lines
11 KiB
Markdown
---
|
||
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/03-索引与查询优化/15-EXPLAIN 完全指南]] — 改写后验证效果的必备工具
|
||
- [[hhs/MySQL/03-索引与查询优化/16-慢查询日志分析]] — 从慢日志中发现需要改写的 SQL
|
||
- [[hhs/MySQL/03-索引与查询优化/18-深分页优化]] — 游标分页的专项深入
|
||
- [[hhs/MySQL/03-索引与查询优化/12-B+Tree 索引原理]] — 理解索引走与否的根本原因
|
||
- [[hhs/MySQL/08-工程实践/40-常见踩坑]] — 生产环境中的典型错误写法合集
|
||
- [[hhs/GORM/15-性能优化]] — GORM 层的批量操作优化
|