データベース●●●○○

スキーママイグレーション

Schema Migration ・ すきーままいぐれーしょん

データベースの構造変更を版管理されたコードとして扱い、どの環境でも再現可能にする仕組み。

✦ 30秒で試す — 航海止めずに列名を1つ変えるのに、数日かけて5段階を踏む入れ替えの途中で、何が起きる?予想してみる

概要

スキーママイグレーションは、データベースの構造(スキーマ)の変更 — テーブルの追加、カラムの変更、インデックスの作成など — を、番号や日時で順序付けられた「マイグレーションファイル」として版管理し、どの環境でも同じ手順で再現できるようにする仕組みです。アプリケーションのコードを Git で管理するのと同じ発想を、データベースの構造に適用したものと言えます。

「migrate」というコマンドを実行すると、そのデータベースにまだ適用されていない変更だけが古い順に適用され、開発者のローカル環境・テスト環境・本番環境のスキーマが同じ履歴をたどって同じ状態に揃います。Rails の Active Record Migrations、Django の migrations、Flyway、Liquibase など、ほとんどのWebフレームワークや ORM が標準機能または定番ツールとして備えており、現代のデータベース運用の前提となっています。

なぜ生まれたか

マイグレーション以前、スキーマの変更は「DBAが本番データベースに手作業で ALTER 文を流す」作業でした。この方法には根本的な問題があります。変更の記録が人の記憶やメモにしか残らず、開発環境と本番環境のスキーマが少しずつずれていく。新しいメンバーの環境構築では「最新のスキーマを誰かのDBからダンプしてくる」しかなく、それが正しい状態なのか誰にも分からない。そしてアプリケーションのコードは SQL の版管理とは無関係にデプロイされるため、「新しいコードが、まだ存在しないカラムを参照して落ちる」といった事故が頻発しました。

スキーママイグレーションは、この「コードは版管理されるのに、それが依存するDB構造は管理されない」という非対称を解消するために生まれました。スキーマ変更をコードと同じリポジトリに置けば、コードレビューの対象になり、コードと同じブランチ・同じデプロイの流れに乗り、「このコミットの時点でスキーマはどうだったか」が常に再現できるようになります。

詳細

マイグレーションファイルと up / down

マイグレーションは「1つの変更 = 1ファイル」を基本とし、ファイル名の連番やタイムスタンプで適用順序を決めます。各ファイルには、変更を適用する up(例: カラムを追加する)と、それを取り消す down(例: そのカラムを削除する)の2方向を記述します。ツールはデータベース内に専用の管理テーブルを持ち、「どのマイグレーションまで適用済みか」を記録します。migrate コマンドは未適用分だけを順に実行し、rollback コマンドは down を使って直前の状態に戻します。

ただし down には限界があります。カラム削除の down は「カラムを作り直す」ことはできても、消えたデータまでは戻せません。実務では down は開発中の試行錯誤のためのものと割り切り、本番で問題が起きたときは「戻す」のではなく「打ち消す新しいマイグレーションを前に進める(roll forward)」のが安全とされています。

難しいのは「動いているシステムを止めずに変える」こと

マイグレーションの本当の難所は構文ではなく、稼働中のシステムとの整合です。デプロイの瞬間、新旧バージョンのアプリケーションは一時的に共存します。ブルーグリーンデプロイやローリング更新では、この共存は仕組み上避けられません。ここで「カラム名を rename する」マイグレーションを一発で流すと、旧コードは消えた旧カラムを参照して即座にエラーになります。

そこで使われるのが expand-contract パターン(parallel change とも呼ばれます)です。破壊的な変更を一度に行わず、「広げる(expand)→ 移行する(migrate)→ 縮める(contract)」の3段階に分けます。

1. expand 広げるADD COLUMN full_name旧版 v1まだ誰も使わないnamefull_name追加しただけで空動いているのは旧版新しい列を知らなくても壊れない2. migrate 移す新版を1台ずつ配り、既存行をコピー旧版 v1新版 v2両方に書くnamefull_name既存行をコピー backfill旧版と新版が同時に動く両方が name を使えるので両立する3. contract 縮めるDROP COLUMN name旧版は退役済み新版 v3name削除full_name動いているのは新版name を使う版が消えてから消す
expand-contract パターン — 列の名前変更を3段に分け、どの段でもそのとき動いているアプリの版がすべて使える形にする

まず新カラムを「追加だけ」する(旧コードは新カラムを知らないままでも壊れません)。次に、新旧両方のカラムに書き込み、読むのはまだ旧カラム、という版(上の図の v2)を1台ずつ配ります。全台に行き渡ってから既存データを新カラムへコピー(backfill)します。順番が大事で、旧版が1台でも残っている間にコピーすると、その旧版がその後に書いた値は旧カラムにしか入らず、新カラムがずれていきます。コピーが済んだら、読み取りを新カラムへ切り替えた版(v3)を配り、全インスタンスが旧カラムを使わなくなったことを確認して、最後に旧カラムを削除します。各段階の間には日をまたいでもよく、どの時点でも、そのとき動いている版のすべてが使える形にスキーマが保たれている — つまり各マイグレーションが常に後方互換であることが、このパターンの核心です。この進め方は parallel change とも呼ばれ、Scott Ambler と Pramod Sadalage の『データベース・リファクタリング』は、新旧の構造を併存させる「移行期間」を置く考え方として整理しています。

