Skip to content
Published on

大規模テーブルのオンラインスキーマ変更 — gh-ost、pt-online-schema-change、ネイティブ Online DDL

Authors

はじめに — 一行の ALTER が障害になる瞬間

小さなテーブルなら、誰もが自信を持って ALTER TABLE を打ちます。カラムを一つ追加するのは一秒もかからず、インデックスを一つ作るのも一瞬です。ところが、同じコマンドを数億行の注文テーブルに打った瞬間、話はまったく変わります。コマンドは数分から数時間も終わらず、その間テーブルに書き込みロックがかかってアプリケーションはタイムアウトを吐き始めます。運用チャンネルには 5xx のアラートがあふれ、誰かが慌てて Ctrl-C を押しますが、もう手遅れです。

この記事は、まさにその瞬間を避けるための話です。大規模テーブルのスキーマをサービス停止なしで変更する方法、すなわちオンラインスキーマ変更を扱います。MySQL のネイティブ Online DDL がどこまでやってくれてどこで止まるのか、その限界を埋めるために登場した pt-online-schema-change と gh-ost がそれぞれどんな原理で動くのか、PostgreSQL はどんな別の道具を提供するのかを、ASCII 図と実際のコマンド例で解きほぐします。最後に、チャンクバックフィル、スロットリング、カットオーバー、ロールバックといった運用のディテールと実践チェックリストまで整理します。

先に結論を述べておきます。大規模テーブルの DDL は「コマンド一行」ではなく「運用手順」として扱うべきです。道具を知るだけでは不十分で、負荷制御とカットオーバー戦略、そして戻す計画まで一緒に設計しなければなりません。

なぜ大規模 ALTER が危険なのか

大規模テーブルに直接 ALTER を打つときに生じる危険は、大きく三つあります。

一つ目はロックです。多くの DDL はテーブルを再構成する間、メタデータロックや書き込みロックを取ります。ロックがかかっている間に入ってくる書き込みトランザクションは待ち行列に積み上がり、待ちが長くなるとコネクションプールが枯渇して読み取りクエリまで一緒に止まります。一つのテーブルの DDL がデータベース全体の障害に広がる典型的な経路です。

二つ目はレプリケーション遅延です。プライマリで長くかかった DDL は、レプリカでも同じように長くかかります。特に MySQL の伝統的なレプリケーションは DDL を直列に適用するため、プライマリで 30 分かかった ALTER はレプリカにそのまま 30 分の遅延を生みます。読み取りトラフィックをレプリカに分散していたサービスなら、この遅延の間に古いデータを読んだり、レプリカへのルーティングが壊れたりします。

三つ目はディスクと負荷です。テーブルを書き直す方式の DDL は、元のサイズと同じだけの追加ディスクを一時的に要求します。100GB のテーブルなら、変更中にさらに 100GB が必要になることがあります。同時に大量の I/O と CPU を消費し、同じサーバー上の他のクエリ性能を落とします。

+-------------------------------------------------------------+
|  直接 ALTER TABLE が障害に至る経路                            |
|                                                             |
|   ALTER 開始                                                 |
|      |                                                      |
|      v                                                      |
|   [メタデータ/書き込みロック取得]                              |
|      |                                                      |
|      +--> 書き込みトランザクションが待ち行列に蓄積             |
|      |        |                                             |
|      |        v                                             |
|      |     コネクションプール枯渇 --> 読み取りもタイムアウト    |
|      |                                                      |
|      +--> レプリカに同じ DDL を直列適用 --> 遅延が膨らむ        |
|      |                                                      |
|      +--> 大量 I/O/CPU --> 同一サーバーの他クエリが低速化       |
|                                                             |
|   => 一つのテーブルの変更が DB 全体の障害に拡散                |
+-------------------------------------------------------------+

MySQL ネイティブ Online DDL — どこまでできるか

MySQL 5.6 から導入された Online DDL は、多くの変更をテーブルロックなしで処理できるようにしました。核心は ALGORITHM と LOCK という二つの句です。

