11 KiB
11 KiB
tags, create time
| tags | create time | ||||
|---|---|---|---|---|---|
|
2026-05-16 00:00 |
查询改写技巧
概述
同样的业务需求可能有多种 SQL 写法,性能差异可达几十倍。本章总结常用的 SQL 改写模式,帮助你将「能跑的 SQL」变成「跑得快的 SQL」。
1. OR → UNION ALL
-- ❌ 差:两个列各自独立,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
-- ❌ 存在重复行的风险
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
-- ❌ 标量子查询可能导致 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;
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. 避免函数包裹索引列
-- ❌ 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';
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)
-- ❌ 深分页灾难
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 条
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 定位下一页起点。
-- ❌ 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 应用层写法
// 第一页:无游标 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 * 陷阱
-- ❌ 拉取不需要的列:浪费网络带宽 + 无法使用 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 与全文搜索
-- ❌ 左模糊匹配导致全表扫描
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 有什么区别?
-- 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)等价于计数。
-- ✅ 简洁写法:布尔表达式直接参与算术
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 利用索引覆盖计数
-- ❌ 无索引:全表扫描
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 批量优化
-- ❌ 逐条插入(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 代替多次查询
-- ❌ 应用层多次查询
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;
改写原则总结
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 层的批量操作优化