Skip to content
Published on

Expand-Contract パターン — 無停止スキーマ変更の定石

Authors

はじめに

稼働中のサービスのデータベーススキーマを変更する作業は、いつも緊張を伴います。トラフィックが流れている最中に、数十のアプリケーションインスタンスが同じテーブルを読み書きしているただ中で、カラムを一つ削除したりリネームしたりという一見単純な変更が、サービス全体を停止させてしまうことがあるからです。

多くのチームが一度はこうした経験をします。デプロイ直前に ALTER TABLE でカラム名を変更し、新しいコードをデプロイしたものの、ローリングデプロイがまだ終わっておらず、旧バージョンのインスタンスが消えたカラムを参照してエラーが噴出する、という状況です。あるいは、大きなテーブルに NOT NULL 制約を一度に付与してテーブル全体がロックされ、その間すべての書き込みクエリがタイムアウトする、という状況もよく起こります。

本記事では、こうした事故を構造的に防ぐ Expand-Contract パターン(拡張-収縮パターン、Parallel Change とも呼ばれます)を整理します。このパターンは「スキーマとコードを同時に変更しない」というたった一つの原則から出発します。その原則を守るための 3 段階の流れと、シナリオごとの実際の SQL、そしてアプリケーションコードとの連携方法を具体的に解説します。

破壊的変更はなぜ危険か

まず「破壊的変更(destructive change)」とは何かを定義しましょう。破壊的変更とは、既存のコードが依存していたスキーマの契約を一瞬で壊す変更を指します。代表的なものは次のとおりです。

  • カラムのリネーム(RENAME COLUMN)
  • カラムの削除(DROP COLUMN)
  • カラム型の変更(ALTER COLUMN ... TYPE)
  • NOT NULL 制約の即時適用
  • テーブルのリネーム

これらの変更が危険な理由は大きく二つあります。

1. デプロイは原子的ではない

現代的なサービスはローリングデプロイ、またはブルーグリーンデプロイを使います。つまり、新バージョンのコードと旧バージョンのコードが 同時に稼働している時間帯 が必ず存在します。短ければ数十秒、長ければ数分です。

スキーマ変更はこの時間帯を考慮しなければ必ず壊れます。スキーマを先に変えれば旧コードが壊れ、コードを先に変えれば新コードがまだ存在しないスキーマを参照して壊れます。どちらを先にやっても片方が壊れるのです。この矛盾を解決するのが、まさに Expand-Contract パターンです。

2. DDL はロックを取る

ALTER TABLE は種類によって強いロック(ACCESS EXCLUSIVE LOCK)を取ります。PostgreSQL を例にとると、大きなテーブルに揮発性のデフォルト値を持つカラムを追加したり、即時検証される制約を追加したりすると、テーブル全体をスキャンしながらロックを保持します。その間に入ってくるすべてのクエリは待ち行列に積まれ、やがてコネクションプールが枯渇してサービス全体が停止します。

時間 ──────────────────────────────────────▶
       ALTER TABLE (全体スキャン + ACCESS EXCLUSIVE LOCK)
      ┌──────────────────────────────┐
      │  テーブルロック中            │
      └──────────────────────────────┘
   読み取り/書き込みクエリ ─ ─ ─ ─ ─ 待機 ▶ タイムアウト

したがって無停止スキーマ変更は、「コードとの互換性」と「ロック回避」という二つの軸を同時に満たさなければなりません。

Expand-Contract パターンの 3 段階

Expand-Contract パターンは一つの変更を 3 段階に分割します。核心は、各段階の間で常に安全に止められ、ロールバックできるという点です。

段階の概要

段階英語スキーマの状態コードの状態目的
1. 拡張Expand旧/新の構造が両方存在変更なし、または両対応既存を壊さず新構造を追加
2. 移行Migrate両方の構造が共存新構造へ段階的に移行データを移し、コードが新構造を使う
3. 収縮Contract旧構造を削除新構造のみ使用不要になった旧構造を整理

ASCII タイムライン

   Expand               Migrate                    Contract
 ┌─────────┐        ┌──────────────┐            ┌──────────┐
 │ 新カラム│        │ バックフィル │            │ 旧カラム │
 │ 追加    │  ───▶  │ + ダブル書き │   ───▶     │ 削除     │
 │ (互換)  │        │ + コード移行 │            │ (整理)   │
 └─────────┘        └──────────────┘            └──────────┘
      │                    │                          │
      ▼                    ▼                          ▼
  ロールバック可       ロールバック可            戻すには
  (無視するだけ)     (旧カラム生存)             再拡張が必要

 ◀── 旧/新バージョンのコードが同時稼働しても安全な区間 ──▶