ALGORITHM には三つの値があります。COPY はテーブルを丸ごとコピーする古い方式、INPLACE はテーブルをコピーせずその場で変更する方式、INSTANT はデータディクショナリのメタデータだけを変更して即座に終わる方式です。MySQL 8.0 から INSTANT アルゴリズムが追加され、カラム追加のような一部の作業は本当に一瞬で終わるようになりました。

LOCK 句は変更中に許可する同時実行レベルを指定します。NONE は読み書き両方を許可、SHARED は読み取りのみ許可、EXCLUSIVE は両方を遮断します。

-- INSTANT: 8.0+、カラム追加が即座に終わる(メタデータのみ変更)
ALTER TABLE orders
  ADD COLUMN coupon_code VARCHAR(32) NULL,
  ALGORITHM=INSTANT;

-- INPLACE + LOCK=NONE: テーブルコピーなしで、読み書きを許可しつつインデックス作成
ALTER TABLE orders
  ADD INDEX idx_customer_created (customer_id, created_at),
  ALGORITHM=INPLACE, LOCK=NONE;

ここで身につけておきたい運用の習慣が一つあります。ALGORITHM と LOCK を明示的に書くことです。指定したアルゴリズムで処理できない変更なら、MySQL はエラーを出して止まります。明示しなければ MySQL が勝手に COPY 方式に落ちてテーブルロックをかけることがありますが、ALGORITHM=INPLACE, LOCK=NONE と書いておけば、その危険なフォールバックを事前に防げます。

ネイティブ Online DDL の限界

ネイティブ Online DDL は強力ですが万能ではありません。実務でぶつかる限界は次のとおりです。

INSTANT アルゴリズムが対応する作業は限られています。カラム追加、カラムのデフォルト値変更、カラム名変更など一部に限定されます。PRIMARY KEY の変更、カラム型の変更、文字セットの変更といった重い作業は、依然として INPLACE か COPY を経る必要があります。

INPLACE であっても変更自体は長い時間がかかります。LOCK=NONE ならその間書き込みは許可されますが、変更が終わる瞬間に短いメタデータロックが必要で、その時点で長時間実行中のトランザクションがあるとロック待ちが長引くことがあります。

最大の限界は負荷制御と中断可能性です。ネイティブ DDL はいったん始めると、途中で滑らかに止めたり進行速度を調整したりするのが難しいです。レプリケーション遅延がひどくなっても勝手に速度を落としてはくれません。まさにこの点で外部ツールが必要になります。

+----------------------------------------------------------+
|  MySQL ネイティブ Online DDL のアルゴリズム選択             |
|                                                          |
|   変更の種類                推奨アルゴリズム   テーブルコピー |
|   ----------------------    --------------    ---------- |
|   カラム追加(末尾)         INSTANT           なし        |
|   カラムのデフォルト変更      INSTANT           なし        |
|   セカンダリインデックス追加   INPLACE           なし        |
|   カラム型の変更             COPY              あり        |
|   PRIMARY KEY の変更        COPY              あり        |
|   文字セットの変更           COPY              あり        |
+----------------------------------------------------------+

pt-online-schema-change — トリガーベースのアプローチ

Percona Toolkit の pt-online-schema-change(略して pt-osc)はトリガーを使ってオンライン変更を実装します。核心のアイデアは「新しい構造の空テーブルを作り、元のデータをゆっくりコピーしながら、その間の変更はトリガーで追いつく」です。

動作の順序は次のとおりです。まず元のテーブルと同じ構造の新テーブルを作り、そこに目的の ALTER を適用します。次に元のテーブルに INSERT、UPDATE、DELETE のトリガーをかけ、これから入ってくるすべての変更が新テーブルにも反映されるようにします。続いて元の既存データを小さなチャンク単位で新テーブルにコピーします。コピーがすべて終わったら、元と新テーブルの名前を原子的に入れ替えます(RENAME)。最後にトリガーと旧テーブルを片付けます。

