Skip to content

필사 모드: 無停止スキーマ変更 完全ガイド: DDLが取るロック等級と安全な実行手順

日本語
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

はじめに

このブログには無停止スキーマ変更に関する記事がすでにいくつかあります。Expand-Contractパターンは、変更を元に戻せる小さな段階に分割する設計パターンを扱い、大規模テーブルのオンラインスキーマ変更はgh-ostやpt-online-schema-changeのようなツールを扱います。

この記事はその下の層です。PostgreSQLが各DDLで実際にどんなロックを取るか、そしてそのロックがサービスのトラフィックとどうぶつかるか。パターンとツールを知っていても、この層を知らなければ事故が起きます。「カラムを一つ追加するだけです」と言われたデプロイがサービスを5分間止めてしまう出来事が実際に起きる理由は、ここにあります。

基準エンジンはPostgreSQL 18であり、ロック等級と再作成の有無はすべてPostgreSQL 18の文書で確認しました。MySQLのオンラインDDLはアルゴリズム選択(INPLACECOPYINSTANT)というまったく別のモデルを使うため、この記事では混ぜていません。

1. 無停止が崩れる本当の理由はロック待ち行列である

最もよくある誤解があります。「DDLが10秒かかるのだから、サービスも10秒遅くなるだけだろう。」実際にはそうなりません。DDLがロックを獲得しようと待っている間、その後ろに来た普通のクエリも一緒に止まります

PostgreSQLのロック要求は待ち行列に入ります。あるセッションがACCESS EXCLUSIVEロックを要求したのに、先行して実行中の長いSELECTのせいで獲得できず待たされると、その後に到着する新しいSELECTもDDLと衝突するロックを要求することになるため、待ち行列の後ろに並びます。結果として、DDL自体は1秒で終わるのに、先行する30秒のクエリ一つのせいで30秒間すべての新規リクエストが止まる状況が生まれます。

この仕組みを理解すると、無停止スキーマ変更の原則が三つ自然に導かれます。

  1. 強いロックを要求するDDLはできる限り避け、避けられなければ最大限短くする。
  2. DDL実行前に長寿命トランザクションがないか確認する。
  3. DDLには必ず lock_timeoutをかけ、待ち行列を長く塞がないようにする。

2. 八つのテーブルロックモード

PostgreSQLのテーブルレベルロックは八つです。弱いものから強いものの順であり、衝突関係は文書の表のとおりです。

ロックモード衝突するモード
ACCESS SHAREACCESS EXCLUSIVE
ROW SHAREEXCLUSIVE, ACCESS EXCLUSIVE
ROW EXCLUSIVESHARE, SHARE ROW EXCLUSIVE, EXCLUSIVE, ACCESS EXCLUSIVE
SHARE UPDATE EXCLUSIVESHARE UPDATE EXCLUSIVE, SHARE, SHARE ROW EXCLUSIVE, EXCLUSIVE, ACCESS EXCLUSIVE
SHAREROW EXCLUSIVE, SHARE UPDATE EXCLUSIVE, SHARE ROW EXCLUSIVE, EXCLUSIVE, ACCESS EXCLUSIVE
SHARE ROW EXCLUSIVEROW EXCLUSIVE, SHARE UPDATE EXCLUSIVE, SHARE, SHARE ROW EXCLUSIVE, EXCLUSIVE, ACCESS EXCLUSIVE
EXCLUSIVEROW SHARE以下を除くほぼすべて
ACCESS EXCLUSIVEすべてのモード

実務で覚えておくべきなのは二行です。

  • ACCESS SHAREは普通のSELECTが取るロックで、ACCESS EXCLUSIVEとだけ衝突します。つまりACCESS EXCLUSIVEを取らないDDLは読み取りを妨げません。
  • ROW EXCLUSIVEINSERTUPDATEDELETEMERGEが取るロックです。これと衝突する等級(SHARE以上)を取るDDLは書き込みを妨げます。

