目录

MySQL-09 查询优化与执行计划

MySQL 查询优化与执行计划


目录

  1. 优化工作流
  2. EXPLAIN 全面解读
  3. 索引优化
  4. JOIN 优化
  5. 子查询优化
  6. 分页优化
  7. 聚合与排序优化
  8. SQL 改写技巧
  9. Optimizer Hints
  10. Go 中的查询优化实践

1. 优化工作流

慢查询 → 找到 SQL → EXPLAIN 分析 → 找出瓶颈 → 针对性优化 → 验证

发现慢 SQL 的途径:
  1. 慢查询日志(long_query_time = 1)
  2. performance_schema.events_statements_summary_by_digest
  3. show processlist(实时)
  4. 监控系统(Prometheus + mysqld_exporter)
  5. 应用层埋点(Go 的 middleware 记录 SQL 耗时)

2. EXPLAIN 全面解读

2.1 基本用法

-- 基本 EXPLAIN
EXPLAIN SELECT * FROM orders WHERE user_id = 1;

-- 显示更多信息(MySQL 5.6+)
EXPLAIN FORMAT=JSON SELECT * FROM orders WHERE user_id = 1;

-- 实际执行并显示真实统计(MySQL 8.0.18+)
EXPLAIN ANALYZE SELECT * FROM orders WHERE user_id = 1;

-- 竖排显示(更易读)
EXPLAIN SELECT * FROM orders WHERE user_id = 1\G

2.2 id 列

-- 简单查询:id=1
EXPLAIN SELECT * FROM orders WHERE id = 1;
-- id = 1

-- 子查询:子查询 id 更大
EXPLAIN
SELECT * FROM users u
WHERE u.id IN (SELECT user_id FROM orders WHERE amount > 1000);
-- id=1: users
-- id=2: orders(子查询)

-- UNION:有 NULL 的行是合并的临时表
EXPLAIN
SELECT id FROM users WHERE status=1
UNION
SELECT id FROM users WHERE status=2;
-- id=1: 第一个 SELECT
-- id=2: 第二个 SELECT
-- id=NULL: UNION RESULT(临时表)

2.3 select_type 列

说明
SIMPLE 简单 SELECT,无子查询/UNION
PRIMARY 最外层查询
SUBQUERY SELECT 中的子查询(非 FROM 子句)
DEPENDENT SUBQUERY 依赖外部查询的子查询(相关子查询)
DERIVED FROM 子句中的子查询(派生表)
UNION UNION 中第二个及后续 SELECT
UNION RESULT UNION 的合并结果
MATERIALIZED 物化子查询(MySQL 5.6+)

2.4 type 列(最重要)

从好到差,遇到 index 以下必须优化:

system  极少,表只有一行
const   主键/唯一键等值查询(id = 1)
eq_ref  JOIN 时被驱动表用主键/唯一键(ON a.id = b.id)
ref     普通索引等值查询(WHERE user_id = ?)
ref_or_null  WHERE col = ? OR col IS NULL
range   范围查询(BETWEEN, IN, >, <, LIKE 'prefix%')
index   全索引扫描(比 ALL 好,因为索引小于数据)
ALL     全表扫描(性能最差!)

2.5 key_len 列

用于判断联合索引使用了几个字段

计算规则:

INT NOT NULL          = 4 字节
INT NULL              = 4 + 1(NULL 标志位)= 5 字节
VARCHAR(N) NOT NULL   = N × 字符集字节数 + 2(长度前缀)
VARCHAR(N) NULL       = N × 字符集字节数 + 2 + 1
CHAR(N) NOT NULL      = N × 字符集字节数
DATETIME NOT NULL     = 5 字节(MySQL 5.6+)
-- 联合索引 (user_id BIGINT, status TINYINT)
-- user_id = 8 + 1(NULL)= 9
-- status  = 1 + 1(NULL)= 2
-- 总 key_len = 11 → 说明用了两个字段
-- key_len = 9 → 只用了 user_id

2.6 rows 列

