Skip to main content

ダウンタイム無しの Postgres スキーママイグレーション

Tianzhou · 2025年9月2日

Postgres のロックを理解する

Postgres は精緻なロックシステムを備え、複数のロックモードを持ちます。DDL 操作は、INSERTUPDATEDELETE といった通常の DML と衝突するロックを取ることがよくあります。つまり一見無害なスキーマ変更でも、慎重に実行しないとアプリケーション全体を止めてしまいます。

DML が取るロックを整理します。

DMLロックモード説明
SELECTACCESS SHAREACCESS EXCLUSIVE とのみ衝突
INSERT/UPDATE/DELETEROW EXCLUSIVESHARE、SHARE ROW EXCLUSIVE、EXCLUSIVE、ACCESS EXCLUSIVE と衝突

代表的な 2 つのスキーママイグレーション操作がどう DML と衝突するかは次の通りです。

DDL 操作ロックモードSELECTINSERT/UPDATE/DELETE
ALTER TABLE (多くの場合)ACCESS EXCLUSIVEブロックブロック
CREATE INDEXSHARE✅ 許可ブロック

多くの ALTER TABLEACCESS EXCLUSIVE ロックを取ります。これは Postgres で最も強いロックで、アプリケーションを完全にブロックします — 読み取りも書き込みもできず、操作完了までそのテーブルでは何も起こりません。

CREATE INDEXSHARE ロックを使い、SELECT は許しますが書き込み (INSERTUPDATEDELETE) はすべてブロックします。大きなテーブルの索引作成は数時間に及ぶことがあり、その間アプリケーションは事実上 read-only になります。

どちらもダウンタイムを引き起こします。以下では、これらのブロッキング挙動を避ける代替手段を見ていきます。

ダウンタイム無しの ALTER TABLE

ほとんどの ALTER TABLE 操作は ACCESS EXCLUSIVE を取りますが、Postgres は一般的なスキーマ変更でダウンタイムを避けるためのテクニックをいくつか提供しています。

DEFAULT 値付きカラムの追加

Postgres 11 以降、多くの場合 DEFAULT 値付きのカラム追加はテーブル書き換えを必要としません。

-- ✅ 高速 (Postgres 11+)
-- テーブル書き換え無し、ロック時間は最小
ALTER TABLE users ADD COLUMN status text DEFAULT 'active';

-- ❌ 低速 (テーブル書き換えが必要)
-- 定数ではないデフォルト値が必要なときだけ使う
ALTER TABLE users ADD COLUMN created_at timestamp DEFAULT now();

仕組み: Postgres は全行を書き換える代わりに、デフォルト値をシステムカタログに保存します。新カラムが無い行を読むときは、Postgres が自動でデフォルト値を返します。

制約を 2 段階で追加する

既存データに対する検証を伴う制約には、NOT VALID 方式を使います。

CHECK 制約

-- ステップ 1: 既存データを検証せずに制約を追加
-- 高速 — カタログ更新のみで一瞬しかブロックしない
ALTER TABLE users ADD CONSTRAINT users_age_positive
  CHECK (age > 0) NOT VALID;

-- ステップ 2: 既存データを検証 (時間はかかるが書き込みをブロックしない)
-- SHARE UPDATE EXCLUSIVE ロックを使うため読み書きは可能
ALTER TABLE users VALIDATE CONSTRAINT users_age_positive;

外部キー制約

-- ステップ 1: 検証無しで外部キーを追加
-- 高速 — カタログ更新のみ、既存データは検証しない
ALTER TABLE orders ADD CONSTRAINT orders_user_id_fk
  FOREIGN KEY (user_id) REFERENCES users(id) NOT VALID;

-- ステップ 2: 既存リレーションを検証
-- 時間はかかるが並行する操作を許可する
ALTER TABLE orders VALIDATE CONSTRAINT orders_user_id_fk;

カラムを NOT NULL にする

SET NOT NULL の最も安全なアプローチは 4 ステップの手順です。

-- ステップ 1: CHECK 制約を追加 (NOT VALID で高速)
ALTER TABLE users ADD CONSTRAINT users_email_not_null
  CHECK (email IS NOT NULL) NOT VALID;

-- ステップ 2: 制約を検証 (並行する操作を許可)
ALTER TABLE users VALIDATE CONSTRAINT users_email_not_null;

-- ステップ 3: NOT NULL を設定 (制約が NULL 無しを保証するので高速)
ALTER TABLE users ALTER COLUMN email SET NOT NULL;

-- ステップ 4: 冗長になった CHECK 制約を削除
ALTER TABLE users DROP CONSTRAINT users_email_not_null;

なぜ機能するか: 検証済みの制約から NULL が存在しないことを Postgres が確認できるため、最後の SET NOT NULL は高速です。

Postgres 18 アップデート: 近く登場する Postgres 18 では、簡略化された形 が導入され、NOT NULL 制約に直接 NOT VALID 属性を指定できるようになります。