このタイムラインで最も重要な点は、Expand と Migrate の段階では、旧バージョンのコードと新バージョンのコードが同時に稼働していても、すべて正常に動作するということです。これが無停止の核心です。

シナリオごとの実践ガイド

ここからは各変更シナリオを Expand-Contract の観点から、具体的な SQL とともに見ていきます。例は PostgreSQL を基準とし、MySQL との違いは別途触れます。

シナリオ 1: カラムの追加

最も単純ですが、ここにも落とし穴があります。新しいカラムを追加するときに、デフォルト値と NOT NULL を一度に付けてしまうことです。

PostgreSQL 11 以降では、定数デフォルト値を持つカラムの追加はメタデータのみを変更する高速な操作に最適化されました。しかし揮発性関数をデフォルトに使うとテーブル全体を書き直すことになるため、注意が必要です。

Expand 段階 — 新しいカラムを nullable で追加します。

-- 安全: nullable カラムの追加(メタデータ変更のみ)
ALTER TABLE orders
  ADD COLUMN discount_code text;

デフォルト値が必要な場合、定数デフォルトは安全ですが、関数ベースのデフォルトは避けます。

-- 安全 (PostgreSQL 11+): 定数デフォルト
ALTER TABLE orders
  ADD COLUMN status text NOT NULL DEFAULT 'pending';

-- 危険: 揮発性関数のデフォルトはテーブル全体の書き直しを引き起こしうる
-- ALTER TABLE orders ADD COLUMN created_at timestamptz DEFAULT now();

コード側では、新しいカラムを読み書きするコードは、カラムが実際にできてからデプロイします。カラムは nullable なので、旧コードはこのカラムを無視すればよく、壊れません。

シナリオ 2: カラムのリネーム

リネームは最も事故が起きやすい作業です。RENAME COLUMN 一行で済みそうに見えますが、その一行が旧コードを即座に壊します。Expand-Contract パターンでは、リネームを 追加 + バックフィル + 切り替え + 削除 の 4 ステップに分解します。

たとえば users.usernameusers.handle に変えるとしましょう。

Expand: 新カラムの追加

ALTER TABLE users
  ADD COLUMN handle text;

Migrate: バックフィルとダブルライト

既存データを新カラムへ移します。大きなテーブルを一度に UPDATE するとロックと WAL の急増を招くため、必ずバッチに分けます。

-- バッチバックフィル: 一度に一定件数だけ処理する
UPDATE users
SET handle = username
WHERE handle IS NULL
  AND id IN (
    SELECT id FROM users
    WHERE handle IS NULL
    ORDER BY id
    LIMIT 5000
  );
-- 上記クエリを影響行数が 0 になるまで繰り返し実行する

バックフィルと同時に、アプリケーションは両方のカラムへ書き込む ダブルライト を行います。こうすることで、バックフィル中に新しく入ってきたデータも両方に反映されます。

# アプリケーションのダブルライト(擬似コード)
def update_user_handle(user_id, new_value):
    db.execute(
        "UPDATE users SET username = :v, handle = :v WHERE id = :id",
        v=new_value, id=user_id,
    )

def read_user_handle(row):
    # 読み取りは新カラム優先、なければ旧カラムにフォールバック
    return row["handle"] if row["handle"] is not None else row["username"]

Contract: 旧カラムの削除

バックフィルが終わり、すべてのインスタンスが新カラムだけを読み書きするコードでデプロイされた後に、旧カラムを削除します。

ALTER TABLE users
  DROP COLUMN username;

この順序を守れば、どの時点でデプロイが止まったりロールバックされたりしても、サービスは壊れません。

シナリオ 3: カラムの削除

カラムの削除も即座に行うと危険です。まだそのカラムを参照する旧コードが稼働している可能性があるからです。削除は常に コードから参照を先に取り除いた後 に行います。

順序は次のとおりです。

  1. 該当カラムを読み書きしないコードを先にデプロイします。
  2. すべてのインスタンスが新コードに置き換わるまで待ちます(観察期間)。
  3. カラムを削除します。
-- すべてのコードがこのカラムをもう参照しないことを確認した後
ALTER TABLE users
  DROP COLUMN legacy_score;

削除の前に十分な観察期間を置くのが望ましいです。カラムを即座に DROP する代わりに、まずアプリケーションレベルで無視するようにし、数日運用しながら本当に誰も使っていないかをモニタリングしてから削除するのが安全です。

シナリオ 4: NOT NULL 制約の追加

