カラーミーショップ データベース Rails

サービスを停止せずにデータベースリファクタリングする

カラーミーショップ データベース Rails

2022年7月13日にカラーミーショップで提供開始した「副管理者機能」のアップデートにあたって、従前の挙動を変えずにデータベーススキーマの構造を変える必要がありました。また、サービスの提供を停止することなく、スキーマの構造の変更を進める必要がありました。

この記事では、サービスを停止せずにデータベースの構造を徐々に変更するデータベースリファクタリングをどのように進めたかについて紹介します。

「データベースリファクタリング」とは

データベースリファクタリングについて体系的に述べた書籍として"Refactoring Databases"があります。この本では、データベースリファクタリングのさまざまなパターンにおいて、スキーマの変更、データマイグレーション(既存データの移行)、アプリケーションの変更それぞれをどのように進めるべきかについて解説しています。ここでは、"Refactoring Databases"からデータベースリファクタリングの定義といえそうな文を引用します。

a database refactoring is a simple change to a database schema that improves its design while retaining both its behavioral and informational semantics

(Pramod Sadalage "Database Refactoring" 1.1 Database Refactoringより)

要約すると、データベースリファクタリングとは「データのふるまいの変更や情報の増減なくデータベーススキーマの設計を改善する単純な変更」といえます。

データベースリファクタリングする理由

データベースリファクタリングをうまく適用すると、データベースのメンテナンスウィンドウを設ける(すなわちサービスを一時停止する)ことなくスキーマに変更を加えていくことができます。つまり、サービスを一時停止することでユーザーにご迷惑をおかけすることがなくなります。また、少しずつスキーマやアプリケーションの変更を積み重ねることで、ある程度安心感を持って作業を進めることができます。

一方、細かい変更の積み重ねは手数が増えるので開発期間が長くなるという短所にもなります。また、大規模なテーブルだとオンラインでスキーマを変更するために相当時間がかかってしまい現実的ではないケースもあります。そのような場合は、サービスにメンテナンスウィンドウを設けて一気にスキーマ変更するほうが現実的なケースもありえます1

今回は、サービス運営の観点からメンテナンスウィンドウを設けたくなかったことと、事前の調査で改善作業に支障があるテーブルの規模ではないとわかっていたことから、データベースリファクタリングを実施することにしました。

サービスの技術的な構成

今回データベースリファクタリングするサービスの技術的な構成について簡単に説明します。

データベースはAmazon RDS for MySQLを利用しています。MySQLのバージョンは5.7系です。レプリケーションを利用しており、リードレプリカを複数台持っています。今回の開発でスキーマを変更する必要があるテーブルのうち、もっとも大きいもので数十万行の規模があります。

アプリケーションはPHPで書かれたものとRuby on Railsで書かれたものの両方が存在します。どちらのアプリケーションも同じデータベースを利用します。ただし、今回スキーマを変更する必要があるテーブルはRailsアプリケーションだけがレコードを挿入、更新、削除しています。PHPアプリケーションは該当テーブルのレコードを参照するだけとなっています。

スキーマの変更はRailsアプリケーションのマイグレーション機能を使ってデプロイなどのタイミングで実行するのではなく、アプリケーションの変更とは独立した作業として実施しています。

データベースリファクタリングに使うツール

pt-online-schema-change

pt-online-schema-change (pt-osc)は、メンテナンスウィンドウを設けず(つまりオンラインで)大規模なテーブルのスキーマを変更するときに使うMySQL用のツールです。

もともと、MySQL 5.6以降のInnoDBはオンラインDDLの機能を持ちます2。この機能を使うと、ディスクのIO負荷を抑えつつスキーマを変更できます。しかし、プライマリDBの大規模なテーブルに対して素朴に時間のかかるDDLを発行すると、レプリカでも同じ時間をかけてSQLスレッドでDDLを実行し、結果としてSQLスレッドにおける通常のレプリケーション用クエリの実行が遅延してしまいます。結果として、レプリケーション遅延が発生し、サービスの障害につながってしまいます3。Rolling Schema Upgradeを利用してレプリケーション遅延を回避しながらオンラインDDLを使うことも可能ですが、適用可能な変更の種類が限られるうえ、作業に手間がかかります。