ローリングデプロイの途中で列の名前を変えてみてください。リクエストは4台のインスタンスに流れ続けています。DDL のチップを users テーブルへ、新版のチップをインスタンスへ1台ずつ運ぶと、そのとき動いている版のリクエストが通るか落ちるかがすぐに見えます。

⚡ 体験: 動いているまま、列の名前を変える
LBusers テーブルid123name佐藤 花子鈴木 一郎田中 陽インスタンス1v1旧版・name を読むインスタンス2v1旧版・name を読むインスタンス3v1旧版・name を読むインスタンス4v1旧版・name を読む新版(インスタンスへ)DDL(テーブルへ)v2 両方に書くv3 新しい列だけADD 列BACKFILLDROP nameRENAME
落ちたリクエスト0おかしな値0動いている版v1
流した DDL・デプロイと、起きたエラー(新しいものが下)
まだ何もしていません
○ RENAME を一発で流すと旧版が落ちる○ ADD だけなら旧版は無傷○ BACKFILL の前に新しい列を読むと空○ 一度も落とさずに DROP まで終える

4台のアプリ(旧版 v1)が users の name 列を読み書きしています。これを full_name に変えたい。右下の DDL チップを users テーブルへ、左下の新版チップをインスタンスへ1台ずつ運んでください。まずは「RENAME」を一発で流すと何が起きるでしょう。

ロードバランサは 4 台に順番にリクエストを振り分けます。v2 は name を読み両方に書く版、v3 は full_name を読む版(移行中は name にも書く)です。

実務では、アプリが「もう使っていない列」でも削除で壊れることがあります。Rails の Active Record のように、ORM が起動時にテーブルの列の一覧を読み込んで覚えておく実装では、覚えている列を INSERT 文に並べてしまうため、列を消した瞬間に旧版でなくてもエラーになります。Rails で列を消す前に ignored_columns でその列をモデルから外した版を先に配るのは、この理由です。

CI/CD への組み込みと運用上の注意

マイグレーションはコードと一緒にリポジトリに入るため、CI/CD パイプラインに自然に組み込めます。CIではまっさらなDBに全マイグレーションを適用してテストを走らせることでスキーマの再現性を検証し、デプロイ時には「アプリの新バージョン起動前に migrate を実行する」のが典型的な流れです。expand-contract を守っていれば、マイグレーション適用と新コード配布の順序に神経質にならずに済みます。複数人開発では、別ブランチで作られたマイグレーション同士が合流時に順序衝突することがあるため、タイムスタンプ方式の採番や、マージ後にCIで全適用を検証する運用が安全網になります。

もうひとつの実務的な注意は、大きなテーブルへの変更です。数千万行のテーブルに対する ALTER やインデックス作成は長時間テーブルをロックし、それ自体が障害になり得ます。PostgreSQL の CREATE INDEX CONCURRENTLY のようなロックを避ける構文や、gh-ost・pt-online-schema-change のようなオンラインスキーマ変更ツールを使い、「マイグレーションは瞬時に終わる小さな変更の積み重ねにする」ことが大規模運用の定石です。もうひとつ見落とされやすいのがロックの待ち行列です。PostgreSQL の ALTER TABLE の多くは、一瞬で終わる変更でもテーブルに最も強いロックを取ります。そのテーブルを読む長いトランザクションが1本走っていると、ALTER はロックを待ち、さらにその後ろに来たふつうの SELECT まで ALTER の後ろに並んで止まります。マイグレーションの前に lock_timeout を短く設定し、取れなければ諦めてやり直すのが安全です。

スキーマという最も壊してはいけない状態を、レビュー可能で再現可能な小さな一歩の連続として前進させる — それがスキーママイグレーションという実践の本質です。

試問

ロードバランサから4台のアプリ(旧版 v1)へリクエストが流れ続け、どれも users テーブルの name 列を読み書きしています。これを full_name 列に変えたい。DDL のチップ(RENAME=名前を変える、ADD=列を足す、BACKFILL=既存の値をコピー、DROP=name を消す)は users テーブルへ、新版のチップ(v2=name を読み両方に書く、v3=full_name を読む)はインスタンスへ1台ずつ、つかんで運べます。

✦ 命題挑戦中

4台のアプリを動かしたまま、リクエストを1件も落とさず、おかしな値も返さずに、name 列を full_name に変え終えよ

  • リクエストを1件も落とさない(落ちた 0 件)
  • 空の値・古い値を1回も返さない(おかしな値 0 件)
  • 全台が full_name だけを使う v3 になり、古い name 列を消し終える
⚡ 体験: 動いているまま、列の名前を変える
LBusers テーブルid123name佐藤 花子鈴木 一郎田中 陽インスタンス1v1旧版・name を読むインスタンス2v1旧版・name を読むインスタンス3v1旧版・name を読むインスタンス4v1旧版・name を読む新版(インスタンスへ)DDL(テーブルへ)v2 両方に書くv3 新しい列だけADD 列BACKFILLDROP nameRENAME
落ちたリクエスト0おかしな値0動いている版v1
流した DDL・デプロイと、起きたエラー(新しいものが下)
まだ何もしていません

4台のアプリ(旧版 v1)が users の name 列を読み書きしています。DDL のチップは users テーブルへ、新版のチップはインスタンスへ1台ずつ運べます。

ロードバランサは 4 台に順番にリクエストを振り分けます。v2 は name を読み両方に書く版、v3 は full_name を読む版(移行中は name にも書く)です。