Skip to content
Published on

DBマイグレーション戦略入門 — スキーマ、データ、そして無停止

Authors

はじめに

データベースマイグレーションは、ほとんどすべてのサービスが避けて通れない作業です。新しい機能を追加するにはテーブルにカラムを足し、インデックスを作り、ときには数十億件のデータを別の形へ移さなければなりません。コードはデプロイが失敗すれば以前のバージョンへロールバックすれば済みますが、データベースはそう単純ではありません。一度実行されたDDLは元に戻しにくく、誤って移されたデータは永遠に復旧できないこともあります。

筆者が運用していたあるサービスでは、トラフィックが最も多い時間帯に大きなテーブルへインデックスを追加しようとして、テーブル全体にロックがかかり30分間の障害が発生したことがあります。別の事例では、カラムの型を変更するマイグレーションを検証なしに本番へ適用し、一部のデータが切り詰められる事故を経験しました。これらの障害はいずれも、マイグレーションを「ただSQLを実行する作業」と軽く見たことに起因します。

この記事では、データベースマイグレーションを一つのエンジニアリング分野として捉え、マイグレーションの種類から核となる原則、バージョン管理型マイグレーション、無停止デプロイ戦略、トランザクショナルDDL、バックアップとドライラン、環境昇格、チームプロセス、そして障害予防チェックリストまで順を追って整理します。特定のツールに依存せず、どこでも適用できる原則を中心に説明します。

マイグレーションの種類

マイグレーションという言葉は非常に広い範囲を含みます。何を移すかによってリスクの性質と対応方法がまったく変わるため、まず種類を区別することが重要です。

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

スキーマ・マイグレーションはデータベースの構造を変更する作業です。テーブルを作成・削除し、カラムを追加・削除し、インデックスや制約を変更することがすべてここに含まれます。DDL(Data Definition Language)文で表現され、ほとんどのマイグレーションツールが最初に扱う領域です。

-- カラム追加(スキーマ変更)
ALTER TABLE users ADD COLUMN last_login_at TIMESTAMPTZ;

-- インデックス追加(スキーマ変更)
CREATE INDEX idx_users_email ON users (email);

-- 制約追加(スキーマ変更)
ALTER TABLE orders ADD CONSTRAINT chk_amount_positive CHECK (amount > 0);

データ・マイグレーション

データ・マイグレーションは構造ではなくデータそのものを移したり変形したりする作業です。新しいカラムにデフォルト値を埋めたり、一つのテーブルのデータを正規化して複数のテーブルへ分割したり、エンコーディングを変換することがこれに当たります。DML(Data Manipulation Language)で表現され、大量の行を扱うときは性能とロック管理が非常に重要になります。

-- 新しいカラムへ値を埋めるデータ・マイグレーション
UPDATE users SET display_name = username WHERE display_name IS NULL;

-- データを別のテーブルへ移すマイグレーション
INSERT INTO user_profiles (user_id, bio)
SELECT id, bio FROM users WHERE bio IS NOT NULL;

エンジン・マイグレーション

エンジン・マイグレーションは同じデータベース製品のバージョンを上げる作業です。PostgreSQL 14から16へ、MySQL 5.7から8.0へ上げる場合が代表的です。SQL互換性、デフォルト設定の変更、性能特性の変化などをすべて考慮しなければなりません。

プラットフォーム・マイグレーション

プラットフォーム・マイグレーションはデータベース製品そのものを変えたり、オンプレミスからクラウドへ移したりする、最も規模の大きい変更です。たとえばOracleからPostgreSQLへ移行したり、自己ホスティングのMySQLをAmazon RDSへ移す場合です。多くの場合、AWS DMSのような専用ツールと長期の並行運用期間を必要とします。

種類別の比較

種類対象主なツールリスク元に戻す
スキーマテーブル、カラム、インデックスFlyway, Liquibase, migrate比較的容易
データ行、値、形式バッチスクリプト、ETL困難
エンジンDBMSバージョンpg_upgrade, mysql_upgrade非常に困難
プラットフォームDBMS製品、インフラAWS DMS, 専用マイグレーター非常に高ほぼ不可能

