MySQL 索引优化-补充总结

21 阅读9分钟
# MySQL 索引优化-补充总结

---

## 一、索引下推(Index Condition Pushdown,ICP)

### 问题引入

```sql
-- 使用索引下推的情况
EXPLAIN SELECT * FROM employees WHERE name LIKE 'LiLei%' AND age = 22 AND position = 'manager';

-- 不使用索引下推的情况(范围查找)
EXPLAIN SELECT * FROM employees WHERE name > 'LiLei' AND age = 22 AND position = 'manager';
```


![image.png](https://p3-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/4e81ed678e2f46399b733ba6dbd193f6~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg55So5oi3NzYyMzUyNDI1OTE=:q75.awebp?rk3s=f64ab15b&x-expires=1781928340&x-signature=wPLtdrjDq3jFeQ51VMnRosDr2%2BQ%3D)

### 原理说明

对于辅助的联合索引 `(name, age, position)`,正常情况按照最左前缀原则,`SELECT * FROM employees WHERE name LIKE 'LiLei%' AND age = 22 AND position = 'manager'` 这种情况**只会走 name 字段索引**,因为根据 name 字段过滤完,得到的索引行里的 age 和 position 是无序的,无法很好地利用索引。

**MySQL 5.6 之前的行为:**

在联合索引里匹配到名字是 `'LiLei'` 开头的索引后,拿这些索引对应的主键逐个**回表**,到主键索引上找出相应的记录,再比对 age 和 position 这两个字段的值是否符合。

**MySQL 5.6 引入索引下推优化(ICP):**

可以在索引遍历过程中,对索引中包含的所有字段先做判断,**过滤掉不符合条件的记录之后再回表**,可以有效减少回表次数。

使用了索引下推优化后,上面那个查询在联合索引里匹配到名字是 `'LiLei'` 开头的索引之后,同时还会在索引里过滤 age 和 position 这两个字段,拿着过滤完剩下的索引对应的主键 id 再回表查整行数据。

![image.png](https://p3-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/c7b9609a01e14030aad661ff1f734a6d~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg55So5oi3NzYyMzUyNDI1OTE=:q75.awebp?rk3s=f64ab15b&x-expires=1781928340&x-signature=jPMEiVJDUC126yohXNog10Mb0Jw%3D)

> **注意:** 索引下推会减少回表次数,对于 InnoDB 引擎的表,索引下推**只能用于二级索引**,InnoDB 的主键索引(聚簇索引)树叶子节点上保存的是全行数据,所以这个时候索引下推并不会起到减少查询全行数据的效果。

### 为什么范围查找 MySQL 没有用索引下推优化?

估计应该是 MySQL 认为范围查找过滤的结果集过大,`LIKE KK%` 在绝大多数情况下,过滤后的结果集比较小,所以 MySQL 选择给 `LIKE KK%` 用了索引下推优化。当然这也不是绝对的,有时 `LIKE KK%` 也不一定就会走索引下推。

这取决于 MySQL 的索引选择 cost 算法,这要自己看 MySQL 的底层源码实现。

---

## 二、Trace 工具分析优化器决策

### 开启 Trace

```sql
-- 开启 trace
SET SESSION optimizer_trace = "enabled=on", end_markers_in_json = on;

-- 执行要分析的 SQL
SELECT * FROM employees WHERE name > 'a' ORDER BY position;

-- 查看 trace 结果
SELECT * FROM information_schema.OPTIMIZER_TRACE;
```

### Trace 输出示例

```json
{
  "steps": [
    {
      "join_preparation": {  -- 第一阶段:SQL 准备阶段,格式化 SQL
        "select#": 1,
        "steps": [
          {
            "expanded_query": "/* select#1 */ select `employees`.`id` AS `id`,`employees`.`name` AS `name`,`employees`.`age` AS `age`,`employees`.`position` AS `position`,`employees`.`hire_time` AS `hire_time` from `employees` where (`employees`.`name` > 'a') order by `employees`.`position`"
          }
        ] /* steps */
      } /* join_preparation */
    },
    {
      "join_optimization": {  -- 第二阶段:SQL 优化阶段
        "select#": 1,
        "steps": [
          {
            "condition_processing": { -- 条件处理
              "condition": "WHERE",
              "original_condition": "(`employees`.`name` > 'a')",
              "steps": [
                { "transformation": "equality_propagation",  "resulting_condition": "(`employees`.`name` > 'a')" },
                { "transformation": "constant_propagation",  "resulting_condition": "(`employees`.`name` > 'a')" },
                { "transformation": "trivial_condition_removal", "resulting_condition": "(`employees`.`name` > 'a')" }
              ] /* steps */
            } /* condition_processing */
          },
          {
            "table_dependencies": [  -- 表依赖详情
              {
                "table": "`employees`",
                "row_may_be_null": false,
                "map_bit": 0,
                "depends_on_map_bits": [] /* depends_on_map_bits */
              }
            ] /* table_dependencies */
          },
          {
            "rows_estimation": [  -- 预估表的访问成本
              {
                "table": "`employees`",
                "range_analysis": {
                  "table_scan": {  -- 全表扫描情况
                    "rows": 100185,   -- 扫描行数
                    "cost": 10108.9   -- 查询成本
                  } /* table_scan */,
                  "potential_range_indexes": [  -- 可以使用的索引
                    {
                      "index": "PRIMARY",
                      "usable": false,          -- false 未使用
                      "cause": "not_applicable"
                    },
                    {
                      "index": "idx_name_age_position",  -- 二级辅助索引
                      "usable": true,                    -- 可以使用
                      "key_parts": ["name", "age", "position", "id"]
                    }
                  ] /* potential_range_indexes */,
                  "analyzing_range_alternatives": {
                    "range_scan_alternatives": [
                      {
                        "index": "idx_name_age_position",
                        "ranges": ["'a' < name"],       -- 索引使用范围
                        "rowid_ordered": false,          -- 使用该索引获取的记录是否按主键排序
                        "using_mrr": false,
                        "index_only": false,             -- 是否使用覆盖索引
                        "rows": 50092,                   -- 扫描行数
                        "cost": 17532.5,                 -- 花费成本
                        "chosen": false,                 -- 是否选择该索引
                        "cause": "cost"
                      }
                    ] /* range_scan_alternatives */
                  } /* analyzing_range_alternatives */
                } /* range_analysis */
              }
            ] /* rows_estimation */
          },
          {
            "considered_execution_plans": [
              {
                "table": "`employees`",
                "best_access_path": {   -- 最优访问路径
                  "considered_access_paths": [  -- 最终选择的访问路径
                    {
                      "rows_to_scan": 100185,
                      "access_type": "scan",  -- 访问类型:为 scan,全表扫描
                      "resulting_rows": 100185,
                      "cost": 10106.8,
                      "chosen": true,         -- 确定选择
                      "use_tmp_table": true
                    }
                  ] /* considered_access_paths */
                } /* best_access_path */
              }
            ] /* considered_execution_plans */
          }
        ] /* steps */
      } /* join_optimization */
    },
    {
      "join_execution": {   -- 第三阶段:SQL 执行阶段
        "select#": 1,
        "steps": [
          {
            "sorting_table": "employees",
            "filesort_summary": {
              "memory_available": 262144,
              "sort_algorithm": "std::stable_sort",
              "sort_mode": "<fixed_sort_key, packed_additional_fields>"
            } /* filesort_summary */
          }
        ] /* steps */
      } /* join_execution */
    }
  ] /* steps */
}
```

> **结论:** 全表扫描的成本(10106.8)低于索引扫描(17532.5),所以 MySQL 最终选择全表扫描。

---

## 三、ORDER BYGROUP BY 优化

### 优化总结

1. **MySQL 支持两种方式的排序:** `filesort` 和 `index`。
   - `Using index`:MySQL 扫描索引本身完成排序,**效率高**
   - `filesort`:文件排序,**效率低**

2. **`ORDER BY` 满足以下两种情况会使用 `Using index`:**
   - `ORDER BY` 语句使用索引最左前列
   - 使用 `WHERE` 子句与 `ORDER BY` 子句条件列组合满足索引最左前列

3. 尽量在索引列上完成排序,遵循索引建立时的**最左前缀法则**4. 如果 `ORDER BY` 的条件不在索引列上,就会产生 `Using filesort`。

5. 能用覆盖索引尽量用覆盖索引。

6. `GROUP BY` 与 `ORDER BY` 很类似,其实质是**先排序后分组**,遵照索引创建顺序的最左前缀法则。
   - 对于 `GROUP BY` 的优化,如果不需要排序的可以加上 `ORDER BY NULL` 禁止排序
   - 注意:`WHERE` 高于 `HAVING`,能写在 `WHERE` 中的限定条件就不要去 `HAVING` 限定

---

## 四、Using filesort 文件排序原理详解

### 两种排序方式

#### 单路排序

一次性取出满足条件行的所有字段,然后在 `sort_buffer` 中进行排序。

用 trace 工具可以看到 `sort_mode` 信息里显示:
- `<sort_key, additional_fields>`
- 或 `<sort_key, packed_additional_fields>`

**单路排序详细过程(以 `name = 'zhuge'` 为例):**

1. 从索引 name 找到第一个满足 `name = 'zhuge'` 条件的主键 id
2. 根据主键 id 取出整行,取出**所有字段的值**,存入 `sort_buffer` 中
3. 从索引 name 找到下一个满足 `name = 'zhuge'` 条件的主键 id
4. 重复步骤 23 直到不满足 `name = 'zhuge'`
5. 对 `sort_buffer` 中的数据按照字段 position 进行排序
6. 返回结果给客户端

#### 双路排序(回表排序模式)

首先根据相应的条件取出相应的**排序字段和行 ID**,然后在 `sort_buffer` 中进行排序,排序完后需要再次取回其它需要的字段。

用 trace 工具可以看到 `sort_mode` 信息里显示:`<sort_key, rowid>`

**双路排序详细过程(以 `name = 'zhuge'` 为例):**

1. 从索引 name 找到第一个满足 `name = 'zhuge'` 的主键 id
2. 根据主键 id 取出整行,把排序字段 position 和主键 id **这两个字段**放到 `sort_buffer` 中
3. 从索引 name 取下一个满足 `name = 'zhuge'` 记录的主键 id
4. 重复步骤 34 直到不满足 `name = 'zhuge'`
5. 对 `sort_buffer` 中的字段 position 和主键 id 按照字段 position 进行排序
6. 遍历排序好的 id 和字段 position,**按照 id 的值回到原表**中取出所有字段的值返回给客户端

### MySQL 如何选择排序方式?

MySQL 通过比较系统变量 `max_length_for_sort_data`(默认 1024 字节)的大小和需要查询的字段总大小来判断:

| 条件 | 选择方式 |
|---|---|
| 字段总长度 < `max_length_for_sort_data` | 单路排序 |
| 字段总长度 > `max_length_for_sort_data` | 双路排序 |

其实就还是**时间换空间,空间换时间**的问题:放入内存的结果不超过特定空间,就用单路更快;占用内存太大,就用双路,再去用主键索引查一次。

> **注意:** 如果全部使用 `sort_buffer` 内存排序一般情况下效率会高于磁盘文件排序,但不能因为这个就随便增大 `sort_buffer`(默认 1M),MySQL 很多参数设置都是做过优化的,不要轻易调整。


![image.png](https://p3-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/51cd270aea8c474fa5004e8ef6b0ca70~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg55So5oi3NzYyMzUyNDI1OTE=:q75.awebp?rk3s=f64ab15b&x-expires=1781928340&x-signature=im1uLxqFL8DrcLJBeGu4VHIpAAE%3D)

![image.png](https://p3-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/2a6bf04e14904c66916de89cfe5fe28e~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg55So5oi3NzYyMzUyNDI1OTE=:q75.awebp?rk3s=f64ab15b&x-expires=1781928340&x-signature=POBj2P9tIbGRW0AI%2B2qXo6J9koY%3D)

![image.png](https://p3-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/15e5761ecd2b4b8e9663f3e785d5cc0b~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg55So5oi3NzYyMzUyNDI1OTE=:q75.awebp?rk3s=f64ab15b&x-expires=1781928340&x-signature=y3XTDsugx56xj1XpERNwvzFzw%2B4%3D)

![image.png](https://p3-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/9b3dac47060b42b88f463e80afae8886~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg55So5oi3NzYyMzUyNDI1OTE=:q75.awebp?rk3s=f64ab15b&x-expires=1781928340&x-signature=ZhAgGHfemfwdpjiu8jCAfo2dW14%3D)
---

## 五、索引设计原则

### 1. 代码先行,索引后上

一般应该等到主体业务功能开发完毕,把涉及到该表相关 SQL 都要拿出来分析之后再建立索引。

### 2. 联合索引尽量覆盖条件

比如可以设计一个或者两三个联合索引(**尽量少建单值索引**),让每一个联合索引都尽量去包含 SQL 语句里的 `WHERE`、`ORDER BY`、`GROUP BY` 的字段,还要确保这些联合索引的字段顺序尽量满足 SQL 查询的**最左前缀原则**。

### 3. 不要在小基数字段上建立索引

索引基数是指这个字段在表里总共有多少个不同的值,比如一张表总共 100 万行记录,其中有个性别字段,其值不是男就是女,那么该字段的基数就是 2。

如果对这种小基数字段建立索引的话,还不如全表扫描了,因为索引树里就包含男和女两种值,根本没法进行快速的二分查找。

> **建议:** 一般建立索引,尽量使用那些**基数比较大**的字段,才能发挥出 B+ 树快速二分查找的优势。

### 4. 长字符串可以采用前缀索引

尽量对字段类型较小的列设计索引(如 `TINYINT` 之类的),因为字段类型较小的话,占用磁盘空间也会比较小,搜索性能也会比较好。

对于 `VARCHAR(255)` 这种大字段,可以稍微优化下,比如针对这个字段的前 20 个字符建立索引:

```sql
KEY index(name(20), age, position)
```

此时在 `WHERE` 条件里搜索时,会先到索引树里根据 name 字段的前 20 个字符去搜索,定位到之后再回到聚簇索引提取出来完整的 name 字段值进行比对。

> **注意:** 假如要 `ORDER BY name`,那么 name 在索引树里仅仅包含了前 20 个字符,所以这个排序是**没法用上索引的**,`GROUP BY` 也是同理。

### 5. WHEREORDER BY 冲突时优先 WHERE

在 `WHERE` 和 `ORDER BY` 出现索引设计冲突时,一般让 `WHERE` 条件去使用索引来快速筛选出来一部分指定的数据,接着再进行排序。

因为大多数情况基于索引进行 `WHERE` 筛选往往可以最快速度筛选出少部分数据,然后做排序的成本可能会小很多。

### 6. 基于慢 SQL 查询做优化

可以根据监控后台的一些慢 SQL,针对这些慢 SQL 查询做特定的索引优化。

> 一般在公司,DBA 都会定期去看慢 SQL 日志,然后告诉相关开发去优化。

### 7. 范围查询字段放在索引最后

- 范围查询的字段,尽量把它放在索引最后,如果放到中间,范围查出来的数据就可能不再是有序的,后面的索引就不会再命中。
- 利用联合索引(**读多写少**可以多建复合索引,**读少写多**不建议建太多复合索引,会影响写的性能),满足 80% 的公司业务,就是合适的索引。

---

## 总结

对于优化,总结一句话就是:

> **合理设计索引,根据索引命中原理,调整业务 SQL 让它命中索引。**

![索引设计原则总结](index_opt2_img/image5.png)