+--------------------------------------------------------------+
|  pt-online-schema-change の動作(トリガーベース)             |
|                                                              |
|   1) 新テーブル _orders_new 作成 + 目的の ALTER 適用          |
|                                                              |
|   2) 元の orders にトリガー設置                               |
|        INSERT/UPDATE/DELETE --> _orders_new へ伝播           |
|                                                              |
|       アプリケーションの書き込み                               |
|            |                                                 |
|            v                                                 |
|         [ orders ] --(トリガー)--> [ _orders_new ]           |
|            ^                                                 |
|            |  3) 既存行をチャンク単位でコピー                  |
|            +--- バックフィル: 1000行ずつ INSERT ... SELECT    |
|                                                              |
|   4) 原子的 RENAME: orders <-> _orders_new を入れ替え         |
|                                                              |
|   5) トリガー削除 + 旧テーブル _orders_old 削除               |
+--------------------------------------------------------------+

実際のコマンドはこのような形です。変更内容は alter オプションに ADD COLUMN のような句だけを書きます。ALTER TABLE キーワードはツールが自動で付けます。

pt-online-schema-change \
  --alter "ADD COLUMN coupon_code VARCHAR(32) NULL" \
  --host=db-primary.internal \
  --user=migrator \
  --ask-pass \
  --chunk-size=1000 \
  --max-load "Threads_running=50" \
  --critical-load "Threads_running=120" \
  --max-lag=2 \
  --check-slave-lag=db-replica.internal \
  D=shop,t=orders \
  --execute

ここで注目すべきオプションがあります。chunk-size は一度にコピーする行数を決め、max-load はサーバーの同時実行スレッド数が一定レベルを超えるとコピーを一時停止して負荷を調整します。critical-load はそれより深刻な閾値で、超えると作業自体を中断します。max-lag と check-slave-lag はレプリケーション遅延を監視し、レプリカが遅れたらバックフィルを遅らせて遅延の回復を待ちます。

トリガーベース方式の長所と短所

長所は、別途バイナリログへのアクセス権限なしでも動作する点と、長く検証された安定したツールである点です。短所は、トリガーが元テーブルの書き込み経路に直接割り込むことにあります。すべての INSERT、UPDATE、DELETE がトリガーをもう一度経るため、書き込み遅延が増えます。また、すでにトリガーがあるテーブルには使いづらく、外部キーがかかったテーブルは追加処理が必要です。何より、カットオーバー時の RENAME が短いながらも明確なロック区間を作ることを覚えておく必要があります。

gh-ost — バイナリログベースのアプローチ

GitHub が作った gh-ost はトリガーをまったく使いません。代わりに MySQL のバイナリログ(binlog)を読んで変更に追いつきます。gh-ost 自身がレプリカのふりをしてプライマリに接続し、binlog イベントをストリームで受け取り、そのイベントを新テーブル(ゴーストテーブル)に反映します。

動作はこうです。まず元と同じ構造のゴーストテーブル _orders_gho を作り、ALTER を適用します。同時に gh-ost は binlog を購読し、元テーブルに入ってくるすべての変更イベントを受け取ってゴーストテーブルに適用します。それとは別に、元の既存行をチャンク単位でコピーするバックフィルを進めます。binlog の適用とバックフィルが追いついたら、カットオーバーを実行してテーブル名を入れ替えます。

+--------------------------------------------------------------+
|  gh-ost の動作(バイナリログベース、トリガーなし)            |
|                                                              |
|   アプリケーションの書き込み                                  |
|        |                                                     |
|        v                                                     |
|    [ orders ] ---> MySQL binlog                              |
|        ^                |                                    |
|        |                v                                    |
|        |          gh-ost(レプリカのふりで binlog 購読)       |
|        |                |                                    |
|        |        +-------+-------+                            |
|        |        |               |                            |
|        |   binlog イベント適用   既存行バックフィル(チャンク) |
|        |        |               |                            |
|        |        v               v                            |
|        |     [ _orders_gho ゴーストテーブル ]                 |
|        |                                                     |
|        +--- 4) カットオーバー: orders <-> _orders_gho 入れ替え |
+--------------------------------------------------------------+

実際のコマンド例です。

gh-ost \
  --host=db-primary.internal \
  --user=migrator \
  --password=secret \
  --database=shop \
  --table=orders \
  --alter="ADD COLUMN coupon_code VARCHAR(32) NULL" \
  --chunk-size=1000 \
  --max-load="Threads_running=50" \
  --critical-load="Threads_running=120" \
  --max-lag-millis=1500 \
  --throttle-control-replicas="db-replica.internal" \
  --allow-on-master \
  --postpone-cut-over-flag-file=/tmp/ghost.postpone \
  --execute

gh-ost の強力な運用機能の一つは、実行中の対話性です。Unix ソケットやパネルを通じて実行中にコマンドを送れます。たとえば、バックフィル速度を落としたり、スロットルを直接かけたり、カットオーバーを前後にずらしたりできます。

# 実行中にスロットルを強制(バックフィル一時停止)
echo "throttle" | nc -U /tmp/gh-ost.shop.orders.sock

# スロットル解除
echo "no-throttle" | nc -U /tmp/gh-ost.shop.orders.sock

# チャンクサイズをリアルタイム変更
echo "chunk-size=200" | nc -U /tmp/gh-ost.shop.orders.sock

postpone-cut-over-flag-file は特に便利です。このフラグファイルが存在する間は、バックフィルがすべて終わってもカットオーバーをせず待機します。おかげで、バックフィルという重い作業は昼間に先に終わらせておき、実際のテーブル入れ替えはトラフィックの少ない時間にファイルを一つ消すことでトリガーできます。

pt-osc と gh-ost の比較

二つのツールの違いを表にまとめると次のとおりです。

項目pt-online-schema-changegh-ost
変更追跡の方式トリガーバイナリログ
元の書き込み経路への影響トリガーで直接介入介入なし(非同期)
追加負荷の位置プライマリの書き込み時gh-ost プロセス
実行中の制御限定的ソケットで動的制御
カットオーバー延期難しいフラグファイルで対応
外部キー処理オプションで一部対応直接対応は限定的
検証の成熟度非常に長い実績GitHub 大規模運用で実証

要約すると、書き込み負荷が非常に高いテーブルやカットオーバーのタイミングを精密に制御したいなら gh-ost が有利です。逆に binlog アクセスが難しい、あるいは非常に単純な変更なら pt-osc のほうが手軽な場合があります。

PostgreSQL のオンライン DDL

PostgreSQL は MySQL と異なるモデルを持ちます。多くの DDL がすでにトランザクション内で動作し、MVCC のおかげで一部の変更は本質的により非侵襲的です。しかし PostgreSQL にも固有の落とし穴があり、それを避けるための専用構文があります。

CREATE INDEX CONCURRENTLY

PostgreSQL では通常の CREATE INDEX はテーブルに書き込みロックをかけ、インデックスが作られる間すべての書き込みを止めます。大規模テーブルでは致命的です。解決策は CONCURRENTLY オプションです。これを使うと書き込みを止めずにインデックスを作ります。代わりにテーブルを二度スキャンするので遅く、トランザクションブロック内では使えません。

-- 書き込みを止めずにインデックス作成(トランザクションブロックの外で実行)
CREATE INDEX CONCURRENTLY idx_orders_customer_created
  ON orders (customer_id, created_at);

-- 失敗して INVALID 状態で残ったインデックスがないか確認
SELECT indexrelid::regclass AS index_name
FROM pg_index
WHERE indisvalid = false;

-- INVALID インデックスも同じく CONCURRENTLY で削除
DROP INDEX CONCURRENTLY idx_orders_customer_created;

CONCURRENTLY は失敗した場合に INVALID 状態のインデックスを残しますが、これはクエリに使われないままディスクだけを占有するので、必ず見つけ出して片付けなければなりません。

lock_timeout でロック待ちの暴走を防ぐ

PostgreSQL では ALTER TABLE は短くても ACCESS EXCLUSIVE ロックを要求することが多いです。問題は、このロックを得ようと待っている間に、その後ろに並んだ他のクエリも一緒に止まることです。ロック待ちがロックキューを作り、ロックキューが障害を作ります。

これを防ぐ要は lock_timeout を短く設定することです。ロックを一定時間内に得られなければ DDL 自体をきれいに失敗させ、他のクエリが無限に止まる事態を防ぎます。その後リトライすればよいのです。

