RailsのWebサービスの速度改善で実施したこと

# はじめに
　日々の活動の中で、負荷試験を行い、データ量が増えた状況でWebサービスのバックエンドのエンドポイントの速度改善やジョブの速度改善を行う機会があったので、備忘録として記載しようと思います。
　なお、本記事では、SQLの実行計画を見て適切にインデックスを貼る、インデックスが効きやすいように条件句を記述するといったことには言及しません。実際に課題と対峙しどうしたかというのをできる限り実例を交えて紹介します。

# 1. find_in_batchesで発行されるSQLの`ORDER BY`の対象となるカラムの改善
find_in_batchesでは、`LIMIT`をつける必要があるため、`ORDER BY`が指定されます。
例えば、以下のような`headers`テーブルがあるとします。
```
headers(id, store_id, date, serial_id ・・・)
```

|id|store_id|date|serial_id|
|---|----|----|----|
|ZzzOZJESkrhqYT80W5J92m+fhXB2N3WCNl13DFxnwtI=|1|2025-03-21|5|
|0023APH9EZRGAxrIHiFHPVJGZN0yGmVXtc+ckCMKums=|2|2025-04-01|8|

`find_in_batches`を使ったコードが以下だとします。
```ruby
store_ids = [1, 2, 3]
Header.where(store_id: store_ids, status: 0).find_in_batches(batch_size: 10_000)
```

すると、以下のようなSQLが生成されます。

```SQL
SELECT
  "headers".*
FROM
  "headers"
WHERE
  "headers"."store_id" IN (1, 2, 3)
  AND "headers"."status" = 0
ORDER BY
  "headers"."id" ASC LIMIT 10000
```
今回のテーブルでは、`id`カラムは`string`ですので、インデックスが貼ってあったとしても、レコード数が多いとそれなりに時間がかかります。
今回の場合、`id`で並び替える必要はなかったので、`bigserial`型の`serial_id`カラムを利用して並び替えることで、速度が改善されました。
Rails7以前だと、`find_in_batches`で`ORDER BY`のカラムは指定できなかったのですが、Rails8からは、`find_in_batches`のオプションに`cursor`を設定でき、ORDER BYのカラムを指定することができます。（ただし、`find_in_batches`では、primary_keyまたは、uniqueなカラムを含む必要があるので、`id`を追加しています。 `serial_id`に`unique`制約を貼ってあれば、`id`カラムを`cursor`に追加する必要はありません）

```ruby
store_ids = [1, 2, 3]
Header.where(
  store_id: store_ids, status: 0
).find_in_batches(
  batch_size: 10_000, cursor: %i[serial_id id]
)
```

```SQL
SELECT
  "headers".*
FROM
  "headers"
WHERE
  "headers"."store_id" IN (1, 2, 3)
  AND "headers"."status" = 0
ORDER BY
  "headers"."serial_id" ASC,
  "headers"."id" ASC
LIMIT 10000
```

