TT Lab
はじめる
学ぶ 学習パス コース

スキーマ変更 — デプロイでサービスを止めない

マイグレーションがサービスを止める仕組み

TT Labで続きを見る

一言でいうと

マイグレーションの事故は、作業に時間がかかるからではなく、ロックを待っている間に後ろが列を作るから起きます。

もう通用しない昔のアドバイス

「デフォルト値のあるカラムを追加すると、テーブルを丸ごと書き直す」というのは古いアドバイスです。PostgreSQL 11以降は、デフォルト値をメタデータにだけ書き込んでおき、読み取るときに埋めます。

30万行のテーブルで測ると、次のようになります。

alter table big add column status text not null default 'new';
Time: 0.510 ms

ミリ秒です。つまり、この作業自体は問題ではありません。

本当の問題はロック待ち

ALTER TABLEはACCESS EXCLUSIVEロックを取得します。これは最も強いロックで、読み取りまで止めてしまいます。

作業が0.5msで終わるとしても、そのロックを得るまで待たなければなりません。前に長いクエリが1つあると、それが終わるまで待ちます。

そして、ここで本当の事故が起きます。

待っているALTERの後ろに来るすべてのクエリが、一緒に列に並びます。

ロック要求は順番どおりに処理されます。ALTERがキューの先頭で待っている間、後から来た単純なSELECTもその後ろに並びます。本来ならALTERとは無関係に通過したはずのクエリです。

ラボで再現すると、次のようになります。

긴 SELECT 실행 중  →  ALTER 가 기다림  →  단순 SELECT 도 막힘
ERROR:  canceling statement due to lock timeout

ロック待ちが列を作る様子。長いSELECTが実行中なのでALTER TABLEが待機し、ロック要求は順番どおりに処理されるため、その後ろに来た単純なSELECT 2件も一緒に止まります。長いSELECTが終わると、ALTERの実際の作業は0.5ミリ秒で終わります

サービス全体が止まったように見えます。ところがDBのメトリクスは正常です。CPUもディスクも空いていて、クエリ1つ1つはどれも速いのです。そのため、原因を見つけるのに時間がかかります。

だからルールは1つ

set lock_timeout = '3s';
alter table ...;

マイグレーションの前には必ずlock_timeoutを設定します。3秒以内に取得できなければ諦めて、あとでやり直します。失敗したマイグレーションは再実行すればよいのですが、列に並ばせてしまった5分は取り戻せません。

statement_timeoutと混同しないでください。それは実行時間の制限で、lock_timeoutは待つ時間の制限です。マイグレーションに必要なのは後者です。

書き込みを止めるインデックス

CREATE INDEXはSHAREロックを取得しますが、これはINSERT・UPDATE・DELETEと競合します。読み取りはできますが、書き込みは止まります。大きなテーブルだと数分かかります。

create index concurrently idx_n on big(n);

CONCURRENTLYはテーブルを2回走査しながら、書き込みを止めません。その代わり、次の3つを受け入れる必要があります。

  1. より遅い: 2回走査します
  2. トランザクションの中では使えない: マイグレーションツールが自動でトランザクションに包むと失敗します
  3. 失敗すると使えないインデックスが残る: indisvalid = falseの状態で残ります。削除して作り直す必要があります

3つ目を知らないと、「インデックスを作ったのに使われない」と長く迷うことになります。確認は次のように行います。

select indexrelid::regclass, indisvalid from pg_index where not indisvalid;

カラムの削除も危険

drop column自体は速いです(メタデータを書き換えるだけです)。問題は、まだそのカラムを読むコードが動いている最中の場合です。

そのため、順序を分けます。

  1. デプロイA: コードでそのカラムを使わないようにします
  2. デプロイB: カラムを削除します

2つを同じデプロイに入れると、その間の数秒間エラーが出ます。カラムの追加も同じです。カラムを先に作り、次のデプロイで使います。

バックフィルは分割して

update big set note = 'x';        -- 30만 행을 한 트랜잭션에

こうすると、その行がすべてロックされ、WALが一度に膨らみ、レプリケーション遅延が跳ね上がります。

update big set note = 'x' where id between 1 and 10000;
-- 커밋하고, 잠깐 쉬고, 다음 구간

分割してコミットすると、ロックが短く保たれ、レプリケーションも追従します。遅く見えますが、サービスは止まりません。

まとめ

作業 ロック リスク
add column(デフォルト値付き) ACCESS EXCLUSIVE 作業は速い。待機が危険
create index SHARE 書き込みが止まる
create index concurrently 弱い 遅く、トランザクション内では不可
set not null ACCESS EXCLUSIVE フルスキャン
大量のupdate 多数の行ロック WAL・レプリケーション遅延

どれも、lock_timeoutを設定すれば最悪の事態を避けられます。

現場では

この失敗はデプロイの時間帯の中で起きるため、特に目立ちます。マイグレーションを実行した人はコマンドが終わらないことしか見えず、ほかの人たちはサービスが丸ごと止まったのを見ます。その間、データベースのメトリクスはすべて緑なので、原因を見つけるまでに数分がただ過ぎていきます。

そこで、チームで決めておくとよいことが3つあります。1つ目は、マイグレーションのセッションには必ずlock_timeoutを設定することです。失敗するほうが、全体を止めるよりましです。2つ目は、実行前にそのテーブルで長く動いているトランザクションがないか確認することです。3つ目は、元に戻す方法を先に書き留めておくことです。カラムの追加は簡単ですが、型の変更は元に戻しにくいものです。

マイグレーションツールを使うなら、そのツールが文をトランザクションで包むかどうかも確認する必要があります。包む場合、CREATE INDEX CONCURRENTLYはその中では実行できないため、その1文だけを切り出して実行しなければなりません。