【Rails】キャッシュとインデックスの有無によるパフォーマンス比較　〜benchmark-ipでベンチマークテストを行う〜

## はじめに

キャッシュを使う実装をしたことが無く、使ってみたい＆パフォーマンスの比較をしてみたい、、

ということで`benchmark-ip`というGemを使ってベンチマークテストを行ってみたので、手順と結果を残しておく。

## 検証内容
Railsアプリケーションにおいて、100万件レコードを持つテーブルからSELECTする際のパフォーマンスについて、以下4つのケースを比較する。

キャッシュの使用/不使用と、ついでに検索対象のカラムの組み合わせでのインデックス有無によるパフォーマンスも比較してみる。

|  | インデックスなし | インデックスあり |
|:-:|:-:|:-:|
| **キャッシュ不使用** |  １ |  ２ |
| **キャッシュ使用** |  ３ | ４  |


## benchmark-ips

ベンチマークテストは`benchmark-ips`というGemを使用する。

`benchmark-ips`は、Rubyのベンチマークテストを実行するためのライブラリで、"ips"は"iterations per second"の略で1秒間に何回の処理を実行できるかを示す。

通常の`Benchmark`モジュールでは実行時間が計測されるが、`benchmark-ips`では実行速度が計測されるため、より結果を比較しやすい。

https://github.com/evanphx/benchmark-ips


## 手順

### 0. 前提

キャッシュの保存場所は、ローカル環境での検証につきメモリとする。

`config/environments/development.rb`に下記の通り記述している。

```ruby
config.cache_store = :memory_store
```

### 1.　テストデータの準備

ベンチマークテストに用いる`MyModel`テーブルに、100万件のレコードを挿入する。 
`seeds.rb`に下記の通り記述し、`rails db:seed`コマンドを実行する。

```ruby:db/seeds.rb
1_000_000.times { MyModel.create!(name: Faker::Name.name, age: rand(20..99), city: Faker::Address.city) }
```


### 2.　ベンチマークテストのスクリプト実装

キャッシュを使用する場合/使用しない場合について、ベンチマークテストのスクリプトを下記の通り実装する。

内容は、`MyModel`テーブルの3つのカラム（`name`、`age`、`city`）に対してランダムな条件で検索してSELECTするというもの。

このテストを、
- インデックスが無い状態
- 検索対象のカラムの組み合わせのインデックスがある状態

で実施し、結果を比較する。

※このファイルはプロジェクトディレクトリ直下に配置する。

```ruby:benchmark_test.rb
require 'benchmark/ips'
require_relative 'config/environment'

def random_query
  name = MyModel.order('RAND()').limit(1).pluck(:name).first
  age = rand(20..99)
  city = MyModel.order('RAND()').limit(1).pluck(:city).first

  MyModel.where(name: name, age: age, city: city).to_a
end

Benchmark.ips do |x|
  x.config(time: 10, warmup: 2)

  x.report('キャッシュなし') { random_query }

  x.report('キャッシュあり') { Rails.cache.fetch('my_model_cache', expires_in: 1.hour) { random_query } }

  x.compare!
end

```

- `x.config`：測定時間（time）とウォームアップ時間（warmup）を設定する。
ウォームアップ時間は、JITコンパイルやガーベジコレクションなどの影響を受けずに測定を行うために使用するらしい。

- `x.report`：測定対象のコードブロックとそのラベルを指定する。

- `x.compare!`：測定したパフォーマンスを比較し、結果を表示する。

### 3. SQLの実行計画確認（インデックスが無い状態）
ベンチマークテスト実行前に、インデックスが無い状態でのSQLの実行計画を確認しておく。

```sql
mysql> EXPLAIN SELECT * FROM `my_models` WHERE `my_models`.`name` = 'Kevin Wolff' AND `my_models`.`age` = 53  AND `my_models`.`city` = 'South Viviana';
+----+-------------+-----------+------------+------+---------------+------+---------+------+--------+----------+-------------+
| id | select_type | table     | partitions | type | possible_keys | key  | key_len | ref  | rows   | filtered | Extra       |
+----+-------------+-----------+------------+------+---------------+------+---------+------+--------+----------+-------------+
|  1 | SIMPLE      | my_models | NULL       | ALL  | NULL          | NULL | NULL    | NULL | 995115 |     0.10 | Using where |
+----+-------------+-----------+------------+------+---------------+------+---------+------+--------+----------+-------------+
1 row in set, 1 warning (0.00 sec)
```

- `type`： ALL。フルスキャンが行われる。
- `possible_keys`： NULL。利用可能なインデックスがないことを示す。
- `key`： NULL。使用されるインデックスがない。
- `rows`： 995,115。これはクエリが実行される際にスキャンされる行数の見積もりで、インデックスがないためほぼ全ての行がスキャンされることを示す。

### 4. ベンチマークテストの実行①（インデックスが無い状態）
`ruby benchmark_test.rb`コマンドを実行して、ベンチマークテストを実施する。
結果は下記の通り。