まとめるとこうなります。SHARE UPDATE EXCLUSIVEまでは読み取りと書き込みの両方を通します。SHARE ROW EXCLUSIVEから書き込みが妨げられ、ACCESS EXCLUSIVEで読み取りまで妨げられます。

文書が明示するコマンド別のロックは次のとおりです。

  • ACCESS EXCLUSIVEDROP TABLETRUNCATEREINDEXCLUSTERVACUUM FULLCONCURRENTLYのないREFRESH MATERIALIZED VIEW、そしてかなりの数のALTER TABLEALTER INDEXの形態
  • SHARE UPDATE EXCLUSIVEFULLのないVACUUMANALYZECREATE INDEX CONCURRENTLYREINDEX CONCURRENTLYCREATE STATISTICSCOMMENT ON、一部のALTER TABLEの形態
  • SHARE ROW EXCLUSIVECREATE TRIGGER、一部のALTER TABLEの形態
  • ROW EXCLUSIVEUPDATEDELETEINSERTMERGE

3. ALTER TABLEの形態別ロック等級

文書の原文は明確です。「各下位形態ごとに必要なロック等級は異なりうる。明示的に言及されていない場合はACCESS EXCLUSIVEロックを獲得する。」

つまりデフォルトが最悪です。例外だけ覚えればよいのです。文書がより低い等級を明示している形態は次のとおりです。

ALTER TABLEの形態ロック等級
SET STATISTICSSHARE UPDATE EXCLUSIVE
SET (...) / RESET (...) カラム別オプション変更SHARE UPDATE EXCLUSIVE
CLUSTER ON / SET WITHOUT CLUSTERSHARE UPDATE EXCLUSIVE
VALIDATE CONSTRAINTSHARE UPDATE EXCLUSIVE
ATTACH PARTITION (親テーブルに対して)SHARE UPDATE EXCLUSIVE
ADD FOREIGN KEYSHARE ROW EXCLUSIVE
ENABLE / DISABLE TRIGGERSHARE ROW EXCLUSIVE
それ以外のすべての形態ACCESS EXCLUSIVE

いくつか重要な細部があります。

ADD FOREIGN KEYは文書が特別に取り上げた例外です。「ADD table_constraintのほとんどの形態はACCESS EXCLUSIVEロックを要求するが、ADD FOREIGN KEYSHARE ROW EXCLUSIVEロックのみを要求する。」それでも書き込みは妨げられます。

ATTACH PARTITIONは親にはSHARE UPDATE EXCLUSIVEしかかけませんが、文書によればアタッチするテーブル自体とDEFAULTパーティション(あれば)にはACCESS EXCLUSIVEをかけます。DEFAULTパーティションがあれば、そのパーティションは完全にロックされるため、大きなDEFAULTパーティションを置いて運用していると、パーティション追加のたびに事故になります。

4. 再作成を引き起こす変更と、そうでない変更

ロック等級と同じくらい重要なのがテーブルの再作成の有無です。ACCESS EXCLUSIVEを1ミリ秒保持するのと1時間保持するのとでは、まったく別の話だからです。

文書が明示する最も有用な事実が一つあります。非揮発性のデフォルト値を持つカラムの追加はテーブルを再作成しません。文書の原文はこうです。「ADD COLUMNでカラムを追加し非揮発性のDEFAULTを指定すると、デフォルト値は文の時点で評価されテーブルのメタデータに保存され、既存の行にアクセスする際に返される。この値はテーブルが再作成されるときにのみ実際に適用されるため、大きなテーブルでもALTER TABLEは非常に高速である。」

-- 再作成なし: メタデータだけが更新される (PostgreSQL 11以降)
ALTER TABLE orders ADD COLUMN channel text DEFAULT 'WEB' NOT NULL;

この動作はPostgreSQL 11で導入されました。10以下を運用しているなら、同じ文がテーブル全体を再作成するため、そのまま実行してはいけません。

