Posted at

Ruby on Railsのmigrationをうまく使って無停止でMySQLのテーブル名変更を行う

More than 3 years have passed since last update.

Ruby on Railsのmigration機能は、DBを育てていくための多種多様な機能を提供してくれています。サービスを運用していく中で、DBは「カチッと安定している」ことが望ましいですが、それは夢物語であり、日々サービスに機能追加や修正を施していく度に、DBも当然影響を受けます。それは、テーブルの追加や列の追加だけでなく、時には「テーブル名の変更」も出てくるでしょう。

もし「itemsテーブルの名前をproductsに変えておいて」と依頼されたら、あなたならどうしますか?


rename_tableでは済まされない状況

もし手元に開発環境があって、自分しかアクセスしないdevelopmenttestのDBであれば、以下のようにしてしまえば良さそうです。


20160707000001_rename_items.rb

class RenameItems < ActiveRecord::Migration

def change
rename_table :items, :products
end
end

そして、モデル名も変更します。

class Item < ActiveRecord:Base

...
end

を、

class Product < ActiveRecord:Base

...
end

にします。

しかし、productionになると、上記のような単純な名称変更では問題が発生することになります。「メンテナンスモードにしてユーザに一切何もさせない」という時間を作ることができれば楽なのですが、できればサービスを止めずに何とかしたいものです。例えば、Ruby on Railsのコードが実行されているアプリサーバが複数台あり、それらが一つのMySQLサーバを参照している、という構成を想定すると、rename_tableを使ったデプロイは以下のような手順となるでしょう。


  • デプロイ開始前の状態



    • itemsテーブルを参照するコード(Itemモデル)が全てのアプリサーバで稼働している。



  • デプロイ


    1. デプロイサーバが、新しいコード一式(Item Productモデル)を全てのアプリサーバに配布する。


    2. rake db:migrateが実行され、itemsテーブルの名称がproductsになる。

    3. Ruby on Railsのサーバプロセスがアプリサーバ1台ずつ再起動されていく。



  • デプロイ終了後の状態



    • productsテーブルを参照するコード(Productモデル)が全てのアプリサーバで稼働している。



上記の場合で問題となるのは、デプロイの途中の2.と3.の間です。rake db:migrateによってitemsテーブルが存在しなくなった直後は、全てのアプリサーバのコードは相変わらずitemsテーブルがあると思っています。つまり、


  • デプロイ


    1. ...


    2. rake db:migrateが実行され、itemsテーブルの名称がproductsになる。

    3. Ruby on Railsのサーバプロセスがアプリサーバ1台ずつ再起動されていく。


      • 再起動前のサーバ: itemsテーブルを見に行く。 => ない!

      • 再起動後のサーバ: productsテーブルを見に行く。





となり、再起動前のサーバにユーザからのリクエストが届いて、その処理がItemモデルが持つ処理を使っていた場合は、エラーとなってしまうわけです。

古いコードと新しいコードが混在した環境下においても、どちらもエラーを出さない状況にしなければなりません。


混在環境を経由する

ではどう解決するかですが、大前提として、「古いコードと新しいコードの両方が稼働している時間帯ができてしまうことを許容する」という方針とします。つまり、DB的には「名前を一気に変えてしまう」ことで「古い名前のテーブルを即抹殺」してしまうのではなく、古いテーブルも新しいテーブルも存在する状況とします。これにより、itemsテーブルを参照する再起動前のサーバと、productsテーブルを参照する再起動後のサーバ、どちらも問題なく動作するようにしてあげます。

使うべきmigrationの機能は、rename_tableではなく、create_tableです。

itemsテーブルを消さずに、同じ構成でproductsテーブルを新規に作成します。そして、すでにitemsテーブルにある内容を、productsテーブルにコピーする処理も行います。


20160707000001_create_products.rb

class CreateProducts < ActiveRecord::Migration

def self.up
create_table :products do |t|
...
end

# Copy all rows
execute <<-SQL.strip_heredoc
INSERT INTO products (id, ...)
SELECT id, ... FROM items
SQL
end

def self.down
dtop_table :products
end
end


ItemモデルクラスをProductモデルクラスに名称変更は、先ほどと同じように行ってしまいます。これにより、デプロイは以下のようになります。


  • デプロイ開始前の状態



    • itemsテーブルを参照するコード(Itemモデル)が全てのアプリサーバで稼働している。



  • デプロイ


    1. デプロイサーバが、新しいコード一式(Item Productモデル)を全てのアプリサーバに配布する。


    2. rake db:migrateが実行され、以下の処理が行われる。



      1. productsテーブルを新規に作成する。


      2. itemsテーブルの内容をproductsテーブルにコピーする。



    3. Ruby on Railsのサーバプロセスがアプリサーバ1台ずつ再起動されていく。


      • 再起動前のサーバ: itemsテーブルを見に行く。

      • 再起動後のサーバ: productsテーブルを見に行く。





  • デプロイ終了後の状態



    • productsテーブルを参照するコード(Productモデル)が全てのアプリサーバで稼働している。



これで大丈夫そうですね。本当ですか?実はこれらだけではまだ問題が発生します。


古いテーブルへの行追加/削除の同期