优化器估算的扫描行数,不是精确值。与实际行数差距大时说明统计信息过期。

-- 更新统计信息
ANALYZE TABLE orders;

2.7 Extra 列详解

-- ✅ Using index:覆盖索引,无需回表
-- 出现在 type=index 时表示全索引扫描,配合覆盖索引也是好事

-- ✅ Using index condition:索引下推(ICP)
-- 引擎层过滤更多,减少回表

-- ⚠️ Using where:Server 层过滤
-- 存储引擎返回的数据中,有一部分不满足 WHERE,需要 Server 再过滤
-- 不一定是坏事,但如果 rows 很大、Rows_sent 很小,说明过滤效率低

-- ❌ Using filesort:额外排序
-- 无法利用索引顺序,需要在内存/磁盘排序
-- 内存排序:sort_buffer_size 足够
-- 磁盘排序:会生成临时文件,慢!

-- ❌ Using temporary:使用了临时表
-- 通常出现在 GROUP BY、DISTINCT、UNION 中
-- 内存临时表:tmp_table_size 和 max_heap_table_size
-- 磁盘临时表:更慢,转为 InnoDB 或 MyISAM

-- Using join buffer (Block Nested Loop):JOIN 无索引,使用 join buffer
-- Using join buffer (Batched Key Access):BKA,批量 key 访问

-- Select tables optimized away:优化器直接算出结果(MIN/MAX+索引)

2.8 EXPLAIN ANALYZE 示例

EXPLAIN ANALYZE
SELECT u.name, COUNT(o.id) AS cnt
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE u.status = 1
GROUP BY u.id
ORDER BY cnt DESC
LIMIT 10;

/* 输出示例:
-> Limit: 10 row(s)  (actual time=12.3..12.3 rows=10 loops=1)
    -> Sort: cnt DESC  (actual time=12.2..12.2 rows=10 loops=1)
        -> Stream results  (actual time=11.8..12.1 rows=100 loops=1)
            -> Group aggregate: count(o.id)  (actual time=0.1..11.5 rows=100 loops=1)
                -> Nested loop left join  (actual time=0.05..10.2 rows=500 loops=1)
                    -> Filter: (u.status = 1)  (actual time=0.02..1.3 rows=100 loops=1)
                        -> Index scan on u using idx_status  (actual time=0.02..1.1 rows=100 loops=1)
                    -> Index lookup on o using idx_user_id (user_id=u.id)  (actual time=0.05..0.08 rows=5 loops=100)
*/

3. 索引优化

3.1 强制/忽略索引

-- 强制使用某个索引
SELECT * FROM orders USE INDEX (idx_user_id) WHERE user_id = 1;
SELECT * FROM orders FORCE INDEX (idx_user_id) WHERE user_id = 1;
-- USE INDEX:建议,优化器还可以选全表
-- FORCE INDEX:强制,优化器只能选这个或全表

-- 忽略某个索引(避免优化器选错)
SELECT * FROM orders IGNORE INDEX (idx_wrong) WHERE user_id = 1;

3.2 统计信息问题

优化器选错索引,通常是统计信息不准确。

-- 更新统计信息
ANALYZE TABLE orders;

-- 查看索引统计
SHOW INDEX FROM orders;
-- Cardinality 列:不同值的估计数量(越大选择性越好)

-- 控制采样数(提高精度,但会增加 ANALYZE 时间)
innodb_stats_persistent_sample_pages = 20  -- 默认 20,可以增大
innodb_stats_auto_recalc = ON             -- 数据变化超过 10% 自动重算

3.3 索引合并(Index Merge)

多个单列索引可以合并使用:

EXPLAIN SELECT * FROM users WHERE name = 'Alice' OR email = 'bob@example.com';
-- type: index_merge
-- key: idx_name,idx_email
-- Extra: Using union(idx_name,idx_email); Using where

但 Index Merge 通常不如联合索引效率高:

-- 如果这个 OR 查询是高频的,考虑改写为 UNION
SELECT * FROM users WHERE name = 'Alice'
UNION ALL
SELECT * FROM users WHERE email = 'bob@example.com' AND name != 'Alice';

4. JOIN 优化

4.1 驱动表选择

-- MySQL 优化器默认选小结果集作为驱动表
-- 可以用 STRAIGHT_JOIN 强制顺序(左表为驱动表)
SELECT STRAIGHT_JOIN u.*, o.amount
FROM users u
STRAIGHT_JOIN orders o ON u.id = o.user_id;

-- 检查 EXPLAIN 中第一行出现的表是驱动表

4.2 被驱动表必须有索引

-- ❌ 被驱动表无索引,导致 Block Nested Loop(BNL)
-- Extra: Using join buffer (block nested loop)

-- 解决:在 JOIN 条件列上建索引
ALTER TABLE orders ADD INDEX idx_user_id (user_id);

4.3 Join Buffer

# BNL 时使用的 join buffer 大小
join_buffer_size = 2M  # 默认 256KB,复杂 JOIN 可调大

4.4 MySQL 8.0 Hash Join

MySQL 8.0.18+ 对等值条件的 JOIN,当被驱动表无合适索引时,自动使用 Hash Join:

BNL(Block Nested Loop):O(n × m / join_buffer)
Hash Join:构建哈希表 O(min(n,m)),探测 O(n+m)

Hash Join 通常比 BNL 快很多

4.5 JOIN 数量控制

# 优化器搜索 JOIN 顺序的深度
optimizer_search_depth = 62  # 默认 62(全搜索)
# 表数量 > 7 时,全排列爆炸(7! = 5040),可以限制
optimizer_search_depth = 5   # 超过 5 张表后,贪心算法

5. 子查询优化

5.1 子查询转 JOIN

-- ❌ 低效:NOT IN 子查询(每次都执行)
SELECT * FROM users
WHERE id NOT IN (SELECT user_id FROM orders);

-- ✅ 优化:LEFT JOIN + IS NULL
SELECT u.*
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE o.user_id IS NULL;

-- ❌ 低效:相关子查询(每行都执行子查询)
SELECT u.*, (SELECT COUNT(*) FROM orders WHERE user_id = u.id) AS cnt
FROM users u;

-- ✅ 优化:JOIN + 聚合
SELECT u.*, COALESCE(o.cnt, 0) AS cnt
FROM users u
LEFT JOIN (
  SELECT user_id, COUNT(*) AS cnt FROM orders GROUP BY user_id
) o ON u.id = o.user_id;

5.2 EXISTS vs IN

-- 外大内小:用 EXISTS(外层循环少,内层 EXISTS 快速短路)
-- 外小内大:用 IN(内层做一次,外层 IN 查)

-- 大表 users,小表 vip_users
-- ✅ 用 IN(vip_users 小,IN 列表短)
SELECT * FROM users WHERE id IN (SELECT user_id FROM vip_users);

-- 大表 users,大表 orders
-- ✅ 用 EXISTS
SELECT * FROM users u
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id AND o.amount > 1000);

5.3 派生表(Derived Table)物化

-- MySQL 5.6+ 会物化派生表(将子查询结果存入临时表),只执行一次
SELECT * FROM (
  SELECT user_id, SUM(amount) AS total
  FROM orders
  GROUP BY user_id
) t
WHERE t.total > 10000;

-- 可以通过 optimizer_switch 控制
SET optimizer_switch = 'derived_merge=on';   -- 合并(避免临时表)
SET optimizer_switch = 'derived_merge=off';  -- 物化

6. 分页优化

6.1 深分页问题

-- ❌ 深分页(LIMIT 100000, 20)
-- 需要先读取并丢弃 100000 行,再返回 20 行
SELECT * FROM orders ORDER BY id DESC LIMIT 100000, 20;
-- 执行时间随 OFFSET 线性增加

6.2 游标分页(推荐)

-- ✅ 游标分页:用上次查询的最后一个 id 作为游标
-- 前提:id 连续递增(或有单调索引列)

