Claude CodeでPostgresのマイグレーションをレビュー — ロックレベルで無停止かを確かめる
CREATE INDEX CONCURRENTLYとNOT VALID制約を軸に、DDLごとのロックレベルをClaude Codeのレビュー専用サブエージェントに確認させる定義と、書き換えの型を示します。
PostgreSQLのマイグレーションが無停止かどうかは、ツールではなくDDL1文ごとのロックレベルで決まります。CREATE INDEXは書き込みを止め、CREATE INDEX CONCURRENTLYは止めません。ADD CONSTRAINTは既定でテーブル全体を止め、NOT VALIDを付けて検証を別の文に分けると止めずに済みます。
この判定は表を引けば機械的にできる作業です。そこでロックレベルの表を持たせた読み取り専用のサブエージェントをClaude Codeに定義し、マイグレーションSQLを書いた直後にレビューさせます。FlywayやAlembicなどの実行手順はツールごとの記事に任せ、ここではSQLそのものの審査に絞ります。
ロックモードの早見表
PostgreSQLのテーブルロックは8種類あり、違いは「どのモードと衝突するか」だけです。名前に「ROW」が付いていても、すべてテーブル単位のロックです。無停止かどうかの判定には、次の4つを押さえれば足ります。
| ロックモード | 通常のSELECTを止めるか | INSERT/UPDATE/DELETEを止めるか | 取得する代表的な文 |
|---|---|---|---|
| SHARE UPDATE EXCLUSIVE | 通常のSELECTを止めるか止めない | INSERT/UPDATE/DELETEを止めるか止めない | 取得する代表的な文CREATE INDEX CONCURRENTLY、VALIDATE CONSTRAINT |
| SHARE | 通常のSELECTを止めるか止めない | INSERT/UPDATE/DELETEを止めるか止める | 取得する代表的な文CREATE INDEX(CONCURRENTLYなし) |
| SHARE ROW EXCLUSIVE | 通常のSELECTを止めるか止めない | INSERT/UPDATE/DELETEを止めるか止める | 取得する代表的な文ADD FOREIGN KEY、CREATE TRIGGER |
| ACCESS EXCLUSIVE | 通常のSELECTを止めるか止める | INSERT/UPDATE/DELETEを止めるか止める | 取得する代表的な文DROP TABLE、TRUNCATE、VACUUM FULL、既定のALTER TABLE |
通常のSELECTを止めるのはACCESS EXCLUSIVEだけです。つまり、読み取りまで止まる文を見つけた時点で最優先の指摘になります。SHARE系は読み取りが生きたまま書き込みだけが詰まるので、画面上は「更新が固まる」形で現れます。
DDLごとのロックレベルと書き換え
ALTER TABLEはサブコマンドごとにロックが違い、ドキュメントには「明記がなければACCESS EXCLUSIVE」と書かれています。複数のサブコマンドを1文に並べたときは、最も強いロックが取られます。レビューで見る対応を表にまとめます。
| 書かれがちなDDL | 取得するロック | 無停止にする書き換え |
|---|---|---|
CREATE INDEX | 取得するロックSHARE(書き込みを止める) | 無停止にする書き換えCREATE INDEX CONCURRENTLYにする |
ADD CONSTRAINT ... CHECK | 取得するロックACCESS EXCLUSIVE | 無停止にする書き換えNOT VALIDで追加し、VALIDATE CONSTRAINTを別の文で実行 |
ADD FOREIGN KEY | 取得するロックSHARE ROW EXCLUSIVE(参照先にも同じロック) | 無停止にする書き換えNOT VALIDで追加し、VALIDATE CONSTRAINTを別の文で実行 |
ALTER COLUMN SET NOT NULL | 取得するロックACCESS EXCLUSIVE(全件走査あり) | 無停止にする書き換え検証済みのCHECKを先に用意して走査を省く |
ADD COLUMN ... DEFAULT <揮発性の式> | 取得するロックテーブル全体の書き換え | 無停止にする書き換え既定値なしで追加し、別途バックフィルする |
ALTER COLUMN TYPE | 取得するロック通常はテーブルと索引の書き換え | 無停止にする書き換え新しい列を足して移す手順に分ける |
ADD COLUMNは注意が要ります。揮発しない既定値(定数など)ならメタデータに保存されるだけで書き換えは起きず、大きなテーブルでも速く終わります。書き換えが走るのは、clock_timestamp()のような揮発性の既定値、保存型の生成列、IDENTITY列を足すときです。「DEFAULTがあるからNG」と一律に指摘するとレビューが空振りするので、サブエージェントには既定値の中身まで見させます。
DROP COLUMNは列を見えなくするだけで、ディスク領域はすぐには戻りません。ロックの面では軽い操作ですが、容量が減る前提のレビューコメントは付けないようにします。
CREATE INDEX CONCURRENTLYの制約
CONCURRENTLYを付ければ解決とは限りません。公式ドキュメントが挙げる条件のうち、マイグレーションのレビューで効くものは次のとおりです。
- トランザクションブロックの中では実行できません。1ファイルを1トランザクションで包むツールでは、そのままだとエラーになります。設定の外し方はツール側の話なので、FlywayとAlembicの手順記事にあたってください
- テーブルを2回走査し、そのテーブルを変更・参照しうる既存トランザクションの終了を待ちます。通常の作成より時間がかかり、長い読み取りトランザクションが残っていると終わりません
- 走査中にデッドロックや一意制約違反が起きると失敗し、INVALIDな索引が残ります。
psqlの\dでINVALIDと表示され、残ったままでも更新のたびにコストがかかります。復旧はDROP INDEXしてやり直すか、REINDEX INDEX CONCURRENTLYです - 同じテーブルで同時に走らせられる
CONCURRENTLYの構築は1つだけです。複数の索引を並列のマイグレーションで張る設計は、順番待ちになります - パーティションテーブルには直接使えません。各パーティションに
CONCURRENTLYで索引を作り、最後にパーティションテーブル側の索引を通常のCREATE INDEXで作ります。この最後の作成はメタデータ操作のみで、書き込みの停止は短く済みます
一意索引をCONCURRENTLYで作る場合は、2回目の走査が始まった時点で一意性がほかのトランザクションに対して有効になります。索引が使える状態になる前でも、ほかのクエリが一意制約違反を受けることがあります。失敗した場合もINVALIDな索引が一意性を守り続けます。
NOT VALID制約は追加と検証を2つの文に分ける
NOT VALIDは、外部キー・CHECK・NOT NULLの制約に付けられるオプションです。付けると既存の行の走査を省いて制約を追加し、すぐコミットできます。以後のINSERT/UPDATEには制約が効きますが、既存行が条件を満たすことは保証されません。保証するのがVALIDATE CONSTRAINTで、取得するロックはSHARE UPDATE EXCLUSIVEなので書き込みを止めません。外部キーの検証では、参照先のテーブルにもROW SHAREロックが必要です。ROW SHAREは通常のSELECTも書き込みも止めないため、参照先が止まる心配はありません。
制約を止めずに追加する3段階
- 1
NOT VALIDで追加する
走査をしないので、ロックを取っている時間が短く済みます。
- 2
新規の違反が入らないことを確かめる
追加した時点から、新しい行は制約に従います。既存行に違反があっても、この段階では失敗しません。
- 3
VALIDATE CONSTRAINTで既存行を検証する
書き込みを止めずに全件を走査します。違反があれば失敗するので、直してから再実行します。
SQLにすると次の形になります。外部キーは参照先のテーブルにもロックが要る点がレビューの見どころです。
-- 止まりやすい書き方
ALTER TABLE orders
ADD CONSTRAINT orders_customer_fk
FOREIGN KEY (customer_id) REFERENCES customers (id);
-- 無停止寄りの書き方
ALTER TABLE orders
ADD CONSTRAINT orders_customer_fk
FOREIGN KEY (customer_id) REFERENCES customers (id) NOT VALID;
ALTER TABLE orders VALIDATE CONSTRAINT orders_customer_fk;NOT VALIDの副次的な使い道もあります。既存データに違反が混ざっているテーブルでも制約だけ先に有効にでき、新しい違反を防ぎながら古い違反を直せます。
NOT NULLはCHECK制約を経由して走査を省く
SET NOT NULLは通常、テーブル全体を走査してNULLがないことを確かめます。ところがドキュメントには、NULLが存在しえないと証明できる有効なCHECK制約がすでにあれば、この走査を省くと書かれています。
ALTER TABLE orders
ADD CONSTRAINT orders_status_nn CHECK (status IS NOT NULL) NOT VALID;
ALTER TABLE orders VALIDATE CONSTRAINT orders_status_nn;
ALTER TABLE orders ALTER COLUMN status SET NOT NULL;
ALTER TABLE orders DROP CONSTRAINT orders_status_nn;SET NOT NULL自体の既定のロックはACCESS EXCLUSIVEのままですが、走査がなくなるので保持時間は短くなります。CHECK制約は同じ文の中で削除しないでください。同じALTER TABLEで消すと、走査を省く根拠が消えます。最後のDROP CONSTRAINTは別の文に置いています。
lock_timeoutを先頭に置く
ロックレベルが軽くても、取る瞬間には待ちが発生します。lock_timeoutは、ロックの取得待ちが指定時間を超えた文を中断します。既定は0で、無効です。statement_timeoutと違い、ロック待ちのときだけ働きます。
SET lock_timeout = '3s';マイグレーションの先頭にこの1行があるかどうかも、サブエージェントの確認項目に入れます。上限を決めておけば、ロックを取れない文は無限に待たず、エラーとして表に出ます。値の目安は運用ごとに違うので、レビュー側は「設定の有無」だけを見ます。
レビュー専用サブエージェントの定義
サブエージェントはMarkdownファイルで定義します。置き場所は、そのリポジトリ専用なら.claude/agents/、全プロジェクト共通なら~/.claude/agents/です。必須フィールドはnameとdescriptionの2つで、toolsを省くと使えるツールをすべて引き継ぎます。レビュー専用なので、読み取り系のツールだけを列挙して編集とコマンド実行を外します。
mkdir -p .claude/agents.claude/agents/pg-migration-lock-reviewer.mdの例です。ロックの表はプロンプトに埋め込み、モデルの記憶ではなく表に従わせます。
---
name: pg-migration-lock-reviewer
description: PostgreSQLのマイグレーションSQLをロックレベルの観点でレビューする。マイグレーションファイルを追加・変更した直後に使う。
tools: Read, Grep, Glob
model: sonnet
---
あなたはPostgreSQLのマイグレーションをレビューする担当です。
ファイルは読むだけで、編集やコマンド実行はしません。
手順:
1. 指定されたSQLファイル(なければ git diff の対象)を読む
2. 文を1つずつ取り出し、下の表でロックレベルを判定する
3. 表にない文や、判断が分かれる文は「要確認」と書く。推測で断定しない
ロックの判定表:
- CREATE INDEX: SHARE(書き込みを止める)。CONCURRENTLY にする
- CREATE INDEX CONCURRENTLY: SHARE UPDATE EXCLUSIVE。トランザクション内では実行不可
- ADD CONSTRAINT(CHECK): ACCESS EXCLUSIVE。NOT VALID と VALIDATE CONSTRAINT に分ける
- ADD FOREIGN KEY: SHARE ROW EXCLUSIVE(参照先にも)。NOT VALID と VALIDATE に分ける
- VALIDATE CONSTRAINT: SHARE UPDATE EXCLUSIVE
- SET NOT NULL: ACCESS EXCLUSIVE。検証済みCHECKがなければ全件走査
- ADD COLUMN: 揮発性DEFAULT・生成列・IDENTITYはテーブル書き換え
- ALTER COLUMN TYPE: 通常はテーブルと索引の書き換え
- DROP TABLE / TRUNCATE / VACUUM FULL / REINDEX: ACCESS EXCLUSIVE
- 上記以外の ALTER TABLE: 明記がなければ ACCESS EXCLUSIVE
追加で確認すること:
- 先頭に SET lock_timeout があるか
- CONCURRENTLY の文がトランザクションの外にあるか
- 1つの ALTER TABLE に複数のサブコマンドが並び、最も強いロックになっていないか
- 同じテーブルへ CONCURRENTLY の構築が複数ないか
- パーティションテーブルへの CREATE INDEX CONCURRENTLY がないか
出力は文ごとの表にします。列は「行番号 / 文の要約 / ロック / 読み取りを止める / 書き込みを止める / 書き換え案」です。
最後に、止まる文の件数と、最優先で直す1件を書きます。tools: Read, Grep, Globは書き込みとシェルを持たないので、レビュー中にSQLを書き換えたりDBに接続したりしません。実際の索引の状態をDBに問い合わせたいときは、Bashを足す代わりにPostgres MCP Proのような接続手段を別のサブエージェントに分けるほうが、権限を狭く保てます。
呼び出し方と渡す入力
自動委任に任せる場合、descriptionに「マイグレーションを追加・変更した直後に使う」と書いておけば、Claudeが判断して呼びます。確実に走らせたいときは、プロンプトで名前を指定するか、@でサブエージェントを選びます。
pg-migration-lock-reviewer サブエージェントで db/migrations/V12__orders_indexes.sql をレビューしてレビュー対象は、ファイル単位でも差分でも構いません。マイグレーションを書かせるセッションとレビューのセッションを分けられる点が、サブエージェントにした利点です。サブエージェントは自分のシステムプロンプトと基本的な環境情報だけで動くので、書いた側の会話の文脈を持たずに表と突き合わせます。
定義を編集した場合、Claude Codeは.claude/agents/の変更を数秒で検知します。ただし、そのディレクトリがセッション開始時点で存在しなかった場合は再起動が必要です。作成後に見つからないときは、再起動を試してください。
静的なレビューでは分からないこと
サブエージェントが見るのはSQLの文面だけです。次の点は、DBの状態や運用の事情に依存するので、レビュー結果の外で判断します。
- 実行時間は、テーブルの大きさと負荷で決まります。ロックレベルが軽くても、
VALIDATE CONSTRAINTの全件走査はテーブルが大きいほど長くかかります - 長いトランザクションが走っているかどうかは、本番のDBを見ないと分かりません。
CONCURRENTLYは既存トランザクションの終了を待つので、特に影響します - 判定表は公式ドキュメントのcurrent版に基づきます。サーバーのメジャーバージョンが古い場合は、そのバージョンのページと表を突き合わせてください
- スキーマの設計そのもの(型の選び方や索引の要否)はロックレビューの範囲外です。設計を相談する側の手段としてpg-aiguideがあります
まとめ
無停止かどうかは、CONCURRENTLYとNOT VALIDを含む文ごとのロックレベルで決まります。レビューの中身は表との突き合わせなので、表を埋め込んだ読み取り専用のサブエージェントに任せると、書いた本人の見落としを別の文脈で拾えます。ツールの実行手順と切り離しておけば、FlywayでもAlembicでも同じ定義を使い回せます。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。