-- ロックを3秒以内に得られなければこの DDL を失敗させる
SET lock_timeout = '3s';

-- 11+ では NOT NULL なしのカラム追加は基本的に即座(テーブル再作成なし)
ALTER TABLE orders ADD COLUMN coupon_code VARCHAR(32);

SET lock_timeout = '3s';
-- DEFAULT 付きのカラム追加も 11+ ではメタデータのみ変更で速い
ALTER TABLE orders ADD COLUMN status SMALLINT NOT NULL DEFAULT 0;

PostgreSQL 11 からは、デフォルト値付きのカラム追加もテーブル全体を再作成せず、カタログにデフォルト値を記録する方式で速く処理されます。これは大きな改善でした。ただし、既存カラムに NOT NULL 制約を新たにかける作業は依然として全スキャンが必要なので、次に扱う検証の分離手法を使う必要があります。

制約追加を NOT VALID で分割する

外部キーや CHECK 制約を大規模テーブルに一度にかけるのも危険です。PostgreSQL はこれを二段階に分割できます。まず NOT VALID で制約を追加すると、既存行は検証せずこれから入る行にだけ適用します。その後、空いている時間に VALIDATE CONSTRAINT で既存行を検証しますが、この検証は弱いロックだけを取り書き込みを止めません。

-- ステップ1: 既存行の検証をスキップして制約追加(短いロック)
ALTER TABLE order_items
  ADD CONSTRAINT fk_order
  FOREIGN KEY (order_id) REFERENCES orders (id)
  NOT VALID;

-- ステップ2: 空いているときに既存行を検証(書き込みを止めない弱いロック)
ALTER TABLE order_items
  VALIDATE CONSTRAINT fk_order;

チャンクバックフィル — データをゆっくり埋める

スキーマを変えた後は、たいてい新しいカラムに値を埋める必要があります。ここでよくある間違いが、単一の UPDATE 一発で数億行を更新しようとすることです。この巨大トランザクションは、長いロック、巨大な undo/WAL、レプリケーション遅延を同時に呼び込みます。定石は小さなチャンクに分けて繰り返すことです。

-- 悪い例: 数億行を一トランザクションで(絶対禁止)
-- UPDATE orders SET coupon_code = 'NONE' WHERE coupon_code IS NULL;

-- 良い例: PK 範囲でチャンクを区切って繰り返す
-- (アプリ/スクリプトのループで batch_start を1000ずつ増やす)
UPDATE orders
SET coupon_code = 'NONE'
WHERE id >= 1000000 AND id < 1001000
  AND coupon_code IS NULL;

バックフィルのループはたいてい次の原則に従います。チャンクサイズを適度に(数百から数千行)取り、各チャンクの間に短い休止を置きます。そして各チャンクごとにレプリケーション遅延を確認し、遅延が閾値を超えたらバックフィルを一時停止し、回復したら再開します。こうすればバックフィルが運用トラフィックを圧倒しません。

// 疑似コードレベルのチャンクバックフィルループ(レプリケーション遅延監視つき)
func backfill(db *sql.DB, maxID, batch int) error {
    for start := 0; start < maxID; start += batch {
        for replicationLagSeconds(db) > 2 {
            time.Sleep(500 * time.Millisecond) // 遅延が回復するまで待機
        }
        _, err := db.Exec(
            "UPDATE orders SET coupon_code = 'NONE' "+
                "WHERE id >= ? AND id < ? AND coupon_code IS NULL",
            start, start+batch,
        )
        if err != nil {
            return err
        }
        time.Sleep(50 * time.Millisecond) // チャンク間の短い休止
    }
    return nil
}

スロットリングとカットオーバー

スロットリング — 負荷を見ながら速度調整

オンラインスキーマ変更の核心的な安全装置はスロットリングです。スロットリングとは、サーバーの負荷やレプリケーション遅延をリアルタイムで観察しながら、危険信号が見えたらバックフィル速度を自動で落としたり止めたりすることをいいます。pt-osc の max-load と max-lag、gh-ost の max-load と max-lag-millis がすべてこの機能を担います。