核となる原則: 元に戻せて、小さく、検証済みで

マイグレーションを安全に扱うための三つの核となる原則があります。これらの原則はツールに関係なく常に有効です。

元に戻せるように (Reversible)

可能であれば、すべてのマイグレーションは元に戻せるように設計すべきです。カラムを追加したならそれを削除する逆方向スクリプトを、インデックスを作ったならそれを削除するスクリプトを併せて用意します。ただし、データを削除したり損失を伴う型変換を行う作業は本質的に元に戻せないことを認識し、こうした作業はより慎重に扱わなければなりません。

小さく (Small)

一つのマイグレーションはできるだけ小さな単位に分割すべきです。一度に十個のテーブルを変える巨大なマイグレーションは失敗箇所を把握しにくく、部分的に失敗したときの復旧が厄介です。小さなマイグレーションはレビューしやすく、問題が起きても影響範囲が狭く済みます。

検証済みで (Verified)

本番へ適用する前に必ず検証を経なければなりません。開発環境とステージング環境で実データに近いデータで実行してみて、実行時間とロックの影響を測定します。「ローカルでうまく動いたから大丈夫だろう」という仮定が最も危険です。

安全なマイグレーションの三本柱

   元に戻せる          小さく            検証済み
   (Reversible)       (Small)        (Verified)
        |               |                |
   逆方向を用意       単位に分割       ステージング実行
        |               |                |
        +---------------+----------------+
                        |
                  安全なデプロイ

バージョン管理型マイグレーション

現代的なマイグレーションの核心は、すべての変更をバージョンの付いたファイルとして管理することです。データベースコンソールへ直接接続して手でSQLを実行する方法は追跡が不可能で再現が困難です。代わりに、マイグレーションをコードと一緒にバージョン管理システムへ保管します。

ファイル命名規則

ほとんどのツールはタイムスタンプや連番を接頭辞として使う命名規則に従います。こうすることでマイグレーションの実行順序が明確になります。

migrations/
  V20260601120000__create_users_table.sql
  V20260602093000__add_email_index.sql
  V20260603140000__add_last_login_column.sql
  V20260604101500__backfill_display_name.sql

golang-migrateのようなツールはupとdownのファイルを対で管理します。

migrations/
  000001_create_users_table.up.sql
  000001_create_users_table.down.sql
  000002_add_email_index.up.sql
  000002_add_email_index.down.sql

マイグレーションファイルの例

各マイグレーションファイルは一つの論理的変更だけを含みます。以下はユーザーテーブルを作成するupファイルと、それを元に戻すdownファイルの例です。

-- 000001_create_users_table.up.sql
CREATE TABLE users (
    id          BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    email       VARCHAR(255) NOT NULL UNIQUE,
    username    VARCHAR(100) NOT NULL,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_users_username ON users (username);
-- 000001_create_users_table.down.sql
DROP TABLE IF EXISTS users;

マイグレーション履歴テーブル

マイグレーションツールはどのバージョンが適用されたかを追跡するため、専用の履歴テーブルをデータベース内に保持します。Flywayはflyway_schema_history、Liquibaseはdatabasechangelogというテーブルを使います。このテーブルのおかげで、ツールはまだ適用されていないマイグレーションだけを選んで実行できます。

flyway_schema_history (概念図)

installed_rank | version | description          | success
---------------+---------+----------------------+--------
1              | 1       | create users table   | true
2              | 2       | add email index      | true
3              | 3       | add last login col   | true

フォワードオンリー vs ロールバック

マイグレーションを元に戻す戦略には大きく二つの哲学があります。

ロールバック方式

ロールバック方式は各マイグレーションごとに逆方向スクリプト(down)を用意し、問題が起きたらそれを実行して以前の状態へ戻すアプローチです。直感的ですが落とし穴があります。データを削除したり変形したりするマイグレーションはdownスクリプトで完全に復元できない場合が多いからです。たとえばカラムをDROPしたあとに再びADDしても、中に入っていたデータは戻りません。

フォワードオンリー方式

フォワードオンリー方式は決して後ろへは戻らず、問題が起きたらそれを正す新しいマイグレーションを前へ追加するアプローチです。誤って作ったカラムがあるなら、それを元に戻す代わりに、そのカラムを整理する新しいマイグレーションを書きます。大規模な本番環境ではdownスクリプトの安全性を保証しにくいため、フォワードオンリーを好むチームが多いです。

項目ロールバック方式フォワードオンリー方式
元に戻す方法downスクリプト実行新しいマイグレーション追加
データ損失リスクdownが不完全になりうる明示的に処理
運用の複雑さ単純に見えるが落とし穴あり一貫的で予測可能
推奨環境小規模、初期段階大規模本番

トランザクショナルDDL

トランザクショナルDDLはDDL文をトランザクション内で実行し、マイグレーションの途中で失敗したらすべての変更が一度にロールバックされることを保証する機能です。PostgreSQLはほとんどのDDLをトランザクション内で処理でき、この点で非常に強力です。一方MySQLは多くのDDL文が暗黙のコミットを引き起こすため、トランザクショナルDDLは期待しにくいです。

PostgreSQLでのトランザクショナルDDL

複数のDDL文を一つのトランザクションにまとめると、途中で一つでも失敗したときに全体がきれいに元へ戻ります。

BEGIN;

ALTER TABLE orders ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'pending';
ALTER TABLE orders ADD COLUMN shipped_at TIMESTAMPTZ;
CREATE INDEX idx_orders_status ON orders (status);

-- ここでエラーが起きると上の三つの変更がすべてロールバックされます。
COMMIT;

トランザクション内で避けるべき作業

注意点があります。PostgreSQLではCREATE INDEX CONCURRENTLYはトランザクションブロック内で実行できません。この命令はテーブルをロックせずにインデックスを作るためのものですが、トランザクション内ではそのロック回避メカニズムが動作できないからです。したがって大型テーブルへ無停止でインデックスを追加するときは、トランザクションの外で別途実行しなければなりません。

-- トランザクションの外で単独で実行(無停止インデックス生成)
CREATE INDEX CONCURRENTLY idx_users_email ON users (email);

無停止マイグレーション戦略

サービスを止めずにスキーマを変えることは、マイグレーションで最も難しい部分です。核となる考え方は、危険な変更を複数の段階に分けて、各段階で既存コードと新コードの両方が正常に動作するようにすることです。

Expand and Contract パターン

最も広く使われる無停止パターンは拡張・縮小(Expand and Contract)パターンです。カラム名を変えるという単純に見える作業さえ、無停止で行うには複数の段階が必要です。

カラム名変更 (username -> handle) の無停止手順

段階1 Expand   : 新カラム handle を追加(既存の username は維持)
段階2 バックフィル : username の値を handle へコピー
段階3 二重書き込み : アプリケーションが両方のカラムへ記録
段階4 読み取り切替 : アプリケーションが handle を読むようにデプロイ
段階5 Contract : 安定後 username カラムを削除

このパターンの核心は、どの段階でも旧バージョンのアプリケーションと新バージョンのアプリケーションが同時に動作できる点です。ローリングデプロイ中に二つのバージョンが共存しても、データの一貫性が崩れません。

危険な作業と安全な代替

危険な作業問題点安全な代替
NOT NULLカラムを即座に追加大テーブル全体の再書き込み、ロックnullableで追加後バックフィル、後で制約追加
通常のCREATE INDEXテーブル書き込みロックCREATE INDEX CONCURRENTLY を使用
カラム型を即座に変更全体再書き込み、長いロック新カラム追加後に段階的バックフィル
大量UPDATEを一度に長いトランザクション、ロック競合バッチに分けて処理

NOT NULL制約を安全に追加する

大きなテーブルへNOT NULLカラムを一度に追加すると、テーブル全体がロックにかかることがあります。次のように段階を分ければ安全です。

-- 段階1: nullableカラムとして追加(速くロックが短い)
ALTER TABLE users ADD COLUMN phone VARCHAR(20);

-- 段階2: 既存の行をバッチでバックフィル
UPDATE users SET phone = '' WHERE phone IS NULL AND id BETWEEN 1 AND 100000;
-- (範囲を変えながら繰り返し実行)

-- 段階3: 検証可能なNOT NULL制約をNOT VALIDでまず追加
ALTER TABLE users ADD CONSTRAINT users_phone_not_null
    CHECK (phone IS NOT NULL) NOT VALID;

-- 段階4: 別途検証(テーブル全体のロックなし)
ALTER TABLE users VALIDATE CONSTRAINT users_phone_not_null;

大量UPDATEをバッチに分ける

数百万件を一度のUPDATEで処理すると長いトランザクションが生じ、ロック競合とレプリケーション遅延を引き起こします。小さなバッチに分けて処理するのが安全です。

-- バッチ単位で繰り返し実行する例
UPDATE orders
SET status = 'archived'
WHERE id IN (
    SELECT id FROM orders
    WHERE status = 'old' AND archived = false
    LIMIT 5000
);

バックアップとドライラン

どんなマイグレーションでも、本番へ適用する前に二つのことを必ず備えなければなりません。それがバックアップとドライランです。

バックアップ

リスクの高いマイグレーションの直前には、常にバックアップを確保します。論理バックアップと物理バックアップの特性を理解し、状況に合わせて選択します。何より重要なのは、バックアップが実際に復元可能かをあらかじめ検証することです。復元したことのないバックアップはバックアップではありません。

# PostgreSQL 論理バックアップ(特定のデータベースをダンプ)
pg_dump --format=custom --file=backup_before_migration.dump mydb

# 復元検証(別の一時データベースへ復元してみる)
pg_restore --dbname=mydb_restore_test backup_before_migration.dump
# MySQL 論理バックアップ
mysqldump --single-transaction --routines --triggers mydb > backup_before_migration.sql

ドライラン

ドライランは実際には変更を適用せず、マイグレーションが何をするかを事前に確認する手順です。多くのツールがこの機能を提供します。また、ステージング環境で本番に近い規模のデータで実際に実行し、所要時間とロックの影響を測定することも広い意味でのドライランです。

# Liquibase: SQLを実際に実行せず出力のみ(ドライラン)
liquibase update-sql

# Flyway: 適用されるマイグレーションの一覧を確認
flyway info

# golang-migrate: 現在のバージョンを確認
migrate -path ./migrations -database "$DATABASE_URL" version

実行計画の確認

大量データマイグレーションの場合、EXPLAINで実行計画を事前に確認して、意図しない全件スキャンや非効率を見つけられます。

EXPLAIN ANALYZE
UPDATE orders SET status = 'archived'
WHERE created_at < now() - INTERVAL '1 year';

環境昇格: devからstgを経てprodへ

マイグレーションは決して本番へ直接適用しません。開発(dev)、ステージング(stg)、本番(prod)の環境を順に経て検証します。同じマイグレーションファイルがすべての環境で同一に適用されてこそ、環境間のスキーマドリフトを防げます。

環境昇格パイプライン

  [dev] ---- 検証 ----> [stg] ---- 検証 ----> [prod]
    |                     |                     |
  開発者ローカル        本番類似             実サービス
  速い反復              データでリハーサル    慎重に適用
    |                     |                     |
  スキーマ作成          実行時間測定          バックアップ後デプロイ
                        ロック影響確認        モニタリング

各環境の役割

環境目的データ主な活動
devマイグレーション作成と速い反復少量のシードデータスキーマ設計、単位検証
stg本番リハーサル本番類似の規模実行時間、ロック影響の測定
prod実際の適用実データバックアップ、デプロイ、モニタリング

ステージング環境はできるだけ本番に近いデータ規模を備えてこそ意味があります。データが千件しかないステージングでうまく動いたマイグレーションが、数億件の本番では何時間もかかることがあるからです。

チームプロセス

マイグレーションは一人の仕事ではなくチームの仕事です。安全なマイグレーション文化のためには明確なプロセスが必要です。

マイグレーションはコードレビューの対象

すべてのマイグレーションファイルはアプリケーションコードと同じくプルリクエストとコードレビューを経ます。レビュアーはロック影響、元に戻せるか、バックフィル戦略、バッチ処理の有無などを確認します。

CIパイプラインでの検証

CIパイプラインでマイグレーションを一時データベースへ自動的に適用してみて、適用後に再び元へ戻せるかまで検証するとよいです。

# CIでマイグレーション適用後のロールバックを検証する例
migrate -path ./migrations -database "$DATABASE_URL" up
migrate -path ./migrations -database "$DATABASE_URL" down 1
migrate -path ./migrations -database "$DATABASE_URL" up

コードデプロイとマイグレーションの順序

アプリケーションコードのデプロイとマイグレーションの順序は慎重に決めなければなりません。一般にカラムを追加する拡張段階はコードデプロイより先に、カラムを削除する縮小段階はコードデプロイより後に実行します。こうすればデプロイ中に旧バージョンと新バージョンのコードが共存しても安全です。

安全なデプロイ順序(カラム追加の場合)

  1. マイグレーション適用(新カラム追加) - 旧コードは新カラムを無視
  2. 新コードデプロイ(新カラム使用)
  3. モニタリング後に安定化

安全なデプロイ順序(カラム削除の場合)

  1. 新コードデプロイ(該当カラムの使用停止)
  2. モニタリング後に安定化
  3. マイグレーション適用(カラム削除)

障害予防チェックリスト

最後に、本番マイグレーションの直前に点検するチェックリストを整理します。このリストを習慣のように確認すれば、ほとんどの障害を予防できます。

本番マイグレーション事前チェックリスト

[ ] マイグレーションはバージョン管理され、コードレビューを通過したか
[ ] ステージングで本番類似データで実行してみたか
[ ] 実行時間とロック影響を測定したか
[ ] 元に戻す戦略(downまたはフォワードフィックス)が準備されているか
[ ] 直前のバックアップが確保され、復元可能性が検証されているか
[ ] 大テーブルのインデックスはCONCURRENTLYで作るか
[ ] 大量UPDATEはバッチに分けたか
[ ] NOT NULL、型変更などロックリスクのある作業を段階に分けたか
[ ] コードデプロイとマイグレーションの順序が決まっているか
[ ] 適用時間帯はトラフィックが低い時点か
[ ] 適用中のモニタリングとロールバック担当者が指定されているか

よく発生する障害の類型

障害の類型原因予防策
ロックによる障害大テーブルの即時インデックス、型変更CONCURRENTLY、段階分け
データ損失検証なしの型変換、削除バックアップ、ステージング検証
レプリケーション遅延一度の巨大なトランザクションバッチ処理
スキーマドリフト環境ごとの手動適用バージョン管理、自動昇格
デプロイ衝突コードとマイグレーションの順序ミスExpand and Contract

おわりに

データベースマイグレーションは単にSQLを実行する作業ではなく、リスクを管理するエンジニアリング分野です。核心は変更を元に戻せて、小さく、検証済みの状態で扱うことであり、すべての変更をバージョン管理型ファイルとして追跡し、環境を順に経て昇格させることです。

無停止マイグレーションは、危険な変更を複数の段階に分けて旧バージョンと新バージョンが共存できるようにするところから始まります。拡張・縮小パターン、トランザクショナルDDL、CONCURRENTLYインデックス、バッチ処理といった技法はすべてこの目標のためのツールです。何よりバックアップとドライラン、そしてチームのコードレビュー文化が、障害を予防する最後の安全網になります。

この記事で扱った原則とチェックリストが、皆さんの次のマイグレーションを少しでも安全にする助けになれば幸いです。結局、最も良いマイグレーションは、誰もそれが起きたと気づかないマイグレーションです。

参考資料