ALTER TABLE コマンドが悪夢になるとき
システム構築の初期、ordersテーブルには数千行しかありませんでした。ALTER TABLEは数秒で完了し、誰も気にしませんでした。しかしシステムが成長するにつれ、そのテーブルは5,000万行、サイズ80GBになりました。今日、PMからpayment_methodカラムの追加を求められました。
ステージング環境で試してみます:ALTER TABLE orders ADD COLUMN payment_method VARCHAR(50)。コマンドの実行に45分かかり、その間、ordersテーブルへの全てのINSERT/UPDATEが完全にブロックされました。本番環境では、それはサービスが45分間ダウンすることを意味し、顧客は注文できず、売上はゼロになります。
gh-ostはまさにこのような状況を解決するために生まれました。
gh-ostとは何か、その仕組み
gh-ost(GitHub Online Schema Transmogrifier)は、GitHubがロックなしでMySQLのALTER TABLEを実行するために自社開発したツールです。ALTERを直接実行する代わりに、gh-ostは4つのステップで処理します:
- 目的のスキーマで新しいテーブル(ghost table)を作成する
- 元のテーブルからゴーストテーブルへ、小さなバッチに分けてデータをコピーする
- コピー中、MySQLバイナリログを通じて元のテーブルへの全変更を監視し、即座にゴーストテーブルに適用する
- 完全に同期が取れたら、2つのテーブルを入れ替える(cutover)——このステップ全体で数秒しかかからない
なぜtriggerではなくbinlogを使うのか?
以前、多くの人がpt-online-schema-change(Percona Toolkit)を使っていました——このツールは変更を捕捉するためにtriggerを使います。問題は:triggerは書き込みの多いテーブルで大きなオーバーヘッドを生み出し、高負荷なシステムでは時にデッドロックを引き起こすことです。gh-ostはtriggerを完全に排除し、MySQLのレプリカのようにbinlogを直接読み込みます。その結果:
- 元のテーブルにtriggerは一切存在しない——オーバーヘッドが大幅に低減
- プロセス全体を停止せずに、リアルタイムでコピー速度をthrottleできる
- ファイルの作成・削除によりmigrationの途中でpause・resumeが可能
- 本番環境に触れずにreplicaで事前確認できる
以前、3,000万行のtransactionテーブルでpt-oscを使ったことがあります——triggerのオーバーヘッドでサーバーの負荷が上がり続け、常にthrottleが必要で、完了まで約8時間かかりました。同じ規模でgh-ostを使うと、負荷は制御可能な範囲に収まり、3時間で完了しました。
gh-ostのインストール
gh-ostは単一のGoバイナリで、追加の依存関係はありません:
# 最新のバイナリをダウンロード
wget https://github.com/github/gh-ost/releases/latest/download/gh-ost-binary-linux-amd64.tar.gz
tar -xzf gh-ost-binary-linux-amd64.tar.gz
chmod +x gh-ost
sudo mv gh-ost /usr/local/bin/
# バージョン確認
gh-ost --version
MySQLもROWフォーマットでbinlogを有効にする必要があります:
-- 確認
SHOW VARIABLES LIKE 'binlog_format';
SHOW VARIABLES LIKE 'log_bin';
-- 設定が正しくない場合は/etc/mysql/my.cnfに追加してMySQLを再起動する
-- [mysqld]
-- log_bin = ON
-- binlog_format = ROW
実践:本番環境での実際のテーブルmigration
user_activitiesテーブルに6,000万行あり、カラムと複合インデックスを追加する必要があるとします。
ステップ1:Dry-runで接続と権限を確認する
--executeなしで必ず先に実行します。gh-ostは全てを確認しますが、実際には何も変更しません:
gh-ost \
--host="localhost" \
--port=3306 \
--user="migration_user" \
--password="your_password" \
--database="myapp" \
--table="user_activities" \
--alter="ADD COLUMN device_type VARCHAR(20), ADD INDEX idx_user_created (user_id, created_at)" \
--allow-on-master \
--chunk-size=1000 \
--max-load="Threads_running=25" \
--critical-load="Threads_running=50" \
--verbose
ステップ2:実際のmigrationを実行する
--executeと、より細かく制御するためのいくつかのフラグを追加します:
gh-ost \
--host="localhost" \
--port=3306 \
--user="migration_user" \
--password="your_password" \
--database="myapp" \
--table="user_activities" \
--alter="ADD COLUMN device_type VARCHAR(20), ADD INDEX idx_user_created (user_id, created_at)" \
--allow-on-master \
--chunk-size=2000 \
--max-load="Threads_running=25" \
--critical-load="Threads_running=50" \
--default-retries=120 \
--panic-flag-file=/tmp/gh-ost.panic \
--postpone-cut-over-flag-file=/tmp/gh-ost.postpone \
--verbose \
--execute 2>&1 | tee /var/log/gh-ost-migration.log
ステップ3:実行中のmigrationを制御する
pt-oscや他の多くのmigrationツールとは異なり、gh-ostは調整のためにプロセスをkillする必要がありません。全ての制御はUnix socketまたはファイルシステム経由で行います:
# Unix socketでリアルタイムの進捗を確認
echo "status" | nc -U /tmp/gh-ost.myapp.user_activities.sock
# 出力例: Copy: 24500000/60000000 40.8%; Applied: 125000; Backlog: 0/1000; Time: 2h34m; ETA: 3h41m
# cutoverを遅延させる:gh-ostはコピーとbinlog読み込みを続けるが、テーブルの入れ替えはまだ行わない
touch /tmp/gh-ost.postpone
# cutoverを実行する
rm /tmp/gh-ost.postpone
# 負荷が上昇したときにchunk sizeを下げる
echo "chunk-size=500" | nc -U /tmp/gh-ost.myapp.user_activities.sock
# 即座に緊急停止
touch /tmp/gh-ost.panic
--postpone-cut-over-flag-fileフラグは特に便利です:そのファイルが存在する間、gh-ostはデータのコピーを完了しますが、テーブルの入れ替えは行いません。トラフィックが最も少ない午前2〜3時まで待ち、ファイルを削除すれば——cutoverは数秒で完了します。この方法により、コピー完了時にgh-ostが自動的に判断するのを待つのではなく、cutoverのタイミングを能動的に選択できます。
必要なMySQL権限
GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, ALTER,
INDEX, SUPER, REPLICATION SLAVE, REPLICATION CLIENT
ON *.* TO 'migration_user'@'localhost' IDENTIFIED BY 'your_password';
FLUSH PRIVILEGES;
よくあるエラーと対処法
外部キー制約(Foreign key constraints)
dry-runを実行すれば、テーブルにforeign keyがある場合はすぐにわかります——gh-ostはサポートしておらず、何もする前に停止します。最も一般的な対処法:先にFKをdropし、migrationを実行し、cutover完了後にFKを再作成する。FKを一時的にdropできない場合(他のチームがそのconstraintに依存している場合)、代替としてアプリケーションレベルのmigrationを検討してください。
binlogが早すぎてpurgeされる
あまり注意されない問題:gh-ostはmigration中ずっとbinlogを継続して読み込みます。gh-ostが読み込む前にMySQLがbinlogをpurgeしてしまうと(expire_logs_daysが低すぎる場合)、migrationが途中で失敗します。長時間のmigrationを実行する前に、retentionを増やしてください:
-- MySQL 8.0以降
SET GLOBAL binlog_expire_logs_seconds = 604800; -- 7日間
-- MySQL 5.7
SET GLOBAL expire_logs_days = 7;
ディスク容量不足
ゴーストテーブルは元のテーブルとほぼ同じディスク容量を消費します——開始前に最低1.2倍の空き容量が必要です。簡単な確認:
df -h /var/lib/mysql
gh-ostを使うべき場面
gh-ostは一つの問題を正確に解決します:サービスを稼働させたまま大きなテーブルのALTER TABLEを行うこと。テーブルが100〜200万行未満であれば、通常のALTER TABLEは短いメンテナンスウィンドウで十分に速く完了します——余分な複雑さは不要です。
しかし、テーブルが数千万行以上あり、サービスが24/7稼働しており、大きなインデックスを追加する必要がある場合——gh-ostはMySQLにとって最も信頼できる選択肢です。以前、100GBのデータベースをMySQLからPostgreSQLへmigrationしたとき、3日間の計画と1日の実行が必要でした——計画時間のほとんどは、相互依存するテーブルのmigration順序を決定することに費やされました。gh-ostはテーブル間の依存関係の問題は解決しません——それは自分で計画する必要があります。しかしlockの問題はきれいに解決してくれます。
注意点として:gh-ostはMySQLのみサポートしています(MariaDBはいくつかの制限あり)。PostgreSQLを使用している場合は、pg_repackやゼロダウンタイムデプロイメントによるアプリケーションレベルのmigrationなど、別のツールを探す必要があります。
