コンテンツへスキップ
DedicatedPHP 接触

書き込みをブロックせずに大規模テーブルのスキーマを移行する

PHPで本番環境のスキーマ変更を計画する方法を解説します。段階的にデプロイし、データをバッチ処理し、旧構造を削除する前に同時実行性を検証します。

バッチバックフィルと同時書き込み制御を用い、旧構造の削除前に検証する段階的なスキーマ移行の図

小規模なデータベースなら、カラムの変更は簡単なこともあります。しかし、大規模で稼働中のテーブルでは、同じ操作でもロックが必要になったり、インデックスの再構築が発生したり、本番クエリとリソースを奪い合ったりする可能性があります。影響はデータベースエンジン、バージョン、変更の種類、設定によって異なるため、操作が瞬時に終わる、あるいはブロックしないと決めつけてはいけません。

大規模テーブルのスキーマ移行では、構造の変更とデータの変換を分けて考えます。移行中もアプリケーションの各バージョンとの互換性を保ち、影響を測定し、処理を停止できるようにすることが目的です。万能な手順はありません。データベースエンジンの機能と、アプリケーションの実際の挙動を確認してください。

小さな変更でも処理をブロックすることがある理由

小さな変更でも処理をブロックすることがある理由 — guía visual de DedicatedPHP

カラムの拡張、制約の追加、型の変更には、テーブルのサイズに比例した作業が伴う場合があります。エンジンによっては、ロックが保持されたり、ディスク負荷が発生したり、レプリカに影響したり、実行中のトランザクションが終わるまで待機したりする可能性があります。オンラインとされる操作でも、短時間のブロックや制約が発生することがあります。

テーブルのサイズと増加率、読み書きの負荷、長時間トランザクション、インデックス、空き容量を評価してください。対象バージョンのドキュメントを確認し、本番環境を代表する環境でテストしましょう。レイテンシ、ロック、ストレージ、レプリケーション遅延について、運用上の上限を定めてください。

通常のスキーマ更新は、たとえばカラムの追加など、構造を変更します。一方、データ移行は既存の値を変換またはコピーし、多くの場合は行単位で処理します。同じ機能変更の一部として実施することはありますが、リスクは異なるため、分けて実行・監視するのが望ましいです。

読み取り側、書き込み側、依存関係を洗い出す

データを読み書きする箇所を探します。PHPコード、SQLクエリ、キュージョブ、スケジュール実行コマンド、インポート、レポート、外部サービスなどが対象です。段階的デプロイの間に共存する可能性があるバージョンを確認してください。動的クエリやリポジトリ外のコンシューマーも考慮する必要があります。

  • NULL、デフォルト値、変換ルールを含め、現在と移行後の形式を記録する。
  • 依存するインデックス、外部キー、制約、ビューを特定する。
  • 各コンポーネントがフィールドの一部だけを更新するか、データを書き込むかを確認する。
  • 無効な値を検出し、修復する方法を決める。

この洗い出しに基づいてデプロイ順序を決めます。まだ参照しているカラムを削除すると、旧バージョンが失敗する可能性があります。すべてのコンシューマーを特定できない場合は、旧コードが想定より長く稼働し続けるものとして扱ってください。

互換性を保ちながら変更を段階に分ける

一般的なパターンは、まず拡張し、その後に縮小する方法です。旧構造を残したまま新しい構造を追加し、互換性のあるコードをデプロイし、既存データをコピーして読み取り先を切り替えます。結果を検証してから、旧構造を削除します。

  1. 拡張:本番稼働中のバージョンを壊さずに、新しいカラムまたはテーブルを追加します。インデックスや制約の作成は、別の操作にすることを検討してください。
  2. 互換性のあるコードをデプロイ:未処理のデータを許容する読み取り側と、両方の表現の整合性を保つ書き込み側をリリースします。
  3. 既存データを補完:進捗を追跡しながら、バックフィルをバッチ単位で実行します。
  4. 利用先を切り替える:新しい構造を主に読み取るようにし、エラー、レイテンシ、差異を監視します。
  5. 縮小:まず旧形式への書き込みを停止し、その後のデプロイで旧構造を削除します。

コードをデプロイしても、機能をすぐにユーザーへ公開する必要はありません。コードをロールバックする必要が生じた場合も含め、各バージョンが各段階のスキーマで動作することを確認してください。

PHPからバックフィルを制御して実行する

テーブル全体をメモリに読み込んだり、全体を覆うトランザクションを維持したりするのは避けてください。PHPのコンソールコマンドなら、バッチサイズを制御し、進捗を記録し、Webリクエストのライフサイクルに依存せず処理を停止できます。以下はPDOを使った楽観的ロックの例です。progressStoreは進捗を同じデータベースに保存し、バッチの行と同じトランザクションで確定するものとします。

$limit = 200;
$maxAttempts = 5;
$cursor = (int) $progressStore->load('backfill');

// 初期の上限。これだけでは遅れて挿入される行を網羅できない。
$upperId = (int) $pdo->query('SELECT MAX(id) FROM records')->fetchColumn();

while ($cursor < $upperId) {
    $done = false;
    for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
        $select = $pdo->prepare(
            'SELECT id, old_value, version FROM records
             WHERE id > :cursor AND id <= :upper_id
             ORDER BY id LIMIT ' . (int) $limit
        );
        $select->execute([':cursor' => $cursor, ':upper_id' => $upperId]);
        $rows = $select->fetchAll(PDO::FETCH_ASSOC);
        if (!$rows) {
            $cursor = $upperId;
            $done = true;
            break;
        }

        try {
            $pdo->beginTransaction();
            $update = $pdo->prepare(
                'UPDATE records SET new_value = :value, version = version + 1
                 WHERE id = :id AND version = :version'
            );
            foreach ($rows as $row) {
                $update->execute([
                    ':value' => transform($row['old_value']),
                    ':id' => $row['id'], ':version' => $row['version'],
                ]);
                if ($update->rowCount() !== 1) {
                    throw new VersionConflict('バックフィル中に行が変更されました');
                }
            }
            $next = (int) end($rows)['id'];
            $progressStore->saveWithinTransaction('backfill', $next);
            $pdo->commit();
            $cursor = $next;
            $done = true;
            break;
        } catch (Throwable $e) {
            if ($pdo->inTransaction()) $pdo->rollBack();
            $retryable = $e instanceof VersionConflict
                || ($e instanceof PDOException && isRetryableDatabaseError($e));
            if (!$retryable || $attempt === $maxAttempts) throw $e;
            usleep(min(100000 * (2 ** ($attempt - 1)), 2000000));
            // 同じカーソルでバッチを再読み込みする。進捗はまだ進んでいない。
        }
    }
    if (!$done) throw new RuntimeException('バッチが未完了です。カーソルは進んでいません。');
}

VersionConflictは、変換エラーまでまとめてしまう汎用例外ではなく、独自の例外にしてください。これにより、transform()の恒久的な失敗時に、診断のためコマンドを停止できます。isRetryableDatabaseError()では、デッドロックやタイムアウトなど、使用しているエンジンとドライバーで一時的と分類されるエラーだけを認識させます。それ以外のデータベースエラーでは処理を停止します。試行回数の上限と上限付きバックオフにより、無限の再試行を防ぎます。試行回数を使い切った場合、コマンドはカーソルを進めずに失敗します。使用しているPDOとデータベースエンジンの組み合わせで、rowCount()がどのように値を返すかを確認してください。

楽観的ロックを有効にするには、すべての書き込み側がデータ更新と同じトランザクション内でversionを増加させる必要があります。この契約はPHPコード、キュー、インポート、外部サービスを含み、バックフィル開始前に導入して検証しなければなりません。契約を守らない書き込み側があると、比較で競合を検出できない可能性があります。その場合は、書き込み側を更新する、共通の仕組みを経由させる、一時的に無効化する、または適切なロックを使ってください。各書き込み側の契約を確認するまでは、この保護が有効だと判断しないでください。

初期の上限値は処理量を減らしますが、後から確定する挿入行を含められる保証はありません。初回の走査後、カーソルより大きいIDに依存せず、未変換または不一致の行を明示的に探す照合作業を行ってください。これらの行を修復して再検証し、検証可能な基準のもと、互換性を維持する書き込み側とともに、未処理がなくなるまで走査を繰り返します。該当する行を確実に検出・照合できない場合は、変更データの捕捉やメンテナンス時間帯を検討してください。カーソルが初期の上限に達しただけで、既存データの移行が完了したと宣言してはいけません。

変換処理は冪等にし、進捗は対象行と原子的に確定してください。カーソルの保存先とデータが同じトランザクションを共有しない場合は、確定済みの行を安全に再処理できるよう、再開の仕組みを設計します。一時停止、バッチに対する上限、構造化ログ、失敗を示す終了コードを追加してください。バッチサイズは推測ではなく、測定結果に基づいて調整します。

同時書き込みとの競合を防ぐ

二重書き込みだけでは、すべての競合を防げるわけではありません。バックフィルが古い値を読み取った後、同時書き込みが両方のカラムを更新し、その後バックフィルが古い結果で新しいカラムを上書きする可能性があります。例のバージョン条件は、同時書き込みによってバージョンが増えていれば更新を防ぎます。また、バッチ全体の確定後にのみカーソルを進めるため、競合を検出した状態が保持されます。

データベースエンジンの保証とワークロードに応じて、元の値に基づく条件付き更新や適切なロックを使う方法もあります。各方法でコストとセマンティクスは異なります。実際に使うエンジンで、書き込みが交錯する状況、デッドロック、タイムアウトを含めて戦略をテストし、既存データの移行が正しく完了したと判断する前に、影響を受けた行と照合結果の両方を検証してください。

旧構造を削除する前に検証する

コマンドが終了しても、データの正しさが証明されたことにはなりません。未処理の行が残っていないことを確認し、制約を検証し、結果を期待される変換結果と比較してください。NULLや境界ケースを確認し、新しいカラムを使った読み取りで、機能上の挙動や性能が悪化していないことを確かめます。

レポートや定期タスクなど、実行頻度の低い処理も監視してください。依存する読み取り側または書き込み側が存在する間は、旧構造を維持します。旧構造が存在することは互換性を保つ手段であり、移行完了の証拠ではありません。

一時停止、ロールバック、メンテナンス時間帯を定める

一時停止、ロールバック、メンテナンス時間帯を定める — guía visual de DedicatedPHP

合意したしきい値を超えるエラーやレイテンシ、ロック、過大なレプリカ遅延、リソース逼迫、増え続ける不一致など、一時停止の条件を定めます。誰が処理を停止するか、最後に確定したバッチからどう再開するかも決めてください。バッチ処理時間、エラー、CPU、ディスク、ロックを監視します。

コードのロールバックは、データのロールバックと同じではありません。新形式だけへの書き込みを受け入れた後では、逆変換によって情報が失われる場合があります。復旧方法はデータを元に戻すことではなく、互換性のあるバージョンに戻し、両方の構造を維持することかもしれません。バージョン間の整合性を維持できない場合、必要なロックを許容できない場合、またはアプリケーションを稼働させたまま結果を検証できない場合は、メンテナンス時間帯を設けるほうが適切なことがあります。

  • 使用するエンジンとバージョンでは、許容できるロックの範囲で変更を実行できるか。
  • 旧バージョンの読み取り側と書き込み側は、中間段階のスキーマと共存できるか。
  • PHPコマンドは、副作用の重複や行の取りこぼしを起こさずに一時停止・再開できるか。
  • 同時書き込み、一時的なエラー、不一致に対する、検証済みの戦略があるか。
  • 旧構造を削除する前に、データの整合性と影響を測定するか。
これらのアイデアをあなたのプロジェクトに活用してみませんか?あなたのPHPプラットフォームについて話し合いましょう。
関連サービスを見る