gh-ost:ダウンタイムなしでMySQLテーブル構造を変更する — GitHubがpt-oscに代わり開発したソリューション

MySQL tutorial - IT technology blog
MySQL tutorial - IT technology blog

問題:大規模テーブルにとってALTER TABLEは悪夢

本番環境で稼働中の5000万行のordersテーブルがあるとします。Product Managerからpayment_verified_atカラムの追加を求められました。ALTER TABLE orders ADD COLUMN payment_verified_at TIMESTAMP NULL;と入力してEnterを押した瞬間、30秒後にウェブサイトがタイムアウトし始めます。

問題は、MySQLがリビルド中にテーブル全体をロックしなければならない点にあります。回避策はありません。一般的なハードウェアで5000万行となれば、30〜60分のダウンタイムが発生します。読み書きのクエリがすべて待機状態となり、コネクションプールが枯渇し、ユーザーには真っ白な画面が表示されます。

以前、深夜3時にデータベース破損のインシデントに遭遇し、バックアップからリストアするのに2時間近くかかったことがあります。それ以来、明確な計画なしに本番スキーマには一切手を触れないようにしています。この記事では、最初から正しいやり方を紹介します。

MySQLスキーマ変更の3つのアプローチ比較

アプローチ1:ALTER TABLE 直接実行

-- 小さなテーブル(50万行未満)のみに使用可能
ALTER TABLE users ADD COLUMN phone_verified_at TIMESTAMP NULL;

シンプルで追加インストール不要。ただし、MySQLはプロセス中にテーブル全体をロックするため、1000万行のテーブルでは10分のダウンタイムが発生しやすい。問題が発覚しても途中で止めることはできません。

アプローチ2:pt-online-schema-change(Percona Toolkit)

pt-oscは長年MySQLコミュニティに親しまれてきた定番ツールです。仕組みはシャドウテーブル_users_newを作成し、元テーブルに3つのトリガー(INSERT/UPDATE/DELETE)を設定して新しいデータを同期しながら、古いデータをチャンク単位でコピーし、完了後にリネームします。

  • メリット:テーブルロックなし、長年の実績あり、大きなコミュニティ。
  • デメリット:トリガーがすべての書き込み操作にオーバーヘッドを追加。GaleraやNDB Clusterと非互換。一時停止・再開が困難。実行中の速度調整が不可。

アプローチ3:gh-ost(GitHub Online Schema Transmogrifier)

GitHubがgh-ostを開発したのは、pt-oscが自社データベースの規模——数十億行、極めて高い書き込みスループット——に対応できなかったからです。トリガーによるオーバーヘッドは許容できませんでした。解決策として、gh-ostはトリガーの代わりにMySQLレプリカのように接続し、データコピー中の変更をbinlogを直接読み取ることで追いかけます。

  • メリット:トリガー不使用、書き込みへの追加オーバーヘッドなし。任意のタイミングで一時停止・再開可能。リアルタイムで速度調整。事前テスト用のdry-runモード。テーブルリネームのタイミングを正確にコントロール。
  • デメリット:binlog形式ROWが必要。REPLICATION SLAVE権限が必要。pt-oscより若干セットアップが複雑。

メリット・デメリット分析 — どのツールを選ぶべきか?

基準 直接ALTER pt-osc gh-ost
小テーブル(<50万行) ✅ 最適 オーバースペック オーバースペック
大テーブル(本番) ❌ 使用不可 ✅ 可 ✅ 最適
既存トリガーあり OK ❌ 競合 ✅ OK
Galera / PXCクラスター 条件次第 制限あり ❌ 非対応
一時停止・再開 ❌ 困難 ✅ 容易
リアルタイムスロットリング 制限あり ✅ 完全対応
リネームタイミング制御 ✅ Postponeフラグ

どれを選ぶべきか?小テーブルや開発環境には直接ALTER。本番環境の大テーブルにはgh-ost。Galeraやbinlogストリーミングをサポートしていない環境にはpt-oscを選びましょう。

gh-ostのステップバイステップ導入ガイド

ステップ1:gh-ostのインストール

gh-ostは単一バイナリで、ランタイム依存関係は不要です:

# GitHub Releasesからバイナリをダウンロード(最新バージョンを確認してください)
VERSION="1.1.6"
wget https://github.com/github/gh-ost/releases/download/v${VERSION}/gh-ost-binary-linux-amd64-${VERSION}.tar.gz
tar -xzf gh-ost-binary-linux-amd64-${VERSION}.tar.gz
sudo mv gh-ost /usr/local/bin/
chmod +x /usr/local/bin/gh-ost

# 確認
gh-ost --version

ステップ2:MySQL binlogの確認

gh-ostはbinlogがROW形式で有効になっていることを要求します:

-- 現在の設定を確認
SHOW VARIABLES LIKE 'log_bin';
SHOW VARIABLES LIKE 'binlog_format';

-- 有効化が必要な場合は /etc/mysql/my.cnf または /etc/my.cnf に追加
-- [mysqld]
-- log_bin = /var/log/mysql/mysql-bin.log
-- binlog_format = ROW
-- server_id = 1
-- その後MySQLを再起動

ステップ3:本番実行前にDry-runを実行

必ずdry-runを先に実行してください。このコマンドは接続、権限、binlog設定を確認しますが、何も変更しません:

gh-ost \
  --host="127.0.0.1" \
  --port=3306 \
  --user="root" \
  --password="your_password" \
  --database="myapp" \
  --table="orders" \
  --alter="ADD COLUMN payment_verified_at TIMESTAMP NULL" \
  --dry-run \
  --verbose

ステップ4:本番マイグレーションの実行

gh-ost \
  --host="127.0.0.1" \
  --port=3306 \
  --user="root" \
  --password="your_password" \
  --database="myapp" \
  --table="orders" \
  --alter="ADD COLUMN payment_verified_at TIMESTAMP NULL" \
  --chunk-size=1000 \
  --max-load="Threads_running=25" \
  --critical-load="Threads_running=50" \
  --switch-to-rbr \
  --exact-rowcount \
  --panic-flag-file=/tmp/ghost.panic \
  --postpone-cut-over-flag-file=/tmp/ghost.postpone \
  --execute

実行前に理解しておくべきフラグです:

  • --chunk-size=1000:1バッチあたり1000行をコピー。サーバーに余裕があれば2000〜5000に増やし、負荷が高ければ500に下げます。
  • --max-load="Threads_running=25":MySQLが過負荷になるとgh-ostは自動で一時停止し、負荷が下がると自動で再開します。
  • --critical-load="Threads_running=50":状況がさらに悪化した場合は緊急停止します。
  • --panic-flag-file=/tmp/ghost.panic:このファイルを作成するとマイグレーションが即座に停止します。
  • --postpone-cut-over-flag-file=/tmp/ghost.postpone:このファイルを作成すると、最終的なテーブルリネームステップを延期します。

ステップ5:リアルタイム監視とコントロール

gh-ostはUnixソケット(パス形式:/tmp/gh-ost.{database}.{table}.sock)を作成し、マイグレーション実行中に直接操作することができます:

# 進捗を確認
echo "status" | nc -U /tmp/gh-ost.myapp.orders.sock

# サーバーに余裕があるときにchunk-sizeを増やす(再起動不要)
echo "chunk-size=3000" | nc -U /tmp/gh-ost.myapp.orders.sock

# 手動スロットル(一時的に速度を下げる)
echo "throttle" | nc -U /tmp/gh-ost.myapp.orders.sock

# スロットルを解除
echo "no-throttle" | nc -U /tmp/gh-ost.myapp.orders.sock

ステップ6:カットオーバーのタイミング制御

これはgh-ostで私が最も気に入っている機能です。データのコピーが完了した後、即座にテーブルをリネームするのではなく、タイミングを正確にコントロールできます:

# コピー完了後にgh-ostが待機するよう、最初からこのファイルを作成しておく
touch /tmp/ghost.postpone

# gh-ostは次のメッセージを出力する:"Postponing cut-over; waiting for /tmp/ghost.postpone to be deleted"
# 準備ができたら(例:最もトラフィックが少ない時間帯を待って)
rm /tmp/ghost.postpone
# gh-ostは即座にリネームを実行する

この機能は、スキーマ変更と同時に新しいコードをデプロイする必要がある場合に特に有効です。先にマイグレーションを実行し、コードのデプロイが完了するまで待ってからカットオーバーします。

ステップ7:緊急停止が必要な場合の対応

# 即座に停止(安全、元テーブルへの影響なし)
touch /tmp/ghost.panic

# gh-ostは2つの一時テーブルを残す。確認後に削除してください
# _orders_gho  — ビルド中のゴーストテーブル
# _orders_ghc  — 変更ログテーブル

gh-ostはリネームが正常に完了するまで元のテーブルを絶対に削除しません——これは意図的な設計です。マイグレーションがどんな理由で中断されても、ordersテーブルはそのまま保持され、1行たりともデータが失われることはありません。

gh-ostでよく使うALTERコマンド

# カラムを追加
--alter="ADD COLUMN is_verified TINYINT(1) NOT NULL DEFAULT 0"

# インデックスを追加
--alter="ADD INDEX idx_status_created (status, created_at)"

# 複数の変更を一度に — データの全コピーを1回に節約
--alter="ADD COLUMN notes TEXT NULL, ADD INDEX idx_user_status (user_id, status)"

# データ型を変更(データの切り捨てに注意)
--alter="MODIFY COLUMN status VARCHAR(50) NOT NULL DEFAULT 'active'"

# デフォルト値付きのカラムを追加
--alter="ADD COLUMN tier ENUM('free','pro','enterprise') NOT NULL DEFAULT 'free'"

実践ワークフロー — マイグレーション前チェックリスト

  1. 当日のバックアップが取得済みで、復元可能であることを確認
  2. 本番と同等のデータでstaging環境にて--dry-runを実行
  3. 所要時間の見積もり:サーバーとchunk-sizeに応じて100万行あたり約5〜10分
  4. 最もトラフィックが少ない時間帯を選ぶ — 柔軟に対応するにはpostponeフラグを活用
  5. 最初から--panic-flag-file--postpone-cut-over-flag-fileを設定しておく
  6. マイグレーション中はサーバー負荷を継続的に監視
  7. カットオーバー後、新しいスキーマを確認し、2つの一時ゴーストテーブル(_orders_gho_orders_ghc)を削除

Share: