📦

Firestore Data Bundlesを活用してFirestoreの読み取りコストを削減する

2021/07/14に公開2

2020年12月頃にFirestore Data Bundlesという機能がリリースされたもののあまり話題になっていないので調べてみました。知らない人も多いのではないでしょうか?

まず参考にしたblogとドキュメントを👇 に載せておきます。公式blogが利用シーンなども書かれておりわかりやすかったのでおすすめです(英語)。

参考

Firestore Data Bundlesとは

Firestoreでは、ユーザー全員が同じデータを読み取る場合でもそれぞれのユーザー(デバイス)ごとに読み取りが発生します👇。この図のように4人がそれぞれ100Readsずつだと合計400Reads分の課金になります。

Firestore Data Bundlesを活用すると、CDNを通してFirestoreからの読み取りをBundleという形式で配信することで、読み取り回数を劇的に減らすことができます👇(図はあくまでイメージ)。しかも、デバイスからは概ねFirestoreが提供するインターフェースを通してデータを取得することができます。

👆の図ではCDNの後ろにFunctionsがいます(公式ドキュメントもそうなっている)が、Google Cloud Storageなどでも問題ありません。

実装

試したコードはこちらにおいています。TypeScriptで書いています。適宜読み替えてください。
https://github.com/mogaming217/firestore-data-bundles-sample

今回は CDN + Functions の部分を Google Cloud Storage(GCS) に置き換えて実装します。ローカルマシンからBundleを作成してGCSにアップし、そのファイルを読み取るようにします。

Bundleの作成とアップロード

import { firestore, storage } from "firebase-admin"
import * as fs from 'fs'

// projectを初期化しているだけです
import { initAdminProject } from "./helper"
initAdminProject()

const BUCKET_NAME = 'YOUR_BUCKET_NAME'
const CREATE_INITIAL_DATA = false

const main = async () => {
  const db = firestore()
  const timestamp = Date.now()

  if (CREATE_INITIAL_DATA) {
    // 100件データを作成
    await Promise.all([...Array(100)].map((_, i) => {
      return db.doc(`bundles/data_${i}`).set({
        body: `${i}`.repeat(1000).slice(0, 1000),
        timestamp: firestore.Timestamp.fromMillis(timestamp + i * 100)
      })
    }))
  }

  // firestoreからデータを読み取り、Data Bundleを作成する
  const snapshots = await db.collection('bundles').orderBy('timestamp', 'asc').get()
  const bundleID = timestamp.toString()
  const buffer = await db.bundle(bundleID).add('bundles-query', snapshots).build()

  // アップロードするためにローカルに一旦書き出す
  const bundledFilePath = `./${timestamp}.txt`
  fs.writeFileSync(bundledFilePath, buffer)

  // GCSにアップロードする
  const destination = `firestore-data-bundles/bundles.txt`
  await storage().bucket(BUCKET_NAME).upload(bundledFilePath, { destination, public: true, metadata: { cacheControl: `public, max-age=60` } })

  console.log(`uploaded to https://storage.googleapis.com/${BUCKET_NAME}/${destination}`)
  process.exit(0)
}

main()

db.bundle(bundleID).add('bundles-query', snapshots).build() の部分でBundleを作成しています。 db.bundle()BundleBuilder が返され、それに対して add で追加していく形になります。今回は QuerySnapshot を1つだけ追加していきますが、 .add() をいくつも呼び出すことができますし DocumentSnapshot を渡すこともできます。どれくらい詰め込めるのか特に記載がなかった(ようにみえた)のですが、クライアントがダウンロードすることを考えると数MBあたりが上限になってきそうです。

また、 bundleID は一旦timestampとしていますが、このIDの役割はクライアント側ですでにこのBundleを取得済みかどうかなどを判定するのに利用されているようです。また、Bundleには作成時刻も同梱されるようです。TypeScriptの方ファイルでは👇のように説明されています。

The ID of the bundle. When loaded on clients, client SDKs use this ID and the timestamp associated with the bundle to tell if it has been loaded already. If not specified, a random identifier will be used.

念の為に記載しますが、Bundleの作成はAdmin SDKを使って行う必要があることにご注意ください。Bundleとしてアップロードしたファイルは基本的には誰でも読み取れるようなデータになるとおもいます(じゃないと読み取り数の削減に効かない)ので、セキュアなデータは入れないようにしてください。

Bundleの読み取り

import axios from "axios"
import { initClientProject } from "./helper"

const app = initClientProject()

const BUNDLE_URL = 'UPLOADED_BUNDLE_URL'

const main = async () => {
  const db = app.firestore()
  const now = Date.now()

  // GCSからBundleデータを取得して読み込み
  const response = await axios.get(BUNDLE_URL)
  await db.loadBundle(response.data)

  // 読み込んだBundleデータから取得
  const query = await db.namedQuery('bundles-query')
  const snaps = await query!.get({ source: 'cache' })

  console.log(`${(Date.now() - now) / 1000}s`)
  process.exit(0)
}

main()

GCSからData Bundleを取得します。シンプルなGETリクエストになりますので、CDN等でのキャッシュが効果を発揮します。

loadBundle で読み込むと、Data Bundleがデバイスのローカルキャッシュとしても展開されるようです。あえて namedQuery メソッドを使って読み取っていますが、 db.collection('bundles').orderBy('timestamp', 'asc').get({ source: 'cache' }) でもロードしたBundleが読み取れます。source: 'cache' にしているのは、展開されたキャッシュから必ず読み取るようにしたかったためです。ここは適宜ロジックに合わせて変えると良いでしょう。

