MySQLのsysスキーマを利用して不要インデックスを断捨離する話

**メリークリスマス!**

この記事は[ナビタイムジャパンアドベントカレンダー](https://qiita.com/advent-calendar/2019/navitime_japan) 25日目の記事です。
2019年も残すところあと1週間切りましたね。年末に向けて大掃除をしないといけませんね。
この記事では、MySQLのいらないインデックスを断捨離して、心地よく2020年を迎える準備をする話です。

## はじめに

こんにちは。ナビタイムジャパンで地点情報などを管理するデータベースについての運用/バッチ処理の開発をしているRNです。
当社では、経路検索サービスをユーザに提供しております。その経路検索には、経路検索の目的地とするための地点(POI)が必要不可欠です。
地点情報のデータストアには主にMySQL(とAurora MySQL)を利用しているのですが、日々、数万、数十万の地点データの更新を安定して、なるべく高速に行うためには工夫が必要です。
今回は「MySQLの更新高速化」のために行った、sysスキーマに基づくインデックス削除について、話していきます。

## MySQLのインデックスについて

### インデックスの基礎

MySQLを含む多くのリレーショナルデータベースは、参照の処理の高速化のために「インデックス」という機能を持ちます。
これは、よく「本の索引と似たようなものだ」と言われています。索引で目的の項目を探して、そのページを開く。そうすることにより、より早く目的の情報を取得することができますよね。
MySQLでも一つまたは複数のカラムに対してインデックスを作成しておくことで、参照パフォーマンスを向上させることが可能です。

### インデックスを作成する

参照時のパフォーマンスを出すためにどのようにインデックスを作成すればよいでしょうか。
新規にテーブル設計を検討する場合には、よくwhere句やorder by句で指定されることの多いカラムや、外部テーブルとの結合キーとなるカラムにインデックスを作成すると良いです。
(今回は主題ではないので、詳細についてはお話いたしませんが、興味がありましたら、「MySQL インデックス設計」などで調べてみてください。)
既存のテーブルにインデックス追加を検討する場合、自分の場合は、以下のように実施しています。

1. スロークエリログを分析し、実行時間のかかるSQLを確認する
2. そのクエリの実行計画を確認する
3. 効果のありそうなインデックスを検討する
4. インデックス作成し、効果を検証する
5. インデックスの採用可否を判断する

MySQLにはクエリの実行の際に使用するインデックスを最適に選択してくれる機能(オプティマイザ)が備わっています。
そのため、無駄なインデックス(検索キーにならないカラムへのインデックスや、他のインデックスで賄えてしまうインデックスなど)を追加したとしても、それによって、参照時のパフォーマンス劣化が起こることは稀です。

### インデックスを断捨離しましょう

インデックスについて気をつけることが2点あります。

1. テーブルのレコードが更新(DELETE/INSERT/UPDATE)される場合、インデックスも更新される
    * 本の例でも、本編が更新されたら索引も変える必要があるのと同じです。
    * インデックスの数が多ければ、その分更新するデータ量が増えてしまいます。
2. インデックスが役にたたないこともある
    * テーブルのレコード数や値の種類(例えば性別)が少ないと、インデックスの効果が薄くなる。
    * 検索のキーとして指定されない場合、作成したインデックスが使われないこともある。

今回主に論じたいことは、1.についてです。
不必要なインデックスを「使うかもしれないから」といって、無闇矢鱈に作成していると、更新データサイズが肥大化し、**更新時のパフォーマンスが劣化します。**(劣化の程度についての実験は後述します。)
しかし、個人的には、インデックスを積極的に減らすことについて書かれている話は多くないかと思います。

### インデックスの断捨離は難しい(?)

個人的にインデックスの断捨離は難しく感じます。その理由として、

1. 参照パフォーマンス劣化を招きうる
    * 使用されているインデックスを消してしまった場合、参照のパフォーマンスが大きく低下する可能性があります。
2. インデックスが使用されるかはMySQLのオプティマイザ次第
    * 例えば、テーブルのレコード件数が大きく減った場合、インデックスが使用されなくなることもありえます。
3. 不要になっても削除されにくい
    * 更新のパフォーマンスやインデックスのデータサイズが問題にならない限り、自発的に断捨離はしない。

それでは、不要なインデックスを特定するために、MySQLの機能の1つである、`sys`スキーマを利用してみましょう。

## `sys`スキーマについて

### `performance_schema`について

MySQL5.5.3以降では、`performance_schema`という機能があります。
これはMySQLのパフォーマンスチューニングのための機能であり、名前の通り、DBのパフォーマンスに関する統計情報を収集する機能です。
ちょっとややこしいのですが、`performance_schema`というスキーマ名で予めDBに存在しています。
DBの`performance_schema`が有効化されているかを調べるには、以下のコマンドを打ってみてください。(MySQL5.6以降ではデフォルトで有効化されています。)

```sql
mysql> show variables like "performance_schema";
+--------------------+-------+
| Variable_name      | Value |
+--------------------+-------+
| performance_schema | ON    |
+--------------------+-------+
1 row in set, 1 warning (0.00 sec)
```

上記の例では有効化されています。

### `sys`について

`performance_schema`自体は便利な機能ではあるのですが、情報が取り出しにくいという課題がありました。
そこで、MySQL5.7以降では、`sys`という機能が登場しました。スキーマ名であるためsysスキーマとも呼ばれるそうです。
これは、`performance_schema`と`information_schema`(テーブルやカラム型などのDB内部メタ情報を持つスキーマ)を見やすくしたもので、実体はビューやプロシージャ、関数の集まりです。
そのため`performance_schem`が有効化されていれば、`sys`の機能も有効となります。
MySQL5.6以前ではsysスキーマがないので、[sysのリポジトリ](https://github.com/mysql/mysql-sys)でクローンして、sqlを実行すれば作成可能です。
そのsysの中に**schema_unused_indexes**が存在しています。これは名前の通り使用されていないインデックスの一覧を表示できるビューです。

## `sys.schema_unused_indexes`を使って断捨離をする

ちょっと実験してみます。

### 実験環境

MySQL Version : MySQL5.7.28
OS : Windows10
CPU : Intel Corei7-4790 
メモリ: 16G

時間測定は各施行5回ずつ行って平均値としています。

### 準備

ダミーデータとして,以下SQLでテーブルを作成します.

```sql:テーブル作成とサンプルデータ投入
create database test;
create table sample(
    txt_1 varchar(5),
    txt_2 varchar(5)
);
insert into sample () values ();
-- 倍々に524288レコードになるまで増やす
insert into sample () select * from sample;
insert into sample () select * from sample;
insert into sample () select * from sample;
insert into sample () select * from sample; 
insert into sample () select * from sample; 
insert into sample () select * from sample; 
insert into sample () select * from sample; 
insert into sample () select * from sample; 
insert into sample () select * from sample; 
insert into sample () select * from sample; 
insert into sample () select * from sample; 
insert into sample () select * from sample; 
insert into sample () select * from sample; 
insert into sample () select * from sample; 
insert into sample () select * from sample; 
insert into sample () select * from sample; 
insert into sample () select * from sample; 
insert into sample () select * from sample; 
insert into sample () select * from sample;
-- ランダム英数5文字の値を入れる
update sample 
set 
    txt_1 = SUBSTRING(MD5(RAND()), 1, 5),
    txt_2 = SUBSTRING(MD5(RAND()), 1, 5);

-- データの確認
select count(*) from sample;
select * from sample limit 3;
```

### 実験1 インデックスのない状態での参照パフォーマンスの確認

この状態での参照パフォーマンスの確認をしてみます。

```sql:実験1
select sql_no_cache count(*) from sample where txt_1 like "a%";
```

平均0.74secでした。

### 実験2 インデックスを作成している状態での参照パフォーマンスの確認

続いてインデックスを`txt_1`カラムに作成して参照パフォーマンスの確認をしてみます。

```sql:実験2
alter table sample add index idx_txt1(txt_1);
select sql_no_cache count(*) from sample where txt_1 like "a%";
```

selectの実行時間を測定したところ、平均0.01secでした。速いですね。
このように文字列型では完全一致、前方一致ではインデックスによる効果が出ます。

### 実験3 インデックスを1つ作成している状態での更新パフォーマンスの確認

さて、この状態での更新パフォーマンスはどうなるでしょうか。

```sql:実験3
begin;
delete from sample where txt_1 like "a%"; 
rollback;
```

delete の処理では平均15.90secかかっていました。

### 実験4 インデックスを2つ作成している状態での更新パフォーマンスの確認

ここで`txt_2`に対してインデックスを追加して同様に計測してみます。

```sql:実験4
alter table sample add index idx_txt2(txt_2);
begin;
delete from sample where txt_1 like "a%"; 
rollback;
```

今度は平均21.3secとなりました。インデックスを追加することで更新パフォーマンスが劣化するとお分かりいただけたかと思います。
(ちなみに、追加で`idx_txt21(txt_2,txt_2)` のように複合インデックスを作成すると更に更新処理が遅くなります。)

### 実験5 `sys.schma_unused_indexed`の確認

本題の使われていないインデックス情報について確認してみます。

```sql:実験5
select * from sys.schema_unused_indexes;
```

以下のような結果が得られると思います.

```sql
+---------------+-------------+------------+
| object_schema | object_name | index_name |
+---------------+-------------+------------+
| test          | sample      | idx_txt2   |
+---------------+-------------+------------+
```

期待どおり、未使用のインデックスの情報が出てきています。
では断捨離をしましょう。

```sql
alter table sample drop index idx_txt2;
```

これにより、更新時のパフォーマンスが15.90secほどに戻ります.

### (補足)`sys.schema_redundant_indexes` について

今回の実験では、`sys.schma_unused_indexes`を用いましたが、sysにはまた別の`schema_redundant_index`というビューがあります。
こちらは他のインデックスと重複している冗長なインデックスを一覧で出せるビューです。
冗長なインデックスであるため、削除による影響が少ないので、こちらから断捨離するほうがよさそうです。
ただ、hint句などでこの冗長なインデックスが指定されていると影響が出てしまうので要注意です。そこは`schema_unused_indexes`と突き合わせて見ましょう.

`schema_redundant_indexes`は`information_schema`からのみ作られるビューのため、`performance_schema`や`sys`が有効になっていない場合でも[github](https://github.com/mysql/mysql-sys/blob/master/views/i_s/schema_redundant_indexes.sql)のSQLを少し組み替えると出力が可能です。

```sql:sys.schema_redundant_indexes
create table sample(
    txt_1 varchar(5),
    txt_2 varchar(5)
);
alter table sample add index idx_txt1(txt_1);  -- idx_txt12(複合インデックス)と重複しているため、idx_txt12で賄える
alter table sample add index idx_txt1_copy(txt_1); -- idx_txt1と同一
alter table sample add index idx_txt12(txt_1,txt_2);


select * from sys.schema_redundant_indexes;
+----------------+------------+----------------------+-------------------------+----------------------------+---------------------+------------------------+---------------------------+----------------+--------------------------------------------------------+
| table_schema   | table_name | redundant_index_name | redundant_index_columns | redundant_index_non_unique | dominant_index_name | dominant_index_columns | dominant_index_non_unique | subpart_exists | sql_drop_index                                         |
+----------------+------------+----------------------+-------------------------+----------------------------+---------------------+------------------------+---------------------------+----------------+--------------------------------------------------------+
| spoit_analysis | sample     | idx_txt1_copy        | txt_1                   |                          1 | idx_txt1            | txt_1                  |                         1 |              0 | ALTER TABLE `test`.`sample` DROP INDEX `idx_txt1_copy` |
| spoit_analysis | sample     | idx_txt1             | txt_1                   |                          1 | idx_txt12           | txt_1,txt_2            |                         1 |              0 | ALTER TABLE `test`.`sample` DROP INDEX `idx_txt1`      |
| spoit_analysis | sample     | idx_txt1_copy        | txt_1                   |                          1 | idx_txt12           | txt_1,txt_2            |                         1 |              0 | ALTER TABLE `test`.`sample` DROP INDEX `idx_txt1_copy` |
+----------------+------------+----------------------+-------------------------+----------------------------+---------------------+------------------------+---------------------------+----------------+--------------------------------------------------------+
3 rows in set (0.05 sec)
```

`sql_drop_index`まで出てくるので非常に使い勝手が良さそうですね。

## 実施するうえでの注意

自分も`schema_unused_indexes`や`schema_redundant_indexes`を元にインデックスの断捨離を実施しています。
ただ、本番稼働しているもので実施するにはいくつか注意事項があります。

* あくまでも断捨離の目安にする
    * `schema_unused_indexes`で出てくるものはたまたま直近で使われていなかったインデックスという場合もあります。あくまでも目安として見て、クエリログと突き合わせてから断捨離しましょう。
* `performance_schema`の統計情報は、DB起動時にリセットされる
    * 定期再起動などをを行っている場合は要注意です。
    * 有効化してすぐに断捨離するのではなく、ある程度統計情報をためてから断捨離しましょう。
* マスタ/スレーブ構成をとっている場合は双方の`schema_unused_indexes`を確認した上で断捨離する
    * それぞれで実行されるSQLも異なるためです。
* `performance_schema`を有効化することで、メモリ/CPU/ストレージを専有する
    * パオーマンスの懸念からかMySQLの派生であるMariaDBやAWSのAurora MySQLではデフォルトで無効化されています。
    * 最近のMySQL(5.7以降)では改善されていると聞きますが([参考](https://yakst.com/ja/posts/3784))、本番環境で有効化する場合は事前の負荷試験を実施することを推奨します。
    * 使用メモリ上限サイズをチューニングすることも可能です。
* **断捨離は年始以降に**
    * 少なからずリスクはあるので、心穏やかに年末年始を過ごされたい場合は、年明け以降に実施しましょう。

## まとめ

* MySQLの機能のひとつであるperformance_schema及びsysスキーマの機能を使って不要なインデックスを断捨離してみました。
* sysには他にもパフォーマンス改善に使えるものが多くあるそうなので、もっと使ってみようかと思います。[公式ドキュメント](https://dev.mysql.com/doc/refman/5.7/en/sys-schema-views.html)