一方で文書は、再作成を引き起こすケースも列挙しています。揮発性のDEFAULT(例: clock_timestamp())、格納生成カラム、アイデンティティカラム、制約のあるドメイン型カラムを追加すると、テーブルとすべてのインデックスが再作成されます。

型の変更も同様です。「既存カラムの型を変更すると、通常はテーブルとインデックス全体が再作成される。例外的に、USING句がカラムの内容を変えず、既存の型が新しい型に二進変換可能(binary coercible)であるか、新しい型に対する制約のないドメインである場合は再作成が不要である。」

警告: ALTER TABLE ... ALTER COLUMN ... TYPEはほとんどの場合、ACCESS EXCLUSIVEロックを保持したままテーブル全体を再作成します。1億行のテーブルなら、数十分間読み取りまで完全に遮断されます。安全な代替策は、新しいカラムを追加し、バックフィルし、アプリケーションを切り替えたあとに旧カラムを削除するexpand-contract手順です。varchar(50)varchar(100)に広げるように長さ制約だけを緩める場合は再作成なしで処理されますが、縮める場合は再作成になります。

5. NOT VALIDとVALIDATE — 制約を二段階に

制約の追加は、無停止スキーマ変更の中で最もよく整理された領域です。文書は方法と理由の両方を書き残しています。

「新しい外部キー、検査、NOT NULL制約を検証するために大きなテーブルをスキャンするのは長い時間がかかることがあり、ALTER TABLE ADD CONSTRAINTコマンドがコミットされるまでそのテーブルへの他の更新は遮断される。NOT VALID制約オプションの主な目的は、制約追加が同時更新に与える影響を減らすことである。NOT VALIDを使うと、ADD CONSTRAINTコマンドはテーブルをスキャンせず即座にコミットできる。その後、VALIDATE CONSTRAINTコマンドで既存の行が制約を満たしているかを検証できる。検証段階は同時更新を遮断する必要がない。なぜなら、他のトランザクションが挿入または更新する行に対してはすでに制約が強制されていることがわかっているからである。既存の行だけを検査すればよいため、検証は対象テーブルにSHARE UPDATE EXCLUSIVEロックのみを獲得する。」

-- 1段階目: 即座にコミット。以後の挿入/更新から制約が強制される
ALTER TABLE order_items
  ADD CONSTRAINT fk_order_items_order
  FOREIGN KEY (order_id) REFERENCES orders (id) NOT VALID;

-- 2段階目: 既存行の検証。SHARE UPDATE EXCLUSIVEのみ取るため読み書きを妨げない
ALTER TABLE order_items VALIDATE CONSTRAINT fk_order_items_order;

NOT NULLの追加にも同じ発想を使えます。文書によれば「SET NOT NULLは通常ALTER TABLEの最中にテーブル全体をスキャンして検査する。ただし、NULLが存在しえないことを証明する有効なCHECK制約がすでにあれば、このテーブルスキャンは省略される。」

-- 1段階目: NOT VALID CHECKで即座に登録
ALTER TABLE users
  ADD CONSTRAINT chk_users_email_nn CHECK (email IS NOT NULL) NOT VALID;

-- 2段階目: 弱いロックで検証
ALTER TABLE users VALIDATE CONSTRAINT chk_users_email_nn;

-- 3段階目: これでSET NOT NULLが全体スキャンを省略する
ALTER TABLE users ALTER COLUMN email SET NOT NULL;

-- 4段階目: 重複したCHECK制約を片付ける
ALTER TABLE users DROP CONSTRAINT chk_users_email_nn;

3段階目と4段階目は依然としてACCESS EXCLUSIVEを取りますが、スキャンがないため一瞬で終わります。ロック等級を下げるのではなく、ロック保有時間を減らす戦略です。

6. 安全な実行手順 — lock_timeoutと再試行

