大規模データベースを安全にマイグレーションする仕組み

こんにちは、Yakumo兼コネクト支援チームの@ueokandeです。 サイボウズには体験入部という制度があり、数週間〜数ヶ月の期間、他チームの業務を体験できます。 自分もこの制度を使い、1ヶ月ほどGaroon開発チームを体験してきました。

自分はこの期間で、Garoonの大規模なデータベースを安全にマイグレーションするための仕組みの設計と、そのプロトタイプを実装しました。

背景

Garoonはサービスのアップデートと同時に、データベースのマイグレーションを実行します。 ここでいうマイグレーションは、主に2つの処理があります。

  • テーブルスキーマの更新。ALTER TABLEによるカラムの追加、削除など。
  • データの変換。既存レコードのデータ編集など。

Garoonはメンテナンスウィンドウを設けてバージョンアップを実施します。 このバージョンアップが毎回確実に成功すればいいのですが、実際はそれを保証するのが難しいです。

Garoonのデータベース (MySQL) は、cybozu.com全体でテラバイト級のデータサイズになります。 アップデート時にむやみにマイグレーションを実行すると、以下の理由でマイグレーションが失敗するケースがあります。

  • 巨大なテーブルに対するALTER TABLEがメンテナンスウィンドウ内で終わらない
  • 巨大なテーブルに対するデータコンバートが、メンテナンスウィンドウ内に終わらない
  • 古いデータに、コンバートに失敗するデータがある

現在はメンテナンスウィンドウ内に確実にマイグレーションが終了するように、本番環境のバックアップデータを使ったリハーサルを実施しています。 しかし、毎回のバージョンアップでテラバイト級のバックアップデータをリストアしてリハーサル環境を構築するのは大変で、リハーサル環境そのものがメンテナンス困難になってきています。 そこでGaroon開発チームでは、リハーサル環境を使わずに、安全にデータベースマイグレーションをする仕組みに取り組んでいます。

この体験入部期間では、この仕組みの設計とそのプロトタイプ実装をしました。

安全にマイグレーションをする仕組み

データベースのマイグレーションを確実に終わらせるために、メンテナンスウィンドウではなく、事前に裏でデータコンバートを実行します。 あらかじめコンバート先のスキーマを持つ空のテーブルを作成しておき、お客様に影響がないよう徐々にデータをコピーとコンバートを実施します。 そしてバージョンアップ時に、コンバート後のテーブルをリネームして新しいテーブルに切り替えることで、マイグレーション終了です。

もしデータコンバートが失敗するデータを含まれていたとしても、アップデートの前に知ることができます。 また格納先のテーブルはマイグレーション後のスキーマを持っているので、ALTER TABLEで時間がかかるということもありません。 以降、コンバート後の格納先のテーブルを shadowedテーブル と呼ぶことにします。

shadowedテーブルの作成から、アップデートが完了までの流れは以下のとおりです。

  1. shadowedテーブルの作成
  2. TRIGGERの作成
  3. データコンバートの開始
  4. shadowedテーブルから元テーブルに切り替え

ステップ1. 2. 3. はダウンタイムなしにバックグラウンドで実施できます。 そしでメンテナンスウィンドウでステップ4.を実行して、アップデート後のGaroonはデータコンバート済みのテーブルを参照します。

どこまでコンバートが進んだかを判断するために、コンバート対象のテーブルに 単調増加するカラム が必要です。 これはAUTO_INCREMENT属性のカラムを使います(以降は id カラムとします)。 shadowedテーブルのレコードは、変換元のデータと同じ id を持ちます。

それぞれのステップについて詳しく説明していきます。

1. shadowedテーブルの作成

shadowedテーブルは、変換後のテーブルスキーマを持ちます。 このとき元テーブルに外部キー制約があっても、この時点ではshadowedテーブルに外部キー制約を付けません (CREATE TABLE ... LIKE 構文を使うと外部キー制約なしで、同じスキーマのテーブルを作成できます)。 そしてshadowedテーブルに、元データの変更があったかを示す converted カラムを追加します。 converted はデータ変換後は1がセットされ、元データに変更があれば0になります。

ここでは todo テーブルのタイムスタンプ created_at を、int(11) から timestamp 型に変換する例を考えます。 この場合CREATE TABLE ... LIKE 構文で新たなテーブルを作成できます。 そこにALTER TABLE構文で、カラムの型を変更して、converted カラムを追加します。

Shadowedテーブルの作成
Shadowedテーブルの作成

2. TRIGGERの作成

