Firestoreを4年間運用してわかった失敗と学び
みなさんこんにちは。
mikanのBackendチームでエンジニアをしております。星(@hoshitocat)です。
こちらはmikan Advent Calendar 2024の21日目の記事になります。
昨日は弊社代表の高岡(@takaokazumasa) から 「先生がもっと生徒に寄り添えるようにしていきたい」新規事業”mikan for School”の現在地 でした。
英語アプリmikanは知っているけどtoB事業ははじめて知った方や、学校や塾向けにもサービス展開していくのかと興味を持った方、mikanの新規事業どんな感じなんだと気になった方など、ぜひこの機会に読んでみてください!
はじめに
mikanでは、2020年の年末にFirestoreへ移行し、本番環境で4年以上運用してきました。
その中で、多くの学びと課題に直面しました。当初はスケーラビリティやオフライン対応に魅力を感じて導入したFirestoreですが、長期的な運用を続ける中で、コストやデータ設計上の制約の問題に悩まされることが多くありました。
この記事では、Firestoreを運用してきた具体的な失敗事例を振り返り、Firestoreを利用中の方やこれから利用しようと考えている方にとって、有益な情報となれば幸いです。
Firestore運用の概要
背景
私たちのシステムでは、もともとモバイルアプリ内でSQLiteやRealmといったアプリ内データベースを使用してデータを管理していました。これらはアプリ単体でのデータ保存や操作には十分な性能を発揮しましたが、次の2点の理由からFirestoreへ移行することにしました。
-
同一アカウント間でのデータ共有
- ユーザが複数のデバイスを使用する場合、デバイス間でのデータ同期が必須となります。たとえば、「移動中はiPhoneで学習し、自宅ではiPadで学習したい!」というような要望が多く寄せられており、ログイン済みユーザがシームレスに学習記録を同期できるようにする必要がありました。
- この要件を満たすため、サーバー側にデータを集約し、複数デバイス間でデータ共有を可能にする仕組みが求められました。
-
オフライン対応を維持
- データをサーバー側で一元管理するだけではなく、アプリ内データベースのようにオフライン時でも快適に動作することが重要でした。特に、ネットワーク接続が不安定な地下鉄車内などの状況でもデータをローカルで保持し、操作後、再接続時に同期が行われる仕組みを持つことが必要でした。
これらのニーズを実現するため、Firestoreの採用を決定しました。
Firestoreを活用したデータ構造
ユーザの学習記録と書籍データの管理を以下のようなデータ構造で管理しました。(構造の具体例は長くなってしまうため、簡単にまとめたものを箇条書きで書きました。気になる方はぜひトグルの中身も参照してください。)
- /users: ユーザー情報を格納し、学習のサマリを保存
- /users/{uid}/studied_books: ユーザが学習した本ごとの進捗を保存
- /users/{uid}/studied_books/{book_id}/studied_chapters: ユーザが学習した本に含まれる章ごとの進捗を保存
- /users/{uid}/studied_books/{book_id}/studied_chapters/{chapter_id}/studied_words: ユーザが学習した本に含まれる章に含まれる単語の進捗を保存
- /books, /books/book_id/chapters, /books/book_id/chapters/chapter_id/words: マスターデータとして本、章、単語情報をそれぞれ格納。全ユーザからアクセス
構造の具体例は以下(長いので興味ある方は参考までに参照してみてください。)
全ユーザ共通となる書籍データ
/books
├── book1
│ ├── name: "中学英単語"
│ ├── description: "中学レベルの英単語が収録されています"
│ └── chapters
│ ├── chapter1
│ │ ├── name: "動詞1"
│ │ └── words
│ │ ├── word1
│ │ │ ├── english: "run"
│ │ │ └── japanese: "走る"
│ │ └── word2
│ │ ├── english: "make"
│ │ └── japanese: "作る"
│ └── chapter2
│ ├── title: "名詞1"
│ └── words
│ ├── word1
│ │ ├── english: "apple"
│ │ └── japanese: "りんご"
│ └── word2
│ ├── english: "pen"
│ └── japanese: "ペン"
└── book2
├── title: "高校英単語"
├── description: "高校レベルの英単語が収録されています"
└── chapters
├── chapter1
│ ├── name: "動詞1"
│ └── words
│ ├── word1
│ │ ├── english: "adopt"
│ │ └── japanese: "を養子にする"
│ └── word2
│ ├── english: "borrow"
│ └── japanese: "(人や銀行などからお金)を借りる"
└── chapter2
├── title: "名詞1"
└── words
├── word1
│ ├── english: "cattle"
│ └── japanese: "畜牛"
└── word2
├── english: "police"
各ユーザごとに管理する学習データ
/users
├── user1
│ ├── current_study_streak: 5 # 連続日数
│ ├── total_studied_time: 3600 # 累計学習時間 (秒単位)
│ ├── total_studied_words: 120 # 累計学習単語数
│ └── studied_books
│ ├── book1
│ │ ├── remembered_word_count: 30 # 覚えた単語数
│ │ ├── word_count: 50 # 収録単語数
│ │ └── studied_chapters
│ │ ├── chapter1
│ │ │ ├── remembered_word_count: 10 # 覚えた単語数
│ │ │ ├── word_count: 20 # 収録単語数
│ │ │ ├── is_locked: false # 解放状態
│ │ │ └── studied_words
│ │ │ ├── word1
│ │ │ │ ├── proficiency: 5 # 習熟度 (1-5のスコア例)
│ │ │ │ └── last_studied_at: 2024-12-20T09:00:00Z # 最後に学習した日時
│ │ │ └── word2
│ │ │ ├── proficiency: 2
│ │ │ └── last_studied_at: 2024-12-19T08:30:00Z
│ │ └── chapter2
│ │ ├── remembered_word_count: 5
│ │ ├── word_count: 10
│ │ ├── is_locked: true
│ │ └── studied_words
│ │ ├── word1
│ │ │ ├── proficiency: 1
│ │ │ └── last_studied_at: null
│ │ └── word2
│ │ ├── proficiency: 0
│ │ └── last_studied_at: null
│ └── book2
│ ├── remembered_word_count: 25
│ ├── word_count: 60
│ └── studied_chapters
│ ├── chapter1
│ │ ├── remembered_word_count: 15
│ │ ├── word_count: 30
│ │ ├── is_locked: false
│ │ └── studied_words
│ │ ├── word1
│ │ │ ├── proficiency: 4
│ │ │ └── last_studied_at: 2024-12-19T20:00:00Z
│ │ └── word2
│ │ ├── proficiency: 3
│ │ └── last_studied_at: 2024-12-18T18:00:00Z
│ └── chapter2
│ ├── remembered_word_count: 10
│ ├── word_count: 20
│ ├── is_locked: true
│ └── studied_words
│ ├── word1
│ │ ├── proficiency: 1
│ │ └── last_studied_at: null
│ └── word2
│ ├── proficiency: 0
│ └── last_studied_at: null
Firestore運用での失敗や課題
高額なコスト
Firestoreは非常にスケーラブルでユーザからのアクセスがスパイクしても、ほとんど問題なくサービスが止まる状況になることはありませんでした。
しかし、導入時にコストの見積もりを正確にできておらず、運用規模が大きくなるにつれてコストの増加が気になるようになりました。現在、ストレージコスト、読み取り/書き込み合わせて 日に数万程度 かかっております。
これは同様のことを実現するために、通常のAPIとRDBインスタンスを立てて運用している場合と比較して、 2倍程度コストがかかっています。 (実は、現在FirestoreからCloud SQLへの移行PJを実施し、一部リリース完了しており、Firestoreを完全に利用しない構成となっているものもあります。その際、ユーザの学習データはすべてCloud SQLへ移行しており、読み取り/書き込みもAPIからCloud SQLへ発生しています。Firestoreの料金とそれらの構成にかかっているコストが2倍程度大きくなっています。)
Firestoreでは日毎に無料枠が設定されているため、初期フェーズではむしろ安い可能性がありますが、運用規模や長期の運用では逆にコストが高くなってしまう場合があります。
複雑なクエリへの対応
データ構造で示した通り、ドキュメントデータベースとなっており、当時は集計や検索がそこまでリッチではありませんでした。(sumやcountなどの集計関数のサポート、曖昧検索、OR条件での検索などがサポートされていませんでした。)
導入当初は問題ありませんでしたが、サービスが大きくなるにつれて、ユーザごとに複雑な条件で検索をしたくなったり、特定の教材ごとに学習しているユーザ数を算出したりすることは簡単には実現できませんでした。例えば、RDBを使えば比較的簡単なクエリを書くだけで出せるようなものが、Firestoreのような構造ですと、 すべてのDocumentを読み取りにいって、それらをアプリケーションレイヤーで集計する 必要がありました。
また、データのちょっとした修正なら、コンソールからGUI上で編集することができるのですが、広範囲にわたるようなデータの修正や、単語の削除のような非正規化されている集計データに影響する範囲でのデータ修正は困難を極めます。私たちはできるだけ削除はしない方向での運用を心がけておりましたが、どうしても必要になってしまった場合は 都度使い捨てのアプリケーションコードを書いて対応していました。
データの不整合
非正規化しているため、そりゃそうだろうという感じなのですが、集計した結果がずれてしまうことがありました。
Firestore security rules によって、簡単なバリデーションはできているものの、アプリケーションコードのバグによって集計結果が不正となってしまう場合があります。
その場合は、再集計して修正することとなるのですが、再集計するにはそのためにまたアプリケーションコードの記述が必要です。
ここでも使い捨てのスクリプトを書いて対応していました。
学び
運用規模を考慮したコスト見積もりをしっかりやるべき
Firestoreでは無料枠が利用できます。特に 新規サービスなど検証段階では高速に作ることを優先する ことも大事かと思いますが、本格的に導入する場合はコストについて慎重に検討した方が良いです。サービスがどの程度の規模まで成長する可能性を見込んでいるのか、ユーザあたりどのくらいのデータ量を扱うことになるのか 明確にし、運用開始から数年後にどのくらいの料金となるのか見積もることが重要 です。
また、サービスがスケールした場合、Firestoreから別のデータストアに移行する可能性も視野に入れるべきです。その 移行コストを将来的に支払えるのか、事前に判断しておくとより良いと思います。
さらに、Firestore以外を選択した場合の実装コストと、これから継続して発生するインフラコストや運用コストを比較し、長期的な視点で判断することが大切です。
複雑なクエリを必要としない範囲での利用を前提に設計する
Firestoreは、複雑なクエリや高度な集計を行うには不向きなデータストアです。そのため、Firestoreを利用する際は、 複雑な条件検索や大規模な集計を必要としない範囲に利用を限定し、その前提でシステム設計を行う必要があります。
Firestoreのようなドキュメントデータベースは、スキーマレスで柔軟なデータ構造を持つため、適切なユースケースでは大きな強みを発揮します。しかし、リレーショナルデータベースが適している場面も多く、Firestoreの利用が最適解でない場合もあります。適材適所を意識し、Firestoreを導入すべきか慎重に検討しましょう。
非正規化する場合、不整合をどう防ぐか、修正が必要になった場合どうするかもセットで考える
Firestoreで非正規化されたデータ構造を採用する場合、必ずデータ不整合のリスクが発生します。
これを防ぐには、FirestoreのIncrement Valueを活用するなど(Increment Valueでも不整合を完全に防げるわけではありません)、整合性を保つための仕組みを設計に組み込むことが必要です。
また、修正が必要になった場合、アプリケーションコードを書いて対応する覚悟を持つべきです。Firestoreの管理コンソールは非常に便利ですが、複雑な要件に応じた管理機能が完全に不要になるわけではありません。特に、非正規化されたデータを後から修正する作業 は手間がかかるため、そのコストを運用前から念頭に置いておくと良いと思います。
そもそもその仕様が誰のためにあるか?
そもそも論ですが、仕様を実装する際の前提として、「その仕様が誰のためにあるのか」を常に考えるべきです。たとえば、アカウント同期やオフライン対応などを実現するために、Firestoreを導入しましたが、それがサービス全体やユーザにどの程度の価値を提供できるか?今後のサービスの成長にどの程度寄与するのか冷静に判断しましょう。
オフライン対応が評価された機能の一つであることは間違いありません。しかし、現在ではサービス方針が変わり、オフライン対応を提供しない方向にシフトしました。重要な機能であることには変わりありませんが、シームレスなオフライン対応が本当に必要だったのか、他に解決手段はなかったのかなど、導入時点で十分に検討できていなかったと反省しています。
まとめ
Firestoreの導入から運用まで、これまでの経験を振り返り、失敗と学びを書き連ねてきました。ここまで読んでくださり、本当にありがとうございます。振り返ってみると、これらの失敗はすべて自分の未熟さに起因するものでした。
Firestoreそのものは、スケーラブルで柔軟性が高く、管理コンソールやSDKの使い勝手も素晴らしいツールです。さらに、公式ドキュメントも充実しており、機能拡張も着実に進んでいます。Firestoreは間違いなく非常に強力で魅力的なツールです。
しかし、それをどのように使いこなすかは設計者や運用者次第です。 Firestoreの特性や制約を十分に理解せず、短期的な視点での設計や判断が、後々の課題となって現れることを痛感しました。 これらの経験は、自分自身への戒めであり、また貴重な学びの機会でもありました。
もしこの文章が、Firestoreを導入しようとしている方や、現在運用中の方の一助になれれば幸いです。
最後に宣伝ですが、現在、mikanでは絶賛採用中です!
- 英語学習アプリmikan
- mikan for school(学習塾や学校向け)
を特に募集しておりますので、HPの応募フォームからでも、XからDMでもなんでも良いのでお声がけください!お待ちしております!
Discussion