ここで1節の待ち行列問題に戻ります。ACCESS EXCLUSIVEを短く保持するDDLであっても、ロックを獲得するまで長く待てば、その間後続の要求がすべて塞がれます。

解決策は「短く試し、獲得できなければ諦め、少し後にまた試す」ことです。ここで必要な道具がlock_timeoutです。文書の定義は「テーブル、インデックス、行、その他のデータベースオブジェクトに対するロックを獲得しようと試み、指定された時間より長く待つ文を中断する」であり、デフォルトは0でタイムアウトが無効です。デフォルト設定では、DDLが無期限に待つことを意味します。

-- 安全なDDL実行テンプレート
BEGIN;
SET LOCAL lock_timeout = '3s';
SET LOCAL statement_timeout = '30s';

ALTER TABLE orders ADD COLUMN channel text DEFAULT 'WEB' NOT NULL;

COMMIT;

SET LOCALを使うと、この設定はこのトランザクションだけに適用され、コミットまたはロールバックとともに元に戻ります。3秒以内にロックを獲得できなければエラーで終わるため、待ち行列を長く塞ぎません。失敗すれば数秒後に再試行します。トラフィックがほんの一瞬空いた瞬間に成功します。

DDLを実行する前に長寿命トランザクションを確認することも手順に組み込んでください。

-- DDL実行直前の点検: 5分以上開いているトランザクション
SELECT pid, state, now() - xact_start AS xact_age,
       wait_event_type, left(query, 80) AS query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
  AND now() - xact_start > interval '5 minutes'
ORDER BY xact_start;

log_lock_waitsを有効にしておくと、ロック待ちがdeadlock_timeout(デフォルト1秒)を超えたときにログが残ります。デプロイ後にこのログを確認すれば、「問題なさそうに見えたが実は3秒間塞いでいた」デプロイを事後に発見できます。

7. インデックスとパーティションの無停止作業

警告: CONCURRENTLYのないCREATE INDEXは、完了するまで対象テーブルへの書き込みを妨げます。文書の表現では「挿入、更新、削除をロックするが読み取りは許可する」とされています。REINDEXはもっと悪く、ACCESS EXCLUSIVEを取るため読み取りまで妨げます。本番テーブルでは常にCREATE INDEX CONCURRENTLYREINDEX INDEX CONCURRENTLYを使ってください。どちらのコマンドもSHARE UPDATE EXCLUSIVEしか取りません。

CONCURRENTLYには三つの制約が伴います。

第一に、トランザクションブロックの中では実行できません。マイグレーションツールがすべてのマイグレーションを一つのトランザクションで包む既定設定であれば失敗します。ツールごとにトランザクションを切る設定を探しておいてください。

第二に、失敗すると無効なインデックスが残ります。文書の表現では、このインデックスは「不完全である可能性があるためクエリでは無視されるが、更新のオーバーヘッドは発生し続ける」とされています。マイグレーション後の点検クエリを自動化してください。

-- デプロイ後の自動点検: 無効なインデックス
SELECT c.relname AS index_name, t.relname AS table_name
FROM pg_index i
JOIN pg_class c ON c.oid = i.indexrelid
JOIN pg_class t ON t.oid = i.indrelid
WHERE NOT i.indisvalid;

第三に、パーティションテーブルには直接使えません。文書は「パーティション化されたテーブルへのインデックスの同時作成は現在サポートされていない」と明記し、回避策を示します。各パーティションに個別にCONCURRENTLYでインデックスを作ったあと、最後に親に非同時的にインデックスを作れば、書き込みがロックされる時間を減らせます。

パーティションの分離には同時モードがあります。ALTER TABLE ... DETACH PARTITION ... CONCURRENTLYは、文書の表現では「パーティション化されたテーブルにアクセスする他のセッションを妨げないよう引き下げられたロック等級で実行される」とされています。古いパーティションを定期的に切り離す保持ポリシーでは、このオプションが要です。

