【懺悔】テーブルのカラム分離を失敗してテーブルフルスキャンが走った話【やらかし】

# はじめに
[Hubble Advent Calendar 2024](https://qiita.com/advent-calendar/2024/hubble)の17日目[^1]です！
[^1]:平日のみの投稿なので24日ですが17日目の記事としています。

Hubbleでバックエンドエンジニアをしている @power3812 です。オブジェクト指向大好きマンで、神クラスを作れないかと模索の日々です:innocent:

今回はクリスマスイブに懺悔として今年で本番環境で一番やらかしたことについて書こうと思います。

# 前提
弊社のHubbleというアプリケーションは、契約書のバージョン管理システムですが、[そのバージョンにコメント](https://faq.hubble-docs.com/--64122fd703ec49001be87fc3)してユーザ同士コミュニケーションを取ることができます。

そして新規アプリケーションを作ることになり、そのアプリケーションにもコメント機能を実装することになりました。

# 設計
この新規アプリケーションにコメント機能が存在し、Hubbleと全く同じ要件でメンションや通知が存在します。
そのため、設計には2パターン考えられました。

## パターン1 新規テーブルを作成する
愚直に考えると、新規にcomments、mentions、notificationsテーブル等を作成することが考えられます。
これのメリット・デメリットは下記です。
- メリット
    - 既存のcommentsテーブルのレコード数が影響を受けない
    - 運用時にバグ調査等で組むクエリが楽
- デメリット
    - 既存のコメントとほぼ同じロジックを書かないといけない
    - コメントの仕様が変わった際に、既存、新規のテーブルのmigration、ロジックの変更が二度手間になったり、漏れたりする

## パターン2 既存テーブルに中間テーブル等を作成し、分割する
もう一つのパターンとして、既存のcommentsテーブルに中間テーブル的なテーブルを作成して紐づく先でコメントを判定する方法が考えられます。
分かりづらいと思うので、テーブルを書くと下記になります。
```sql
create table comments
(
    id,
    user_id,    
    body
)

create table comment_versions
(
    id,
    comment_id,
    version_id     
)

create table comment_new_apps
(
    id,
    comment_id,
    new_app_id  
)
```
こうすることで、Rails側のActiveRecordで下記のように設定できます。
```ruby
class CommentBase < ApplicationRecord
  # コメントの共通処理をまとめる
  self.abstract_class = true
  self.table_name = :comments
end

class VersionComment < CommentBase
  has_one :comment_version, dependent: :destroy
  has_one :version, through: :comment_version
end

class NewAppComment < CommentBase
  has_one :comment_new_app, dependent: :destroy
  has_one :new_app, through: :comment_version
end
```

これのメリット・デメリットは下記です。
- メリット
    - コメントの共通ロジックをまとめることができる
    - notificationやmentionはコメントがHubbleなのか新規アプリケーションなのか意識しなくて良い
- デメリット
    - 共通テーブルなので、レコード数がパフォーマンスに影響する可能性がある

結論、今回の場合は工数や拡張性を考えて、パターン2の中間テーブルを作成して共通ロジックやモデルをまとめることにしました。

# マイグレーション計画
commentsテーブルは現在下記のように、version_idを持っています。
```sql
create table comments
(
    id,
    version_id,
    user_id,    
    body
)
```
これを下記のようなcomment_versionsテーブルを作成して、version_idを分離する必要があります。
```sql
create table comment_versions
(
    id,
    comment_id,
    version_id     
)
```

Hubbleでは基本的にダウンタイムを伴う実装やマイグレーションは、やらない方向性なので既存のコメントに影響を与えずversion_idを分離するため下記のようなマイグレーション計画を立てました。

1. comment_versionsテーブルを作成
2. commentsのversion_idをnull許容するようにする
3. 新規のHubbleコメントはversion_idをnullにして、comment_versionsにversion_idを持つようにする
4. コメント取得の時は、LEFT JOINして下記のようなWHERE文を書く
```sql
LEFT OUTER JOIN 
  version_comments ON version_comments.comment_id = comments.id 
WHERE comments.version_id IN (version_ids) 
OR comment_versions.version_id IN (version_ids)
```
5.コメントの過去レコードをcomment_versionsに移行する

# テーブルフルスキャンが本番で走る
結論から言うと、マイグレーション計画の3、4が間違っていたためコメント取得でフルスキャンが発生し、サーバに高負荷がかかり他リクエストも捌けなくなりました。

## 原因
これの原因には、マイグレーション計画4のコメント取得SQLが原因でした。
```sql
LEFT OUTER JOIN 
  version_comments ON version_comments.comment_id = comments.id 
WHERE comments.version_id IN (version_ids) 
OR comment_versions.version_id IN (version_ids)
```
外部結合してWHERE文でOR条件を実行したことでINDEXが効かずに、テーブルのフルスキャンが発生し、サーバに負荷がかかりすぎ他のAPIにも影響を及ぼしてしまいました。

## 復旧方法
commentsテーブルはまだversion_idをnull許容にしただけなので、追加のmigrationは必要ないので、コードをrevertすれば良いように思われました。

しかし、マイグレーション計画の3で新規コメントは、commentsテーブルでversion_idをnullにして、comment_versionsにversion_idをもたせるようになっているため、この新規コメントのversion_idをcomments.version_idに転写するrakeを書く必要がありました。
このrakeを書いて実行し、コードをrevertし復旧させました。

# なぜQA段階で発覚しなかったか
Hubbleでは、新規機能やリファクタ、重要な内部変更がある際にはstaging環境に上がる際にQAチームに連携し、テストしてもらいます。
では、なぜstagingチェックの際に発覚しなかったかというと、commentsテーブルのレコードの差でした。

commentsテーブルのstagingのレコード数は約8000件、本番環境は約200万件で約250倍の差がありました。そのため、stagingでフルスキャンが走ってもサーバにそれほど負荷がかからずに普通に使うことができました。

# 再チャレンジ
以上のことを踏まえて、新しく下記のような2段階のマイグレーション計画立てました。

## STEP1
1. comment_versionsテーブルを作成
2. コメント作成の際、commentsにversion_idを保存するかつcomment_versionsテーブルにもversion_idを保存する
3. Rails側のコメントのロジックは既存のまま

これにより、新規コメントでも既存ロジックのまま動きつつ、comment_versionsにもversion_idが残ります。

## STEP2
1. STEP1より前に作成されたコメントに対して、comment_versionsテーブルにversion_idを保存するrakeを実行する
2. 1を実行後、コメントのコードロジックを全てcomment_versionsテーブルが存在する前提のコードにする

以上のSTEP1、STEP2を段階的に実行することで完全にcommentsテーブルからversion_idを移行することができました。(なぜ初めからこの方法が思い浮かばなかったのか自分の能力の無さに絶望しました:innocent:)


また、前回の反省も踏まえてstagingチェックの際に、commentsテーブルのレコードを本番相当にするために200万レコードを作成してチェックするようにしました。

# まとめ
今回は今年起こした最大のやらかしについて書きました。
大量データの移行はもっと慎重に計画すべきなのと、フルスキャンの恐ろしさについて骨の髄まで理解しました。

次回はCTOで大トリの @katsuya0515 さんです！