--- 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行
成本极低"] 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 行
仅提取主键(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 改写
核心原则)) 早过滤 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 层的批量操作优化