8. マイグレーションをゲートで塞ぐ

これまでの規則を人の記憶に頼っていると、いつか破られます。コードレビューとCIにゲートを置くほうが賢明です。

チェックリストにするとこうなります。

  • 本番テーブルに対するCREATE INDEX / REINDEX / DROP INDEXCONCURRENTLYが付いているか
  • すべてのDDLの前にSET LOCAL lock_timeoutがあるか
  • 制約の追加がNOT VALID + VALIDATE CONSTRAINTの二段階に分かれているか
  • カラムの型変更が含まれていないか(あればexpand-contractに置き換える)
  • カラムの削除がデプロイと同じリリースにないか(旧バージョンのアプリケーションがまだそのカラムを参照しうる)
  • ADD COLUMNのデフォルト値が非揮発性か
  • マイグレーションが一つの巨大なトランザクションにまとめられていないか

カラムの削除について一つ付け加えます。ALTER TABLE ... DROP COLUMNは実際にはデータを消さずカラムを隠すだけなので高速です。しかし旧バージョンのアプリケーションインスタンスがまだ生きているローリングデプロイの最中は、そのインスタンスのSELECT *が壊れます。カラムの削除は常に、アプリケーションのデプロイが完全に終わったあとのリリースに回してください。

戻す計画も一緒に用意しておく必要があります。スキーマ変更のロールバックは「逆方向のDDL」ではなく「逆方向のDDL + その間に積み上がったデータの処理」です。カラムを削除してから元に戻しても、その間のデータはありません。だからこそ、破壊的な変更は常に最後の段階に回すのが原則です。

クイズ: 理解度を確認しましょう

クイズ1: 下のDDLは1秒で終わったのに、サービスは40秒間止まりました。なぜでしょうか。
ALTER TABLE orders ADD COLUMN memo text;

正解: DDL自体は速かったのですが、ACCESS EXCLUSIVEロックを獲得するまで待っている間に、後続のすべてのクエリが待ち行列に積み上がりました。

説明: ALTER TABLE ADD COLUMNACCESS EXCLUSIVEロックを要求し、このロックは普通のSELECTが取るACCESS SHAREとも衝突します。実行時点で40秒かかるレポートクエリが動いていたなら、DDLはそれが終わるまで待ちます。そしてDDLが待ち行列にある間、新しく届くリクエストもその後ろに並びます。対応はSET LOCAL lock_timeout = '3s'で短く試し、失敗したら再試行すること、そしてDDL実行前にpg_stat_activityで長寿命トランザクションを確認することです。

クイズ2: 1億行のテーブルにNOT NULL制約を追加する必要があります。最も短くロックする順序は何でしょうか。

正解: NOT VALIDのCHECK制約を先に作って検証したあとにSET NOT NULLをかけます。

説明: ALTER TABLE ... SET NOT NULLをそのまま実行すると、ACCESS EXCLUSIVEロックを保持したままテーブル全体をスキャンします。文書によれば、NULLが存在しえないことを証明する有効なCHECK制約がすでにあれば、このスキャンは省略されます。

ALTER TABLE users ADD CONSTRAINT chk_email_nn
  CHECK (email IS NOT NULL) NOT VALID;
ALTER TABLE users VALIDATE CONSTRAINT chk_email_nn;
ALTER TABLE users ALTER COLUMN email SET NOT NULL;
ALTER TABLE users DROP CONSTRAINT chk_email_nn;

1段階目はスキャンなしで即座にコミットされ、2段階目はSHARE UPDATE EXCLUSIVEしか取らないため読み書きを妨げず、3段階目はACCESS EXCLUSIVEを取りますがスキャンがないため一瞬で終わります。ロック等級を下げたのではなく、強いロックの保有時間をミリ秒単位に減らしたのです。

