はじめに
DataflowでFirestoreに大量データを投入する機会があって、性能チューニング周りで苦労したのでまとめておく。
大人の事情でサンプルコードは出せないので、概念だけ。
TL;DR
- とにかくエラーレスポンスを発生させないことが大事
- https://firebase.google.com/docs/firestore/best-practices?hl=ja を真面目に全部やる
やりたかったこと
- 約1億件のデータを、なるべく短時間でfirestoreにインポートしたい
- インポートは、dataflowを使って並列処理で行う
基本戦略
エラーが起きたらリトライ
並列処理だと、クオータに引っかかったり、サーバ側がたまたま過負荷だったりして、リクエストが正しくてもエラー応答を食らうことがまれによくある。そのため、リトライの機構が必須。(負荷起因のエラーなら、同じリクエストを投げ続ければ、いつかは成功する)
リトライ戦略は、 Exponential backoff を採用。
(GCSのページだけど、 https://cloud.google.com/storage/docs/exponential-backoff?hl=ja あたりが参考になる)
要は 1回目 1000ms待つ, 2回目 2000ms待つ, 3回目 4000ms待つ, , , と waitを倍々していくリトライ戦略。
pythonなら、 https://pypi.org/project/retry/ あたりで実装されている。
とはいえ、エラーは極力起こさないように努める
エラーが! 消えるまで! 投げるのを! やめない!
という戦略を取るので、一見、エラーが起きても気にせず実行すれば良い気がしてくる。
しかし、 前述の exponential backoffは、リトライ回数が増えるごとにwaitが指数関数的に増えるので、エラーが連続すればするほど、waitが伸び、トータルの実行時間に響いてくる。
よって、エラーをなるべく起こさない投げ方をするほうが、結果的に早く終わる。
エラーを起こさないために出来ること
ここからは、エラーの発生を抑えつつ、秒あたりのリクエスト量を最大化するために実施したことを並べていく。
世の中にはあまり動作するサンプルがなかったので苦労した。(公開出来ないのが悲しい)
ID設計
連番IDで書き込むと、書き込み先が分散しないので性能が出ない。
Quotaとは直接関係しないが、 インスタンスのスケール速度を上回るリクエストを投げると、 5xx系のエラーが返ってくるため、書き込み先を分散させるために、連番のIDはhash化するなど、程よく分散させる必要がある。
リクエストレート
firestoreは、リクエスト量に応じて段階的にスケールするらしく、いきなり Quotaギリギリで投げると耐えられないことがある。そこで、スケールする時間を稼ぐために、 500/50/5ルール に従って段階的にリクエストを増やす必要がある。
具体的には
新しいコレクションに対するオペレーションは、毎秒 500 回を上限とし、その後、5 分ごとにトラフィックを 50% 増やしていく
この制御はSDKでは出来ないので、dataflowジョブ側で実装する必要があった。
Dataflowは並列実行でfirestoreにリクエストを投げるため、1workerあたりの秒間リクエストが 500/worker数 からスタートするように制御する。そしてそれぞれのジョブが、5分ごとに50%ずつリクエスト量を増やす。
リクエスト量の調整は、リクエスト送信後に一定時間のwaitをかませることで実現した。
なお、ジョブ数の計算が複雑になるので、Dataflowのオートスケールは無効にして、最初から最後まで同数のworkerで実行した。
注意点
- リクエスト量を増やしすぎると今度はQuotaにぶつかるので注意
- waitの計算時に、firestoreのTATを考慮(減算)すると少しだけ効率が良くなる
Indexの無効化
Googleに聞いた話の中に、documentのInsert/Updateとは別に、Indexを作成しているジョブがあり、こちらも負荷に応じてスケールしているという話があった。
この、Index作成の負荷も馬鹿にならず、5xxエラーの原因になりうるとのことだった。
ということで、不要なIndexを作成しないように設定を行う。
具体的には、 インポートを始める前に、以下のリンク先の手順に従い、Indexの除外設定を行えばよい。
https://firebase.google.com/docs/firestore/best-practices?hl=ja#index_exemptions
注意点
- 除外設定はコレクション、フィールド単位で行う必要がある。
- すでにドキュメントが存在するコレクションに対してIndex除外設定を追加すると、Index削除のジョブが走ってしまうので、それもまた新たな負荷になってしまう。
- Index除外設定は、空っぽのCollectionに対して行うようにする
- どうしても、ドキュメントが存在するコレクションに行いたい場合は、設定してからIndexが削除されるまで待つ(Doc量によるが、私は確実を期すため、帰りがけに実施して翌朝から作業再開した)
その他、知っておくと役に立ちそうなこと
エラーの種類
正直、調べても満足な情報が出てこない。大まかに以下のように理解して、試行錯誤を続けた。
489エラー(Deadline Exceed)
自分が API Limit にぶつかったときのレスポンス。
よって、リクエスト量(秒間write数、データ量)を再計算し、 API Limit を下回るように調整する必要がある。
500, 503エラー
自分は API Limit に抵触していないが、スケール速度を超えて負荷をかけてしまった場合や、(運悪く)他のユーザの負荷が同時にかかっていて、Firestoreサイドが耐えられなかった場合のエラー。
前述のホットスポットや 500/50/5ルールなどを見直して、原因を予想しながらエラーの発生を抑えるチューニングをする必要がある。
(ただし、実行時間をずらすだけで解消する場合や、 ただDataflowジョブを実行し直すだけで解消する場合もあるので、運も絡む)
結論
Firestoreはちゃんと調教すればできる子。