同じ ALTER TABLE のはずなのに、なぜ一瞬で終わる時と本番を止める時があるのか?
Rails で add_column のマイグレーションを書いて rails db:migrate を叩いたことは何度もあるはずだ。たいていは一瞬で終わり、拍子抜けするほど何も起きない。しかしある日、似たような1行の ALTER TABLE が本番のテーブルを何十秒もロックし、その間 API のリクエストが軒並みタイムアウトした経験はないだろうか。
ALTER TABLEは PostgreSQL では ACCESS EXCLUSIVE という最も強いロックを取ることが多く、これは単純な SELECT とすら共存できない。- 新しい列の追加は PostgreSQL 11 以降、定数のデフォルト値ならメタデータの書き換えだけで一瞬に終わるが、既存の列に NOT NULL を課す操作は全行スキャンを伴い、その間ずっと ACCESS EXCLUSIVE を握り続ける。
- NOT VALID な CHECK 制約 → VALIDATE CONSTRAINT → SET NOT NULL という3段階に分ければ、重いスキャンを読み書きを止めない軽いロックの間に逃がせる。
ロックは一枚岩ではない
PostgreSQL では、テーブルへの操作の種類ごとに要求するロックの強さが違う。通常の SELECT はACCESS SHARE、INSERT/UPDATE/DELETE はROW EXCLUSIVEという軽いロックしか取らないため、複数のトランザクションが同時に読み書きできる。これらは互いに競合しない設計になっている。
一方で ALTER TABLE の多くの操作はテーブルの定義そのものを変えるため、ACCESS EXCLUSIVEという最も強いロックを要求する。公式ドキュメントによれば ACCESS EXCLUSIVE は ACCESS SHARE を含む他のすべてのロックモードと競合し、そのテーブルへのあらゆるアクセスを独占する。つまり ALTER TABLE がこのロックを要求した瞬間、単純な SELECT ですら順番待ちの列に並ばされる。
列の追加は、今は意外と一瞬で終わる
PostgreSQL 11 より前は、デフォルト値付きの列を追加すると既存の全行に値を書き込むテーブル全体の書き換えが発生し、その間 ACCESS EXCLUSIVE を握り続けていた。11 以降は「fast default」という最適化が入り、デフォルト式が定数や STABLE な式であれば、その評価結果をカタログ(pg_attribute の attmissingval)に1回だけ保存し、古い行を読むときにその値を補完で返すようになった。行数に関係なくミリ秒オーダーで終わる。
-- 速い: 定数なのでカタログに1回保存するだけ ALTER TABLE users ADD COLUMN plan text DEFAULT 'free'; -- 遅い: volatile関数なので全行を書き換える ALTER TABLE users ADD COLUMN uid uuid DEFAULT gen_random_uuid();
この近道が使えるのは「呼ぶたびに結果が変わらない」式に限られる。gen_random_uuid() のような volatile 関数をデフォルトに指定すると、行ごとに異なる値を書き込む必要があるため、PostgreSQL は全行を書き換えるテーブルリライトに戻ってしまう。
ACCESS EXCLUSIVE は「本日休業」の張り紙を出してビルの玄関ごと閉め、中にいる客も外から来る客も一切通さない状態に近い。一方 SHARE UPDATE EXCLUSIVE は、廊下の一部だけ「工事中、他の通路からお回りください」と規制するだけで、店は普段どおり営業を続けられる。同じ「工事」でも、玄関を閉めるか廊下の一部だけ塞ぐかで、客が受ける影響はまったく違う。
危ないのは「既存の列」に NOT NULL を課すとき
実務でよくあるのは、列を NULL 許容で追加してデータをバッチで埋め、後から NOT NULL を課す2段階の進め方だ。この最後の一手が重い。ALTER TABLE t ALTER COLUMN v SET NOT NULL; は「既存の行に1つも NULL が無い」ことを保証するために全行を読む必要があり、PostgreSQL はこの検証を ACCESS EXCLUSIVE ロックを握ったまま実行する。数百万行のテーブルなら、その間ずっと読み書きすべてが止まる。
さらに厄介なのは、ACCESS EXCLUSIVE の取得自体が長時間トランザクションの後ろに並ばされる点だ。誰かが同じテーブルに対して重いレポートクエリを実行中なら、その軽い ACCESS SHARE ロックが解放されるまで ALTER TABLE は待たされる。そして待っている ALTER TABLE のさらに後ろには、新しく来た SELECT まで列を作って詰まっていく。
安全な3段階 — NOT VALID → VALIDATE → SET NOT NULL
重い検証を軽いロックの間に逃がす手順として PostgreSQL が用意しているのが、CHECK 制約の NOT VALID オプションである。
素朴な1行: ALTER TABLE t ADD COLUMN c bool NOT NULL DEFAULT false; → ACCESS EXCLUSIVE(全停止)のまま 全行を検査して書き込む 安全な3段階: ① ADD CONSTRAINT chk NOT VALID ACCESS EXCLUSIVE だが一瞬 ② VALIDATE CONSTRAINT chk SHARE UPDATE EXCLUSIVE (読み書きは止めない) ③ SET NOT NULL ACCESS EXCLUSIVE だが ②のおかげでスキャン省略
-- ① 既存行は検査せず、制約だけ追加(一瞬) ALTER TABLE t ADD CONSTRAINT v_not_null CHECK (v IS NOT NULL) NOT VALID; -- ② 全行スキャンはここで行うが、通常の -- 読み書きとは共存できるロックで済む ALTER TABLE t VALIDATE CONSTRAINT v_not_null; -- ③ PostgreSQL 12+では②の検証結果を再利用し -- スキャンなしで一瞬で完了する ALTER TABLE t ALTER COLUMN v SET NOT NULL;
①はまだ既存行を検査しないため ACCESS EXCLUSIVE を取っても一瞬で終わる。②の VALIDATE CONSTRAINT が要求するのはSHARE UPDATE EXCLUSIVEロックで、これは通常の SELECT/INSERT/UPDATE/DELETE と共存できる。時間がかかっても本番のトラフィックは流れ続ける。③の SET NOT NULL 自体は依然として ACCESS EXCLUSIVE を取るが、PostgreSQL 12 以降は②で検証済みの CHECK 制約が「NULL が存在しない」ことを既に証明しているとみなし、再スキャンを省略する。
MySQL では事情が違う
MySQL(InnoDB)は 8.0.12 以降、列追加などの一部 DDL を ALGORITHM=INSTANT で実行できる。これはテーブルのデータファイルに触れずデータディクショナリのメタデータだけを書き換えるため、行数によらず即座に終わる。ただし対象になる行バージョンの上限は 64 件で、行フォーマットが COMPRESSED のテーブルには使えないといった制限があり、条件から外れると従来どおりテーブルコピーを伴う重いアルゴリズムにフォールバックする。PostgreSQL の fast default と発想は近いが、対応する操作の範囲や制限は別物なので、使っている RDBMS のバージョンごとにドキュメントを確認する必要がある。
Rails で列を NULL 許容のまま追加してデータをバックフィルし、最後に NOT NULL を課す2段階マイグレーションは、まさにこの ACCESS EXCLUSIVE ロックの問題を避けるための定石だ。strong_migrations のような gem は危険なマイグレーションのパターンを検知してブロックし、NOT VALID を使った安全な代替手順を提案してくれる。Go やその他の言語でマイグレーションツールを自作している場合も、SQL を手書きする以上は同じロックの理屈がそのまま当てはまる。
「軽そうな SET NOT NULL が、実は長時間トランザクションの後ろで待たされる」様子を Docker の PostgreSQL で再現する。
docker run -d --rm --name pgdemo \
-e POSTGRES_PASSWORD=pass -e POSTGRES_DB=demo \
-p 5544:5432 postgres:16
until docker exec pgdemo pg_isready -U postgres \
>/dev/null 2>&1; do sleep 1; done
docker exec pgdemo psql -U postgres -d demo \
-c "CREATE TABLE t(id serial primary key, v int);"
docker exec pgdemo psql -U postgres -d demo \
-c "INSERT INTO t(v) SELECT g FROM generate_series(1,50000) g;"
# セッションA: 8秒間トランザクションを保持したままにする
docker exec pgdemo psql -U postgres -d demo \
-c "BEGIN; SELECT pg_sleep(8) FROM t LIMIT 1; COMMIT;" &
sleep 1
# セッションB: lock_timeoutを2秒にしてSET NOT NULLを試す
time docker exec pgdemo psql -U postgres -d demo \
-c "SET lock_timeout='2s';
ALTER TABLE t ALTER COLUMN v SET NOT NULL;"
wait
docker stop pgdemo
セッション A の pg_sleep(8) は単なる SELECT なので ACCESS SHARE ロックしか持たないが、それでも ALTER TABLE が求める ACCESS EXCLUSIVE とは共存できない。セッション B は2秒の lock_timeout に引っかかり canceling statement due to lock timeout で失敗するはずだ。lock_timeout を外して再実行すると、今度は A の8秒間 B がブロックされ続ける挙動が確認できる。
- PostgreSQL 11 以降なら ALTER TABLE での列追加は常に一瞬 — 非 volatile な定数デフォルトなら一瞬だが、
gen_random_uuid()のような volatile 関数をデフォルトにすると全行書き換えのテーブルリライトに戻る。 - NOT NULL を課すのが重いのは、制約の内容そのものが複雑だから — 重いのは制約の複雑さではなく全行スキャンの有無であり、NOT VALID な CHECK 制約で既に保証済みなら SET NOT NULL 自体はスキャンを省略して一瞬で終わる。
- ALTER TABLE が一瞬で終わるなら本番への影響はない — ACCESS EXCLUSIVE ロックの取得自体が既存のどんな軽いロックとも共存できないため、長時間トランザクションの後ろに並ばされ、さらにその後ろに新しいクエリが列を作ることがある。
- ACCESS EXCLUSIVE ロック
- PostgreSQL で最も強いロックモード。他の全モードと競合し、対象テーブルへのあらゆるアクセスを独占する。
- SHARE UPDATE EXCLUSIVE ロック
- 通常の SELECT/INSERT/UPDATE/DELETE とは共存できるが、他の DDL や VACUUM とは競合するロックモード。
- fast default
- PostgreSQL 11 で入った最適化。定数デフォルトの評価結果をカタログに1回だけ保存し、全行への書き込みを省略する仕組み。
- NOT VALID 制約
- 追加時に既存行を検査しない CHECK/外部キー制約のオプション。追加自体を軽くし、検証を別ステップに遅延させる。
- VALIDATE CONSTRAINT
- NOT VALID な制約が既存行を満たすか確認するコマンド。SHARE UPDATE EXCLUSIVE ロックのみで実行できる。
- ALGORITHM=INSTANT
- MySQL 8.0.12 以降で使えるオンライン DDL の方式。データファイルに触れずメタデータのみを書き換える。
- PostgreSQL: Documentation — ALTER TABLE — ADD COLUMN・SET NOT NULL・NOT VALID・VALIDATE CONSTRAINT それぞれが要求するロックレベルを一次情報として確認できる。
- PostgreSQL: Documentation — Explicit Locking — ACCESS EXCLUSIVE や SHARE UPDATE EXCLUSIVE を含む全ロックモードの競合表を掲載する公式ページ。
- MySQL 8.0 Reference Manual — InnoDB and Online DDL Operations — ALGORITHM=INSTANT が対応する操作と制限事項を公式にまとめている。
- GitHub: ankane/strong_migrations — Rails マイグレーションの危険なパターンを検知し、NOT VALID を使った安全な書き換え例を提示するツールの実装。