列を1つ足すだけのつもりだった作業が、夜間メンテナンスの計画に化けることがあります。ALTER TABLE を流したらテーブル全体が書き換わって、その間は書き込みが詰まる。行数が数千万あれば、待ち時間は分では収まりません。だから停止時間を取る、という段取りになります。
止めずに進める方法はあります。1回の変更で終わらせることを諦めて、小さな変更に分けます。
増やす変更と減らす変更を分ける
構造の変更は、増やすものと減らすものに分けられます。NULL を許す列の追加、テーブルの追加、インデックスの追加。これらは既存のコードを壊しません。列の削除、NOT NULL の付与、型の変更、名前の変更。これらは古いコードが動いている限り壊します。
両方を同時にやるから、止める必要が出てきます。順番にやれば、どの時点でも新旧どちらのコードでも動く状態を保てます。
名前の変更は、増やす変更と減らす変更の組み合わせです。新しい名前の列を足して、値を移して、古い列を消す。RENAME COLUMN 一発で済ませたくなりますが、その瞬間に古いコードは落ちます。
新しい形を先に作る
最初にやるのは、新しい列を NULL 許容で足すことです。アプリはまだこの列を知らないので、流しても何も起きません。失敗しても DROP COLUMN で戻せます。
ここで確かめておくのは、その ALTER がテーブルを書き換えるかどうかです。PostgreSQL も MySQL も、条件が揃えばメタデータの更新だけで列を足せます。条件はバージョンと指定の仕方で変わるので、使っている環境で確かめてください。デフォルト値を付けるかどうかで挙動が変わることもあります。
つまずくのは、本番と同じ行数で試していないときです。開発環境の1万行では一瞬で終わります。所要時間を知りたいなら、本番のコピーで流すしかありません。
両方に書く期間を作る
次にアプリを変えて、新旧の両方に書くようにします。読むのはまだ古い列のままです。
この段階を飛ばして読み書きを一度に切り替えると、デプロイの最中に壊れます。入れ替えが順番に進む構成なら、新しいコードと古いコードが同時に動く時間が必ずあります。その間に新しいコードが書いたデータを古いコードが読めない、あるいはその逆が起きます。数秒のことですが、その数秒に注文が入ります。
二重の書き込みはアプリ側に置きます。トリガーで済ませる手もありますが、後で消し忘れます。消し忘れたトリガーは、半年後に別の作業を邪魔します。
過去の行を埋める
新しく入る行は埋まるようになりましたが、過去の行は空のままです。これは別の作業として流します。
一括の UPDATE は使いません。主キーの範囲で区切って、数千行ずつ処理して、間に待ち時間を入れます。待ち時間は本番の負荷を見ながら決めます。深夜に一気に流したくなりますが、夜間バッチと重なると最悪です。
途中で止めても困らない形にしておきます。対象を「新しい列が NULL の行」で絞れるようにしておけば、どこまで進んだかを別に記録する必要がありません。止めて、翌日また流す。それで続きから進みます。
埋め終わったら、残りが0件であることを数えて確かめます。
読む先を切り替える
ここでようやく、読む先を新しい列に変えます。アプリのデプロイだけで、データベースには触りません。
問題が出たら、前のバージョンに戻せば済みます。古い列にも同じ値が入り続けているので、戻した先でも正しく動きます。切り戻しにデータの復旧が要らない状態を作っておくことが、この手順の狙いです。
切り替えたあとは、1日か2日そのまま置きます。この間も二重の書き込みは続けます。
消すのは最後で、急がない
古い列を消す、NOT NULL を付ける。壊れる変更をするのはここです。
消す前に、本当に誰も読んでいないかを確かめます。アプリのコードを検索するだけでは足りません。集計用のバッチ、BIツールの定義、運用担当が手で叩いている SQL、外部に渡しているエクスポートのファイル。リポジトリの外に読み手がいることのほうが多いくらいです。
急ぐ理由がなければ、古い列は数週間残しておいてかまいません。使っているのはディスクだけです。
やらなくていいのは、これを1日で終わらせようとすることです。各段階の間にデプロイが挟まるので、普通は数日から数週間かかります。長いと感じるかもしれませんが、どの段階で手が止まってもシステムは動き続けます。途中で別の障害対応が入って2週間放置されても、誰も困らない。速く終わらせるための手順ではなく、途中で止まれる手順です。