Featured image of post MySQL 索引优化实战:700 万行表从 3 秒到 50 毫秒

MySQL 索引优化实战:700 万行表从 3 秒到 50 毫秒

本文以一个真实的 700 万行隐患排查任务表为案例,记录如何从 EXPLAIN 输出发现 Using temporary + Using filesort,一步步通过 INPLACE 在线 DDL 加复合索引、删冗余索引、改名复用,把单条 SQL 从 3 秒压到 50 毫秒。

为什么写这篇:很多教程讲索引原理头头是道,但真碰到 700 万行、6 张表 join、含 GROUP BY 和 ORDER BY 的复杂 SQL 时,仍是无从下手。本文还原一次完整的"看 EXPLAIN → 找瓶颈 → 设计索引 → 上线验证"过程,所有 SQL 都在 INPLACE LOCK=NONE 下执行,不阻塞业务读写

适用读者:在生产库做 SQL 优化的后端 / DBA,需要 INPLACE 在线 DDL 经验。

前置知识:了解 B+ 树索引、EXPLAIN 各字段含义、InnoDB 在线 DDL。

目录

  1. 场景还原:3 秒的复杂 SQL
  2. 第一次 EXPLAIN:定位瓶颈
  3. 索引设计原则:覆盖索引 + 复合索引顺序
  4. 实施步骤:INPLACE 在线 DDL
  5. 第二次 EXPLAIN:瓶颈消除
  6. CTE + GROUP BY 场景的索引设计
  7. 经验总结

1. 场景还原:3 秒的复杂 SQL

业务背景:某安全风险排查平台,要查"过去一个月内、某岗位/某单位/某风险点下的排查任务",分页展示。

表数据量

表名含义估算行数
risk_task排查任务主表100 万
risk_task_info任务时间信息表700 万
risk_ctrl风险管控措施50 万
risk_info风险点信息5 万
risk_unit风险单元5000
risk_object风险对象2000
dangersrc危险源500

原始 SQL(6 张表 join,3 排序字段,分页 20):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
EXPLAIN
SELECT
    hprt.id AS task_id,
    hprg.id AS main_id,
    hprt.create_time,
    hprti.begin_time,
    hprti.end_time,
    hprt.check_time,
    hprt.check_user_id,
    hprt.check_status,
    CASE
        WHEN hprt.check_status = 0 AND hprti.end_time >= NOW() AND hprti.begin_time <= NOW() THEN '10'
        WHEN hprt.check_status = 2
             OR (hprt.check_status = 0 AND hprti.end_time < NOW()) THEN '11'
        WHEN hprt.check_status = 1 THEN '20'
    END AS STATUS,
    hpro.risk_object_name,
    hpru.risk_unit_name,
    hpd.dangersrc_name,
    hpri.risk_info,
    hpru.risk_type,
    hpri.risk_level,
    hprc.F_Id AS ctrl_id,
    hprc.screening_type,
    hprc.screening_num,
    hprc.screening_department,
    hprc.screening_position,
    hprc.post_psn_id,
    hprc.ctrl_measure,
    hprc.hd_content,
    hprc.post_psn_name,
    hpro.F_CreateUserId
FROM
    risk_task hprt
    INNER JOIN risk_task_info hprti USE INDEX (index_begin_time)
        ON hprt.task_info_id = hprti.task_info_id
    INNER JOIN risk_ctrl hprc
        ON hprt.ctrl_id = hprc.F_Id AND hprc.F_DeleteMark = 0
    INNER JOIN risk_info hpri
        ON hprc.risk_id = hpri.F_Id AND hpri.F_DeleteMark = 0
    INNER JOIN risk_unit hpru
        ON hpri.risk_unit_id = hpru.F_Id AND hpru.F_DeleteMark = 0
    INNER JOIN risk_object hpro
        ON hpru.risk_object_id = hpro.F_Id AND hpru.F_DeleteMark = 0
    INNER JOIN dangersrc hpd
        ON hpri.dangersrc_id = hpd.F_Id AND hpd.F_DeleteMark = 0
    LEFT JOIN risk_governance hprg
        ON hprg.task_id = hprt.id
WHERE
    hprt.F_DeleteMark = 0
    AND hprti.begin_time <= NOW()
    AND hprti.begin_time >= '2025-09-9 00:00:00'
    AND '2025-09-9 23:59:59' >= hprti.begin_time
ORDER BY
    hpro.risk_object_name,
    hpru.risk_unit_name,
    hprti.begin_time DESC,
    hprt.check_time DESC
LIMIT 20;

实测耗时3.1 秒(业务方强烈要求压到 1 秒内)。


2. 第一次 EXPLAIN:定位瓶颈

EXPLAIN 后重点看 3 个字段:

idtabletypekeyrowsExtra
1hprtirangeindex_begin_time21548Using index condition; Using temporary; Using filesort
1hprtrefindex_task_info_id1Using where
1hprceq_refPRIMARY1Using where
1hprieq_refPRIMARY1Using where
1hprueq_refPRIMARY1Using where
1hproeq_refPRIMARY1
1hpdeq_refPRIMARY1Using where
1hprgrefindex_task_id16Using where; Using index

