Rails大規模データマイグレーションの停止時間を減らす──段階実行と事前準備の現実的な進め方

本番で「ロックが長引く」という呼び出しを減らすために

データベースの大規模な更新をRailsのマイグレーションで実行するとき、テーブル全体をロックする操作があると、その間はアプリケーションが応答しなくなります。件数が多いほど、ロック時間は長くなり、ユーザーの不満や監視アラートの対応に追われることになります。

現場では「マイグレーションは夜間に実行すればいい」という判断で進められることが多いのですが、実際には深夜の実行でも予期しない時間がかかったり、ロールバックが必要になったりするケースに直面します。単純に「停止時間を短くしたい」という要望だけでなく、その背景にある制約や判断を整理する必要があります。

この記事では、停止時間の最小化を狙ったマイグレーション戦略を、設計判断から実装、運用上の注意まで、実務的に解説します。

マイグレーション中にロックが長くなる理由

Railsのマイグレーションでテーブルの構造を変更するとき、多くのデータベースシステムではテーブル全体に対する排他ロックが発生します。特に以下の操作では避けられません。

  • カラムの追加(DEFAULT値なし):既存行に対して値を埋める必要があり、その間テーブルがロックされる
  • インデックスの作成:テーブルスキャンが走り、その間は書き込みが止まる
  • NOT NULL制約の追加:既存データの検証とスキーマ変更が一度に行われる
  • カラムタイプの変更:すべての行を読み直して変換する必要があり、ロック時間が長くなりやすい

数万行程度のテーブルでも、本番環境のディスク速度やCPU負荷の状況によって、予想以上に時間がかかることがあります。テスト環境では数秒で終わった操作が、本番では数分かかることは珍しくありません。

段階実行による停止時間の分散

最も現実的な対策は、マイグレーションを複数のステップに分け、各ステップでのロック時間を短縮することです。

ステップ1:カラムの追加(DEFAULT値なし)

まず、新しいカラムをDEFAULT値なしで追加します。この操作自体はメタデータの変更だけなので、ロック時間は短く済みます。

class AddStatusToUsers < ActiveRecord::Migration[7.0]
  def change
    add_column :users, :new_status, :string
  end
end

この段階では、アプリケーションコードはまだこのカラムに触れません。

ステップ2:バックグラウンドジョブで段階的に値を埋める

次に、バックグラウンドジョブ(SidekiqやDelayed Jobなど)を使って、既存データを小分けにして更新します。ロックを短時間に抑えるため、1回の更新で処理する行数を制限します。

class PopulateUserStatusJob
  include Sidekiq::Worker
  sidekiq_options retry: 3

  def perform(batch_size = 1000)
    User.where(new_status: nil).limit(batch_size).each do |user|
      user.update_column(:new_status, determine_status(user))
    end

    # 次のバッチをスケジュール
    if User.where(new_status: nil).exists?
      PopulateUserStatusJob.perform_async(batch_size)
    end
  end

  private

  def determine_status(user)
    # ロジックはここに
  end
end

この方法では、各バッチの更新時間が短いため、ロックによるアプリケーション停止時間は最小限に抑えられます。進捗状況もモニタリング可能です。

ステップ3:NOT NULL制約の追加

データが埋まったことを確認してから、NOT NULL制約を追加します。この段階でもロック時間は比較的短いはずです。

class AddNotNullConstraintToUserStatus < ActiveRecord::Migration[7.0]
  def change
    change_column_null :users, :new_status, false
  end
end

ステップ4:アプリケーションコードの更新と古いカラムの削除

アプリケーション側で新しいカラムを使うようにコードを変更し、テストを十分に行ってからデプロイします。その後、古いカラムを削除します。

class RemoveOldStatusFromUsers < ActiveRecord::Migration[7.0]
  def change
    remove_column :users, :old_status, :string
  end
end

インデックス作成時の工夫

インデックスの作成も時間がかかる操作ですが、PostgreSQLやMySQL 8.0以降では、テーブルロックを最小化するオプションが用意されています。

PostgreSQLの場合、CONCURRENTLYオプションを使うことで、インデックス作成中もテーブルへの読み書きが可能になります。

class AddIndexToUsersEmail < ActiveRecord::Migration[7.0]
  disable_ddl_transaction!

  def change
    add_index :users, :email, algorithm: :concurrently
  end
end

disable_ddl_transaction!は重要です。このメソッドを呼び出さないと、マイグレーション全体がトランザクションでラップされ、CONCURRENTLYオプションが機能しません。

MySQLの場合は、ALGORITHM=INPLACE, LOCK=NONEを指定できます(バージョン依存)。

class AddIndexToUsersEmail < ActiveRecord::Migration[7.0]
  def change
    execute "ALTER TABLE users ADD INDEX idx_email (email), ALGORITHM=INPLACE, LOCK=NONE"
  end
end

本番実行前の検証リスト

段階実行の戦略を立てた後、本番環境で実行する前に確認すべき項目があります。

  • テスト環境で実際の本番データ量に近いデータセットで動作確認:少量のテストデータでは、本番での問題が見つかりません
  • バックグラウンドジョブの実行状況をモニタリング:ジョブが詰まったり失敗したりしないか、事前に確認します
  • ロールバック手順の検証:途中で失敗した場合、どうやって巻き戻すかを事前に決めておきます
  • 実行スケジュールの調整:アプリケーションのトラフィックが少ない時間帯を選びます
  • 監視とアラートの設定:マイグレーション実行中は、データベース接続数やロック待ち時間を監視します

避けるべきパターンと判断

すべてのマイグレーションを段階実行にする必要はありません。以下のような場合は、単一の操作で問題ない可能性が高いです。

  • テーブルの行数が1万行以下で、操作が単純な場合
  • 既に停止時間を確保した夜間メンテナンス枠がある場合
  • テスト環境で実行時間を測定済みで、許容範囲内の場合

逆に、以下の場合は段階実行を検討する価値があります。

  • テーブルの行数が100万行を超える
  • 本番環境の負荷が高く、停止時間を最小化する必要がある
  • 複数の関連テーブルに対する連鎖的な変更が必要な場合

小さく始める現実的な進め方

段階実行の戦略を導入するとき、最初からすべてを完璧に設計する必要はありません。

  1. まず1つのマイグレーションで試す:小さなテーブルで段階実行のパターンを確立します
  2. チーム内で手順を共有:実装、テスト、本番実行の流れを文書化し、チーム全体で認識を合わせます
  3. 実行結果をフィードバックに:実際の実行時間やトラブル対応の経験を記録し、次のマイグレーションに活かします

技術的な完璧さより、現場で再現可能で、チーム全体で対応できる手順を作ることが大切です。