🏑

FirestoreのcollectionGroupクエリで気をつけるべきindex設定

に公開

こんにちは、stakです。

現在立ち上げているサービスは、コストを抑えるためにNoSQLのFirestoreをデータベースに採用しています。なかなか厳しい技術選定ですが、将来のRDBへの移管も見据えながらも今はとにかくこれでやるしかないという感じでやっています。その上で、普段はなかなか出会うことのないエラーについて書き留めて置こうと思っております。

背景

Firestoreには、同名のサブコレクションを横断して検索する collectionGroupクエリ という機能があります。

今回、あるサブコレクションを横断検索するcollectionGroupクエリを実装しました。

Firestore構造:
parents/{parentId}/items/{itemId}
const snapshot = await db
  .collectionGroup('items')
  .where('category', '==', someValue)
  .get();

課題

ローカルのエミュレータでは問題なく動作していましたが、本番環境にデプロイしたところ以下のエラーが発生しました。

FAILED_PRECONDITION: The query requires an index.

「インデックスが足りないんだろうな」と思ってcomposite indexを追加したものの、解決できませんでした。

「COLLECTION_GROUPスコープのインデックスが必要なら、composite indexで作ればいいのでは?」と考え、以下のように定義しました。

# 最初の試み(失敗)
{
  name        = "items_category_collection_group"
  collection  = "items"
  query_scope = "COLLECTION_GROUP"
  fields = [
    { field_path = "category", order = "ASCENDING" },
  ]
}

しかし、これは正しく動作しません。最終的にsingle-field index exemptionという仕組みに辿り着くまでに少し遠回りしました。

Firestoreインデックスの基礎知識

コレクションとコレクショングループ

Firestoreでは、ドキュメントは コレクション の中に格納されます。さらに、各ドキュメントの下に サブコレクション を持つことができます。

users/              ← 「usersコレクション」
  ├── alice/
  │   └── posts/    ← aliceのサブコレクション「posts」
  │       ├── post1
  │       └── post2
  └── bob/
      └── posts/    ← bobのサブコレクション「posts」
          ├── post3
          └── post4
  • コレクション: 特定のパス直下にあるドキュメントの集まり。db.collection('users/alice/posts') とすると、post1・post2だけが対象になります。
  • コレクショングループ: 同じ名前のコレクションを、パスに関係なくすべてまとめたもの。db.collectionGroup('posts') とすると、alice・bob両方のpostsからpost1〜post4すべてが対象になります。

この「コレクショングループ」に対してクエリを実行するのが、collectionGroupクエリです。

自動インデックス(単一フィールドインデックス)

Firestoreは各フィールドに対して 自動的に単一フィールドインデックスを作成 します。特別な設定は不要で、以下のようなシンプルなクエリはそのまま動きます。

// 自動インデックスで動く
db.collection('users').where('name', '==', 'Alice');

ただし、この自動インデックスには重要な制約があります。

自動インデックスのスコープは COLLECTION のみ

つまり、通常のコレクションクエリには使えますが、collectionGroupクエリには使えません。

コレクションクエリ vs コレクショングループクエリ

■ コレクションクエリ (COLLECTION スコープ)
  特定のパス配下のドキュメントだけを検索

  parents/alice/items ← ここだけ検索
  parents/bob/items


■ コレクショングループクエリ (COLLECTION_GROUP スコープ)
  同名のサブコレクションを横断して検索

  parents/alice/items ← 両方検索
  parents/bob/items   ←

自動インデックスはCOLLECTIONスコープしか持たないため、コレクショングループクエリではインデックスが見つからず FAILED_PRECONDITION になります。

composite index(複合インデックス)

複数フィールドを組み合わせたクエリや、COLLECTION_GROUPスコープが必要な場合は、手動でcomposite indexを作成します。

// composite indexが必要(複数フィールド × COLLECTION_GROUP)
db.collectionGroup('comments')
  .where('type', '==', 'bot')
  .where('hidden', '==', true)
  .get();

このケースでは、Terraformの google_firestore_index リソースで定義できます。

# 複数フィールド × COLLECTION_GROUP → composite index でOK
resource "google_firestore_index" "comments_type_hidden" {
  collection  = "comments"
  query_scope = "COLLECTION_GROUP"

  fields {
    field_path = "type"
    order      = "ASCENDING"
  }
  fields {
    field_path = "hidden"
    order      = "ASCENDING"
  }
}

今回の事象の原因

今回のクエリは 単一フィールド のcollectionGroupクエリでした。

db.collectionGroup('items')
  .where('category', '==', someValue)
  .get();

Firestoreのcomposite indexは 2つ以上のフィールドを含むインデックス として設計されています。単一フィールドでcomposite indexを作成しようとすると、APIレベルで拒否されるか、期待通りに機能しません。

整理するとこうなります。

クエリ条件 スコープ 必要なインデックス
単一フィールド COLLECTION 自動インデックス(設定不要)
単一フィールド COLLECTION_GROUP single-field index exemption
複数フィールド COLLECTION composite index
複数フィールド COLLECTION_GROUP composite index(query_scope指定)

単一フィールド × COLLECTION_GROUP は、composite indexではなく single-field index exemption で対応する必要があります。

解決策: single-field index exemption

single-field index exemptionは、自動インデックスの挙動を上書きして、特定のフィールドに対して追加のスコープやソート順を有効化する仕組みです。

設定方法

Terraform

google_firestore_field リソースを使います(google_firestore_index ではありません)。

resource "google_firestore_field" "items_category" {
  project    = var.project_id
  database   = "(default)"
  collection = "items"
  field      = "category"

  index_config {
    # COLLECTION_GROUP スコープ(これが今回必要だったもの)
    indexes {
      order       = "ASCENDING"
      query_scope = "COLLECTION_GROUP"
    }
    indexes {
      order       = "DESCENDING"
      query_scope = "COLLECTION_GROUP"
    }
    # COLLECTION スコープ(自動インデックスの代わり)
    indexes {
      order = "ASCENDING"
    }
    indexes {
      order = "DESCENDING"
    }
  }
}

まとめ

collectionGroupクエリで FAILED_PRECONDITION が出たときの判断フローをまとめます。

collectionGroupクエリでFAILED_PRECONDITION

├─ クエリ条件が2フィールド以上?
│   └─ YES → composite index (google_firestore_index) で
│            query_scope = "COLLECTION_GROUP" を指定

└─ クエリ条件が1フィールドのみ?
    └─ YES → single-field index exemption (google_firestore_field) で
             COLLECTION_GROUP スコープを有効化
  • 自動インデックスはCOLLECTIONスコープのみ。 collectionGroupクエリには追加のインデックス設定が必要
  • 単一フィールド × COLLECTION_GROUPはcomposite indexではなくsingle-field index exemption。 Terraformでは google_firestore_index ではなく google_firestore_field を使う
  • exemption設定時は自動インデックスが無効化される。 COLLECTIONスコープのインデックスも明示的に定義しないと通常のクエリが壊れる

感想

Firestoreのindexは種類がいろいろあってRDBにはない独自の複雑性があるなと思いました。ただやはり、NoSQLとは言ってもindexは必要ですし、目的はRDBと同じで、設定方法が異なるだけです。NoSQLであっても適切に張っていきたいと思います。

参考

Discussion