大きなテーブルに SET NOT NULL を直接付けると、全体スキャンとともに ACCESS EXCLUSIVE LOCK を取ります。PostgreSQL では、CHECK 制約を NOT VALID で先に追加し、後から検証する迂回路があります。

Expand: 検証を先送りする CHECK 制約の追加

-- NOT VALID: 既存行は検査せず、新規/変更行のみ検査する
ALTER TABLE orders
  ADD CONSTRAINT orders_amount_not_null
  CHECK (amount IS NOT NULL) NOT VALID;

NOT VALID は短いロックだけを取って即座に終わります。この時点から、新しく入ってくるデータは制約を満たす必要があります。

Migrate: 既存データのバックフィル後に検証

-- 1) NULL の既存行を埋める(バッチで)
UPDATE orders SET amount = 0 WHERE amount IS NULL AND id IN (
  SELECT id FROM orders WHERE amount IS NULL ORDER BY id LIMIT 5000
);

-- 2) 制約の検証: ACCESS EXCLUSIVE ではなく弱いロックで全体検査
ALTER TABLE orders
  VALIDATE CONSTRAINT orders_amount_not_null;

VALIDATE CONSTRAINT はテーブル全体を読みますが、SHARE UPDATE EXCLUSIVE レベルの弱いロックだけを取り、同時の読み書きを妨げません。これが重要な違いです。

シナリオ 5: カラム型の変更

型変更(ALTER COLUMN ... TYPE)はほぼ常にテーブルを書き直す必要があるため、最も重い作業です。安全なアプローチはリネームと同じく、新カラムを作って移す 方式です。

priceinteger から bigint に変える例です。

-- Expand: 新しい型のカラムを追加
ALTER TABLE products
  ADD COLUMN price_v2 bigint;

-- Migrate: バッチバックフィル + アプリケーションのダブルライト
UPDATE products
SET price_v2 = price
WHERE price_v2 IS NULL AND id IN (
  SELECT id FROM products WHERE price_v2 IS NULL ORDER BY id LIMIT 5000
);

-- (コード移行完了後) Contract: 旧カラム削除、必要なら新カラム名を整理
ALTER TABLE products DROP COLUMN price;
ALTER TABLE products RENAME COLUMN price_v2 TO price;

最後の RENAME はメタデータだけを変えるので高速ですが、この瞬間に再びカラム名が変わるため、この名前を読むコードとの同期に注意が必要です。保守的に進めるなら、最後の RENAME を省略して price_v2 という名前をそのまま維持するのも一つの手です。

インデックス追加は CONCURRENTLY で

スキーマ変更にはインデックスの追加も含まれます。通常の CREATE INDEX はテーブルに書き込みロックを取りますが、CONCURRENTLY オプションはロックなしでインデックスをビルドします。

-- 書き込みを妨げずにインデックスを作成
CREATE INDEX CONCURRENTLY idx_orders_discount_code
  ON orders (discount_code);

ただし CONCURRENTLY はトランザクションブロック内で実行できず、失敗すると無効(invalid)なインデックスを残すことがあるため、失敗時の整理ロジックが必要です。

ダブルライトと段階的バックフィルを深掘りする

Migrate 段階の二本の柱はダブルライトと段階的バックフィルです。この二つが一緒に動いてこそ、データの整合性が保証されます。

なぜ両方が必要か

バックフィルだけしてダブルライトをしないと、バックフィル進行中に入ってきた書き込みが旧カラムにだけ反映され、新カラムが空になります。ダブルライトだけしてバックフィルをしないと、ダブルライト開始前の過去データは新カラムが永遠に空のままです。だから両方を使って「過去(バックフィル)と未来(ダブルライト)」の両方を覆うのです。

   ダブルライト開始時点
  過去データ  │   未来データ
 ◀─────────┼──────────▶
  バックフィル │  ダブルライトが
  が埋める    │  自動で埋める

バッチバックフィルのパターン

バックフィルは必ず小さなバッチに分け、バッチの間に少し休んでレプリケーション遅延とロック競合を緩和します。

# 段階的バックフィルのループ(擬似コード)
BATCH = 5000
while True:
    rows = db.execute("""
        UPDATE users SET handle = username
        WHERE handle IS NULL
          AND id IN (
            SELECT id FROM users WHERE handle IS NULL
            ORDER BY id LIMIT :n
          )
        RETURNING id
    """, n=BATCH)
    if len(rows) == 0:
        break
    sleep(0.2)  # レプリケーション遅延/ロック競合の緩和

こうすれば巨大なテーブルでも、サービスに影響を与えずにゆっくり埋められます。

アプリケーションコードとの連携

Expand-Contract パターンはデータベースだけの仕事ではなく、アプリケーションのデプロイと拍子を合わせる必要があります。段階ごとにコードがどの状態であるべきかを整理すると次のようになります。

