Cloud FirestoreからPostgreSQLへ移行したお話

OGP

はじめに

こんにちは。ブランドソリューション開発本部FAANSバックエンドブロックの田村です。普段はサーバサイドエンジニアとしてFAANSのバックエンドシステムの開発をしています。
FAANSとは、弊社が2022年8月に正式ローンチした、アパレル店舗のショップスタッフの販売サポートツールです。FAANSでは、データベースとしてGCPのサーバレスでドキュメント指向のNoSQLデータベースであるCloud Firestoreを当初採用していました。Cloud Firestoreはサーバレスなので運用負荷が掛からず、また安価でスケーラビリティにも優れたハイパフォーマンスなデータベースです。
しかし、Cloud Firestoreを使用して開発・運用していく中で直面した様々な課題からGCPのフルマネージドのリレーショナルデータベースであるCloud SQLのPostgreSQLにデータベースのリプレイスを実施しました。
本記事では、Cloud FirestoreからPostgreSQLへのリプレイス過程を紹介します。

Cloud Firestoreを採用していた理由

FAANSのバックエンドのシステムで利用するデータベースとしてCloud Firestoreを当初採用していました。FAANSのシステムは、アパレル企業毎にその企業と紐付いたスタッフ情報やショップ情報をデータベースで管理します。ユーザーが自分の所属企業とは別の企業のデータにアクセスできてしまわないように、データベース内で企業をテナントの単位としたマルチテナンシーを実現できるデータベースが必要でした。
Cloud Firestoreにはサブコレクションという概念があり、階層構造でデータを保持できます。トップレベルのコレクションとして企業データを管理し、その配下のサブコレクションとしてその企業と紐づくデータを保持することによって、企業単位でデータを完全に分離しマルチテナンシーを安全に運用できます。

Cloud Firestore時代の開発と直面した課題

Cloud Firestoreはドキュメント指向のNoSQLデータベースであるにも関わらず、普段使い慣れているリレーショナルデータベースで求められるようなリレーションモデルの正規化思考が抜けずにデータモデリングしてしまっていて反省すべき点でもあるのですが、データを取得するために仕方なくN+1問題が発生するようなクエリを発行していました。
その結果、Web APIサーバの一部のエンドポイントではリクエストあたりのCloud Firestoreへのクエリ発行回数が極めて多い実装となり、Web APIサーバへのリクエストのレスポンスタイムが大変遅くなってしまうという問題が発生しました。

この問題を解決するために、画面仕様に合わせて適切に非正規化されたデータモデルを設計する必要があると考えました。具体的にどういうことかを、スタッフ一覧の画面を例に説明していきます。

画面仕様を「スタッフの名前とスタッフが所属している店舗の名前をリストで表示する」とします。Cloud Firestoreのデータベース設計を下記のように仮定します。

Cloud Firestoreのデータベース1

StaffMemberは、とある企業に所属しているスタッフを表現しています。Shopは、店舗ショップ情報を表現しており、スタッフはいずれかのショップに所属しているとします。必要な情報を取得する場合、StaffMemberコレクションから取得した後に、ShopIDを基にShopコレクションからデータを取得する必要があります。パフォーマンス最適化のことを考えると、1コレクションのみから取得できるのが理想です。
そのため、画面仕様に合わせてスタッフ名や所属ショップ名といったデータを適切に非正規化されたデータモデルで保持するために、そのための新しいコレクションを用意するのが良いと考えられます。以下の例では、StaffMemberInfoという新規コレクションを作成しています。

Cloud Firestoreのデータベース2

そうすることでN+1問題は回避できますが、新たな問題が生まれてしまいます。画面仕様が「スタッフ名、所属ショップ名、所属事業名のリストを表示する」に変更された場合、新たなフィールドの追加が必要となります。仕様変更の度に変更する必要があり、仕様変更に弱い作りとなります。
さらに、ショップ名等更新があるたびに非正規化している値も更新する必要があります。更新処理が複雑になったり、実装漏れによる更新忘れが発生してしまうことも考えられます。また、非正規化された設計がゆえに、依然として1リクエストあたりのクエリの発行回数が多いためWeb APIサーバーのレスポンスタイムが遅いという問題も残りました。
今後FAANSは事業や組織としても大きくなり、仕様変更の頻度も増えていくと考えられます。そのため、データはできる限り正規化して正しく保持して仕様変更に強くしておくべきだと考えていました。

PostgreSQL採用理由

マルチテナンシーの要件に対応できるデータベースを改めて技術調査したところ、PostgreSQLでRLS(Row Level Security)の機能を利用して実現できることが分かりました。Row Level Securityとは、行単位へのアクセス制御を可能にする仕組みで、PostgreSQL Version 9.5から利用できます

例えば、企業情報を保持しているcompaniesというテーブルがあるとします。下記のように設定することで、特定のcompany_idに一致するデータのみ取得可能となります。

ALTER TABLE companies ENABLE ROW LEVEL SECURITY;
CREATE POLICY multi_tenant_policy ON companies AS PERMISSIVE FOR ALL TO public USING ((id)::text = current_setting('app.company_id'::text));

