Firestore で格安な全文検索・ページネーションを実現するには
モチベ
Firestore を使った Web アプリを開発しており、全文検索に対応したくなった。
Firestore は全文検索に対応していないので、公式では Elasticsearch, Algolia 等の使用を勧めている。
しかし、それらを使うにしても、検索だけ RDB を使うにしても基本的に固定費がかかる。最小無料、従量課金制を実現したい。
実現したいこと
- 全文検索
- クエリは2つのフィールドに
LIKE
検索をかける、ORDER BY
する、くらい - 今後検索条件が増える見込みはあまりない
- クエリは2つのフィールドに
- offset, limit を指定してのページネーション
- 従量課金制
妥協できる点
- レイテンシの増大 (何秒まで許容できるかは動かしながら探る)
- インデックス反映までの時間
データ量
私のユースケースでは、検索対象のドキュメントのサイズは 30 MiB (1024文字 * 3 (日本語) * 10,000ドキュメント) あればおさまる程度。
結論
BigQuery に Firestore のデータを入れ、LIKE
検索とページネーションを捌く。
なお、Firebase 公式が出している、BigQuery と簡単にデータを同期できる firebase/firestore-bigquery-export@0.1.51
extension は、Firebase Emulator が対応していない機能を使っているために動作せず、なおかつ Firebase Emulator 側で対応される目処がないので採用しなかった[1]。
なお、BigQuery Emulator も使っていない。Storage Write API で書き込みできなかったからだ[2]。
ローカル環境、CI 環境ではそれぞれ Firebase Emulator から本物の BigQuery に読み書きするように設定した[3]。
flexsearch, AWS EFS, AWS Lambda を使った全文検索
というときに見つけたのが flexserach, AWS Lambda, AWS EFS を使って作る面白い全文検索。
私のユースケースだとレイテンシーが数秒超えそうな匂いがするが、実際どんなもんかを検証したい。
Google Cloud 版
Google Cloud の Cloud Filestore + Cloud Functions でも実装できないかと思ったが、Filestore は今回のユースケースだと高額になってしまった。Filestore の料金説明には
インスタンス容量: 未使用の場合でも、割り当てられたストレージ容量に対して課金されます。
とあり、最低 1 TiB の割り当てしかできないのでインスタンスタイプ「リージョン」だと $460.80/月。
BigQuery を使う
BigQuery にデータを保持しておき、SQL で全文検索なりページネーションクエリを捌けばいいのでは?
Firestore のデータを BigQuery に流し込む公式の extension があってお手軽に実装できる説がある。
今回のユースケースではストレージ料金は無視できるほど安い。なんなら無料枠に収まる。
問題はクエリの料金だが検索対象のサイズが小さいので問題なさそうな見込み。
extension のインストール。
firebase ext:install firebase/firestore-bigquery-export --project=PROJECT_ID
同期したいコレクションのパスは途中で質問される。ワイルドカードを使わない場合、コレクション1つに対して1つの拡張インストールが必要っぽい。
Collection path: What is the path of the collection that you would like to export? You may use {wildcard} notation to match a subcollection of all documents in a collection (for example: chatrooms/{chatid}/posts). Parent Firestore Document IDs from {wildcards} can be returned in path_params as a JSON formatted string.
? Enter a value for Collection path: (posts)
初期設定のためにいくつか質問されるが、インストールが完了すると extensions/firestore-bigquery-export.env ファイルが生成される。設定内容はこのファイルに記載されているので、変更したければファイルをいじればいい。
BIGQUERY_PROJECT_ID=<PROJECT_ID>
COLLECTION_PATH=works
DATASET_ID=firestore_export
DATASET_LOCATION=asia-northeast1
EXCLUDE_OLD_DATA=yes
firebaseextensions.v1beta.function/location=us-central1
MAX_DISPATCHES_PER_SECOND=100
TABLE_ID=works
TABLE_PARTITIONING=NONE
TIME_PARTITIONING_FIELD_TYPE=omit
USE_COLLECTION_GROUP_QUERY=yes
USE_NEW_SNAPSHOT_QUERY_SYNTAX=yes
WILDCARD_IDS=true
各オプションの意味:
- BIGQUERY_PROJECT_ID
- BigQuery インスタンスを置くプロジェクト ID。Firebase と別プロジェクトにすることも可能な模様
- COLLECTION_PATH
- エクスポートしたいコレクションのパス
- DATASET_ID
- BigQuery dataset ID
- DATASET_LOCATION
- BigQuery dataset を作るロケーション
- EXCLUDE_OLD_DATA
-
onDocumentUpdate
イベントで変更される前のスナップショットを含めるかどうか - BigQuery 上では
old_data
というカラムで保存されるようだ
-
- firebaseextensions.v1beta.function/location
- (推測)extension で自動生成される functions のロケーション
- MAX_DISPATCHES_PER_SECOND
- 1秒あたりの最大同期ドキュメント数
- TABLE_ID
- BigQuery のテーブルの prefix として使われる文字列
- TABLE_PARTITIONING
- パーティショニングするかどうかと、パーティショニングの粒度
- パーティショニングはクエリのパフォーマンスに影響するのでした方がいいだろう
- TIME_PARTITIONING_FIELD_TYPE
- パーティショニングのカラム名
- USE_COLLECTION_GROUP_QUERY
- コレクショングループクエリを有効にするか。つまり、対象のコレクションがサブコレクションの場合は有効にする必要がある
- USE_NEW_SNAPSHOT_QUERY_SYNTAX
- スナップショットを新しい syntax で生成する。パフォーマンス向上らしい
- WILDCARD_IDS
- 全てのドキュメントを同期する
firebase.json には拡張が自動的に設定されている。
diff --git a/firebase.json b/firebase.json
index 006b2dd..833d57d 100644
--- a/firebase.json
+++ b/firebase.json
@@ -32,5 +32,8 @@
"enabled": true,
"host": "0.0.0.0"
}
+ },
+ "extensions": {
+ "firestore-bigquery-export": "firebase/firestore-bigquery-export@0.1.51"
}
}
デプロイ。
firebase deploy --only extensions --project=PROJECT_ID
Firestore でドキュメントを追加すると BigQuery にも反映された。Firestore ドキュメントの中身は data フィールドに JSON として格納されている。
あとやりたいこと:
- JSON を任意の View に変換する
- Cloud Functions から BigQury にクエリを投げて結果を返す
- ローカルではエミュレータを使う
- 複数コレクションを同期する
- 既存のドキュメントを同期する
参考になりそうな記事
JSON を任意の View に変換する
下記の手順に従う。
extensions/firestore-bigquery-export/guides/GENERATE_SCHEMA_VIEWS.md at master · firebase/extensions
スキーマ定義のファイルを作って
{
"fields": [
{
"name": "title",
"type": "string"
},
{
"name": "description",
"type": "string"
}
]
}
コマンドを実行するとエラーになった。
$ npx @firebaseextensions/fs-bq-schema-views \
--non-interactive \
--project=<PROJECT_ID> \
--big-query-project=<PROJECT_ID> \
--dataset=firestore_export \
--table-name-prefix=works \
--schema-files=./schema.json
Need to install the following packages:
@firebaseextensions/fs-bq-schema-views@0.4.7
Ok to proceed? (y)
npm WARN deprecated inflight@1.0.6: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.
npm WARN deprecated glob@8.1.0: Glob versions prior to v9 are no longer supported
npm WARN deprecated glob@7.1.5: Glob versions prior to v9 are no longer supported
error: unknown option '--big-query-project=<PROJECT_ID>'
--big-query-project
を抜いて実行したらできた。
結果
npx @firebaseextensions/fs-bq-schema-views \
--non-interactive \
--project=<PROJECT_ID> \
--dataset=firestore_export \
--table-name-prefix=works \
--schema-files=./schema.json
(node:38565) [DEP0040] DeprecationWarning: The `punycode` module is deprecated. Please use a userland alternative instead.
(Use `node --trace-deprecation ...` to show where the warning was created)
BigQuery creating schema view works_schema_schema_changelog:
Schema:
{"fields":[{"name":"title","type":"string","extractor":"title"},{"name":"description","type":"string","extractor":"description"}]}
Query:
SELECT
document_name,
document_id,
timestamp,
operation,
JSON_EXTRACT_SCALAR(data, '$.title') AS title,
JSON_EXTRACT_SCALAR(data, '$.description') AS description
FROM
`<PROJECT_ID>.firestore_export.works_raw_changelog`
BigQuery created schema view works_schema_schema_changelog
BigQuery creating schema view works_schema_schema_latest:
Schema:
{"fields":[{"name":"title","type":"string","extractor":"title"},{"name":"description","type":"string","extractor":"description"}]}
Query:
-- Given a user-defined schema over a raw JSON changelog, returns the
-- schema elements of the latest set of live documents in the collection.
-- timestamp: The Firestore timestamp at which the event took place.
-- operation: One of INSERT, UPDATE, DELETE, IMPORT.
-- event_id: The event that wrote this row.
-- <schema-fields>: This can be one, many, or no typed-columns
-- corresponding to fields defined in the schema.
SELECT
document_name,
document_id,
timestamp,
operation,
title,
description
FROM
(
SELECT
document_name,
document_id,
FIRST_VALUE(timestamp) OVER(
PARTITION BY document_name
ORDER BY
timestamp DESC
) AS timestamp,
FIRST_VALUE(operation) OVER(
PARTITION BY document_name
ORDER BY
timestamp DESC
) AS operation,
FIRST_VALUE(operation) OVER(
PARTITION BY document_name
ORDER BY
timestamp DESC
) = "DELETE" AS is_deleted,
FIRST_VALUE(JSON_EXTRACT_SCALAR(data, '$.title')) OVER(
PARTITION BY document_name
ORDER BY
timestamp DESC
) AS title,
FIRST_VALUE(JSON_EXTRACT_SCALAR(data, '$.description')) OVER(
PARTITION BY document_name
ORDER BY
timestamp DESC
) AS description
FROM
`<PROJECT_ID>.firestore_export.works_raw_latest`
)
WHERE
NOT is_deleted
GROUP BY
document_name,
document_id,
timestamp,
operation,
title,
description
BigQuery created view works_schema_schema_latest
done.
works_schema_schema_latest
テーブルが作られた。最高!
ローカルではエミュレータを使う
ローカルでの開発や CI でのテストのために、Firebase と BigQuery をそれぞれエミュレータで動かしてデータの同期をしたい。
BigQuery エミュレータと Firebase Local Emulator をそれぞれ起動した。
エミュレータ用のプロジェクト ID は demo-bq-firestore にした。demo-* にするとデモプロジェクトとなり、実際のプロジェクトは使用されない。
BigQuery エミュレータは起動してる。
$ bq --api http://localhost:9050 query --project_id=demo-bq-firestore 'SELECT 1'
+-------+
| $col1 |
+-------+
| 1 |
+-------+
Emulator に --project
オプションで指定すると demo-bq-firestore が使われるようになる。
firebase emulators:start --project=demo-bq-firestore
i emulators: Detected demo project ID "demo-bq-firestore", emulated services will use a demo configuration and attempts to access non-emulated services for this project will fail.
⚠ Extensions: The following Extensions make calls to Google Cloud APIs that do not have Emulators. demo-bq-firestore is a demo project, so these Extensions may not work as expected.
┌─────────────────────────┬──────────────────────────┬──────────────────────────────┬────────────────────────────────────────────┐
│ API Name │ Instances using this API │ Enabled on demo-bq-firestore │ Enable this API │
├─────────────────────────┼──────────────────────────┼──────────────────────────────┼────────────────────────────────────────────┤
│ bigquery.googleapis.com │ firestore-bigquery-expo… │ No │ https://firebase.tools/l/LoMXec6nyrFK9XGx6 │
└─────────────────────────┴──────────────────────────┴──────────────────────────────┴────────────────────────────────────────────┘
Enabled on demo-bq-firestore
が No になっており、Firebase Emulator は BigQuery に対応していないのでそれはそう。
試しに Firestore にドキュメントを作ると、同期に失敗してエラーが出る。
⚠ functions: Error: Failed to determine service account. Initialize the SDK with service account credentials or set service account ID as an app option.
Firebase Emulator と BigQuery Emulator を接続することを一旦やめて、Firebase Emulator と本物の BigQuery が接続できるかを検証してみる。
結局これになってしまった。
Error: Failed to determine service account. Initialize the SDK with service account credentials or set service account ID as an app option.
Firebase Emulator 起動時の警告
⚠ Function 'fsimportexistingdocs is missing a trigger in extension.yaml. Please add one, as triggers defined in code are ignored.
⚠ Function 'syncBigQuery is missing a trigger in extension.yaml. Please add one, as triggers defined in code are ignored.
⚠ Function 'initBigQuerySync is missing a trigger in extension.yaml. Please add one, as triggers defined in code are ignored.
⚠ Function 'setupBigQuerySync is missing a trigger in extension.yaml. Please add one, as triggers defined in code are ignored.
⚠ functions: The functions emulator is configured but there is no functions source directory. Have you run firebase init functions?
⚠ functions: The following emulators are not running, calls to these services from the Functions emulator will affect production: database, hosting, pubsub, storage
i firestore: Firestore Emulator logging to firestore-debug.log
✔ firestore: Firestore Emulator UI websocket is running on 9150.
i ui: Emulator UI logging to ui-debug.log
i functions: Watching "/Users/9sako6/.cache/firebase/extensions/firebase/firestore-bigquery-export@0.1.51/functions" for Cloud Functions...
✔ functions: Loaded functions definitions from source: fsexportbigquery, fsimportexistingdocs, syncBigQuery, initBigQuerySync, setupBigQuerySync.
✔ functions[asia-northeast1-ext-firestore-bigquery-export-cnf7-fsexportbigquery]: firestore function initialized.
⚠ Unsupported function type on ext-firestore-bigquery-export-cnf7-fsimportexistingdocs. Expected either an httpsTrigger, eventTrigger, or blockingTrigger.
i functions[asia-northeast1-ext-firestore-bigquery-export-cnf7-fsimportexistingdocs]: function ignored because the unknown emulator does not exist or is not running.
⚠ Unsupported function type on ext-firestore-bigquery-export-cnf7-syncBigQuery. Expected either an httpsTrigger, eventTrigger, or blockingTrigger.
i functions[asia-northeast1-ext-firestore-bigquery-export-cnf7-syncBigQuery]: function ignored because the unknown emulator does not exist or is not running.
⚠ Unsupported function type on ext-firestore-bigquery-export-cnf7-initBigQuerySync. Expected either an httpsTrigger, eventTrigger, or blockingTrigger.
i functions[asia-northeast1-ext-firestore-bigquery-export-cnf7-initBigQuerySync]: function ignored because the unknown emulator does not exist or is not running.
⚠ Unsupported function type on ext-firestore-bigquery-export-cnf7-setupBigQuerySync. Expected either an httpsTrigger, eventTrigger, or blockingTrigger.
i functions[asia-northeast1-ext-firestore-bigquery-export-cnf7-setupBigQuerySync]: function ignored because the unknown emulator does not exist or is not running.
この警告は Firebase Emulator 側がサポートしていない機能を使っているために出る。
SHIFT さんの記事にあったバージョンに合わせると、上記エラーも警告も出なくなった。
firebase/firestore-bigquery-export@0.1.51
-> firebase/firestore-bigquery-export@0.1.31
が、下記エラーが出るようになった。
この問題はすでに修正してくれているようなので、修正が取り込まれているバージョンに切り替えて試す。
0.1.34 にて修正が取り込まれたようだ。
しかし残念なことに、0.1.33 から下記警告が出るようになり、service account に関するエラーも再発した。したがって 0.1.34 に上げることができない。uh...
⚠ Function 'syncBigQuery is missing a trigger in extension.yaml. Please add one, as triggers defined in code are ignored.
⚠ Function 'setupBigQuerySync is missing a trigger in extension.yaml. Please add one, as triggers defined in code are ignored.
Error when mirroring data to BigQuery FirebaseFunctionsError: Failed to determine service account. Initialize the SDK with service account credentials or set service account ID as an app option.
0.1.32 から 0.1.33 にかけての差分はこちら。
影響がありそうな変更
Firebase Emulator 側が対応してくれるのが一番いい。しかし、issue は2年くらいオープンなままなのでサポートを期待するのはきつそう。
firebase/firestore-bigquery-export を使うのは諦めて、自前で BigQuery に連携することにした。
BigQuery の書き込み・読み込み
デザイン
- Firestore のドキュメント追加・更新イベントをトリガーとして、検索用 BigQuery に書き込む
- 書き込みは Storage Write API でやる
- 高頻度な更新・削除は BigQuery のユースケースにそぐわないため、ドキュメントは常にレコードに追加一辺倒。ただし、
inserted_at
カラムを用意し、アプリではそれが最新のものだけを取得して扱うようにする。
[
...
{
"name": "inserted_at",
"type": "TIMESTAMP",
"description": "The timestamp when the record was inserted into the BigQuery table",
"defaultValueExpression": "CURRENT_TIMESTAMP()"
}
]
上記のスキーマ定義と併せて、書き込み側では JSONWriter
の defaultMissingValueInterpretation
を設定することで、inserted_at
は自動的に現在のタイムスタンプが入るようになっている。
const writer = new managedwriter.JSONWriter({
connection,
protoDescriptor,
defaultMissingValueInterpretation:
protos.google.cloud.bigquery.storage.v1.AppendRowsRequest.MissingValueInterpretation.DEFAULT_VALUE,
});
読み取り側では、inserted_at
が最新のものだけを扱う。
const query = `
SELECT id, title, description
FROM (
SELECT
id,
title,
description,
ROW_NUMBER() OVER (PARTITION BY id ORDER BY inserted_at DESC) AS record_num
FROM \`${bigqueryProjectId.value()}.${bigqueryDatasetId.value()}.works\`
)
WHERE record_num = 1 AND (title LIKE @word OR description LIKE @word)
`;
一通り方針を立てて実装して、CI 上では Firebase Emulator + 本物 BigQuery でテストするようにした。
ローカルの開発環境においても、Firebase Emulator + 本物 BigQuery を使っている。
BigQuery Emulator を使いたかったが、Storage Write API を使った場合に書き込めなかったので諦めた。README を見る限りは対応してそうだけどなあ。
version: 0.6.2
Supports gRPC-based read/write using BigQuery Storage API. Supports both Apache Avro and Arrow formats.
ほぼ自分しか開発していないため現状は問題ない。
注意点として、たとえ new BigQuery()
する際に本物の BigQuery がある projectId
を指定しても、疎通できなかった。GOOGLE_CLOUD_QUOTA_PROJECT
も一時的に書き換える必要がある。
try {
// NOTE: 設定しないとエミュレータを使う場合にエミュレータのプロジェクトIDにクエリが飛ぶ
process.env.GOOGLE_CLOUD_QUOTA_PROJECT = bigqueryProjectId.value();
const bigquery = new BigQuery({
projectId: bigqueryProjectId.value(),
...(process.env.CI
? {
keyFilename: bigqueryCredentialsPath.value(),
}
: {}),
});
// ...
} finally {
process.env.GOOGLE_CLOUD_QUOTA_PROJECT = projectID.value();
}
環境
- @google-cloud/bigquery-storage@4.8.0
- @google-cloud/bigquery@7.8.0
- firebase-functions@5.0.1
- firebase-admin@12.1.1