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つのマイグレーションで試す:小さなテーブルで段階実行のパターンを確立します
- チーム内で手順を共有:実装、テスト、本番実行の流れを文書化し、チーム全体で認識を合わせます
- 実行結果をフィードバックに:実際の実行時間やトラブル対応の経験を記録し、次のマイグレーションに活かします
技術的な完璧さより、現場で再現可能で、チーム全体で対応できる手順を作ることが大切です。