データ取得の際は、下記のようにクエリを発行することで、company_idが123である企業情報を取得することが可能となります。

BEGIN;

SET LOCAL app.company_id = '123';
SELECT * FROM companies;

COMMIT;

SET LOCALにすることにより、このトランザクション内で有効なcompany_idを指定できます。ここでapp.company_idを指定せずにcompaniesを取得しようとするとエラーが出てクエリの実行に失敗します。これにより、誤って別企業のデータを閲覧してしまうことは無くなります。また、適切に正規化できていれば、画面仕様を変更する際は取得するクエリを変更するだけで済みます。

Row Level Securityで安全にマルチテナンシーを運用できる点と、 リレーショナルデータベースで採用されているリレーショナルモデルの方が画面仕様の変更に強い作りとしやすい点からPostgreSQLを採用しました。

Row Level Securityの適用方法

Row Level Securityを利用することになり、安全に運用するために必ずトランザクション内でCRUD処理を行うようにしています。指定された企業ID(companyID)で企業情報を取得する処理のGo言語による実装例を以下に示します。RunTransaction内では、前述のSET LOCAL app.company_idが実行され、必ず企業IDが設定されている状態にしています。もし、RunTransactionを忘れて取得処理を記述してしまっても、クエリの実行時にエラーで失敗するように実装していますのでそのようなミスは発生しません。

err = txm.RunTransaction(ctx, companyID, func(ctx context.Context) error {
    // 企業情報取得処理...
})
if err != nil {
    return err
}

PostgreSQL移行実装

PostgreSQL移行の実装をするにあたり、下記の項目を決定しつつ行いました。

  • スキーマ管理方法
  • 開発環境、本番環境へのDDL(Data Definition Language)適用方法
  • データベースアクセスライブラリの選定
  • 既存開発と並行して移行を実装する方針
  • データベースマイグレーション
  • リリース作業

スキーマ管理方法

開発時のDDLの定義方法は、自分でSQLを実行するかデータベース管理ツールでテーブル定義をするかは、個人の選択に任されています。ただし、最終的なテーブル定義は、チーム全体で揃える必要があります。
そこで利用したのが、sqldefというツールです。sqldefは、適用先のデータベーススキーマとの差分を検出して、必要なDDLを自動で生成や実行してくれる、データベース変更管理ツールです。その中の機能で、指定のデータベースに存在するテーブルのDDLをSQLファイルとして出力できます。生成されたSQLファイルをコミットして管理することで、開発時の定義方法は異なっていたとしても、最終的な成果物は同じ形式で出力されます。

開発環境、本番環境へのDDL適用方法

適用時もsqldefを用いて、コミットされているsqlファイルと適用先のデータベースのスキーマとの間で差分を検出し、その差分のDDLを適用する形で運用しています。テーブル定義に変更が入るような改修をする場合は、差分DDLの結果をPull Requestのコメントとして自動出力されるようにしました。そうすることで、意図しない変更をしようとしていないかを確認できます。レビューで問題なくマージされた場合、CI/CDにより自動でDDL適用がされます。
まとめると、下記の流れとなります。

  1. ローカル環境のデータベースに新規テーブル定義を追加する
  2. sqldefで生成されるSQLファイルをgit commitする
  3. Pull Requestを作成して、開発環境で実行されるDDLがPull Requestのコメントに自動で出力される
  4. 問題なければ、mainブランチにマージして開発環境に自動的に適用される

データベースアクセスライブラリの選定

Go言語でPostgreSQLにクエリを発行する際に使用するライブラリとして、データベースのテーブル定義を元にコードの自動生成が可能で、型安全で記述ができるSQLBoilerを採用しました。マイグレーション機能はありませんが、前述の通りマイグレーションに関してはsqldefを利用しています。
下記にデータ投入時と取得時のコード例を記載します。ここでは、企業データのモデルをCompanyとします。

データ投入例

company := &model.Company{
  Name: company.Name,
}
if err := company.Insert(ctx, db, boil.Infer()); err != nil {
  return nil, err
}

データ取得例

企業名が、「株式会社ZOZO」のデータを取得する際を例にすると、次のように型安全で記述できます。

companies, err := model.Companies(
    model.CompanyWhere.Name.EQ("株式会社ZOZO"),
).All(ctx, db)
if err != nil {
    return nil, err
}

既存開発と並行して移行を実装する方針

FAANSのバックエンドで動作しているWeb APIサーバーのアーキテクチャとして、クリーンアーキテクチャとRepositoryパターンを採用しています。省略している箇所もありますが、アーキテクチャ図は下記のようになります。

Cloud Firestoreバージョンのアーキテクチャ図

PostgreSQLを実装するに当たり、変更箇所は下記の赤い部分となります。

PostgreSQLバージョンのアーキテクチャ図