一方、pt-oscを使うと、レプリケーション遅延の監視機能を利用してを遅延を防ぎつつスキーマを変更できます。pt-oscとオンラインDDLと比較すると一長一短があり、pt-oscと同種のツールもいくつかあります。現段階での知見の得やすさも考慮して、カラーミーショップで大規模なテーブルのスキーマを変更するときはpt-oscを利用することにしています。

Active Recordの機能

今回変更を入れることになったテーブルは、RailsアプリケーションがActive Recordを通じて更新しています。Active Recordの機能にはデータベースリファクタリングの作業におけるデータマイグレーションとアプリケーションの書き換えに役立つものが存在します。

まずはコールバックです。Active Recordはモデルオブジェクトのライフサイクルに応じたフックを実行できる仕組みをコールバックとして提供しています。データベースリファクタリングでスキーマを変更するときは、移行期間としてスキーマに移行前後のカラムが存在する状態になることがあります。このとき、移行前のカラムに値を保存する際に after_validation4 before_validationコールバックで移行後のカラムにも値を入れるようにしておくことで、リアルタイムで新旧のカラムどちらにも値を入れられます。

ignored_columns=もデータベースリファクタリングに便利な機能です。このメソッドにカラム名の配列を渡すと、Active Recordがそのカラムを利用しないようになります。モデルはそのカラムが存在しないものとみなすので、アプリケーション中でそのカラムを利用していないことを確かなものにできます。リファクタリングの終盤で、利用しなくなったカラムをアプリケーションで使っていないことを確実にしてから削除するときに役立ちます。

データベースリファクタリングの事例

今回のデータベースリファクタリングの事例を2件紹介します。具体的には次の変更を入れました。

  • 事例1: あるテーブルに外部キー制約を持つ新たなカラムを追加し、古いカラムの利用をやめる
  • 事例2: あるテーブルのカラムの名前は変えずに型を文字列から整数に変更する

どちらも"Refactoring Databases"の"Replace Column"パターンと"Add Foreign Key Constraint"パターンが関係するものになっています。

それぞれの事例について説明します。ここから示すテーブル名は架空のものです。また、DDLはテーブルの規模に応じてpt-oscを使って実行しています。

事例1: あるテーブルに外部キー制約を持つ新たなカラムを追加し、古いカラムの利用をやめる

2要素認証(2FA)設定テーブル(mfasとします)は、従来はショップ主管理者テーブル(manager_accountsとします)に対して外部キー制約を持っていました。しかし、副管理者の認証強化に伴い、ショップを管理する主副管理者を抽象化したショップ管理者テーブル(accountsとします)に対して外部キー制約を持つカラムを追加する必要が生じました。

mfaからmanager_accountへの外部キー制約を削除し、accountへ新たな外部キー制約を張るために、次の手順で作業を進めました。

  1. 新しいカラムを追加して適切な値が入るようにする
  2. 新しいカラムにNOT NULLを付与する
  3. 新しいカラムを使うようにアプリケーションを書き換える
  4. 古いカラムを削除する

1. 新しいカラムを追加して適切な値が入るようにする

リファクタリング開始前のmfaテーブルは次のような状態です。

まず、accountへの外部キーとなる値を入れるカラムを新規に追加します。

ALTER TABLE mfas
ADD COLUMN account_id INT,
ADD FOREIGN KEY account_id (account_id) REFERENCES accounts (id) ON DELETE CASCADE;

mfaテーブルは次のような状態になります。

次に、全レコードのこのカラムに古いカラムと同期した値が入るようにします。このような場合、"Database Refactoring"ではデータベースのトリガー機能を使うように説明されています。しかし、少なくともカラーミーショップではトリガー機能を運用することはなく、テスト容易性などの観点からアプリケーションのレイヤーで次のようにデータを投入しています。

  • 新規に追加されるmfaのレコード
    • Active Recordのコールバックを利用して、親となるmanager_accountsのレコードに対応するaccountsのレコードのIDを入れる
  • 既存のmfaのレコード
    • 親となるmanager_accountsのレコードに対応するaccountsのレコードのIDを入れるためのワンショットのスクリプトを作成して、実行する