続いて元テーブルの更新、削除時を知らせるTRIGGERの作成です。 元データの BEFORE DELETEBEFORE UPDATE 時に、shadowedテーブルに converted=0 をセットします。 このTRIGGERによりshadowedテーブルにあるレコードが、古いデータということに気づくことができます。

たとえばtodoテーブルに対しては、以下の構文でそれぞれのTRIGGERを作成できます。

CREATE TRIGGER update_todo_replica BEFORE UPDATE ON todo
FOR EACH ROW
BEGIN
    UPDATE todo_shadowed SET `converted`=0 WHERE id = NEW.id;
END;

CREATE TRIGGER delete_todo_replica BEFORE DELETE ON todo
FOR EACH ROW
BEGIN
    UPDATE todo_shadowed SET `converted`=0 WHERE id = OLD.id;
END;

3. データコンバートの開始

shadowedテーブルとTRIGGERを作成したら、データコンバートをスタートできます。 データコンバートはバックグラウンドで実行し、お客様の利用に影響が無いように少しずつ処理を進めます。 この処理では以下の2つの条件を満たすまで、コンバートを繰り返します。

  1. 元テーブルとshadowedテーブルのmax(id)が一致する
  2. shadowedテーブルからconverted=0なレコードが存在しない。

もし条件1.を満たさないときは、元テーブルに新しいデータが追加されていることになります。 その場合はshadowedテーブルの max(id) より大きなIDから、条件1.を満たすまでデータコンバートしてshadowedテーブルにINSERTし続けます。 INSERTするときは元データと同じ id を使い、converted=1に設定します。

Shadowedテーブルに差分をデータコンバートする
Shadowedテーブルに差分をデータコンバートする

もし条件2.を満たさないときは、元テーブルのデータが更新または削除されたことになります。 同じ id を持つデータを再変換してshadowedテーブルをREPLACEするか、削除されている場合はshadowedテーブルからDELETEします。 REPLACEするときは同じ id を使い、再びconverted=1をセットします。

更新、削除されたデータを再コンバート
更新、削除されたデータを再コンバート

この処理を繰り返すことで、最終的に上記の条件を満たすことになり、そのときshadowedテーブルにはデータコンバート済みのデータのみが存在します。

4. テーブルの切り替え

いよいよGaroonアップデートの時間です。 テーブルの切り替えはメンテナンスウィンドウ内で実行されます。

まずテーブルの切り替え前に、上記のコンバートを確実に終了します。 このとき残りのデータ件数は少ないので即座に終了します。 それが終わると、shadowedテーブルを元のテーブル名にリネームして、元テーブルの外部キー制約を新しいテーブルに設定します。 このときforeign_key_checksを無効化して、外部キー制約のチェックをスキップします。

foreign_key_checksが有効になっていると、他のテーブルから外部キー制約で参照されているテーブルは削除できません。 またマイグレーション対象が外部キー制約を持っていたとしても、キーそのものをコンバートしない限りはマイグレーション後も外部キー制約が守れられます。 そのためforeign_key_checksを無効化しても、安全にテーブルを切り替えることができます。

TRIGGERを削除して、新しいテーブルに切り替えると、バージョンアップ完了です。

テーブルのリネーム
テーブルのリネーム

このときconvertedカラムは残り続けますが、内部のテーブル再構築を避けるためにカラムは削除しません。

制約事項

この仕組みにもいくつかの制限があります。 現段階では設計や実装の容易化のために、以下の条件は制約事項としました。

  • 外部キー自体のコンバートはできません。shadowedテーブルには外部キー制約を設定しないため、たとえば外部キー制約が設定されているuser_idの値が変わる場合は、参照先のデータがあるか保証できません。
  • MySQL 8.0未満はサポートしません。MySQLにはサーバー再起動時にAUTO_INCREMENTがリセットされる不具合(#199)があり、その場合は正常にコンバートできません。この不具合はMySQL 8.0で修正されました。

おわりに

この仕組みがうまく動くと、安全にデータコンバートを実行できるだけでなく、ゼロダウンタイムにもつなげる事ができます。 今回はプロトタイプ実装だけで終わりましたが、本番データのコンバート時間や、データベースの負荷計測などの課題も残っています。 そこは未来のGaroon開発チームに託して、自分は一旦Yakumoチームに戻ります。

以上で自分のGaroon体験入部の報告は終わりです。 体験入部ではGaroonという製品に向き合えただけでなく、Garoon開発チームの文化や開発フローについても知る、貴重な体験となりました。 Garoon開発チームの皆さんありがとうございました!