这篇讲的是SQL 优化技巧。内容大概如下:

  • explain 使用,如何分析一条SQL 查询是否走了索引?

  • 索引优化示例

  • 如何定位一条慢查询日志?

  • SQL 语句优化示例

  • 深分页优化方案讨论

  • 索引优化示例

  • MySQL 调优

  • MySQL 连接池 小技巧

explain 使用

这里主要介绍的是explain 几个主要关注的输出列对应关键词是什么意思,以及explain 的两个用法:

explain基本用法

  • explain SQL express : 在需要观察的SQL 语句前加 explain format=json xxx \G; 即可看到该语句的执行计划, \G 格式化数据, format=json 数据格式为json, json 格式输出执行计划会带有梅香计划的cost 执行成本。

  • show warnings :紧接上一次执行的explain,键入 show warnings 可获得额外信息,该信息可共开发者参考。

explain 输出项说明

这里以 MySQL 官网提供的 Sakila 电影制作公司样本数据库作为示例:

MySQL :: Sakila Sample Database :: 1 Preface and Legal Notices

下载地址: https://downloads.mysql.com/docs/sakila-db.zip

导入方法:

mysql -u 用户名 -p密码 数据库名 < 文件路径文件名.sql

查看表结构

desc table_name;

查看表索引

SHOW INDEX FROM your_table_name;

查看库所有索引

SELECT TABLE_NAME, INDEX_NAME, COLUMN_NAME
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = 'your_database_name';

表结构