再起動前のサーバは、itemsテーブルに行を追加したり削除したりします。再起動後のサーバは、productsテーブルに行を追加したり削除したりします。上記の手順の中で、itemsテーブルからproductsテーブルに内容をコピーしているので一見大丈夫そうに見えますが、ユーザは待ってくれません。


  • デプロイ


    1. ...

    2. ...

    3. Ruby on Railsのサーバプロセスがアプリサーバ1台ずつ再起動されていく。


      • 再起動前のサーバ: itemsテーブルを見に行く。 => itemsテーブルの内容が変わる

      • 再起動後のサーバ: productsテーブルを見に行く。





itemsテーブルからproductsテーブルに内容のコピーが完了し、全てのアプリサーバが新しいコードに入れ替わるまでの間にユーザからリクエストが来ると、再起動前のサーバがitemsテーブルに行を追加したり削除したりすることになります。しかし、再起動後はproductsテーブルの内容を参照することになるため、itemsテーブルへの行追加/削除は「なかったこと」になってしまいます。

この「空白の時間」に対して、itemsテーブルへの操作をproductsテーブルにも同期させるために、トリガーを使います。


20160707000001_create_products.rb

class CreateProducts < ActiveRecord::Migration

def self.up
create_table :products do |t|
...
end

# Copy all rows
execute <<-SQL.strip_heredoc
INSERT INTO products (id, ...)
SELECT id, ... FROM items
SQL

# Register triggers for sync
execute <<-SQL.strip_heredoc
CREATE TRIGGER trigger_insert_items_to_products AFTER INSERT ON items FOR EACH ROW
BEGIN
INSERT INTO products (id, ...)
VALUES (NEW.id, ...);
END
SQL

execute <<-SQL.strip_heredoc
CREATE TRIGGER trigger_delete_products_from_items AFTER DELETE ON items FOR EACH ROW
BEGIN
DELETE FROM products WHERE id = OLD.id;
END
SQL
end

def self.down
# Drop triggers for sync
execute 'DROP TRIGGER IF EXISTS trigger_delete_products_from_items'
execute 'DROP TRIGGER IF EXISTS trigger_insert_items_to_products'

dtop_table :products
end
end


これにより、空白の時間でitemsテーブルに行追加/削除が起きたときには、productsテーブルにそれが同期されることになるので、再起動前のアプリサーバの処理によって発生する「なかったこと」になってしまう状況を救うことができるようになります。


移行後の後始末

上記の手順によって、もはやitemsテーブルは無用になったので、次のmigrationで消し去ります。トリガーも。


20160707000002_drop_items.rb

class DropItems < ActiveRecord::Migration

def change
execute 'DROP TRIGGER IF EXISTS trigger_delete_products_from_items'
execute 'DROP TRIGGER IF EXISTS trigger_insert_items_to_products'

drop_table :items
end
end



行コピー中の操作への対応

実は上記の一連の手順の中で、認識していたけど解決していなかった問題があります。それは、itemsテーブルからproductsテーブルへの内容コピーの最中に、itemsテーブルに対して行の追加あるいは削除が行われたとき、です。コピーの最中に行の追加や削除が行われてしまうと、トリガーを後から仕掛ける都合上、productsテーブル側に反映されない状況が生まれてしまいます。


  • デプロイ


    1. ...


    2. rake db:migrateが実行され、以下の処理が行われる。



      1. productsテーブルを新規に作成する。


      2. itemsテーブルの内容をproductsテーブルにコピーする。


        • この最中にitemsテーブルの内容に増減が発生することがあり得る





    3. Ruby on Railsのサーバプロセスがアプリサーバ1台ずつ再起動されていく。



これは、元のitemsテーブルにどれだけの行が存在しているか?コピー処理がどのくらいの時間で終わるか?そのitemsテーブルへの更新頻度がどの程度か?に依存して発生頻度が変わってきます。今回僕が実際に行ったサービスでの対応は、更新頻度がそれほど多くない(1日に一度も更新されないこともある程度)テーブルだったので、あえて対応しませんでした。デプロイ後に、itemsテーブルの内容とproductsテーブルの内容を自分の目で見て差があるか確認して、もし差があった際には手作業で補正すれば良い、という戦略を取りました(幸いなことに特に差はなかった)。

ちなみに、主キーのidが自動採番だったため、もし本当にコピー中に行追加が行われてしまうと、productsテーブルへの行追加が行われた際に、itemsテーブルでの採番とずれてしまう可能性も否定できません。これについては、更新頻度が低いという特性を生かして、手動で調整すれば良いと考えていました。もし更新頻度が高い状況の場合は、何か別の手を考える必要があったかもしれません。


今思うこと

幸いにも上記の手順のみで無事に名称変更をやり終えることができました。しかし、仮に「めっちゃ更新頻度が高い」とか「既存の行数が多くてコピーに時間がめっちゃかかる」とかの要因があったとしたら・・・、上記の手順は十分ではなかったかも知れません。今考えると、例えば、



  1. ALTER TABLE items RENAME TO productsでテーブル名の変更をする。

  2. 直後に、CREATE VIEW items AS SELECT * FROM productsでビューを作成する。

というように、ビューをうまく使えば、行コピーの時間を節約することができたかもしれません。1.と2.の間の絶妙なタイミングでitemsテーブルを参照されてしまうとエラーになってしまいますが。


まとめ

テーブル名の変更は、実は結構いろいろ考えないとエラーを発生させてしまうクリティカルな行為です。上記の他にもいろいろな手法があると思います。今回は、自分が実際に行った手順を紹介しました。もし「いや、この方がうまく行くぜ!」とかありましたら、投稿していただくか、コメントで教えていただけると嬉しいです。