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

IIIF(International Image Interoperability Framework)で配信されている画像に注釈(アノテーション)を付ける Web エディタを Next.js + Firebase で運用しています。アノテーションは Firestore に保存しており、当初は「1 アノテーション = 1 ドキュメント」という素直なデータモデルでした。

この構成である日、アノテーションが表示されず、新規追加もできない状態になりました。調べた範囲では、Firestore の無料枠にある 1 日あたりの読み取り回数の上限に達していたことが原因でした。プラン変更で読み書き自体はすぐ戻りましたが、そもそも読み取りが上限に達したこと自体が設計に起因していたため、保存モデルを見直しました。

この記事では、「1 件 1 ドキュメント」という保存単位が読み取りコストの面でどう効いてくるか、実データを見ながら整理し、canvas 単位でまとめて保存する構成へ移行した過程を書きます。

「1件1ドキュメント」で読み取りが増える仕組み

旧構成では、annotations コレクションに 1 アノテーションを 1 ドキュメントとして保存していました。ドキュメントには userId / manifestId / canvasId と、本文・対象領域(矩形やポリゴンのセレクタ)などが入ります。

Firestore の課金と無料枠は、読み書きした「ドキュメント数」で数えます。ここが要点で、あるコマ(IIIF の canvas)を開いたときに何ドキュメント読むかが、そのまま読み取り回数になります。

エディタでは、canvas を表示するたびにその canvas のアノテーションを取得します。旧実装では、この取得が manifest 全体のアノテーション(全 canvas 分)を読み込んでからクライアント側で現在の canvas だけに絞る、という形になっていました。つまり、

  • canvas を 1 枚開く → その manifest の全アノテーションを読む
  • ページを送る → また全アノテーションを読む

という具合に、読み取り回数がアノテーション件数に比例して増えていきます。加えて「自分の全アノテーション一覧」のページも、ユーザーの全アノテーションを 1 件ずつ読んでいました。

実データで見えたこと

対象は歴史資料(国絵図など)の注釈で、バックアップ時点の件数は次のようになっていました。

アノテーション総数: 14,338 件
(manifest × canvas) のグループ数: 920
1 コマあたり最大: 1,215 件

特に、ある 1 枚の国絵図(1 manifest = 1 canvas)に 1,215 件の注釈が付いていました。旧実装だと、このコマを 1 回開くだけで 1,215 ドキュメントの読み取りが発生します。ページを何度か開き直したり、複数人で作業したりすれば、読み取りはあっという間に積み上がります。書き込みが 1 日数十件でも、読み取りだけが桁違いに大きくなる状態でした。

保存単位を canvas 側に寄せる

読み取り回数を「アノテーション件数」ではなく「もっと粗い単位」に切り替えたいので、保存単位を canvas に寄せます。IIIF でいえば、1 つの canvas に対する注釈の集まりは AnnotationPage に相当します。これをそのまま 1 ドキュメントにして、注釈を配列 items[] として持たせます。

annotationPages/{docId}
  {
    userId, manifestId, canvasId,
    items: [ { id, body, target, ... }, ... ]
  }

こうすると、canvas を 1 枚表示するのに読むドキュメントは原則 1 件で済みます。注釈が 1,215 件あっても、1 ドキュメントを読むだけです。

1MiBの上限とシャード分割

ただし Firestore には 1 ドキュメントあたり 1MiB という上限があります。ポリゴンの対象領域(SVG セレクタ)はそれなりに大きく、1 件あたり概ね 1〜2KB になります。1,215 件だと単純計算で 1.2〜2.4MB となり、単一ドキュメントには収まりません。

そこで、1 つの canvas をバイトサイズで複数の「シャード」ドキュメントに自動分割します。ソフト上限を 700KB に置き、追加すると超えそうなら次のシャードに回します。

docId  = base + "_" + shard     (shard = 0, 1, 2, ...)
base   = sha256(userId + "\n" + manifestId + "\n" + canvasId)

basemanifestId も含めているのは、同じ canvasId が複数の manifest から参照されうる IIIF の性質に合わせて、manifest ごとに集合を分けるためです。

実データで移行計画を確認すると、920 グループが 921 シャードになりました。複数シャードに分かれたのは例の 1,215 件のコマだけで、2 シャードに分かれました。分割は、シャード 0 をソフト上限(700KB)の手前まで詰めてから残りを次のシャードに回す形なので、最大のシャードで 683KB 程度、もう一方はその残りで、これより小さくなります。

