Cloud Pub/Sub の Ordering Key によるレースコンディションの解消(Firestore -> Elasticsearchのデータ同期)

この記事は、10X 新春ブログリレー 2026 の1月21日の記事です。


はじめに

CXチームでソフトウェアエンジニアとして働いている@kotaroooo0です。 本記事では、FirestoreからElasticsearchへのリアルタイムデータ同期において発生したレースコンディションの問題と、それをCloud Pub/Sub(以下、Pub/Sub)のOrdering Keyを活用して解消した事例について紹介します。

システムの前提

10Xが提供する「Stailerネットスーパー」では、商品の検索や推薦にElasticsearchを利用しています。 一方で、マスターデータとして汎用的に使用しているデータベースはFirestoreです。 検索の精度を高く保つためには、Firestoreの更新を可能な限り速やかにElasticsearchへ反映させる必要があります。

データ連携アーキテクチャは次のとおりです。

  1. Firestoreのドキュメントが更新される
  2. 更新イベントをトリガーに、Pub/Subへメッセージをパブリッシュする
  3. SubscriberがメッセージをPullし、Elasticsearchへインデキシングを行う

大量に更新が発生した時もSubscriberがオートスケールすることで、キュー内でのメッセージの滞留を防ぎ、ニアリアルタイムなデータ同期を可能にしています。

※説明を分かりやすくするため、実際のアーキテクチャや事象については簡略化して記載しています。

発生した問題と原因

あるとき、「管理画面で商品のステータスを変更したのに、Elasticsearch側で古いステータスのままになっている」という事象が発生しました。 調査の結果、同一の商品に対して短時間に連続した更新が行われた際に、レースコンディションが発生していることが判明しました。

具体的な発生シナリオ

  1. 管理画面から、商品を「ステータスA」に変更。
  2. その直後に、同じ商品を「ステータスB」に変更。

このとき、2つのメッセージがほぼ同時にPub/Subに届き、複数のSubscriberが並列で処理を開始します。

  1. (Subscriber-1) ステータスAのメッセージをPullし、処理を開始
  2. (Subscriber-2) ステータスBのメッセージをPullし、処理を開始
  3. (Subscriber-2) 処理が先に完了し、Elasticsearchドキュメントを「ステータスB」に更新
  4. (Subscriber-1) 遅れて処理が完了し、Elasticsearchドキュメントを「ステータスA」に更新

結果として、Firestore側は「ステータスB」であるにもかかわらず、Elasticsearch側は古い「ステータスA」で確定してしまいます。 Subscriberはバッチ処理を行っており、Subscriber-1の処理件数がSubscriber-2より極端に多かった場合などに、この逆転現象が発生しやすい状況でした。

解決策: Pub/SubのOrdering Keyを導入

この問題を解決するには、「同じ商品に対するメッセージは、必ずパブリッシュされた順序で処理する」必要があります。 ここで役立つのが、Pub/Subのメッセージの順序付け機能です。

メッセージの順序付けは Pub/Sub の機能であり、パブリッシャー クライアントによってパブリッシュされた順序でサブスクライバー クライアントでメッセージを受信できます。 たとえば、あるリージョンのパブリッシャー クライアントがメッセージ 1、2、3 を順番にパブリッシュするとします。メッセージの順序付けを使用すると、サブスクライバー クライアントはパブリッシュされたメッセージを同じ順序で受信します。

Pub/Sub での順序付けは、次の要素によって決定されます。

順序付けキー: Pub/Sub メッセージ メタデータで使用される文字列であり、メッセージの順序付けが必要なエンティティを表します。並べ替えキーの長さは最大 1 KB です。リージョンで順序付きメッセージのセットを受け取るには、同じ順序付けキーを使用して、同じリージョンにすべてのメッセージをパブリッシュする必要があります。順序付けキーの例としては、お客様 ID やデータベース内の行のプライマリ キーがあります。

cloud.google.com

導入後のフロー

各メッセージに商品IDをOrdering Keyとして設定することで、同じ商品に関するメッセージは必ず「前のメッセージがAckされてから次のメッセージが配信される」ようになります。

  1. (Subscriber-1) ステータスAを処理開始
  2. (Pub/Sub) Subscriber-1がステータスAのメッセージに対してAckするまで、同一KeyをもつステータスBのメッセージを配信せず待機
  3. (Subscriber-1) ステータスAの処理を完了し、Ackを返す
  4. (Pub/Sub) ステータスBのメッセージを配信可能にする
  5. (Subscriber-x) ステータスBの処理開始

これにより、処理順序が保証され、データの不整合が解消されました。

Ordering Keyの挙動について深掘り

導入にあたり、公式ドキュメントだけでは確証が持てなかった挙動について検証しました。

Q. Ack前に次のメッセージをPull可能か?

A. 不可。Ackを返すまで、同一Keyの次のメッセージはブロックされる

「配信順序を保証するだけ(受け取り側で頑張る必要があるのか)」のか、「処理の完了を待ってくれるのか」を確認するため、次の環境でテストしました。

  • メッセージ数: 10
  • Subscriber: 3並列
  • それぞれのSubscriberは1メッセージずつ処理
シナリオ 結果
すべて同じOrdering Key 前のメッセージのAck完了まで、後続はブロック。1つのSubscriberのみが順次処理を行う。
すべて異なるOrdering Key 順序制約がないため、3つのSubscriberが並列でフル稼働する。
2つのKeyグループ(メッセージ5個はKey1, メッセージ5個はKey2) Key1とKey2のそれぞれの系列は順次処理されるが、Key1とKey2同士は並列で処理される。2つのSubscriberが稼働する。

Q. キューに溜まっている同一Keyのメッセージを「同時Pull」するか?

A. 同時にPullするが、別個のSubscriberが同時に処理できない

1つのSubscriberが「一度に最大10件メッセージ取得する」設定でPullした場合、複数の同一Keyのメッセージを含むことがあります。 また、そのSubscriberがAckを返さない限り、他のSubscriberインスタンスが同じKeyのメッセージを奪うことはありません。

おわりに

本記事では、Pub/SubのOrdering Keyを用いて、FirestoreとElasticsearch間のレースコンディションを解消した事例を紹介しました。 ドキュメントから読み取りにくかった部分を調査し紹介したので、Pub/Subを使う方々の参考になれば幸いです。


僕が所属しているCXチームではエンジニアを募集中です! 少しでも気になった方はぜひカジュアル面談をしましょう!

product.10x.co.jp open.talentio.com