# 2. 履歴テーブルは退避する
在庫システムにおいて、例えば、日々の在庫数を保持するテーブルがあり、在庫一覧を表示する際には、最新の日付の在庫数を表示するとします。在庫計算の詳細に関しては、以前の記事「[在庫計算から考える「データ」と「情報」〜在庫計算は漸化式〜](https://qiita.com/yuyasat/items/a23c1b344f3f59e78984)」に委ねますが、テーブルのスキーマは以下になります。
```
daily_stocks(date, product_id, stock_quantity)
```
この形でレコードを保持する場合、最新の一件をとってくるクエリは、そーだいさんが「[履歴テーブルから最新の1件を取ってくる方法](https://soudai.hatenablog.com/entry/2024/12/10/115848)」という記事にていくつか記載がありますが、その中でも述べられているようにデータの削除ができるなら定期的に削除するほうが良いです。今回の場合、データそのものを削除することはできなかったので、バックアップ用のテーブルを用いてそちらにデータを退避する手段をとりました。そうすることで、頻繁に参照される`daily_stocks`テーブルのデータ量を減らし、最新の一件を取ってくる際の速度が改善されます。

# 3 結合が必要のないケースにおいては結合しない
## 3-1. 初回描画時と並び替え項目指定時で状況が変わるケース
あるテーブルの一覧を表示する場合、`SELECT * FROM some_table;`というSQLになります。ここで、そのテーブルに付随する情報も表示したいときに、テーブルを結合して情報を取得しようとすることがあります。
例えば、医薬品一覧で、医薬品名と在庫数を取得したい場合のクエリは以下の様な形です。
```SQL
SELECT
  sm.name
  , ds.stock_quantity
FROM
  store_medicines AS sm
INNER JOIN
  daily_stocks AS ds
ON
  sm.id = ds.store_medicine_id
  AND ds.date = CURRENT_DATE
;
```
在庫数での並び替えを考慮したときには、結合した上で在庫数（stock_quantity）でORDER BYする必要があります。
```SQL
SELECT
  sm.name
  , ds.stock_quantity
FROM
  store_medicines AS sm
INNER JOIN
  daily_stocks AS ds
ON
  sm.id = ds.store_medicine_id
  AND ds.date = CURRENT_DATE
ORDER BY
  ds.stock_quantity
;
```
こういう処理の場合、データ量次第では、結合の処理が重くなる場合があります。例えば初回描画は医薬品名でソートして、ソート順が在庫数で指定された場合には在庫数で並び替えるという場合、初回描画の際は結合する必要はなく、クエリを分割することで処理を高速化することができます。

```SQL
-- 在庫数でソートする必要がない場合は、daily_stocksテーブルと結合せず、クエリを分割する
SELECT
  sm.id
  , sm.name
FROM
  store_medicines AS sm
ORDER BY
  sm.name
; -- クエリ1

SELECT
  sm.id
  , ds.stock_quantity
FROM
  daily_stocks AS ds
WHERE
  ds.date = CURRENT_DATE
  AND ds.store_medicine_id IN (:store_medicine_ids) -- クエリ1の結果を入れる
;
```
ここで、処理の共通化を意識するあまり、初回描画のときも必ず結合するような処理で実装してしまう場合があります。今回の要件の場合、初回描画時は医薬品名のソートで、在庫数でソートする必要はありませんから、結合する必要はありません。初回描画時と在庫数でのソート順指定時でクエリが変わりますが、初回描画は実行回数が多く負荷を減らしたいので、処理を変えました。

## 3-2. ページネーションで表示されたものだけ関連する他テーブルのレコードを取得するケース
状況としては前項(3-1)に引き続きです。シンプルな処理であれば、前項のような処理はフレームワークの機能を使って実現することができます。例えばRuby on Railsであれば、`preload`を使うことでクエリを分割できます。単純な1対多のようなテーブル構成であれば良いのですが、そうなっていない場合、例えば他店舗の在庫数も一覧に表示したいと言う場合には自分で実装する必要があります。
本項での説明で出てくるテーブルは `companies`、`company_medicines`、`stores`、`store_medicines`の４つであり、簡易的なER図は以下になります。
```mermaid
erDiagram
    companies ||--o{ stores : ""
    companies ||--o{ company_medicines : ""
    stores ||--o{ store_medicines : ""
    company_medicines ||--o{ store_medicines : ""
```

まず、自店舗は適当にレコードを取得し、`limit`と`offset`を用いて`store_medicines`を変数に格納します。
```ruby
# 自店舗を取得
store = Store.first

# 店舗が保有する医薬品一覧を取得
store_medicines = store.store_medicines.offset(50).limit(10)
```
この状況で、他店舗の本日の在庫数をRailsのpreloadを用いで実装しようとすると例えば以下のようになります。
```ruby
store_medicines.preload(
  store: {
    company: {
      stores: { store_medicines: :today_daily_stocks }
    }
  }
)
# today_daily_stocksは以下のようにhas_manyで定義されているものとします
class StoreMedicine < ApplicationRecord
  has_many :today_daily_stocks, -> { where("date = CURRENT_DATE") },
           class_name: "DailyStock"
end
```
ただし、この場合だと、所属する法人の店舗に属する医薬品をすべて引っ張ってきてしまいますので、そこからlimitで絞った医薬品を見て他店舗の在庫数を取得する必要があります。これでは、クエリが重くなってしまいます。`eager_load`を使えば結合先を絞り込むことができますが、結合を使うとまたクエリが重くなるので結合は避けたいです。[^1]
[^1]: このように、対象のテーブル（今回だとstore_medicine）から、小集団(store→company）へ、小集団からまた大集団（他店舗のstore_medicine）へ`preload`したいとき、そして、大集団では対象テーブルのレコードの条件で絞り込みたい時というのは、Railsの`preload`は少し力不足だと思います。
そこで、Railsの機能を使うのは諦め、自ら実装していきます。例えば以下のようにすれば、limitで指定した医薬品の他店舗の在庫数が取得できます。

```ruby
# 店舗が所属する法人に所属する店舗（自店舗を除く）の医薬品を取得
other_store_store_medicines = StoreMedicine(
  store: company.stores.where.not(store_id: store.store_id),
  company_medicine: store.store_medicines
).preload(:today_daily_stocks)
```

3-1, 3-2に関しては、Railsのコードも交えながら説明したので、Railsに馴染みがない方はわかりにくかったかもしれません。この項で伝えたかったことは、一覧をページネーションを実装した場合、ページネーションした結果のレコードに関連する他テーブルのレコードのみを取得するべきであるということです。ただし、関連する他テーブルの値で並び替えをしたい場合は、結合したあと`ORDER BY`する必要があるので、この限りではありません。状況に応じて使い分ける必要があります。

# 4. サマリーテーブルを用意する
サマリーテーブルは必ずしも必要ないテーブルです。「[在庫計算から考える「データ」と「情報」〜在庫計算は漸化式〜
](https://qiita.com/yuyasat/items/a23c1b344f3f59e78984)」でいうところの「情報」を持つテーブルになります。したがって、必要な時に「データ」を持つテーブルから集計することで取得できます。しかし、データ量を増やしていく中で、必要な時に毎回「データ」を持つテーブルから集計するのは処理コストが重く、期待する速度を達成しないことがわかりました。そこで、当初は作る予定のなかった店舗ごと・日ごとの情報を集計するテーブルを導入しました。この場合、そのテーブルを更新するタイミングはどうするのかを検討する必要があるのと、そのテーブルを更新した後に後続の処理を行う必要があり、そもそもの設計の見直しに繋がる部分でもあるので、コードの修正はそれなりに広範囲にわたる可能性があります。しかし速度を出すために覚悟して修正する必要があります。

# 5. 正規化を崩す
```
headers(id, store_id, dispensing_date)
medicines(id, header_id, name, quantity)
```
headersテーブルがあり、それに1:多で紐づくmedicinesテーブルがあるとします。この時、画面描画上で欲しい情報として、調剤日(dispensing_date)ごとの医薬品数を知りたいというものがあります。その場合のSQLは以下のようになりますが、テーブルのレコード数が多い場合、INNER JOINで処理が重くなる場合があります。
```sql
SELECT
  headers.store_id
  , headers.dispensing_date
  , medicines.name AS medicine_name
  , count(*)
FROM
  medicines
INNER JOIN
  headers
ON
  medicines.header_id = headers.id
WHERE
  headers.store_id = 5
  AND headers.dispensing_date >= '2025-01-01'
GROUP BY
  headers.dispensing_date
  , medicines.name
```
その場合、medicinesテーブルに`store_id`と`dispensing_date`を追加することで結合を回避できます。下記のSQLでわかるように、headersテーブルとの結合を回避することができます。
```
headers(id, store_id, dispensing_date)
medicines(id, header_id, store_id, dispensing_date, name, quantity)
```

```sql
SELECT
  store_id
  , dispensing_date
  , name AS medicine_name
  , count(*)
FROM
  medicines
WHERE
  medicines.store_id = :store_id
  AND medicines.dispensing_date >= :target_date
GROUP BY
  headers.store_id
  , medicines.dispensing_date
  , medicines.name
```

ただし、最初からこの方針で正規化を崩して設計するべきではありません。正規化して設計し、クエリの最適化、インデックスの最適化等を行なった上で最終手段として正規化を崩すことを考えるべきです。

# 最後に
　ここまで実際に私が速度改善で実施した内容について記載しました。後から見返せば必ずしもベストな方法ではないものもある思いますが、その時の知識・経験・状況においてはベストを尽くしたと思っています。また、速度改善といっても対峙する課題によりさまざまで、十把一絡げに取り扱うことは難しいなと感じました。地道に設計を理解し、実行計画を見て、現在のレコード数なども加味して総合的にどうすべきかを考える必要があると思いました。