良いスロットル設定は二つの閾値を置きます。一つはソフトな閾値で、超えると速度を落とします(pt-osc の max-load)。もう一つは緊急閾値で、超えると作業をまったく止めます(pt-osc の critical-load)。レプリケーション遅延も同様に監視対象に入れ、レプリカが遅れ始めたらバックフィルを落として遅延が累積しないようにします。

カットオーバー — 最も危険な瞬間

カットオーバーは新テーブルと元テーブルの名前を入れ替える瞬間です。バックフィルと変更追跡がすべて終わった後に起こり、通常は非常に短いですが、この瞬間だけはロックが必要です。カットオーバーが危険な理由は、もしこの時点で長時間実行中のトランザクションがテーブルをつかんでいると、名前の入れ替えがそのトランザクションを待ってロックキューを作るからです。

そのため、カットオーバーはトラフィックが少なく、長いトランザクションがない時間帯を選んで行うのがよいです。gh-ost の postpone-cut-over-flag-file はまさにこの目的のための仕掛けです。重いバックフィルは先に終わらせておき、カットオーバーという短く繊細な瞬間だけを人が直接選んだタイミングで実行するのです。

+--------------------------------------------------------------+
|  カットオーバーのタイムライン(理想的な場合)                 |
|                                                              |
|   バックフィル進行 =========================>  (昼間、スロットル) |
|                                            |                 |
|                                  [カットオーバー待機、フラグファイル] |
|                                            |                 |
|   トラフィック少ない時間 ------------------> [カットオーバー実行] |
|                                            |  短いロック       |
|                                            v                 |
|                                  orders <-> ghost 入れ替え    |
|                                            |                 |
|                                  旧テーブル片付け             |
+--------------------------------------------------------------+

ロールバック — 戻す計画を先に立てる

オンラインスキーマ変更でのロールバックは、段階ごとに意味が異なります。カットオーバー以前ならロールバックは簡単です。新テーブル(ゴーストテーブルまたは _new テーブル)をただ捨ててトリガーを除けば、元は一度も変わっていないのできれいに原状復帰します。gh-ost や pt-osc を途中で止めることが、この段階のロールバックです。

カットオーバー以降は話が違います。すでに名前が変わり新しい構造が運用中なので、戻すには逆方向の変更を改めて適用しなければなりません。だから本当に安全なマイグレーションは、スキーマ変更をアプリケーションのデプロイと分離し、逆方向にも互換性のある段階に分割します。

代表的なパターンが拡張-収縮(expand-contract)です。カラム名を変える場合を例にすると、まず新しいカラムを追加し(拡張)、アプリケーションが両方のカラムを書くようにし、データをバックフィルし、読み取りを新しいカラムに移し、最後に古いカラムを削除(収縮)します。各段階が独立してデプロイされ各段階ごとに戻せるので、どの時点で問題が起きても安全に止められます。

+--------------------------------------------------------------+
|  拡張-収縮(expand-contract)パターン                         |
|                                                              |
|   1) 拡張: 新カラム追加(旧カラムはそのまま維持)             |
|   2) 二重書き込み: アプリが旧+新カラム両方に記録              |
|   3) バックフィル: 既存行の新カラムをチャンクで埋める          |
|   4) 読み取り切替: アプリが新カラムを読むようにデプロイ        |
|   5) 収縮: 安定後に旧カラムを削除                            |
|                                                              |
|   => 各段階が独立デプロイ + 段階ごとにロールバック可能         |
+--------------------------------------------------------------+

マイグレーションツールとの統合

オンラインスキーマ変更は通常、マイグレーション管理ツールと一緒に運用されます。Flyway、Liquibase、golang-migrate といったツールはマイグレーションのバージョンと適用順序を管理しますが、基本的には SQL をそのまま実行します。つまり大規模テーブル変更では、これらのツールが直接 ALTER を打つ代わりに、gh-ost や pt-osc を呼び出す形で包んで使う場合が多いです。