クイズ3: 下の二つの文のうち、どちらがテーブルを再作成するでしょうか。
-- A
ALTER TABLE events ADD COLUMN created_by text DEFAULT 'system' NOT NULL;
-- B
ALTER TABLE events ADD COLUMN created_at timestamptz DEFAULT clock_timestamp() NOT NULL;

正解: Bが再作成します。

説明: 文書は、非揮発性のDEFAULTを指定したADD COLUMNはデフォルト値をテーブルのメタデータに保存し、再作成しないと明記しています。Aの'system'は定数なので非揮発性です。一方Bのclock_timestamp()は揮発性の関数なので行ごとに値が異なる必要があり、そのためテーブルとすべてのインデックスが再作成されます。同じ目的ならnow()(トランザクション開始時刻に固定される安定した関数)を使うか、カラムをデフォルトなしで追加したあとチャンク単位でバックフィルし、最後にデフォルト値とNOT NULLをかけるほうが安全です。

クイズ4: CREATE INDEX CONCURRENTLYがマイグレーションツールで失敗し続けます。エラーは「cannot run inside a transaction block」です。

正解: マイグレーションツールが各マイグレーションをトランザクションで包んでいるためです。

説明: 文書は「通常のCREATE INDEXコマンドはトランザクションブロックの中で実行できるが、CREATE INDEX CONCURRENTLYはできない」と明記しています。CONCURRENTLYは内部的に複数のトランザクションを使うためです。ツールごとに、そのマイグレーションだけをトランザクションの外で実行する設定があります。そしてトランザクションの外で実行されるということは、失敗時に自動ロールバックがないという意味でもあります。失敗すると無効なインデックスが残るため、マイグレーション後の無効インデックス点検クエリを必ず自動化してください。

クイズ5: DEFAULTパーティションがあるパーティションテーブルに新しい月次パーティションをアタッチするたびにサービスが止まります。なぜでしょうか。

正解: ATTACH PARTITIONがDEFAULTパーティションにACCESS EXCLUSIVEロックをかけるためです。

説明: 文書によれば、ATTACH PARTITIONは親テーブルにSHARE UPDATE EXCLUSIVEロックをかけますが、アタッチするテーブル自体とDEFAULTパーティション(ある場合)にはACCESS EXCLUSIVEロックをかけます。さらに、DEFAULTパーティションに新パーティションの範囲と重なる行がないかを確認するためのスキャンが必要です。DEFAULTパーティションが大きいとこのスキャンが長くなり、その間DEFAULTパーティションへのすべてのアクセスが遮断されます。対応は二つあります。DEFAULTパーティションをそもそも置かず、パーティションを先に余裕を持って作っておくか、DEFAULTパーティションを常に空のまま保つことです。文書もDEFAULTパーティションについて「アタッチするパーティションの制約を排除するCHECK制約を作っておき、不要なスキャンを避けよ」と勧めています。

おわりに

無停止スキーマ変更はツールの問題ではなく、ロック等級を知る問題です。gh-ostのようなツールが必要になるケースは思ったより少なく、PostgreSQLではほとんどの変更を文書に書かれた方法だけで安全に処理できます。NOT VALIDVALIDATE CONSTRAINTCONCURRENTLYSET LOCAL lock_timeout。この三つがツールの大部分を代替します。

チームに残しておくべき規則は一文に要約できます。すべてのDDLは、自分がどのロックを何秒間保持するかを知った状態で実行する。わからなければ、ステージングでlog_lock_waitsを有効にして測定してから実行します。

マイグレーションの順序を視覚的に点検したいならDBマイグレーション エクスプローラーを、DDLを自分で実行してみたいならPostgreSQLプレイグラウンドを活用してください。

参考資料

関連記事

현재 단락 (1/148)

このブログには無停止スキーマ変更に関する記事がすでにいくつかあります。[Expand-Contractパターン](/blog/database/2026-06-16-expand-contract-z...

작성 글자: 0원문 글자: 12,854작성 단락: 0/148