本記事は生成AIと共同で執筆しています。事実関係は可能な範囲で公式ドキュメント等と照合していますが、誤りが含まれている可能性があります。重要な判断を行う前にご自身でも一次情報をご確認ください。

地図上にピンを立てて和歌・謡曲・物語などの文学作品を場所と紐づける、実験的なアーカイブアプリを開発しています。当初はデータストアに Firebase Firestore を使っていましたが、運用してみると、このアプリのワークロードとは相性が合わない場面が増えてきたため、データ層を Postgres(Neon)+ Drizzle ORM(Object-Relational Mapping)へ移行しました。本記事ではその経緯と、判断の材料になったワークロードの特徴を整理します。

「Firestore か Postgres か」という二択というよりも、そのアプリで一番多い操作が「キーで引く」なのか「横断的に数える・検索する」なのかで、向き不向きが分かれるようだ、という整理になります。

何を作っているか

データモデルはおおよそ次の構成です。

  • projects — プロジェクト
  • pins — 地図上のピン(緯度経度・タグ・資料参照)
  • materials — 資料
  • yorei(用例)— 作品の本文(原文・読み・訳・考察)を pin や material に紐づけたもの
  • activity — 操作ログ

ログイン(Google アカウント)と合わせて Firebase で一通り完結し、サーバーレスで運用も手軽だったため、小規模な実験ツールには順当な選択に見えていました。データ件数自体も数千件規模で、量の面で困るような状況ではありませんでした。

読み取り課金で一覧ページが止まった

運用中に、用例一覧ページ(/yorei)の表示が止まることがありました。調べた限りでは、Firestore 無料枠の 1 日あたりの読み取り上限(公開されている範囲では 50,000 回)に達していたことが原因のようでした。

数千件規模のデータでなぜ読み取りが枯渇するのか、というのが当初の疑問でした。Firestore の課金は、基本的には読み取ったドキュメントの件数で測られます(厳密には、結果が 0 件のクエリでも最低 1 read、走査したインデックスエントリ 1000 件ごとにも 1 read が加算されます)。一方で、このアプリが一覧ページでやりたかったことは、件数課金とは相性が合わないものでした。

  • 資料ごとの用例件数を一覧の横に表示する
  • ピンごとの用例件数を表示する
  • 絞り込み用のカテゴリ候補を列挙する
  • 本文に対する部分一致検索

Firestore にも count() / sum() / avg() の集計クエリがあり(count() は 2023 年、sum() / avg() は 2024 年に追加)、サーバー側で集計して結果だけを返すぶん、全ドキュメントを読むより安価です。ただしこれは単一の値を返すもので、「資料ごと」「ピンごと」のようなグループ別のファセットを出すにはグループの数だけクエリを投げることになり、GROUP BY 相当はこの時点の標準のクエリ API には用意されていませんでした(グループ別集計は後に Enterprise 版の Pipelines で対応)。集計クエリはリアルタイムリスナーと併用できないといった制約もあります。結局このアプリでは対象ドキュメントを読んでアプリ側で数える実装になっており、一覧を開くたびに数千ドキュメントを読み、それが利用者数と操作回数で積み重なって、上限に達していました。

つまり、行き詰まった理由はデータ量ではなく、ワークロードの形にありました。

必要だった操作の形

整理すると、このアプリで頻度の高い操作は次の三つでした。

  1. 集計・ファセット — 「この資料に用例が何件」「このピンに何件」を一覧の横に常時表示する
  2. 全文検索 — 本文(原文・読み・訳・考察)への部分一致
  3. 複合ソート — 「巻3-12」のような文字列を数値 2 軸に分解して並べる

これらはリレーショナルデータベースが得意とする領域で、ドキュメントデータベースでは工夫が要る部分です。Firestore は、キーで引く・等価条件で絞る・変更をリアルタイムに購読する、といった用途では扱いやすい一方、横断的に数える・検索する・結合するといった操作は設計の主眼から外れているようです。

加えて、Firestore の利点であるリアルタイム同期を、このアプリはあまり使っていませんでした。複数人で編集はするものの、同じピンを同時に奪い合う共同編集ではなく、非同期に分担して触る程度の使い方だったため、購読の仕組みから得られるものが限られていました。

Postgres に移して素直に書けた部分

移行先には Neon(サーバーレスの Postgres)を選びました。理由は、アイドル時にコンピュートを停止する scale-to-zero があり無料枠で小規模な実験を回せること、デプロイ先の Vercel との接続が扱いやすいことです。ORM は SQL に近い書き味の Drizzle を使いました。