-- Postgres 18+ — 簡略化されたアプローチ
ALTER TABLE users ALTER COLUMN email SET NOT NULL NOT VALID;
ALTER TABLE users VALIDATE CONSTRAINT users_email_not_null;

ダウンタイム無しの CREATE INDEX

ブロッキングな CREATE INDEX の代わりに、CREATE INDEX CONCURRENTLY を使い、アプリケーショントラフィックを乱さずに索引を構築します。

-- ❌ 索引作成中、すべての書き込みをブロックする
CREATE INDEX idx_users_email ON users(email);

-- ✅ 索引作成中も書き込みを続行できる
CREATE INDEX CONCURRENTLY idx_users_email ON users(email);

CREATE INDEX CONCURRENTLYSHARE ロックの代わりに SHARE UPDATE EXCLUSIVE ロックを使い、INSERTUPDATEDELETE を通常通り続けられます。

トレードオフ: 並行的な索引作成はダウンタイムを避けられますが、完了までに時間がかかり、いくつかの制約があります (トランザクション内では使えない、エラー処理に注意が必要など)。

並行索引作成の総合ガイドは、別記事「CREATE INDEX CONCURRENTLY」を参照してください。

lock_timeout を使う

ロックレベルに関係なく、すべての DDL 操作で lock_timeout を使うべきです。アプリケーションを凍りつかせるロックキューを防ぐためです。

ロックキュー問題

ACCESS EXCLUSIVE 操作が既存クエリの完了を待っている間、後続の操作はすべて開始をブロックされます。たった 1 つの長いクエリの終わりを待っている間に、アプリケーション全体が凍りついたように見えます。

-- セッション 1: 長時間クエリ (ACCESS SHARE を保持)
SELECT COUNT(*) FROM large_table; -- 5 分かかる

-- セッション 2: ACCESS EXCLUSIVE を待つ DDL
ALTER TABLE large_table ALTER COLUMN description TYPE text; -- SELECT の後ろで待つ

-- セッション 3: 新しいアプリケーションクエリ
SELECT * FROM large_table WHERE id = 123; -- 待ち状態の DDL によりブロック!

lock_timeout を設定する

lock_timeout パラメータは複数のレベルで設定できます。

-- オプション 1: セッションレベル (一時的)
SET lock_timeout = '5s';

-- オプション 2: ロールレベル (推奨 — セッションをまたいで永続)
CREATE ROLE ddl_user WITH LOGIN PASSWORD 'secure_password';
ALTER ROLE ddl_user SET lock_timeout = '5s';

-- オプション 3: データベースレベル (すべての接続に適用)
ALTER DATABASE mydb SET lock_timeout = '5s';

Postgres のロールレベルで lock_timeout を設定するのが推奨です。理由は次の通り。

  1. そのロールのすべてのセッションに設定が適用される
  2. DDL セッションごとに設定する必要が無い
-- スキーマ移行には専用 DDL ユーザーを使う
-- ddl_user として接続し、次を実行:
ALTER TABLE users ALTER COLUMN description TYPE text;

ロックを 5 秒以内に取れなかった場合、操作は無限にブロックする代わりにエラーで失敗します。

リトライロジック

lock_timeout をリトライロジックと組み合わせれば、混雑時にも対応できます。

-- リトライ付き DDL スクリプトの例
DO $$
DECLARE
    max_attempts INTEGER := 10;
    attempt INTEGER := 1;
    success BOOLEAN := FALSE;
BEGIN
    WHILE attempt <= max_attempts AND NOT success LOOP
        BEGIN
            SET lock_timeout = '2s';
            ALTER TABLE users ALTER COLUMN description TYPE text;
            success := TRUE;
            RAISE NOTICE 'DDL succeeded on attempt %', attempt;
        EXCEPTION
            WHEN lock_not_available THEN
                RAISE NOTICE 'Attempt % failed, retrying in 30 seconds...', attempt;
                PERFORM pg_sleep(30);
                attempt := attempt + 1;
        END;
    END LOOP;

    IF NOT success THEN
        RAISE EXCEPTION 'DDL failed after % attempts', max_attempts;
    END IF;
END $$;

Bytebase はスキーママイグレーション中にリトライロジックを自動で処理します。

結論

ゼロダウンタイムの Postgres スキーママイグレーションを実現するには、ロックを理解し、正しいテクニックを適用する必要があります。

  1. 影響を理解する: どの DDL がアプリケーションをブロックするかを把握する
  2. モダンな Postgres 機能を使う: CONCURRENTLYNOT VALID などの最適化を活用する
  3. 常に lock_timeout を設定する: アプリケーションを凍りつかせるロックキューを防ぐ
  4. リトライを計画する: 混雑時にリトライロジックで優雅に対処する

これらの実践に従えば、ユーザーへの影響無くスキーマ変更を自信を持ってデプロイできます。

参考文献

  1. Postgres のロックモード
  2. Postgres 11: ADD COLUMN with DEFAULT optimization
  3. Postgres 18: NOT NULL constraints with NOT VALID
  4. How to Use Postgres CREATE INDEX CONCURRENTLY
ブログに戻る

データベース開発のスタンダードを体験する