```bash
# ruby benchmark_test.rb    // ベンチマークテスト実行

Warming up --------------------------------------
    キャッシュなし      1.000  i/100ms
    キャッシュあり      1.000  i/100ms
Calculating -------------------------------------
    キャッシュなし       0.634  (± 0.0%) i/s -      7.000  in  11.048828s
    キャッシュあり       9.441k (±13.9%) i/s -     80.881k in   8.822440s

Comparison:
    キャッシュあり インデックスなし:     9441.1 i/s
    キャッシュなし インデックスなし:        0.6 i/s - 14901.22x  slower
```

- キャッシュなし： 1秒あたり約0.634回のクエリが実行できる。
- キャッシュあり： 1秒あたり約9,441回のクエリが実行できる。

キャッシュありの場合、キャッシュなしの場合に比べて`約14,901倍`速いパフォーマンスが得られており、キャッシュを使用することで大幅にパフォーマンスが向上していることがわかる。


### 5.　インデックスの追加

検索対象のカラムにインデックスを追加するため、下記の通りマイグレーションファイルを作成する。

```ruby:db/migrate/20230506012211_add_composite_index_to_my_model.rb
class AddCompositeIndexToMyModel < ActiveRecord::Migration[7.0]
  def change
    add_index :my_models, %i[name age city]
  end
end

```

`rails db:migrate`コマンドを実行して、インデックスを追加する。


### 6. SQLの実行計画確認（インデックスがある状態）
インデックスを追加したところで、SQLの実行計画を確認しておく。

```sql
mysql> EXPLAIN SELECT * FROM `my_models` WHERE `my_models`.`name` = 'Kevin Wolff' AND `my_models`.`age` = 53  AND `my_models`.`city` = 'South Viviana';
+----+-------------+-----------+------------+------+------------------------------------------+------------------------------------------+---------+-------------------+------+----------+-------+
| id | select_type | table     | partitions | type | possible_keys                            | key                                      | key_len | ref               | rows | filtered | Extra |
+----+-------------+-----------+------------+------+------------------------------------------+------------------------------------------+---------+-------------------+------+----------+-------+
|  1 | SIMPLE      | my_models | NULL       | ref  | index_my_models_on_name_and_age_and_city | index_my_models_on_name_and_age_and_city | 2051    | const,const,const |    1 |   100.00 | NULL  |
+----+-------------+-----------+------------+------+------------------------------------------+------------------------------------------+---------+-------------------+------+----------+-------+
1 row in set, 1 warning (0.01 sec)
```

- `type`： ref。インデックスが使用されてフルスキャンが回避されている。
- `possible_keys`： index_my_models_on_name_and_age_and_city。利用可能なインデックスが表示されている。
- `key`： index_my_models_on_name_and_age_and_city。このインデックスがクエリ実行時に使用される。
- `rows`： 1。インデックスのおかげで、スキャンされる行数が大幅に減少している。

### 7. ベンチマークテストの実行②（インデックスがある状態）

```bash
# ruby benchmark_test.rb
Warming up --------------------------------------
    キャッシュなし インデックスあり     1.000  i/100ms
    キャッシュあり インデックスあり     1.000  i/100ms
Calculating -------------------------------------
    キャッシュなし インデックスあり      0.969  (± 0.0%) i/s -     10.000  in  10.322245s
    キャッシュあり インデックスあり      9.417k (±18.3%) i/s -     77.719k in   8.820943s

Comparison:
    キャッシュあり インデックスあり:     9417.2 i/s
    キャッシュなし インデックスあり:        1.0 i/s - 9720.09x  slower
```

- キャッシュなし： 1秒あたり約0.969回のクエリが実行できる。
- キャッシュあり： 1秒あたり約9,417回のクエリが実行できる。

## ベンチマークテストの結果まとめ

ベンチマークテストの結果、1秒あたりに実行できるクエリの回数は下記の通りとなった。

|  | インデックスなし | インデックスあり |
|:-:|:-:|:-:|
| **キャッシュ不使用** | 0.634回 |  0.969回 |
| **キャッシュ使用** | 9,441回 | 9,417回 |

誤差はあるものの、この表から下記のような結果が読み取れる。
- キャッシュの使用により、インデックスが無い場合で約14,901倍、インデックスがある場合でも9約9718倍パフォーマンスが向上する。

- インデックスにより、約1.5倍パフォーマンスが向上する。


## さいごに

ハードウェア面のスペックや、データ件数、キャッシュの有効時間や保管場所など、実際にはパフォーマンスに影響する要因が色々あるのはもちろんだが、**総じてキャッシュの利用と複合インデックスの適用により大幅にパフォーマンスが向上する**という結果が確認できた。

また`benchmark-ip`というGemは非常に簡単に使えて結果の比較もしやすかったので、今後もパフォーマンスが気になった時にぜひ使ってみたいと思う。

キャッシュの使用可否や有効時間は業務要件によるものであり、キャッシュの保管場所の確保やその費用など考慮すべきことはたくさんあるけれど、今回わかったキャッシュによりこんなにも大幅にパフォーマンスが向上するということを頭に置き、適切なアプリケーションの構成を考えられるようになりたい。