Active Recordのコールバックとしてbefore_validationコールバックを使うことで、レコードを保存するとき新しいカラムに値が入るようになります。擬似コードを次に示します。

class Mfa < ApplicationRecord
  before_validation :set_account_id

  private

  def set_account_id
    # self.account_id = <manager_accountからaccountをたどって取得したID>
  end
end

新しいカラムをaccountsのIDで埋めるためのスクリプトの作成は単純な作業なので詳細については省略します。

2. 新しいカラムにNOT NULLを付与する

手順1でmfasの全レコードのaccount_idに値が入るようになりました。このタイミングで該当のカラムにNOT NULL制約を付与しておきます。

ALTER TABLE mfas MODIFY COLUMN account_id INT NOT NULL;

3. 新しいカラムを使うようにアプリケーションを書き換える

ここまでで、新しいカラムにつねに値が入っており、さらに外部キーも張られています。一方で、アプリケーションはいまだに古いカラムを利用しているので、新しいカラムを外部キーとして利用するようにアプリケーションを書き換えてデプロイします。この作業が完了すれば、当初の目的をほぼ達成できたことになります。

もし、このタイミングで不具合を発見した場合、デプロイを切り戻せば従来どおりのアプリケーションの挙動に戻せます。

4. 古いカラムを削除する

新しいカラムを使うようになったアプリケーションをしばらく運用して問題がなければ、mfasのレコードを保存するとき古いカラムを使わないようにアプリケーションを書き換えます。また、before_validationを利用したデータの同期も削除します。

Active Recordであれば、ignored_columnsを利用して古いカラムを使わずにアプリケーションが動くことを確実に検証できます。擬似コードを次に示します。

class Mfa < ApplicationRecord
  self.ignored_columns = [:manager_account_id]
end

このような変更をデプロイして問題がなければ、該当のカラムを削除します。

ALTER TABLE mfas DROP COLUMN manager_account_id;

これでリファクタリングは完了です。サービスを停止せずに外部キーを張り替えることができました。

事例2: あるテーブルのカラムの名前は変えずに型を文字列から整数に変更する

2つ目の事例として、サービスの認可機構に関するテーブルのカラムの型を変更し、外部キーを別テーブルへ張り替える必要がありました。また、利用しているライブラリの都合上、カラム名は同じものに保つ必要がありました。

この事例について、仮にauthorizationsというテーブルの文字列型のカラムauthorized_account_idの名前を変えずに整数型のカラムに変える、という状況を想定して説明します。

今回は事例1よりさらに手数が増えます。

  1. 新しい型の一時カラムを追加して適切な値が入るようにする
  2. 一時カラムを使うようにする
  3. 元のカラムを削除する
  4. 元のカラムを新しい型で再追加する
  5. 元のカラムに一時カラムと同じ値が常に入るようにする
  6. 元のカラムを使うようにする
  7. 一時カラムを削除する

1. 新しい型の一時カラムを追加して適切な値が入るようにする

リファクタリング開始前のmfaテーブルは次のような状態です。

まず、変更後の型を持つカラムを一時的に追加します。

ALTER TABLE authorizations
ADD COLUMN tmp_authorized_account_id INT,
ADD FOREIGN KEY tmp_authorized_account_id (tmp_authorized_account_id) REFERENCES account (id) ON DELETE CASCADE;

次に、事例1と同様に全レコードのこのカラムに値が入るようにします。必要に応じてNOT NULLも付与します。

  • 新規に追加されるauthorizationsのレコード
    • Active Recordのコールバックを利用して、元のカラムaccount_idと同じIDが入るようにする
  • 既存のauthorizationsのレコード
    • 元のカラムaccount_idと同じIDを入れるワンショットのスクリプトを作成して、実行する

詳細については事例1と同じなので省略します。

2. 一時カラムを使うようにする

次に、追加した一時カラムを使うようにアプリケーションを書き換えていきます。

