EXERCISE 90分

ストーリー

本番環境の大規模なスキーマ変更が決定した。高橋アーキテクトから計画立案を任された。

「マイグレーションは計画がすべてだ。何をいつどの順番で実行し、失敗したらどうロールバックするか。バックアップは? モニタリングは? すべてを事前に文書化してくれ」


ミッション概要

項目内容
目標4つのマイグレーションシナリオの計画立案
評価基準計画の網羅性、安全性、ロールバック戦略の適切さ
制限時間90分

Mission 1: カラム型変更(VARCHAR → ENUM)

シナリオ

注文テーブルの status カラム(VARCHAR(20))をENUM型に変更したい。

-- 現在のテーブル
CREATE TABLE orders (
  id SERIAL PRIMARY KEY,
  user_id INT NOT NULL,
  status VARCHAR(20) NOT NULL DEFAULT 'pending',  -- これを ENUM に
  total DECIMAL(12, 2) NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 500万行、24時間稼働中のサービス

課題: ゼロダウンタイムでのマイグレーション計画を立てよ。Expand-Contract パターンの各フェーズを具体的なSQLとデプロイ手順で示すこと。

ヒント

ENUMに直接変更するのではなく、新カラムを追加して段階的に移行する方法を検討しよう。既存データのバリデーションも重要。

回答例

事前準備:

# バックアップ取得
pg_dump -h $DB_HOST -d myapp -F c -f pre_migration_enum.dump
aws s3 cp pre_migration_enum.dump s3://backups/pre-migration/

Phase 1: Expand(ENUM型作成 + 新カラム追加)

-- マイグレーション 1: ENUM型と新カラム作成
CREATE TYPE order_status AS ENUM ('pending', 'confirmed', 'paid', 'shipped', 'delivered', 'cancelled');

ALTER TABLE orders ADD COLUMN status_new order_status;

-- 既存データのバリデーション(不正値の確認)
SELECT status, COUNT(*)
FROM orders
WHERE status NOT IN ('pending', 'confirmed', 'paid', 'shipped', 'delivered', 'cancelled')
GROUP BY status;
-- → 不正値があれば事前にクリーニング

Phase 2: データ移行 + Dual Write

-- バッチでデータコピー
UPDATE orders SET status_new = status::order_status
WHERE status_new IS NULL
AND id BETWEEN 1 AND 100000;
-- ... バッチを繰り返す
// アプリ更新: Dual Write
async function updateOrderStatus(orderId: number, status: string) {
  await prisma.$executeRaw`
    UPDATE orders
    SET status = ${status}, status_new = ${status}::order_status
    WHERE id = ${orderId}
  `;
}

Phase 3: 切り替え

-- 全データの移行完了を確認
SELECT COUNT(*) FROM orders WHERE status_new IS NULL;  -- 0であること

-- NOT NULL制約追加
ALTER TABLE orders ALTER COLUMN status_new SET NOT NULL;
ALTER TABLE orders ALTER COLUMN status_new SET DEFAULT 'pending';
// アプリ更新: 新カラムから読み取り
async function getOrderStatus(orderId: number): Promise<string> {
  const order = await prisma.order.findUnique({ where: { id: orderId } });
  return order.statusNew;  // 新カラムを使用
}

Phase 4: Contract(旧カラム削除)

-- 十分な検証期間後
ALTER TABLE orders DROP COLUMN status;
ALTER TABLE orders RENAME COLUMN status_new TO status;

ロールバック計画:

  • Phase 1-2: 新カラムをDROPするだけ
  • Phase 3: アプリを旧バージョンに戻す
  • Phase 4: バックアップからリストア

Mission 2: テーブル分割

シナリオ

users テーブルが肥大化。プロフィール情報を別テーブルに分離したい。

CREATE TABLE users (
  id SERIAL PRIMARY KEY,
  email VARCHAR(255) NOT NULL UNIQUE,
  password_hash VARCHAR(255) NOT NULL,
  name VARCHAR(100) NOT NULL,
  -- 以下をuser_profilesに移動したい
  bio TEXT,
  avatar_url VARCHAR(500),
  website VARCHAR(500),
  location VARCHAR(100),
  birth_date DATE,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 100万行

課題: テーブル分割のマイグレーション計画を立てよ。

ヒント

新テーブル作成 → データコピー → アプリのDual Read/Write → 旧カラム削除の手順で考えよう。

回答例

Phase 1: Expand

-- 新テーブル作成
CREATE TABLE user_profiles (
  user_id INT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
  bio TEXT,
  avatar_url VARCHAR(500),
  website VARCHAR(500),
  location VARCHAR(100),
  birth_date DATE,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

Phase 2: データコピー + Dual Write

// バッチでデータコピー
async function migrateProfiles(batchSize: number = 5000) {
  let lastId = 0;
  while (true) {
    const result = await prisma.$executeRaw`
      INSERT INTO user_profiles (user_id, bio, avatar_url, website, location, birth_date, created_at)
      SELECT id, bio, avatar_url, website, location, birth_date, created_at
      FROM users
      WHERE id > ${lastId}
      AND id NOT IN (SELECT user_id FROM user_profiles)
      ORDER BY id
      LIMIT ${batchSize}
      ON CONFLICT (user_id) DO NOTHING
    `;

    if (result === 0) break;
    lastId += batchSize;
    await sleep(100);
  }
}

// Dual Write: 更新時は両方に書き込み
async function updateProfile(userId: number, data: ProfileData) {
  await prisma.$transaction([
    prisma.user.update({
      where: { id: userId },
      data: { bio: data.bio, avatarUrl: data.avatarUrl },
    }),
    prisma.userProfile.upsert({
      where: { userId },
      update: data,
      create: { userId, ...data },
    }),
  ]);
}

// Dual Read: 新テーブル優先
async function getProfile(userId: number) {
  const profile = await prisma.userProfile.findUnique({ where: { userId } });
  if (profile) return profile;

  // フォールバック
  const user = await prisma.user.findUnique({ where: { id: userId } });
  return { bio: user.bio, avatarUrl: user.avatarUrl, ... };
}

Phase 3: アプリ切り替え

// 新テーブルのみ使用
async function getProfile(userId: number) {
  return prisma.userProfile.findUnique({ where: { userId } });
}

Phase 4: Contract

ALTER TABLE users DROP COLUMN bio;
ALTER TABLE users DROP COLUMN avatar_url;
ALTER TABLE users DROP COLUMN website;
ALTER TABLE users DROP COLUMN location;
ALTER TABLE users DROP COLUMN birth_date;

ロールバック計画: 各フェーズでuser_profilesをDROPし、アプリを旧バージョンに戻す。


Mission 3: マイグレーション失敗時の対応手順書

シナリオ

本番マイグレーション中に以下の障害が発生した場合の対応手順書を作成せよ。

  1. マイグレーションSQL実行中にタイムアウト
  2. データ移行バッチが途中で失敗
  3. 新旧アプリの切り替え後にバグが発覚
  4. ロールバックのマイグレーションが失敗
回答例

障害対応手順書

1. マイグレーションSQL実行中にタイムアウト

確認:
  □ pg_stat_activity でクエリの状態を確認
  □ ロックの有無を確認: SELECT * FROM pg_locks WHERE NOT granted;

対処:
  □ 実行中のクエリをキャンセル: SELECT pg_cancel_backend(pid);
  □ 改善策: statement_timeout を設定してリトライ
  □ 大量データの場合: バッチ処理に分割

エスカレーション:
  □ 30分以内に解決しない場合 → DBA チームに連絡

2. データ移行バッチが途中で失敗

確認:
  □ 移行済み/未移行の件数を確認
  □ エラーログで失敗原因を特定
  □ データ不整合の範囲を確認

対処:
  □ 原因を修正して未移行分のみリトライ
  □ 冪等な設計であれば全体リトライ可能
  □ 移行済みデータの整合性チェック

エスカレーション:
  □ データ不整合が発見された場合 → バックアップからリストア検討

3. 新旧アプリの切り替え後にバグが発覚

確認:
  □ バグの影響範囲を特定
  □ データ破損の有無を確認

対処:
  □ 即座にアプリを旧バージョンにロールバック
  □ Dual Write期間中であればデータは旧カラムにも存在
  □ 新カラムのデータは破棄して再移行

エスカレーション:
  □ データ破損がある場合 → インシデント対応プロセス起動

4. ロールバックのマイグレーションが失敗

確認:
  □ ロールバックSQL のエラー内容を確認
  □ 依存関係(FK制約等)の確認

対処:
  □ 依存を解消してリトライ
  □ 手動でSQLを修正して実行
  □ 最終手段: バックアップからPITRで復旧

エスカレーション:
  □ 即座にDBAチームとインフラチームに連絡
  □ RPO/RTOに基づいてバックアップリストアを判断

Mission 4: モニタリングダッシュボード設計

シナリオ

マイグレーション中と運用時のデータベースモニタリングダッシュボードを設計せよ。

要件:

  • マイグレーション中の進捗確認
  • パフォーマンスの変化検知
  • 異常の早期発見

課題: 監視項目、閾値、アラート条件を設計せよ。

回答例

マイグレーション用ダッシュボード

パネルメトリクス閾値/アラート
マイグレーション進捗移行済み/未移行の件数進捗が30分停滞 → Warning
クエリレスポンスp50, p95, p99p95 > 200ms → Warning
テーブルロックロック待ち数と時間ロック > 30秒 → Critical
CPU使用率リアルタイム> 90% → Critical
コネクション数active / idle / maxactive > 80% → Warning
レプリケーション遅延秒数> 5秒 → Critical
エラー率5xx / 総リクエスト> 1% → Critical
ディスクI/Oread/write IOPS通常の3倍 → Warning

監視クエリ(マイグレーション進捗):

-- 移行進捗の監視
SELECT
  (SELECT COUNT(*) FROM users WHERE full_name IS NOT NULL) AS migrated,
  (SELECT COUNT(*) FROM users WHERE full_name IS NULL) AS remaining,
  ROUND(
    (SELECT COUNT(*) FROM users WHERE full_name IS NOT NULL)::numeric /
    NULLIF((SELECT COUNT(*) FROM users), 0) * 100, 2
  ) AS progress_percent;
// Prometheusメトリクス
const migrationProgress = new Gauge({
  name: 'db_migration_progress_percent',
  help: 'Migration progress percentage',
  labelNames: ['migration_name'],
});

const dbQueryDuration = new Histogram({
  name: 'db_query_duration_seconds',
  help: 'Database query duration',
  labelNames: ['query_type'],
  buckets: [0.01, 0.05, 0.1, 0.5, 1, 5],
});

達成チェックリスト

  • Mission 1: ENUM型変更のExpand-Contractパターンを設計できた
  • Mission 2: テーブル分割のマイグレーション計画を立てられた
  • Mission 3: 障害対応手順書を作成できた
  • Mission 4: モニタリングダッシュボードを設計できた

推定所要時間: 90分