Items(移行対象): 14,338
Page shards to write: 921
Max shards / canvas: 2
Largest shard items JSON: 683.0 KiB

つまり、密なコマでも読み取りは 2 件、ふつうのコマは 1 件です。1,215 → 2 なので、このコマに関しては読み取りが 600 分の 1 ほどになります。

id設計と更新・削除

注釈を個別に更新・削除できる必要があります。配列に入れてしまうと個々の注釈のアドレスが失われそうですが、注釈の iddocId + ":" + suffix の合成 id にすることで、id だけから所属シャードのドキュメントを復元できます。HTTP の DELETE はボディを持たせにくいので、id 単体で場所が特定できる形にしておくと扱いやすくなります。

書き込みはトランザクションで行い、対象シャードを読み、配列を更新して書き戻します。所有者チェック(userId の照合)もサーバ側で明示的に行います。Firestore の Admin SDK はセキュリティルールをバイパスするため、ここでの照合が実質的な権限チェックになります。

// 概念コード(抜粋)
const base = sha256(`${userId}\n${manifestId}\n${canvasId}`);
// 追加してもソフト上限を超えないシャードを探し、無ければ新規シャード
await db.runTransaction(async (tx) => {
  const snap = await tx.get(ref);
  const items = snap.exists ? snap.data().items : [];
  items.push({ id: `${docId}:${uuid()}`, body, target, /* ... */ });
  tx.set(ref, { userId, manifestId, canvasId, base, shard, items }, { merge: true });
});

配列に対して arrayUnion で末尾追加もできますが、シャード選択のために既存サイズを知りたいことと、更新・削除では結局配列を読み書きするため、ここでは素直にトランザクションで読み書きしています。

移行の進め方

本番データを触るので、非破壊・冪等・検証可能を原則にしました。

  • annotations コレクションは変更せずそのまま残す(そのままバックアップになる)
  • annotationPages へは決定的なドキュメント id で書き込むので、再実行しても同じ結果になる
  • 移行後に、旧件数と新 items の合計件数を突き合わせる

実行はドライラン(書き込みなし)で件数と最大シャードサイズを確認してから、本番切替の短い時間帯にまとめて行いました。切替中は書き込みを一時停止するメンテナンスフラグを用意し、移行 → デプロイ → 表示確認 → 解除、の順で進めました。最終的な件数突合は次のように一致しました。

Verify (global): dest items = 14,338, expected = 14,338
OK: 件数一致

読み取りが減った経路

保存モデルの変更に合わせて、読み取り経路も整理しました。

  • エディタの canvas 表示: manifest 全体を読む → その canvas のシャードだけを読む(1〜数件)
  • 自分の注釈一覧: 全アノテーションを 1 件ずつ読む → シャードを読んで集計。さらに、一覧を manifest → canvas → 注釈のドリルダウンにし、概要は件数だけの軽い API で取得、注釈本文は開いた canvas の分だけ取得する
  • 作成・更新・削除の後: 全件再取得して全再描画していたのを、変更した 1 件だけ反映するようにした

読み取りの単位が「アノテーション件数」から「シャード数」に変わったことで、件数が増えても読み取りが比例して増えることはなくなりました。

トレードオフ

いくつか副作用もあります。

  • 書き込みは、対象シャード(最大 700KB 程度)を丸ごと書き戻すため、旧構成の「小さな 1 ドキュメントを書く」より 1 回あたりは重くなります。ただし課金はバイト数ではなくドキュメント数なので、書き込み 1 回は 1 write のままです。書き込みは読み取りに比べて回数が桁違いに少ないため、全体としては読み取りを軽くする方が効きます。
  • 1 コマの注釈本文をまとめて返すため、密なコマでは応答が 1MB 前後になります。これは注釈の量そのものに由来する大きさで、シャード化で増えたわけではありません。JSON はホスティング側(Vercel)で gzip / brotli 圧縮されます。座標やタグ名の繰り返しが多い JSON はよく圧縮されるため、転送量は本文サイズより小さくなります。

「1 件 1 ドキュメント」は書き込みや個別操作が素直で分かりやすい一方、読み取りの単位が細かくなるぶん、表示のたびに件数分のドキュメントを読む構成になりがちです。今回のように 1 つの対象に注釈が多く付くケースでは、保存単位を一段まとめて(ここでは canvas 単位・1MiB を超える場合はシャード分割)、読み取りの単位を粗くすることで、読み取り回数を件数から切り離せました。同じ Firestore でも、何を 1 ドキュメントにするかで読み取りコストの出方が変わる、という一例です。