Railsアプリケーションでは、Active Recordのalias_attributeという機能を使います。alias_attribute <新しいカラム名> <元のカラム名>という形式でDSLを書くと、元のカラム名を新しいカラム名で扱えるようになります。また、新しいカラム名と既存のカラム名が重複するときは、alias_attributeが優先されます5。これにより、モデルでのauthorized_account_idはテーブルにおけるtmp_authorized_account_idを指すようになります。

class Authorization < ApplicationRecord
  alias_attribute :authorized_account_id, :tmp_authorized_account_id
end

今回はこのテーブルの該当カラムを使っているのがRailsアプリケーションだけだったので、この対応だけで済みました。もし、PHPアプリケーションも該当のカラムを使っていたら、そちらでもモデルの属性とカラムのマッピングを一時的に変更する必要があります。

3. 元のカラムを削除する

一時カラムに移行し終えたら、カラム名と型を変更するために、いったん古い型を持つ元のカラムを削除します。

Active Recordを使っているときは、カラムを実際に削除する前に、ignored_columnsでそのカラムが確実に使われないようにします。その後、本番でアプリケーションが問題なく動いていることを確かめて、カラムを削除します。

ALTER TABLE authorizations DROP COLUMN authorized_account_id;

この時点でテーブルは次のようになります。

4. 元のカラムを新しい型で再追加する

元のカラムを新しい型であらためて追加します。

ALTER TABLE authorizations
ADD COLUMN authorized_account_id INT,
ADD FOREIGN KEY authorized_account_id (authorized_account_id) REFERENCES account (id) ON DELETE CASCADE;

5. 元のカラムに一時カラムと同じ値が常に入るようにする

全レコードの元のカラムに一時カラムと同じ値が同期されるように、アプリケーションでデータを投入します。

  • 新規に追加されるauthorizationsのレコード
    • Active Recordのコールバックを利用して、一時カラムtmp_account_idと同じIDが入るようにする
  • 既存のauthorizationsのレコード
    • 一時カラムtmp_account_idと同じIDを入れるワンショットのスクリプトを作成して、実行する

6. 元のカラムを使うようにする

つねに元のカラムに値が入るようになったら、元のカラムを使うようにアプリケーションを書き換えてデプロイします。

一時カラムと型は同じなので、ステップ2で説明したalias_attributeを外したり、利用するカラムの名前を一括で書き換えたりすれば同じ挙動を得られるはずです。アプリケーションの書き換えが終わったあと、Railsアプリケーションでは念のため一時カラムをignored_columnsで無視しておきます。

7. 一時カラムを削除する

一時カラムに完全にアクセスしなくなったアプリケーションが本番で問題なく動いていることを確認したら、その一時カラムをテーブルから削除します。

ALTER TABLE authorizations DROP COLUMN tmp_authorized_account_id;

これでリファクタリングは完了です。サービスを停止せず、カラムの名前が同一のまま型と外部キーを変更できました。

まとめ

この記事では、サービスを停止せずにデータベースのスキーマを徐々に変更していくデータベースリファクタリングについて、その概要とカラーミーショップでの実事例を紹介しました。

コードのリファクタリングは、開発を通じてコードに対する理解が深まったときに、その理解を反映して、コードの構造を改善するために実行します。同様に、データベースリファクタリングも、開発を通じてよりよいスキーマを見つけ、そのスキーマへと変更する作業です。このような作業はWebサービスを進化させながら運用していくうえで重要なものだと思います。

このようなWebサービスの漸進的な改善に興味があるかたは、ぜひ応募ページからご連絡ください。


  1. 規模が大きすぎるテーブル自体に問題があるのは事実ですが、歴史のあるサービスにはそのようなテーブルがしばしば存在します 

  2. 詳しくはMySQL道普請便りの「InnoDBオンラインDDLについて」などをご覧ください 

  3. 詳しくは「pt-online-schema-changeと5.6 InnoDBのオンラインALTER TABLE使い分け」をご覧ください 

  4. 2022-07-22追記: 誤りをご指摘いただいたので修正しました。ありがとうございます 

  5. この挙動はドキュメントに記載がないので仕様ではないかもしれません