两个关键信号

  1. Using temporary:MySQL 在内存中建了一张临时表
  2. Using filesort:在临时表上做了文件排序

这俩同时出现意味着:当前索引对 ORDER BY 的顺序毫无贡献,MySQL 只能把所有满足条件的行拉到临时表里再排。

driver 表的索引问题

hprti 表上只有:

1
2
KEY `index_end_time` (`end_time`)
KEY `index_begin_time` (`begin_time`)  -- 范围过滤用了

这个单列索引对 ORDER BY hprti.begin_time DESC 有帮助(Backward index scan),但对 4 个字段的复合排序完全不够。


3. 索引设计原则:覆盖索引 + 复合索引顺序

按"驱动表先建、JOIN 列次之、ORDER BY 列最后"的原则设计复合索引。

3.1 驱动表 risk_task_info:覆盖 WHERE + JOIN + ORDER BY

1
2
3
4
5
-- 旧索引(删)
KEY `index_begin_time` (`begin_time`)

-- 新索引:begin_time(过滤)+ task_info_id(JOIN hprt)+ end_time(覆盖 WHERE)
KEY `index_begin_time` (`begin_time`, `task_info_id`, `end_time`)

复合索引列顺序铁律:等值查询 > 范围查询 > 排序。这里 begin_time 是范围过滤,放最前;task_info_id 是等值 JOIN 字段放第二;end_time 顺手覆盖到 WHERE 条件。

3.2 关联表 risk_object:覆盖 JOIN + ORDER BY

1
2
3
-- 排序第一字段是 risk_object_name
-- JOIN 字段是 F_Id
KEY `idx_risk_object_name` (`risk_object_name`, `F_Id`)

3.3 关联表 risk_unit:覆盖 JOIN + ORDER BY + JOIN

1
2
3
4
5
-- 删除旧索引
KEY `index_object_id` (`risk_object_id`)

-- 新索引:risk_object_id(JOIN hpro)+ risk_unit_name(排序)+ F_Id(JOIN hpri)
KEY `idx_risk_unit_covering` (`risk_object_id`, `risk_unit_name`, `F_Id`)

3.4 关联表 risk_task:覆盖 JOIN + 过滤 + 排序

1
2
3
4
5
6
-- 删除旧索引
KEY `index_task_info_id` (`task_info_id`)
KEY `index_ctrl_id` (`ctrl_id`)

-- 新索引:task_info_id(JOIN hprti)+ F_DeleteMark(过滤)+ ctrl_id(JOIN hprc)+ check_time(排序)
KEY `idx_task_covering` (`task_info_id`, `F_DeleteMark`, `ctrl_id`, `check_time`)

4. 实施步骤:INPLACE 在线 DDL

MySQL 5.6+ 支持在线加索引:ALGORITHM=INPLACE + LOCK=NONE。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
-- 1. 删旧索引(INPLACE 操作,瞬间完成)
ALTER TABLE risk_task_info
  ALGORITHM = INPLACE,
  DROP INDEX index_begin_time;

-- 2. 加新索引(INPLACE,不锁表)
ALTER TABLE risk_task_info
  ALGORITHM = INPLACE,
  ADD INDEX idx_hprti_time_covering (begin_time, task_info_id, end_time);

-- 3. 改名复用(SQL 写死了 USE INDEX (index_begin_time),不能改名)
ALTER TABLE risk_task_info
  ALGORITHM = INPLACE,
  RENAME INDEX idx_hprti_time_covering TO index_begin_time;

-- 4. risk_object 加索引
ALTER TABLE risk_object
  ALGORITHM = INPLACE,
  ADD INDEX idx_risk_object_name (risk_object_name, F_Id);

-- 5. risk_unit 替换索引
ALTER TABLE risk_unit
  ALGORITHM = INPLACE,
  DROP INDEX index_object_id;

CREATE INDEX idx_risk_unit_covering ON risk_unit(
  risk_object_id, risk_unit_name, F_Id
) ALGORITHM = INPLACE;

-- 6. risk_task 替换索引
ALTER TABLE risk_task
  ALGORITHM = INPLACE,
  DROP INDEX index_task_info_id,
  DROP INDEX index_ctrl_id;

CREATE INDEX idx_task_covering ON risk_task(
  task_info_id, F_DeleteMark, ctrl_id, check_time
) ALGORITHM = INPLACE;

实操记录

操作表大小耗时
DROP + ADD + RENAME index_begin_time700 万行57 秒
DROP + CREATE idx_risk_unit_covering5000 行< 1 秒
DROP + CREATE idx_task_covering100 万行128 秒

700 万行上加复合索引耗时 57 秒,期间应用读写不阻塞(INPLACE LOCK=NONE)。这就是 MySQL 5.6+ 最大的运维红利——以前做这种事要停机维护。


5. 第二次 EXPLAIN:瓶颈消除