メリット

コスト削減

上述の通りFirestoreの読み取り回数を減らせるため、ランニングコストを削減することができます。下図は、各デバイスがFirestoreから直接Readする場合と、Firestore Data Bundlesを活用する場合(storageの料金)の比較です。1ドキュメント1KB、1ユーザーあたりに読み込むデータは100件として、ユーザー数を増やしていくと料金がどうなるのかを表しています。

そもそもFirestoreは非常に安価なのであまり差がありませんが、それでもGCS経由で配信するほうが5割以上安くなっています。ユーザー数が多ければ多いほど効果を発揮します。

計算式は下記です。東京リージョンで計算しています。間違っていたら教えて下さい。

  • Firestore
  • Google Cloud Storage
    • 転送量(GB) × $0.12
      • Bundle作成時の読み取りやストレージの保存料金は無視

読み取り速度の改善(ができるデータがある)

公式ドキュメントには初回に限らず読み取り速度が早くなる旨が記載されています。

While the developer benefits from the cost savings, the biggest beneficiary is the user. Loading these 50 documents from the Firebase Hosting CDN rather than from Cloud Firestore directly can easily shave 100-200ms or more from the content load time of the page. Studies have repeatedly shown that speedy pages mean happier users.

また、FirestoreはgRPCのコネクションを張る必要があることが原因で、初回読み取りが遅くなってしまう問題があります。Data Bundleの場合はCDNから読み取り、Firestoreに読み込んだ上でローカルキャッシュから取得できるため、この問題には影響を受けません。これが効果的に働くシーンはありそうです。こちらが、どれくらい早くなるのか検証してみたのですが、繰り返し実行しているとFirestoreのコネクションがマシン上である程度プールされてしまうのか、うまく検証することができませんでした。あくまで上記は理論上であることをご留意ください。一応測定値の最大を以下に記載しておきます。

  • Firestoreから100件のデータを取得する場合(初回):1.4s
  • GCSから100件分が入ったData Bundleをloadしてデータを取得する場合:0.7s
    • 上記はCDNのキャッシュにHitしなかった場合で、Hitした場合は0.2sとかになりました

デメリット

Firestoreから直接読み取るよりは、少し実装が煩雑になります。公式blogでも言及されていますが、これは少し高度な機能なため、ユーザー数が大きくないうちはあまり効果を発揮しないでしょう。

また、キャッシュの扱いを失敗してしまうと見えてはいけないデータが見えてしまったり、その他にもいろいろな事故が起こりえます。理解して使うようにしましょう。

利用シーン

公式blogに記載されていることを噛み砕いて書きます。

使うと良さそうなのは以下です。

  • アプリケーション立ち上げ時に、全クライアントが読み取るようなデータがあるとき(マスターデータなど)
  • ニュース、ブログサービスなどで、全クライアントが読み取るであろうTop10の記事など
  • 非ログインユーザーが読み取るようなスターターデータ

逆に、使わないほうがいいのは以下です。

  • ユーザーごとに異なる結果になるクエリ(usersコレクションのサブコレクションにあるようなデータ)
  • プライベートな情報が入っているデータ

個人的な感想など

いわゆる一般的なWeb APIでやっていたCDNでのレスポンスキャッシュがFirestoreでもできるようになったわけですが「APIでFirestoreのデータをJSONにしてレスポンス返して使えばいいのでは?」と一瞬思いました。しかし、Firestoreのローカルのキャッシュとして展開される点や、細かいですがTimestampの変換がいらない点など、これを使うメリットは結構あるなと思いました。

もし僕が利用するならマスターデータもそうですが、ランキング的なデータで活用すると思います。その際にはランキングが更新されたタイミングでGCSにData Bundleを配置して、クライアントからは常に同じGCSのURLを見るようにしておき、bundleIDやCache-Controlをうまく調整して扱う気がします。

ただ、非常に良さそうではあるものの、ユーザー数が大きくなってこないとあまり効果を発揮しないものなので、まだまだ話題になっていないのかなという印象です。実際に料金を見てみても、効果が出てくるようなユーザー数がいるならそれだけ利益も上がっているだろうし、その際にData Bundleを導入した場合の運用費の変化は誤差みたいなものになりそう…みたいな気持ちもあります。むずかしいですね。個人で運用しているけど結構ユーザー数がいて、運用費を削りたいなぁみたいなケースにはバッチリだと思うので、導入を検討してみてもいいかもしれません。

ちなみに、こういう新機能系はFlutterのSDK対応は遅くなりがちですが、すでに対応されているようです(リリースからそこそこ時間経ってる)。

おわりに

Firebaseを始めとした技術的な話をTwitterでもよくしているのでよければフォローお願いします!

https://twitter.com/_mogaming/status/1415113825813241864

Discussion

watari_masahirowatari_masahiro

firebase v9だと

const query = await namedQuery(db, "bundles-query");
const snaps = await query!.get({ source: "cache" });

となるのですが、
プロパティ 'get' は型 'Query<DocumentData>' に存在しません。
となります。代替案を教えていただけませんでしょうか?

mogamoga

v9ではQuery型からドキュメント群を取得するには getDocs メソッドをimportして使う必要があるので、getDocs(query!) にすればいいだけだと思いますよ!