CREATE TABLE actor (
  actor_id SMALLINT UNSIGNED NOT NULL AUTO_INCREMENT,
  first_name VARCHAR(45) NOT NULL,
  last_name VARCHAR(45) NOT NULL,
  last_update TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY  (actor_id),
  KEY idx_actor_last_name (last_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE city (
  city_id SMALLINT UNSIGNED NOT NULL AUTO_INCREMENT,
  city VARCHAR(50) NOT NULL,
  country_id SMALLINT UNSIGNED NOT NULL,
  last_update TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY  (city_id),
  KEY idx_fk_country_id (country_id),
  CONSTRAINT `fk_city_country` FOREIGN KEY (country_id) REFERENCES country (country_id) ON DELETE RESTRICT ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

数据库ER图:

示例

mysql> explain select city, country_id from city where country_id > 55 \G;
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: city
   partitions: NULL
         type: ALL
possible_keys: idx_fk_country_id
          key: NULL
      key_len: NULL
          ref: NULL
         rows: 600
     filtered: 47.67
        Extra: Using where
1 row in set, 1 warning (0.00 sec)

ERROR: 
No query specified
  • type: 访问数据表的方法

    • system : 访问的是引擎自带的数据,非常精准。比如MyISAM count(*) 返回的就是引擎维护的counter;

    • const: 通过有唯一约束的索引访问的数据。意味着匹配到合适数据马上停止,不会项普通索引那样扫描整个索引

    • ref : 普通二级索引查询

    • eq_ref // 表连接

    • range : 区间查询,比如使用in(...): where keyword=values in(values1,values2, ...)

    • index: 覆盖索引

    • index_merge: 索引合并,查询中发现条件列来自几个索引,这几个索引可以分别查找后(通过笛卡尔集合运算)合并数据返回。

    • all: 全表扫描

  • key: 访问表中数据使用的索引

  • possible_keys: 本次计划可能会使用到的索引

  • extra: 额外信息,这个很有用

    • impossible where : where 条件永远不成立。

    • using index: 索引覆盖

    • using index condition: 索引下推

    • using where : 全表扫描,使用SQL 语句中的where 条件 匹配数据

    • using join buffer(block nested loop):连接表查询不能通过索引有效匹配数据,就会用到临时内存做匹配数据。

    • using temporary: 使用了临时表

    • using filesort: 使用了临时文件去辅助排序,很有可能会将存有临时数据辅助文件存在磁盘里.

  • key_len: 本次访问的索引长度,索引半失效可可通过观查此项。比如联合索引由 (char(100) , INT)len = 100 + 4, explan key_len 输出项若小于 104 则说明索引部分失效,出现场景:索引覆盖-> 最左匹配生词效,但因后半部分条件语句中由 首字模糊查询、范围查询、反向查询等操作导致的失效... 具体失效哪一个可以观察key_len, 补数哪一个keylen 就是哪一个key失效。

  • row: 预计要扫描行数

  • filtered: 预计要在数据结果在row 中的占比.

如何分析一条SQL 查询是否走了索引?

Example 1 范围查询-索引失效

mysql> explain select city, country_id from city where country_id > 55 \G;
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: city
   partitions: NULL
         type: ALL
possible_keys: idx_fk_country_id
          key: NULL
      key_len: NULL
          ref: NULL
         rows: 600
     filtered: 47.67
        Extra: Using where
1 row in set, 1 warning (0.00 sec)

ERROR: 
No query specified

从表中 key: NULL 可以看出本次查询没有使用索引.

从 Extra: using where 可以看出扫描方式是全表扫描同过,where 条件来匹配数据.

Example 2 索引下推

mysql> explain select city, country_id from city where country_id in (55,31) \G;
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: city
   partitions: NULL
         type: range
possible_keys: idx_fk_country_id
          key: idx_fk_country_id
      key_len: 2
          ref: NULL
         rows: 2
     filtered: 100.00
        Extra: Using index condition
1 row in set, 1 warning (0.01 sec)

ERROR: 
No query specified

从 type: range 区间查找(区间匹配) where xxx in(...)

从 key: 可以看出本次查询使用索引 idx_fk_country_id.

从 key_len: 2 可以看出使用的索引列字段长度 len(SMALLINT) = 2

从 Extra: Using index condition 可以看出数据匹配使用了索引下推.

fows: 2 预计需要扫描的数据行数 2 行

filtered: 100.00 匹配记录数/本次扫描总行数 = 100.00%

SQL优化示例

本次使用的索引优化数据库样本仍然上面的样本。

Example 1 只取需要的列 节省内存

# 查寻 store_id = 62 的 customer 名称
select * from customer where store_id = 62;
--> 
select first_name, last_name from city where store_id = 62;

select* 会使 查询器将查询列扩展为所有列,存在无用大列的话会浪费内存空间。

Example 2 巧用 limit 只去取需求数据量的记录量 节省内存

explain (select first_name, last_name from sakila.actor order by last_name) union all (select first_name, last_name from sakila.customer order by last_name) limit 20;

explain format=json (select first_name, last_name from sakila.actor order by last_name) union all (select first_name, last_name from sakila.customer order by last_name) limit 20 \G;

*************************** 1. row ***************************
EXPLAIN: {
  "query_block": {
    "union_result": {
      "using_temporary_table": false,
      "query_specifications": [
        {
          "dependent": false,
          "cacheable": true,
          "query_block": {
            "select_id": 1,
            "cost_info": {
              "query_cost": "20.25"
            },
            "table": {
              "table_name": "actor",
              "access_type": "ALL",
              "rows_examined_per_scan": 200,
              "rows_produced_per_join": 200,
              "filtered": "100.00",
              "cost_info": {
                "read_cost": "0.25",
                "eval_cost": "20.00",
                "prefix_cost": "20.25",
                "data_read_per_join": "73K"
              },
              "used_columns": [
                "first_name",
                "last_name"
              ]
            }
          }
        },
        {
          "dependent": false,
          "cacheable": true,
          "query_block": {
            "select_id": 2,
            "cost_info": {
              "query_cost": "61.15"
            },
            "table": {
              "table_name": "customer",
              "access_type": "ALL",
              "rows_examined_per_scan": 599,
              "rows_produced_per_join": 599,
              "filtered": "100.00",
              "cost_info": {
                "read_cost": "1.25",
                "eval_cost": "59.90",
                "prefix_cost": "61.15",
                "data_read_per_join": "341K"
              },
              "used_columns": [
                "first_name",
                "last_name"
              ]
            }
          }
        }
      ]
    }
  }
}
1 row in set, 1 warning (0.01 sec)

ERROR: 
No query specified

优化后

 explain (select first_name, last_name from sakila.actor order by last_name limit 20) union all (select first_name, last_name from sakila.customer order by last_name limit 20) limit 20;

mysql> explain format=json (select first_name, last_name from sakila.actor order by last_name limit 20) union all (select first_name, last_name from sakila.customer order by last_name limit 20) limit 20 \G;
*************************** 1. row ***************************
EXPLAIN: {
  "query_block": {
    "union_result": {
      "using_temporary_table": false,
      "query_specifications": [
        {
          "dependent": false,
          "cacheable": true,
          "query_block": {
            "select_id": 1,
            "cost_info": {
              "query_cost": "20.25"
            },
            "ordering_operation": {
              "using_filesort": true,
              "table": {
                "table_name": "actor",
                "access_type": "ALL",
                "rows_examined_per_scan": 200,
                "rows_produced_per_join": 200,
                "filtered": "100.00",
                "cost_info": {
                  "read_cost": "0.25",
                  "eval_cost": "20.00",
                  "prefix_cost": "20.25",
                  "data_read_per_join": "73K"
                },
                "used_columns": [
                  "actor_id",
                  "first_name",
                  "last_name"
                ]
              }
            }
          }
        },
        {
          "dependent": false,
          "cacheable": true,
          "query_block": {
            "select_id": 2,
            "cost_info": {
              "query_cost": "61.15"
            },
            "ordering_operation": {
              "using_filesort": true,
              "table": {
                "table_name": "customer",
                "access_type": "ALL",
                "rows_examined_per_scan": 599,
                "rows_produced_per_join": 599,
                "filtered": "100.00",
                "cost_info": {
                  "read_cost": "1.25",
                  "eval_cost": "59.90",
                  "prefix_cost": "61.15",
                  "data_read_per_join": "341K"
                },
                "used_columns": [
                  "customer_id",
                  "first_name",
                  "last_name"
                ]
              }
            }
          }
        }
      ]
    }
  }
}
1 row in set, 1 warning (0.00 sec)

ERROR: 
No query specified

OS: 这么写可以减少数据扫描项(《高性能 mysql 第四版》P290)

  • 语句一会取扫描sakila.actor 全表 200条记录和 sakila.customer 599 条记录。扫描完后再对这些记录做连接,返回几乎给上层

  • 语句一会取扫描sakila.actor 只取 20条记录和 sakila.customer 只取 20 条记录。扫描完后再对这些记录做连接,返回几乎给上层

优化的点在于内存上的节省... 但是实验并未看出来....得抽时间复现环境...

Example 3 表连接,以小驱大,减少IO

city --> country

select sakila.country.country, sakila.country.country_id, sakila.city.city, sakila.city.city_id from sakila.city join sakila.country where sakila.country.country_id = sakila.city.country_id;

优化后:

country --> city

 explain select sakila.country.country, sakila.country.country_id, sakila.city.city, sakila.city.city_id from sakila.country join sakila.city where sakila.country.country_id = sakila.city.country_id;

OS1:左连接左右表可以互换,MySQL 优化器会根据每个表的引擎统计信息 调换表连接顺序。

OS2: 右连接可以改写成左连接,套娃上面的OS 优化....

Example 4 以小驱大,子查询放大表,主查询尽可能小,主 --> 子

把大表方子查询,小表方主查询,开启半连接优化...

 explain select sakila.city.country_id from sakila.city where sakila.city.country_id in (select sakila.country.country_id from sakila.country);

优化后 ---->

 explain select sakila.country.country_id from sakila.country where sakila.country.country_id in (select sakila.city.country_id from sakila.city);

{
  "query_block": {
    "select_id": 1,
    "cost_info": {
      "query_cost": "98.46"
    },
    "nested_loop": [
      {
        "table": {
          "table_name": "country",
          "access_type": "index",
          "possible_keys": [
            "PRIMARY"
          ],
          "key": "PRIMARY",
          "used_key_parts": [
            "country_id"
          ],
          "key_length": "2",
          "rows_examined_per_scan": 109,
          "rows_produced_per_join": 109,
          "filtered": "100.00",
          "using_index": true,
          "cost_info": {
            "read_cost": "0.25",
            "eval_cost": "10.90",
            "prefix_cost": "11.15",
            "data_read_per_join": "22K"
          },
          "used_columns": [
            "country_id"
          ]
        }
      },
      {
        "table": {
          "table_name": "city",
          "access_type": "ref",
          "possible_keys": [
            "idx_fk_country_id"
          ],
          "key": "idx_fk_country_id",
          "used_key_parts": [
            "country_id"
          ],
          "key_length": "2",
          "ref": [
            "sakila.country.country_id"
          ],
          "rows_examined_per_scan": 5,   # 5
          "rows_produced_per_join": 600, # ----------- 600 注意这里的连接行 ---> 5 * 600
          "filtered": "100.00",
          "using_index": true,
          "cost_info": {
            "read_cost": "27.31",
            "eval_cost": "60.00",
            "prefix_cost": "98.46",
            "data_read_per_join": "126K"
          },
          "used_columns": [
            "country_id"
          ]
        }
      }
    ]
  }
}

{
  "query_block": {
    "select_id": 1,
    "cost_info": {
      "query_cost": "98.46"
    },
    "nested_loop": [
      {
        "table": {
          "table_name": "country",
          "access_type": "index",
          "possible_keys": [
            "PRIMARY"
          ],
          "key": "PRIMARY",
          "used_key_parts": [
            "country_id"
          ],
          "key_length": "2",
          "rows_examined_per_scan": 109,
          "rows_produced_per_join": 109,
          "filtered": "100.00",
          "using_index": true,
          "cost_info": {
            "read_cost": "0.25",
            "eval_cost": "10.90",
            "prefix_cost": "11.15",
            "data_read_per_join": "22K"
          },
          "used_columns": [
            "country_id"
          ]
        }
      },
      {
        "table": {
          "table_name": "city",
          "access_type": "ref",
          "possible_keys": [
            "idx_fk_country_id"
          ],
          "key": "idx_fk_country_id",
          "used_key_parts": [
            "country_id"
          ],
          "key_length": "2",
          "ref": [
            "sakila.country.country_id"
          ],
          "rows_examined_per_scan": 5,   # 
          "rows_produced_per_join": 109, # ----------- 600 -> 109 优化up! ---> 5 * 109
          "filtered": "100.00",
          "using_index": true,
          "first_match": "country",
          "cost_info": {
            "read_cost": "27.31",
            "eval_cost": "10.90",
            "prefix_cost": "98.46",
            "data_read_per_join": "22K"
          },
          "used_columns": [
            "country_id"
          ]
        }
      }
    ]
  }
}

注意观察上面的成本日志,从5*600 笛卡尔连接 ,使用以小驱大后,优化成 5*109 -- 因为开启了半连接优化

Example 5 以小驱大 In(...) 后置

优化规则同上 Example 4

select xxx, xxx, xxx from table where colum in(sql) and id = 10;

--- 优化后 -->

select xxx, xxx, xxx from table where id = 10 and colum in(sql);

深分页优化方案讨论

深分页问题是指 limit offset 参数大,导致扫描记录过多,占用内存过多导致查询过慢的问题。全表扫描的原因是 innodb 官方未实现索引下推。以下是可行的优化方案

  • 改写SQL,如果数据有序,可以改写 为 where key >= 区间起始,limit 需求记录行数,这样就可以走索引加速查询。

Example :

select uid,name,age from user limit 1000 10; # uid 有序 假设1000 偏移1000 后的第一条数据uid=1000 
那么有 1000 的后10条 id = 1001 ....
此时可以改写为 where
select uid,name,age from user where id > 1000 limit 10;
  • 使用厂商优化版本,比如阿里云、腾讯的优化过的MySQL 就支持深分页索引下推。

  • 从实际业务出发,分页加载,比如查询1000行后的后10行,可以将1000分成20页,每页查询50行,分成20页显示等用户触发翻页时再去查下一个50行,减少扫描量降低内存使用,最后,显示深分页参数值不能过大,从源头拒绝深分页。上面这个策略很常见,在搜索引擎中,比如百度最大分页只有76页。

索引优化示例

where a > 1 and b = 2 and c < 3 该怎么建立索引

表结构

mysql> desc actor;
+-------+-------------------+------+-----+---------+----------------+
| Field | Type              | Null | Key | Default | Extra          |
+-------+-------------------+------+-----+---------+----------------+
| a     | smallint unsigned | NO   |     | NULL    | auto_increment |
| b     | smallint unsigned | NO   |     | NULL    |                |
| c     | smallint unsigned | NO   |     | NULL    |                |
+-------+-------------------+------+-----+---------+----------------+

原始查询计划

mysql> explain select a,b,c from actor where a > 1 and b = 2 and c < 6;
+----+-------------+-------+------------+-------+---------------+---------+---------+------+------+----------+-------------+
| id | select_type | table | partitions | type  | possible_keys | key     | key_len | ref  | rows | filtered | Extra       |
+----+-------------+-------+------------+-------+---------------+---------+---------+------+------+----------+-------------+
|  1 | SIMPLE      | actor | NULL       | range | PRIMARY       | PRIMARY | 2       | NULL |   12 |     7.69 | Using where |
+----+-------------+-------+------------+-------+---------------+---------+---------+------+------+----------+-------------+
1 row in set, 1 warning (0.01 sec)

别急看答案,先看最左匹配原理

假设我们现在为上面的表建立索引 index_abc(a,b,c), actor 表有如下数据

那个建立起来的B+ 数索引大概是这样的(画的不是实际innodb 存储的样子,只是示意图,别喷)

innoDB 会以定义索引的首列作为B+树的master key(就是用来做查找时匹配的key,有点像红黑树中的key), 之后按定义数据去存储剩余列的数据...

别急看答案,再看最左匹配规则

最左匹配规则:

  • 8.0 之前,查询中一定要出现最左索引列(出现顺序任意),否则索引失效,8.0 后没写最左列会自动加入,且出现的最左列一定会被使用去查询,生效再去做匹配

  • 如果查询条件中有范围查询(或是模糊查询),那个范围查询之后的索引列失配(失效)

那么问题来了怎么建立索引才能使查询更高效?

ALTER TABLE actor ADD INDEX index_bac (a,b,c); or ALTER TABLE actor ADD INDEX index_bac (a,c,b); ... # 1

ALTER TABLE actor ADD INDEX index_bac (b,a,c); # 2

根据最左匹配原则:

如果把范围查询的a或c 放最左列,那么会a,c就会被最先使用去匹配查询,又因为a、c 是范围查询,所以范围查询之后的索引列会全部失效。

所以如果要把覆盖索引最大化利用,就应该把非范围查询的列放到最前面,也就是采用方案二,让等值查询先上

验证:

添加索引

ALTER TABLE actor ADD INDEX index_bac (b,a,c);
ALTER TABLE actor ADD INDEX index_bac (a,b,c);
ALTER TABLE actor ADD INDEX index_bac (a,c,b);

查询计划 #1 (添加 #1 索引后,此时# 2 未添加,否则会选择#2)

查询计划 #2

可以看到 #2 比 #1 占比 33.33 > 7.69,key_len 4 > 2, 优化有效!

其他SQL 优化小技巧

  • 连表查询尽量不要关联太多表

  • 不要使用like 左模糊查询和全模糊查询,会导致索引失效

  • 尽量不要对字段做 is null、not is null 判断,会导致索引失效,因为区分度不大

  • 不要在条件查询=前对字段做任何运算,不要让字段参与运算,因为索引是对字段值的索引,不是对函数的索引,如果要对函数的值索引,请使用函数索引

  • !=、!<>、not in、no like、or 慎用,or 可以用 uion all 替代

  • 必要情况下可以强制指定 索引 select xxx from table_name force index(index_name) ...;

  • 避免频繁创建、销毁临时表

  • 尽量拆分大事务为小事务,因为小事务的数据可能耦合对不会有大事务那么耦合,原子性更强可以复用。

  • 尽量避免深分页,因为MySQL 官方引擎 innodb 不会做索引下推. 如 select xxx from yyy limit 10000,10; 取10000行数据后的 10行记录,innoDB 不会直接定位到10000行后开始扫描,而是从第一条开始扫描,扫描到10000+10 后停止,抛弃前10000条后返回目标数据。其他厂商的可能会提供这一优化.

  • SQL 列名写完整,select tablename.colum_name1,tablename.colum_name2 ... from xxx where tablename.colum_name1 = tablename.colum_name2; 因为MySQL 后面重写SQL语句的时候会自己加上,会多做一步...

  • 明确仅返回一条数据时请使用 limit 1;

MySQL 调优小技巧

  • 配置文件名 my.ini/my.conf

  • 设置buffer pool 大小 innodb_buffer_pool_size

性能优化步骤

  1. 监控,发现瓶颈

  2. 排查瓶颈原因

  3. 定位瓶颈位置

  4. 解决性能瓶颈

连接池小技巧

  • MySQL 线程池常驻线程参考公式 core * 1 + 有效磁盘数(SSD)

  • MySQL 线程池最大线程 core *2 + 有效磁盘数 (PostgreSQL 官方建议)