idtabletypekeyrowsExtra
1hprtirangeindex_begin_time21548Using index condition; Backwards index scan
1hprtrefidx_task_covering1Using where
1hprceq_refPRIMARY1Using where
1hprieq_refPRIMARY1Using where
1hprueq_refidx_risk_unit_covering1Using where
1hproeq_refidx_risk_object_name1
1hpdeq_refPRIMARY1Using where
1hprgrefindex_task_id16Using where; Using index

变化

  • Using temporary 消失
  • Using filesort 消失
  • 出现 Backwards index scan(对 index_begin_time 倒序扫,刚好满足 ORDER BY ... DESC

实际耗时:从 3.1 秒 → 0.18 秒17 倍提升),单次压测稳定在 50~180ms 区间。


6. CTE + GROUP BY 场景的索引设计

业务方后来又加了个"按风险对象统计完成率"的需求,用到了 CTE + GROUP BY:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
WITH baseInfos AS (
    SELECT d.F_Id AS mId, c.F_Id AS tId, b.F_Id AS oId, a.F_Id AS lId,
           d.NAME AS NAME, a.work_type AS workType
    FROM risk_ctrl a
        INNER JOIN risk_info b ON a.risk_id = b.F_Id
        INNER JOIN risk_object c ON b.risk_object_id = c.F_Id
        INNER JOIN hazard_major_management d ON d.identifier = c.riskobject_code
    WHERE b.F_DeleteMark = 0
      AND c.F_DeleteMark = 0
      AND d.F_DeleteMark = 0
      AND a.work_type IN ('1', '2', '3')
)
SELECT
    t.*,
    p.person_in_charge_main main,
    p.person_in_charge_technical technical,
    p.person_in_charge_operation operation
FROM (
    SELECT
        baseInfos.mId id,
        baseInfos.workType,
        baseInfos.NAME riskObjectName,
        date_format(info.begin_time, '%Y-%m-%d') beginTime,
        date_format(info.end_time, '%Y-%m-%d') endTime,
        IF(count(1) = sum(IF(k.check_status = 1, 1, 0)), '已完成', '未完成') AS checkStatus,
        sum(k.is_risk) hdCount,
        max(k.check_time) checkTime,
        k.check_user_id checkUser
    FROM risk_task k
        INNER JOIN risk_task_info info ON k.task_info_id = info.task_info_id
        INNER JOIN baseInfos ON k.ctrl_id = baseInfos.lId
    WHERE k.F_DeleteMark = 0
    GROUP BY
        baseInfos.mId,
        IF(checkUser IS NULL OR checkUser = '', baseInfos.workType, checkUser),
        info.begin_time,
        info.end_time
    ORDER BY info.begin_time DESC
) t
LEFT JOIN hazard_major_management p ON t.id = p.F_Id
LIMIT 20;

关键索引

1
2
3
4
5
6
7
8
9
-- 覆盖过滤 + JOIN + 排序
CREATE INDEX idx_k_covering ON risk_task
  (F_DeleteMark, ctrl_id, task_info_id, check_time)
  ALGORITHM = INPLACE LOCK = NONE;

-- 覆盖 JOIN + 排序
CREATE INDEX idx_info_covering ON risk_task_info
  (task_info_id, begin_time, end_time)
  ALGORITHM = INPLACE LOCK = NONE;

CTE 本身不会自动建立索引,所以外层 JOIN 用到的字段(ctrl_idtask_info_id)必须在底层表上有索引。


7. 经验总结

7.1 三类必看 EXPLAIN 字段

字段出现什么优化方向
typeALL必须加索引
ExtraUsing filesortORDER BY 字段没有命中索引顺序
ExtraUsing temporaryGROUP BY / DISTINCT 没有命中索引顺序
rows大于实际过滤后行数索引选择度不够

7.2 复合索引列顺序铁律

1
等值过滤 > 范围过滤 > 排序 > 覆盖字段

错误示范:(order_field, where_field, id) —— 排序字段放最前,会导致 ORDER BY 之后无法再用 WHERE 字段做范围过滤。

7.3 INPLACE DDL 安全守则

守则原因
先 DROP 旧索引再 ADD 新索引避免索引膨胀导致 buffer pool 命中率下降
LOCK = NONE 必须显式写不写默认可能是 LOCK = SHARED
一次 DDL 只动一张表减少主从延迟
大表 DDL 前先在从库试主从切换可用

7.4 不能光看 EXPLAIN 推算耗时

EXPLAIN 输出的 rows估算,不是实际扫描行数。type=ref 估算 1 行,真实扫了 50 万行的案例比比皆是。必须以 SELECT BENCHMARK() 或业务压测的真实耗时为准


下一步

  • EXPLAIN 全字段解读 + 经典 SQL 题:见《MySQL 实战速查》
  • 慢查询日志 + sysbench 压测:见《MySQL 慢查询与性能调优》
  • mysqldump + ibd2sql 备份恢复:见《MySQL 备份恢复实战》
使用 Hugo 构建
主题 StackJimmy 设计