-- 第一页
SELECT * FROM orders ORDER BY id DESC LIMIT 20;
-- 返回最后一行 id = 9980

-- 第二页(传入 last_id = 9980)
SELECT * FROM orders WHERE id < 9980 ORDER BY id DESC LIMIT 20;
-- 稳定 O(log n),不受页码影响

游标分页的局限:不支持随机跳页,只能顺序翻页。

6.3 延迟关联(Deferred Join)

-- ✅ 延迟关联:先查 id,再回表
SELECT o.*
FROM orders o
JOIN (
  SELECT id FROM orders ORDER BY created_at DESC LIMIT 100000, 20
) t ON o.id = t.id;

-- 原理:子查询只查 id(覆盖索引,不回表),获得 20 个 id 后再 JOIN 回表
-- 比直接 LIMIT 100000, 20 快得多(覆盖索引扫描 vs 数据页扫描)

6.4 分页总数查询优化

-- ❌ COUNT(*) 配合复杂 WHERE,每次都全扫描
SELECT COUNT(*) FROM orders WHERE user_id = 1 AND status = 1;

-- ✅ 方案1:维护计数器表
-- 异步更新(缓存 + 定时写)

-- ✅ 方案2:估算值(对精确度要求不高时)
EXPLAIN SELECT * FROM orders WHERE user_id = 1;
-- rows 列是估算值,可以直接用于"约 N 条结果"的提示

-- ✅ 方案3:加适当的索引
-- WHERE 列有索引时,COUNT(*) 可以走覆盖索引
CREATE INDEX idx_user_status ON orders (user_id, status);
SELECT COUNT(*) FROM orders WHERE user_id = 1 AND status = 1;
-- Extra: Using index(覆盖索引,不回表)

7. 聚合与排序优化

7.1 GROUP BY 优化

-- 有索引时,GROUP BY 可以利用索引顺序,不需要临时表和排序
-- INDEX (user_id, status)
SELECT user_id, status, COUNT(*)
FROM orders
GROUP BY user_id, status;
-- Extra: Using index

-- GROUP BY 与索引顺序不一致时
SELECT status, user_id, COUNT(*)
FROM orders
GROUP BY status, user_id;
-- 与索引顺序不符,可能 Using temporary + Using filesort

-- 关闭 ONLY_FULL_GROUP_BY 模式(不推荐,可能返回不确定的值)
-- SELECT 中的非聚合列必须在 GROUP BY 中出现
SET sql_mode = sys.list_drop(@@sql_mode, 'ONLY_FULL_GROUP_BY');

7.2 ORDER BY 优化

-- ✅ 利用索引排序(无需 filesort)
-- INDEX idx_user_created (user_id, created_at)

SELECT * FROM orders
WHERE user_id = 1
ORDER BY created_at DESC;
-- Extra: Using where(如果 SELECT *)
-- 或 Extra: Using index condition

-- ❌ 排序方向不一致,无法利用索引(MySQL 5.7-)
SELECT * FROM orders WHERE user_id = 1
ORDER BY status ASC, created_at DESC;

-- ✅ MySQL 8.0+ 支持降序索引
CREATE INDEX idx_user_status_desc ON orders (user_id, status ASC, created_at DESC);

7.3 排序内存

# 排序使用的内存缓冲
sort_buffer_size = 2M  # 默认 256KB

# 单行数据超过 max_length_for_sort_data 时,MySQL 使用两趟排序(慢)
max_length_for_sort_data = 4096  # MySQL 8.0.20+ 弃用

# MySQL 8.0 使用 sort_buffer_size 控制,并支持 chunk-based 排序

7.4 filesort 算法

MySQL 有两种 filesort 算法:

单次传送(Single-pass):将 SELECT 的所有列放入 sort buffer,排序后直接返回。内存需求大,但 I/O 次数少。

两次传送(Double-pass):只将排序列 + 行指针放入 sort buffer,排序后再根据指针回表取数据。I/O 更多,但内存占用少。