PostgreSQLの実装には、Use Case層とRepository層の箇所のみ変更が必要でした。Use Case層は、RLSを適用するために若干の変更が必要でしたが、ほとんどは既存コードのままで実装できました。Repository層の部分をPostgreSQL接続に変更することで、実装が完了する流れでした。

データベースの移行を決断した当時、FAANSは既にCloud Firestoreで本番運用されている状態にあり、また既存機能の改修や新機能の開発が日々進行していました。そのため、既存機能に影響がないようにデータベース移行の開発をする必要があったので、PostgreSQL用のUse Case層とRepository層の実装は、別ファイルで作成することにしました。つまり、Cloud Firestore用のUse Case, RepositoryとPostgreSQL用のUse Case2, Repository2の、どちらのコードも混在している状態です。
ただし、Use Case層とRepository層をinterfaceで抽象化し、wireというGo言語の依存性注入ライブラリでレイヤー間の依存性を管理するようにしていました。そうすることで、本番稼働しているCloud Firestore版の既存機能に影響を出さずに並行してPostgreSQL版の開発を進められるようにしました。

もちろん、Cloud Firestore版の実装の仕様に修正が入った場合は、PostgreSQL版の実装も同様の修正が必要になります。こちらに関しては、定期的に差分を確認して取り込むということを行っておりました。しかし、定期的に差分を取り込むといっても、手作業となるため漏れが発生していました。そこで、移行コード開発終盤時に最終確認のため、全差分チェックを行うことで漏れを防ぐように気をつけました。
PostgreSQL版の開発の途中からは、Cloud Firestore版のコードを改修する際にPostgreSQL版の実装との間に差分が発生しないようにPostgreSQL版の改修も併せて行う決まりにしたので、それ以降の実装漏れは最小限に抑えられたかと思います。反省点としては、開発速度は少し落ちてしまいますが、もう少し早い段階からこのような方針で進めておくべきだったと思います。

データベースマイグレーション

Cloud FirestoreからPostgreSQLへのデータ移行では、データをマイグレーションする必要があります。そのため、Cloud Firestoreからデータを取得してPostgreSQLにデータを投入する処理を作成しました。
この処理の流れは、下記のシーケンス図で示します。

データベースマイグレーション処理のシーケンス図

ただし、データ投入だけでは正しいデータで登録されているか不安になります。そこで、Cloud Firestoreから取得したデータとPostgreSQLから取得したデータを比較して検証し、移行の安全性を高めました。

30個ほどのテーブルを、依存関係に応じてグループ分けし、マイグレーションを実施するように実装しました。例えば、事業データは企業データに依存しているので、企業データが作成された後に事業データを作成する必要があります。このような依存関係を元に、企業はAグループ、事業はBグループのように割り振っていきました。その結果、4つのグループに絞られました。

SQLBoilerにはBulk Insertの処理が実装されておらず、また1件ずつ書き込む場合は許容できないほどの時間が掛かってしまうことが想定されました。そこで、Bulk Insertを行うSQLBoilerのテンプレートファイルを作成することで、効率的にデータ投入しました。Bulk Insertテンプレートファイルは、こちらの記事を参考にさせていただきました。

リリース作業

リリース作業は、以下のような流れでした。

  • メンテナンス開始
  • データベースマイグレーション実施
  • Web APIサーバのサービス切り替え
  • QA(品質保証)テスト
  • メンテナンス解除

Web APIサーバのサービスの切り替えは、下記の図のようにドメイン先のサービスをPostgreSQL版に切り替えるようなイメージです。

Web API サーバのサービスの切り替えイメージ

まずは、開発環境で実際にリプレイス実施してみて、手順等に問題がないかリハーサルを行いました。リハーサル時には、表示言語の差によって付与したい権限を探すのに手間取ったり、意図したサービスの方にトラフィックが流れているかの確認方法が少し手間だったり、細かいところに気づけました。本番作業までに、それらの手間を無くすようにして、万全の状態で本番リリース作業を行ったため、スムーズに切り替えを完了できました。

リプレイスの結果

Cloud Firestore利用時は、パフォーマンス最適化のためではありましたが、別コレクションに同じ情報を保持するように非正規化を行っていました。PostgreSQLにリプレイスすることにより、正規化されたデータとして全てのデータを持つことができるようになりました。これにより、非正規化されていたデータが故の複雑な処理を実装する必要もなくなり、処理がシンプルで理解しやすくなったように思います。今後の新機能開発は、特別な場合を除いて非正規化のことを考える必要がなくなりましたので、よりスムーズに開発できそうです。

さらに、パフォーマンス最適化が出来ていなかったAPIのレスポンスタイムが、劇的に改善されました。下図から、リプレイスを実施した2022年12月15日以降でWeb APIのレイテンシーが全体的に改善されていることが分かります。

Web APIのリプレイス前リプレイス後のレイテンシー推移

また、リリース後から一ヶ月ほどシステムの様子をみましたが、リプレイスによる目立ったシステム障害は発生しませんでした。データの表示が速くなり快適になったとのお声もいただき、リプレイスは大成功に終わりました。

さいごに

ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。

corp.zozo.com

カテゴリー