時点スキーマ読み取りコード書き込みコード
Expand 直後新カラム追加済み旧カラムを読む旧カラムのみ書く
ダブルライトデプロイ両方存在新優先、旧フォールバック両方に書く
新カラム切り替えデプロイ両方存在新カラムのみ読む新カラムのみ書く
Contract旧カラム削除新カラムのみ読む新カラムのみ書く

核心となるルールは、各デプロイの間に必ず「すべてのインスタンスが置き換わるまで」待つことです。ローリングデプロイが完全に終わったかを確認せずに次の段階へ進むと、まだ旧コードが残っていて前提が崩れることがあります。

マイグレーションツール(Flyway、Liquibase、golang-migrate など)を使うなら、一つのリリースに一つの安全な段階だけを入れるのが望ましいです。Expand のマイグレーションと Contract のマイグレーションを別々のリリースに分ければ、その間にコードデプロイと観察期間を挟み込めます。

ロールバックを常に可能に保つ

Expand-Contract の最大の利点は、各段階が独立してロールバック可能 であることです。

  • Expand 段階のロールバック: 新しく追加した nullable カラムは誰も使っていないので、そのままにしても後で削除してもかまいません。コードのロールバックも安全です。
  • Migrate 段階のロールバック: 旧カラムがまだ生きていて、ダブルライトで最新の状態を保っているため、コードを旧バージョンに戻してもデータは無事です。
  • Contract 段階のロールバック: この段階だけは注意が必要です。旧カラムをすでに削除していると、単純なコードのロールバックでは戻せません。だから Contract は最後に、新構造が十分に安定してからのみ実行します。

この非対称性のために、現場には一つの格言があります。「Expand は自由に、Contract は慎重に」。拡張は戻しやすいが、収縮は戻しにくいのです。

よくある落とし穴

実務で頻繁に出会う落とし穴を整理します。

  1. 一つのリリースに Expand と Contract を一緒に入れる。 最もよくある失敗です。両者の間にコードデプロイと観察期間が入るべきなのに、一度に処理すると無停止の保証が崩れます。

  2. バックフィルを一発の UPDATE で行う。 数百万行を一トランザクションで更新すると、ロック、WAL の急増、レプリケーション遅延がついてきます。必ずバッチに分けます。

  3. ダブルライトを忘れる。 バックフィル中に入ってきた新規データが新カラムから漏れます。バックフィルが終わったのに新カラムに NULL が残るミステリーの主犯です。

  4. 関数デフォルトでカラムを追加する。 DEFAULT now() のような揮発性デフォルトはテーブル全体の書き直しを引き起こしうります。定数デフォルトを使うか、追加後にバックフィルで埋めます。

  5. 即時検証される制約を追加する。 NOT NULLCHECK を直接付けると全体スキャン + 強いロックです。NOT VALID の後に VALIDATE で分けます。

  6. CONCURRENTLY をトランザクション内で実行する。 CREATE INDEX CONCURRENTLY はトランザクションブロックの外でのみ動作します。マイグレーションツールが自動でトランザクションを巻かないように設定する必要があります。

  7. ローリングデプロイの完了を待たない。 すべてのインスタンスが新コードに置き換わる前に次の段階へ進むと前提が崩れます。

無停止スキーマ変更チェックリスト

デプロイ前に次を点検すると良いでしょう。

  • この変更を Expand / Migrate / Contract の 3 段階に分割したか?
  • Expand と Contract を別々のリリースに分離したか?
  • 新カラムは nullable で追加するか?(または定数デフォルトのみ使用)
  • バックフィルはバッチに分け、バッチの間に休むか?
  • ダブルライトで過去と未来のデータを両方覆うか?
  • 制約は NOT VALID の後に VALIDATE で分けたか?
  • インデックスは CONCURRENTLY で作るか?
  • 各段階の間でローリングデプロイの完了を待つか?
  • Contract 直前まで旧構造を維持してロールバック経路を確保したか?
  • カラム削除前に十分な観察期間を置いたか?

おわりに

Expand-Contract パターンの本質は「一度に変えない」ことです。破壊的な変更一つを、それぞれ安全で戻せる小さな段階に分解し、旧バージョンと新バージョンのコードが共存する時間帯を常に無事に通過できるようにするのです。

最初は段階が多くて煩わしく感じるかもしれません。しかし、深夜のデプロイでカラム一つのためにサービスが止まる経験を一度でもすれば、この煩わしさがどれほど価値ある保険であるかが分かります。無停止は運ではなく手順で作られます。

参考資料