为什么写这篇:很多教程讲索引原理头头是道,但真碰到 700 万行、6 张表 join、含 GROUP BY 和 ORDER BY 的复杂 SQL 时,仍是无从下手。本文还原一次完整的"看 EXPLAIN → 找瓶颈 → 设计索引 → 上线验证"过程,所有 SQL 都在 INPLACE LOCK=NONE 下执行,不阻塞业务读写。
适用读者:在生产库做 SQL 优化的后端 / DBA,需要 INPLACE 在线 DDL 经验。
前置知识:了解 B+ 树索引、EXPLAIN 各字段含义、InnoDB 在线 DDL。
目录
- 场景还原:3 秒的复杂 SQL
- 第一次 EXPLAIN:定位瓶颈
- 索引设计原则:覆盖索引 + 复合索引顺序
- 实施步骤:INPLACE 在线 DDL
- 第二次 EXPLAIN:瓶颈消除
- CTE + GROUP BY 场景的索引设计
- 经验总结
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 个字段:
| id | table | type | key | rows | Extra |
|---|
| 1 | hprti | range | index_begin_time | 21548 | Using index condition; Using temporary; Using filesort |
| 1 | hprt | ref | index_task_info_id | 1 | Using where |
| 1 | hprc | eq_ref | PRIMARY | 1 | Using where |
| 1 | hpri | eq_ref | PRIMARY | 1 | Using where |
| 1 | hpru | eq_ref | PRIMARY | 1 | Using where |
| 1 | hpro | eq_ref | PRIMARY | 1 | — |
| 1 | hpd | eq_ref | PRIMARY | 1 | Using where |
| 1 | hprg | ref | index_task_id | 16 | Using where; Using index |
两个关键信号:
Using temporary:MySQL 在内存中建了一张临时表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_time | 700 万行 | 57 秒 |
DROP + CREATE idx_risk_unit_covering | 5000 行 | < 1 秒 |
DROP + CREATE idx_task_covering | 100 万行 | 128 秒 |
700 万行上加复合索引耗时 57 秒,期间应用读写不阻塞(INPLACE LOCK=NONE)。这就是 MySQL 5.6+ 最大的运维红利——以前做这种事要停机维护。
5. 第二次 EXPLAIN:瓶颈消除
| id | table | type | key | rows | Extra |
|---|
| 1 | hprti | range | index_begin_time | 21548 | Using index condition; Backwards index scan |
| 1 | hprt | ref | idx_task_covering | 1 | Using where |
| 1 | hprc | eq_ref | PRIMARY | 1 | Using where |
| 1 | hpri | eq_ref | PRIMARY | 1 | Using where |
| 1 | hpru | eq_ref | idx_risk_unit_covering | 1 | Using where |
| 1 | hpro | eq_ref | idx_risk_object_name | 1 | — |
| 1 | hpd | eq_ref | PRIMARY | 1 | Using where |
| 1 | hprg | ref | index_task_id | 16 | Using 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_id、task_info_id)必须在底层表上有索引。
7. 经验总结
7.1 三类必看 EXPLAIN 字段
| 字段 | 出现什么 | 优化方向 |
|---|
type | ALL | 必须加索引 |
Extra | Using filesort | ORDER BY 字段没有命中索引顺序 |
Extra | Using temporary | GROUP 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 备份恢复实战》