FirestoreのMongoDB互換性におけるretryWrites=falseの罠
FirestoreはMongoDBのワイヤープロトコルに対応していますが、再試行可能な書き込みは拒否します。お使いのドライバーでデフォルトで有効になっている場合、書き込みは一見ランダムに見える形で失敗します。ここではその理由と修正方法を説明します。
Firestore エンタープライズ版は MongoDB ワイヤープロトコルを解釈するため、標準的な MongoDB ドライバーを接続するだけで、ほとんどの機能がそのまま動作します。MongoDB のクエリ構文、MongoDB のインデックス、そしてサーバーの運用が不要なマネージドデータベースを利用できます。しかし、その後、データとは無関係のエラーで一部の書き込みが失敗し始め、しかもそれは時々しか発生しません。原因は retryWrites です。これはドライバーのデフォルト設定ですが、Firestore ではサポートされていません。この記事では、この設定の機能、互換性レイヤーがそれを拒否する理由、その障害を認識する方法、そして、それをオフにした後でシステムに対して何をすべきかを説明します。
リトライ可能な書き込みの実際の動作
実際のMongoDBでは、リトライ可能な書き込みにより、最初の試行がネットワーク層または選出層で失敗した場合に、ドライバーは書き込みを厳密に1回再送信できます。これには、書き込みが2回適用されるリスクがありません。これは、操作にトランザクション番号を付与することで機能します。サーバーはその番号を記録するため、同じ操作を再度受け取った場合、それがすでに適用した可能性のある書き込みの再試行であることを認識し、2つ目のコピーを書き込む代わりに重複を排除します。
これは実に優れたデフォルト設定です。ネットワークは信頼性が低く、プライマリーの選出は発生します。リトライ可能な書き込みは、一時的な障害というクラス全体を、サイレントで安全な復旧に変えます。これが、すべての最新のMongoDBドライバーがretryWrites=trueを付けて出荷され、ほとんどの人がそれについて考えることがない理由です。それは目に見えない形でその役割を果たしているのです。
重要な詳細は、その安全性が、サーバーがそのトランザクション番号の記録管理を実装しているかどうかに完全に依存しているという点です。クライアントは、単独で書き込みをべき等にすることはできません。クライアントにできるのは、サーバーに重複排除を依頼し、サーバーがその方法を知っていると信頼することだけです。
なぜFirestoreはそれを拒否するのか
FirestoreのMongoDB互換性は、ワイヤプロトコルとクエリサーフェスを実装していますが、その内部は異なるトランザクションモデルを持つ異なるストレージエンジンです。プロトコルのトランザクション番号が想定するretryable-writeのブックキーピングを実装していません。そのため、ドライバーが気を利かせてinsertにretryable-writeメタデータを付加すると、サーバーはサポートしていない機能を目にし、その操作を拒否します。
この失敗モードによって、午後の時間を無駄にすることになります。エラーには「retryWritesはサポートされていません。無効にしてください。」とは表示されません。より一般的な書き込み失敗として表面化し、どのコードパスがいつメタデータを付加したかに依存するため、断続的に発生するように見えることがあります。一部の書き込みでは発生し、他の書き込みでは発生しなかったり、あるいは、起動時には発生するが、異なる接続を使用するユニットテストでは発生しなかったりします。直感的に、ドキュメント、スキーマ、インデックスを調査したくなるでしょう。ドキュメントに問題はありません。接続の設定が間違っているのです。
修正は1つのフラグであり、それをどこに置くかが重要です
接続URIに retryWrites=false を設定します。これが修正のすべてであり、最初に開く接続に存在している必要があります。
// retryWrites=false is required. Firestore's MongoDB compatibility rejects
// the retryable-write protocol that the driver enables by default.
uri := "mongodb://USER:PASS@HOST/db?retryWrites=false"
client, err := mongo.Connect(ctx, options.Client().ApplyURI(uri))
if err != nil {
return nil, fmt.Errorf("connect firestore: %w", err)
}
コードオプション (options.Client().SetRetryWrites(false)) で無効にすることもできますが、URIを使用することを推奨します。理由は技術的なものではなく、人間的なものです。URI文字列は、人々がコピーするものです。新しいサービスへ、移行スクリプトへ、一回限りのデバッグシェルへ、チームメイトの環境ファイルへ。これらのコピーはすべて、設定が一緒に付いてこないコード内にある場合、デフォルトの true が暗黙的に返されてしまう場所になります。接続文字列に含めれば、データベースを使用するたびに一緒に移動します。
起動時に検証することで、設定し忘れることを防ぎます。サービスに渡されたURIでリトライ可能な書き込みが無効になっていない場合、最初の書き込み時に不可解なエラーが発生するのではなく、起動時に明確に失敗させます。
if !strings.Contains(uri, "retryWrites=false") {
return nil, errors.New("firestore URI must set retryWrites=false")
}
オフにしても書き込みは安全になりません
ここが、人々がエラーを修正して次に進んだ後に陥る部分です。retryWritesを無効にしても、リトライの必要性がなくなったわけではありません。ネットワークは依然として切断され、タイムアウトも発生し、あなたのコードもどこかで、成功したかどうかわからない書き込みを再送信するでしょう。変わったのは、その再送信を重複排除するものがなくなったことです。本物のMongoDBにあったセーフティネットは失われ、その責任はあなたに移りました。
システム内の書き込みパスがリトライされる可能性があり、分散システムではすべての書き込みパスがリトライされる可能性がある場合、それらの書き込みは構造的に冪等でなければなりません。同じ書き込みを2回実行しても、データベースは1回実行したときと同じ状態にならなければなりません。
これを実現するクリーンな方法は、再送信する可能性のあるものにランダムなドキュメントIDを使用するのをやめることです。ドキュメントを識別するコンテンツからIDを派生させます。そうすることで、リトライされた挿入は同じIDをターゲットとし、重複した行ではなく無害な上書きに変わります。
// Same logical document, same id, every time. A retried upsert overwrites
// in place instead of inserting a second copy.
id := PostID(slug, lang, publishedAt)
_, err := coll.UpdateByID(ctx, id,
bson.M{"$set": doc},
options.Update().SetUpsert(true),
)
upsertと決定論的なIDを持つUpdateByIDは、サーバー側の重複排除を必要とせず、それ自体で冪等です。最初の呼び出しで挿入され、すべてのリトライで同一のデータが上書きされ、行数が増えることはありません。後で変更すると古いドキュメントが孤立してしまうため、決して変更してはならない1つのフィールドを含む完全な導出については、冪等なupsertのための決定論的ULIDで説明されています。
知っておくべき、その他の互換性に関する注意点
retryWritesは、通常の書き込みで失敗するため、最初に問題となるものです。しかし、同じ原則が互換性レイヤー全体に適用されます。ワイヤープロトコルはサポートされていますが、その背後にあるすべてのサーバーサイド機能がサポートされているわけではありません。何かに依存する前に、互換エンジンが単にコマンドを受け入れるだけでなく、それを実装していることを確認してください。実際には、これは互換性レイヤーをドロップインのMongoDBとしてではなく、独自の制限を持つ独自のデータベースとして扱うことを意味します。本番環境のインシデントを通じて各ギャップを発見するのではなく、サポートされている機能のドキュメントを事前に一度読んでおきましょう。
考え方の転換こそが、本当の教訓です。互換性レイヤーは、プロトコルとクエリ言語を提供します。これは、使いやすさの大部分を占めるものです。それは、オリジナルのすべての運用上の保証を約束するものではありません。そして、省略される保証は、自動書き込みの重複排除のように、あなたが気づかずに依存していた、まさに目に見えないものである傾向があります。
接続を信頼する前のチェックリスト
retryWrites=falseが、単一のクライアントのコードオプションだけでなく、URI自体に含まれていること。これにより、接続文字列のすべてのコピーにこの設定が付随します。- 起動時の検証で、このフラグがない場合は大きなエラーで失敗すること。これにより、設定ミスのある環境が最初の書き込みに到達できなくなります。
- すべての書き込みパスが、ランダムなIDを持つinsertではなく、安定したコンテンツ由来のIDをキーとするべき等なupsertであること。
- リトライロジックが少なくとも1回の配信を前提としており、どの書き込みを再実行しても同じ状態になること。
最初の2つを実行すると、互換性レイヤーはランダムに見えるエラーをスローしなくなります。後の2つを実行すると、サーバーのセーフティネットがなくなったときに、独自のリトライが静かにデータを重複させることがなくなります。最初の2つは症状を消し去ります。後の2つはその症状が警告していた根本的な問題を修正します。