Firestore の料金は「あとで見ればいい細かい話」ではなく、スキーマ設計とクエリの書き方をそのまま縛ってくる制約だ。課金単位は ドキュメント操作1回 であって、クエリ1回でもバイト数でも接続数でもない。ここさえ腹に落ちれば、世に出回っている「Firestore で破産した」話のほとんどは事前に読める。
課金モデルを1段落で
読み取り・書き込み・削除は回数、ストレージは GiB/月、外向き転送は転送量で課金される。100件返すクエリはドキュメントサイズに関係なく100読み取り。0件のクエリでもゼロにはならず最小課金が発生する。Blaze の目安(US マルチリージョン)はざっくり読み取り $0.06 / 10万回、書き込み $0.18 / 10万回、削除 $0.02 / 10万回、ストレージ $0.18 / GiB/月。無料枠は1日あたり読み取り5万・書き込み2万・削除2万、保存1 GiB。
この単価は安く見える。ユーザー数 × 画面表示回数 × 1画面あたりのドキュメント数を掛けるまでは。
N+1 はレイテンシの問題ではなく請求書の問題
RDB での N+1 はレイテンシとコネクションプールの話だが、Firestore では請求金額の掛け算そのものになる。
// 投稿20件
const postsSnapshot = await db.collection('posts').limit(20).get();
for (const doc of postsSnapshot.docs) {
const post = doc.data();
// 1件ごとに +1 read、しかも直列
const userDoc = await db.collection('users').doc(post.authorId).get();
render(post.title, userDoc.data().name);
}
タイムライン1回の描画で40読み取り。DAU 1,000人が1日3回開くだけで12万読み取り/日。表示名を出すためだけに午前中で無料枠が溶ける。
逃げ道は3つ、コミットの重い順に。
1. まとめて引く
author ID を重複排除して in クエリにする。ユーザードキュメントの読み取り数自体は変わらないが、ラウンドトリップが1回になり、同じ著者が複数回出てくるケースでは実際に読み取りも減る。
const ids = [...new Set(posts.map(p => p.authorId))];
// in は1クエリ最大30値
const chunks = chunk(ids, 30);
const users = (await Promise.all(
chunks.map(c => db.collection('users').where('__name__', 'in', c).get())
)).flatMap(s => s.docs);
2. 表示用フィールドを親に埋め込む(非正規化)
NoSQL としての本来の答えはこれ。
// posts/post_123
{
"title": "Firestore の料金設計",
"author": { "id": "user_abc", "name": "山田太郎", "avatarUrl": "https://..." }
}
40読み取りが20読み取りになる。代わりにコストは書き込み側へ移る。ユーザーが名前を変えたら過去の投稿へファンアウト更新が要る。読み取りが書き込みを桁で上回るなら明確に得で、タイムライン系はまさにそう。逆に頻繁に変わるフィールドや、強い整合性が要るもの(認可判断に使うメールアドレスなど)を埋め込んではいけない。
3. join は受け入れてキャッシュで潰す
ユーザードキュメントは小さく・変化が少なく・再利用率が高い。クライアント SDK のローカルキャッシュがタダで面倒を見てくれる領域(後述)。
数えるために全件取るな
一番高くつくミスがこれ。
// 「1000」という数字を表示するために1,000読み取り
const likes = await db.collection('posts').doc(id).collection('likes').get();
return likes.size;
集計クエリを使う。課金はドキュメント数ではなくスキャンしたインデックスエントリ1,000件につき1読み取り。
import { getCountFromServer } from 'firebase/firestore';
const snap = await getCountFromServer(db.collection('posts').doc(id).collection('likes'));
return snap.data().count;
一覧画面に出るようなホットなカウンタは、投稿ドキュメント側に likeCount を持たせて FieldValue.increment(1) で維持するほうがいい。ただし単一ドキュメントの持続的な書き込み上限は毎秒1回程度なので、バズる可能性があるならカウンタをシャーディングする。
onSnapshot は見ていない間も課金し続ける
get() は自分が聞きに行ったときに課金される。onSnapshot はデータが変わったときに課金される。これはリスクの質が根本的に違う。コストを決めるのが他人の書き込みトラフィックと、タブが開きっぱなしの時間だからだ。
リスナーは初回の結果セットを1回課金し、その後は配信された変更ドキュメント数ぶん課金する。壊れ方は掛け算で効く。
- 投稿ドキュメントに
likeCountがあり、リアルタイム更新される - 100人がいいね → 100回の更新 → 開いている全リスナーそれぞれに 100読み取り
- その画面を1,000人が開いていたら → 数秒で10万読み取り
抑え込むルール:
-
onSnapshotはリアルタイムが機能そのものである場所だけ(チャット、プレゼンス、共同編集)。設定画面・アーカイブ・一覧はget()。 - 必ず unsubscribe する。剥がし忘れたリスナーは、ユーザーが画面を離れたあとも回り続けるメーター。
useEffect(() => {
const unsubscribe = onSnapshot(docRef, snap => setData(snap.data()));
return () => unsubscribe(); // 任意ではない
}, [docRef]);
- 広くリッスンされるドキュメントに、頻繁に変わるフィールドを置かない。
likeCountが投稿ドキュメントにあると、いいね1回ごとに投稿全体が全リスナーへ再配信される。詳細画面だけが購読する別ドキュメントに逃がす。 - 長時間起動するアプリなら
visibilitychangeでバックグラウンドタブのリスナーを外す。
クライアントキャッシュは一級のコスト対策
Web / モバイル SDK は、一度取得したデータをローカルから返してくれる。
import { initializeFirestore, persistentLocalCache } from 'firebase/firestore';
const db = initializeFirestore(app, {
localCache: persistentLocalCache(),
});
一度開いた画面に戻る操作がタダになる。個別の読み取りを getDocFromCache(失敗したらネットワーク)でキャッシュ優先にすることもできる。スキーマ変更が要らない、一番安い最適化。
Terraform で作る
DB 本体・複合インデックス・セキュリティルールはすべてコードで宣言できる。deletion_policy は本番なら ABANDON(+削除保護)にしておく。ワークスペースを間違えた terraform destroy が DB を持っていかないように。
resource "google_firestore_database" "database" {
project = var.project_id
name = "(default)"
location_id = "asia-northeast1"
type = "FIRESTORE_NATIVE"
concurrency_mode = "OPTIMISTIC"
app_engine_integration_mode = "DISABLED"
deletion_policy = "ABANDON"
}
resource "google_firestore_index" "posts_status_created_at" {
project = google_firestore_database.database.project
database = google_firestore_database.database.name
collection = "posts"
fields {
field_path = "status"
order = "ASCENDING"
}
fields {
field_path = "created_at"
order = "DESCENDING"
}
}
resource "google_firebaserules_ruleset" "firestore" {
project = var.project_id
source {
files {
name = "firestore.rules"
content = file("${path.module}/firestore.rules")
}
}
}
resource "google_firebaserules_release" "firestore" {
project = var.project_id
name = "cloud.firestore"
ruleset_name = google_firebaserules_ruleset.firestore.name
}
FIRESTORE_NATIVE か DATASTORE_MODE かは、あとから戻せない唯一の決定。モードは DB 作成時に固定される。Native mode はリアルタイムリスナー・クライアント SDK 直結・セキュリティルールが使える。Datastore mode は旧 Cloud Datastore 互換のための存在で、アクセス制御は IAM のみ。新規なら、バックエンドからしか触らない場合でも無条件に Native mode でいい。
クライアントアクセスの防壁はセキュリティルールだけ
ブラウザが Firestore を直接叩く構成では、ルールは多層防御の一枚ではなく防御のすべてだ。デフォルト全拒否から始めて、狭く開け、書き込みの形も検証する。
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /{document=**} {
allow read, write: if false;
}
match /users/{userId} {
allow read: if request.auth != null;
allow write: if request.auth != null && request.auth.uid == userId;
}
match /posts/{postId} {
allow read: if true;
allow create: if request.auth != null
&& request.resource.data.authorId == request.auth.uid
&& request.resource.data.title is string
&& request.resource.data.title.size() <= 100;
allow update, delete: if request.auth != null
&& resource.data.authorId == request.auth.uid;
}
}
}
よく間違えるのが2点。
-
resource.dataは書き込み前のドキュメント、request.resource.dataはクライアントが送ってきた変更後の内容。所有者チェックは前者、フィールド検証は後者を見る。 -
ルールはフィルタではない。 「自分のドキュメントだけ読める」ルールを書いても
collection('posts').get()が絞り込まれた結果を返すわけではなく、クエリごと失敗する。クエリ側をルールに合う形に制約する必要がある。
あと Admin SDK はルールを完全にバイパスする。その資格情報を持つバックエンドは全権限を持つと考えて、サービスアカウントには roles/datastore.user を与え、roles/datastore.owner は渡さない。
チェックリスト
- 1画面の描画で何 read 走っているかをネットワークログで数え、想定 DAU × セッション数を掛ける
- ループの中で
await get()していないか。inでまとめるか非正規化する - 数を出すためにコレクションを
.get()して.sizeしていないか。count()かカウンタフィールドを使う -
onSnapshotはリアルタイムが機能である場所だけ、かつ必ず unsubscribe - 頻繁に更新されるフィールドを、広くリッスンされるドキュメントに置かない
- 永続ローカルキャッシュを有効にする
- 必要になる前に予算アラートを設定する