要は二つを分けて考えることです。一つは「どの変更をどの順序で適用したか」というバージョン管理であり、もう一つは「その変更をどう安全に実行するか」という実行戦略です。マイグレーションツールは前者を、オンライン DDL ツールは後者を担います。安全なカラム追加のような軽い変更はマイグレーションツールが直接実行し、重いテーブル再作成はオンライン DDL ツールに委譲する、というふうに分ければきれいです。

実践的な落とし穴集

現場で繰り返し出くわす落とし穴を整理します。

一つ目、ディスク容量を忘れることです。テーブルを再作成するすべての方式は、一時的に元のサイズと同じだけの追加ディスクを要求します。変更前に必ず空き容量を確認してください。変更途中でディスクがいっぱいになると、最悪のタイミングで作業が死にます。

二つ目、外部キーを無視することです。トリガーベースのツールも binlog ベースのツールも、外部キーがかかったテーブルでは追加処理が必要です。子テーブルの外部キーが元テーブルを指しているなら、名前の入れ替え過程で外部キーが壊れないよう、ツールの外部キーオプションを必ず検討しなければなりません。

三つ目、長いトランザクションを放置することです。カットオーバー直前に長時間実行中のトランザクションがあると、カットオーバーがそのトランザクションを待ってロックキューを作ります。変更前に長く回るクエリや未コミットのトランザクションがないか確認してください。

四つ目、スロットル閾値を緩く取りすぎることです。負荷が見えたら止まるよう設定すべきなのに、閾値が高すぎると、すでにサービスが遅くなった後にようやくスロットルがかかります。最初は保守的に低く取り、観察しながら上げるほうが安全です。

五つ目、リハーサルなしで本番にいきなり打つことです。同じサイズのステージングデータで一度回してみれば、所要時間、ディスク使用量、負荷影響を事前に見積もれます。本番で初めて回すのは無謀です。

六つ目、PostgreSQL で CONCURRENTLY の INVALID インデックスを掃除しないことです。失敗した CONCURRENTLY は静かに使えないインデックスを残すので、作業後は必ず INVALID インデックスを点検して片付けてください。

運用チェックリスト

大規模テーブルのオンラインスキーマ変更を実行する前に、次を点検してください。

[ ] 変更を軽い変更(INSTANT/即座)と重い変更(再作成)に分類したか
[ ] 軽い変更はネイティブ DDL/マイグレーションツールで直接処理できるか
[ ] 重い変更に gh-ost か pt-osc のどちらを使うか決めたか
[ ] 元のサイズと同じだけの追加ディスク余裕を確保したか
[ ] 外部キー/トリガーがかかったテーブルか確認しオプションを用意したか
[ ] スロットル閾値(負荷/レプリケーション遅延)を保守的に設定したか
[ ] レプリカ遅延の監視対象をツールに指定したか
[ ] カットオーバーをトラフィックの少ない時間帯に計画したか(延期フラグ活用)
[ ] カットオーバー直前に長い/未コミットのトランザクションがないと確認したか
[ ] ロールバック手順(カットオーバー前/後)を文書化したか
[ ] 同規模のステージングでリハーサルを終えたか
[ ] PostgreSQL なら lock_timeout 設定と INVALID インデックス点検の計画があるか
[ ] 拡張-収縮でアプリのデプロイとスキーマ変更を分離したか

おわりに

大規模テーブルのスキーマ変更は、データベース運用で最も緊張する作業の一つです。しかしその緊張の正体ははっきりしています。ロック、レプリケーション遅延、ディスク、そして戻せないことです。この四つをそれぞれどう扱うかを事前に設計しておけば、怖かった作業が手順に変わります。

核心を改めて整理するとこうです。軽い変更はネイティブ Online DDL と INSTANT アルゴリズムで速く終わらせ、重い変更は gh-ost や pt-osc のようなツールに委譲してチャンクバックフィルとスロットリングで負荷を制御します。PostgreSQL なら CONCURRENTLY、lock_timeout、NOT VALID 分離といった固有の手法を積極的に活用します。そして何より、カットオーバーを人が制御する短い瞬間にし、拡張-収縮でいつでも止められるマイグレーションを設計することです。

道具は手助けにすぎません。安全なマイグレーションを作るのは、結局は手順とリハーサル、そして戻す計画です。

参考資料