先に挙げた三つの操作は、いずれも 1 つのクエリにまとまりました。

資料別の用例件数は GROUP BY 一回で、アプリ側は件数だけを受け取ります。

select material_id, count(*)::int as n
from yorei
where project_id = $1
group by material_id;

用例は複数のピンに紐づくため pin_ids を配列で持っていますが、ピン別件数は配列を unnest して数えられます。

select pid as pin_id, count(*)::int as n
from yorei, unnest(pin_ids) as pid
where project_id = $1
group by pid;

本文の部分一致は pg_trgm 拡張の GIN(Generalized Inverted Index)インデックスを使いました。タイトル・著者・原文・読み・訳・考察を連結した式にインデックスを張り、ILIKE の部分一致が効くようにしています。

create extension if not exists pg_trgm;

create index yorei_search_trgm_idx on yorei using gin (
  (
    coalesce(title, '') || ' ' ||
    coalesce(author, '') || ' ' ||
    coalesce(body_original, '') || ' ' ||
    coalesce(body_reading, '') || ' ' ||
    coalesce(body_translation, '') || ' ' ||
    coalesce(context, '')
  ) gin_trgm_ops
);

「巻3-12」のような文字列での並べ替えも、正規表現で 2 軸に分解して宣言的に書けました。

order by
  nullif(substring(location from '巻(\d+)-'), '')::int asc nulls last,
  nullif(substring(location from '巻\d+-(\d+)'), '')::int asc nulls last;

ここで効いてくるのは、これらが何件を対象にしても「クエリの実行」という単位で扱われる点です。読み取り件数に比例した課金ではないため、インデックスを適切に張れば、集計やファセットを一覧の標準機能として持たせやすくなりました。

スキーマ側では、外部キーと ON DELETE CASCADE、enum 型、NOT NULL 制約といった制約を宣言できるようになりました。Firestore の柔軟なスキーマレス構造は、裏を返すと整合性の担保をアプリ側で持つ必要があり、その分の負担が減った形です。

移行に伴って変えた設計

良い面だけではなく、移行に伴って作り直した部分や、トレードオフもあります。

  • リアルタイム同期は自前で扱う前提になりました。Firestore の購読(onSnapshot)に相当する仕組みはないため、必要なら別途実装します。今回は要件として不要だったため外せましたが、共同編集が中心の用途では逆の判断になります。
  • コールドスタートがあります。Neon の無料枠は一定時間アイドルが続くとコンピュートが停止するため、久しぶりのアクセスでは起動待ちが入ります。常時即応が必要であれば、有料の使用量ベースプランが選択肢になります。
  • 接続管理という新しい関心事が増えました。サーバーレス関数から Postgres へ接続する構成では接続が枯れやすいため、接続を張る箇所を 1 ファイル(src/lib/db/client.ts)に集約し、遅延初期化する形にしています。
  • ブラウザから直接データベースを触れなくなりました。Firestore はクライアント SDK とセキュリティルールで直接アクセスできましたが、Postgres ではそうはいかないため、すべて API ルート経由に変更し、認可ロジック(所有者と編集者の和集合)を 1 ファイル(authz.ts)に集約しました。手数は増えましたが、アクセス制御の見通しはむしろ良くなりました。
  • ログインは Firebase Auth のまま残しました。データ層をすべて移す必要はなく、認証は Firebase が担当し、ID トークンの検証にだけ Admin SDK を使っています。RAG(Retrieval-Augmented Generation)チャット側のベクトル検索基盤も変更していません。強い部分は残し、相性の合わない部分だけ差し替える形にしています。

ワークロード別の向き不向き

今回の経験を一般化すると、次のような整理になりそうです。

キーで引く・リアルタイムに同期する操作が中心なら Firestore が扱いやすく、横断的に数える・検索する・並べる操作が中心ならリレーショナルデータベースが扱いやすい。

判断の分かれ目はデータ量の大小ではなく、ワークロードの形にあるように見えます。このアプリは、データが数千件しかない時期から、実はリレーショナルデータベース寄りのワークロードでした。それが無料枠の読み取り上限という形で表面化したのだと考えています。

Firestore に問題があるというより、集計・ファセット・全文検索が中心のアプリにドキュメントデータベースを当てたところに相性の不一致があった、という捉え方をしています。リアルタイムの共同編集や、キー引き中心のモバイルアプリであれば、そのまま Firestore を使い続けるのが素直だったはずです。技術選定の段階で「このアプリで一番多い操作は何か」を一文で言えていれば、最初から Postgres を選んでいたかもしれない、というのが振り返っての所感です。