MySQL 5.6+ 默认使用单次传送,前提是 sort_buffer_size 足够。


8. SQL 改写技巧

8.1 避免 SELECT *

-- ❌ SELECT *
SELECT * FROM users WHERE id = 1;

-- ✅ 只查需要的列
SELECT id, name, email FROM users WHERE id = 1;

-- 好处:
-- 1. 减少网络传输量
-- 2. 利用覆盖索引避免回表
-- 3. 避免 TEXT/BLOB 列传输(这些列通常很大)

8.2 大 IN 列表分批

// ❌ 一次 IN 1000 个 ID,影响性能
db.Query("SELECT * FROM users WHERE id IN (?)", ids) // 1000 个 id

// ✅ 分批处理
func batchQuery(db *sql.DB, ids []int64) ([]User, error) {
    batchSize := 100
    var users []User
    for i := 0; i < len(ids); i += batchSize {
        batch := ids[i:min(i+batchSize, len(ids))]
        // 查询这批
        rows := queryBatch(db, batch)
        users = append(users, rows...)
    }
    return users, nil
}

8.3 改写 OR 为 UNION

-- ❌ OR 两侧有一个无索引
SELECT * FROM orders WHERE user_id = 1 OR status = 2;

-- ✅ UNION ALL(去重用 UNION,不去重用 UNION ALL 更快)
SELECT * FROM orders WHERE user_id = 1
UNION ALL
SELECT * FROM orders WHERE status = 2 AND user_id != 1;

8.4 用 EXISTS 替换 IN(大子查询)

-- ❌ IN 大子查询(先执行子查询,结果可能很大)
SELECT * FROM users
WHERE id IN (SELECT user_id FROM orders WHERE amount > 1000);

-- ✅ EXISTS(相关子查询,短路效果)
SELECT * FROM users u
WHERE EXISTS (
  SELECT 1 FROM orders o
  WHERE o.user_id = u.id AND o.amount > 1000
);

8.5 日期范围替代函数

-- ❌ 对索引列用函数,索引失效
SELECT * FROM orders WHERE DATE(created_at) = '2024-01-15';
SELECT * FROM orders WHERE YEAR(created_at) = 2024;

-- ✅ 范围查询
SELECT * FROM orders
WHERE created_at >= '2024-01-15 00:00:00'
  AND created_at <  '2024-01-16 00:00:00';

SELECT * FROM orders
WHERE created_at >= '2024-01-01 00:00:00'
  AND created_at <  '2025-01-01 00:00:00';

8.6 合理使用 LIMIT 1

-- 只需要知道"是否存在"时,加 LIMIT 1 找到即停
SELECT 1 FROM orders WHERE user_id = 1 LIMIT 1;
-- 等价 EXISTS 效果,但更直观

-- 比 COUNT(*) > 0 快

9. Optimizer Hints

MySQL 8.0 提供了更丰富的 Optimizer Hints(优化器提示)。

-- 指定 JOIN 算法
SELECT /*+ BNL(o) */ u.*, o.amount
FROM users u JOIN orders o ON u.id = o.user_id;

SELECT /*+ NO_BNL(o) */ ...  -- 禁止 BNL

SELECT /*+ HASH_JOIN(u, o) */ ...  -- 强制 Hash Join(8.0.18+)

-- 指定索引
SELECT /*+ INDEX(u idx_email) */ * FROM users u WHERE email = 'alice@example.com';

-- 控制 JOIN 顺序
SELECT /*+ JOIN_ORDER(u, o, p) */ ...

-- 禁止查询缓存(5.7及以前)
SELECT SQL_NO_CACHE * FROM users WHERE id = 1;

-- 强制优化器重新计算统计
SELECT /*+ SET_VAR(optimizer_switch='mrr=on') */ * FROM orders WHERE id > 1000;

10. Go 中的查询优化实践

10.1 只查需要的字段

// 定义不同场景的结构体
type UserFull struct {
    ID        int64
    Name      string
    Email     string
    Phone     string
    Age       int
    // 20+ 字段...
}

type UserSummary struct {
    ID    int64
    Name  string
    Email string
}

// 列表页用轻量结构体
func ListUsers(db *sql.DB, page, size int) ([]UserSummary, error) {
    rows, err := db.Query(
        "SELECT id, name, email FROM users WHERE status = 1 LIMIT ?, ?",
        (page-1)*size, size,
    )
    // ...
}

// 详情页用完整结构体
func GetUser(db *sql.DB, id int64) (*UserFull, error) {
    // ...
}

10.2 预编译语句缓存

// 高频查询用预编译语句,减少解析开销
type UserRepo struct {
    db         *sql.DB
    stmtGetByID *sql.Stmt
    stmtList    *sql.Stmt
}

func NewUserRepo(db *sql.DB) (*UserRepo, error) {
    r := &UserRepo{db: db}
    var err error

    r.stmtGetByID, err = db.Prepare("SELECT id, name, email FROM users WHERE id = ?")
    if err != nil {
        return nil, err
    }

    r.stmtList, err = db.Prepare("SELECT id, name FROM users WHERE status = ? LIMIT ?, ?")
    if err != nil {
        return nil, err
    }

    return r, nil
}

func (r *UserRepo) GetByID(ctx context.Context, id int64) (*User, error) {
    var u User
    err := r.stmtGetByID.QueryRowContext(ctx, id).Scan(&u.ID, &u.Name, &u.Email)
    return &u, err
}

10.3 慢查询日志集成

// SQL 耗时 middleware(配合 database/sql 的 wrapper)
type LoggingDB struct {
    *sql.DB
    logger *slog.Logger
    slow   time.Duration
}

func (d *LoggingDB) QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error) {
    start := time.Now()
    rows, err := d.DB.QueryContext(ctx, query, args...)
    elapsed := time.Since(start)
    if elapsed > d.slow {
        d.logger.Warn("slow query",
            "query", query,
            "args", args,
            "duration", elapsed,
        )
    }
    return rows, err
}

// GORM 慢查询日志
import "gorm.io/gorm/logger"

newLogger := logger.New(
    log.New(os.Stdout, "\r\n", log.LstdFlags),
    logger.Config{
        SlowThreshold:             200 * time.Millisecond,
        LogLevel:                  logger.Warn,
        IgnoreRecordNotFoundError: true,
    },
)
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{Logger: newLogger})

10.4 连接池调优

// 根据业务特征调整连接池参数
func configurePool(db *sql.DB, env string) {
    switch env {
    case "production":
        db.SetMaxOpenConns(100)
        db.SetMaxIdleConns(20)
        db.SetConnMaxLifetime(30 * time.Minute)
        db.SetConnMaxIdleTime(10 * time.Minute)
    case "development":
        db.SetMaxOpenConns(10)
        db.SetMaxIdleConns(5)
        db.SetConnMaxLifetime(time.Hour)
    }
}

// 监控连接池状态
func monitorPool(db *sql.DB) {
    stats := db.Stats()
    metrics.Gauge("db.open_conns", float64(stats.OpenConnections))
    metrics.Gauge("db.in_use", float64(stats.InUse))
    metrics.Gauge("db.idle", float64(stats.Idle))
    metrics.Counter("db.wait_count", float64(stats.WaitCount))
    metrics.Gauge("db.wait_duration", stats.WaitDuration.Seconds())
}

小结

优化优先级(从高到低):

1. 正确使用索引(最大收益)
   → EXPLAIN 查 type 和 Extra
   → 消除全表扫描和文件排序

2. 减少数据量
   → 只 SELECT 需要的列
   → 合理的 WHERE 过滤
   → 分页时用游标而非大 OFFSET

3. JOIN 优化
   → 小表驱动大表
   → 被驱动表有索引

4. 子查询 → JOIN/EXISTS 改写

5. 应用层优化
   → 批量操作
   → 缓存热点数据
   → 读写分离