はじめに
Firestoreをデータの正本、Elasticsearchを検索用のRead Modelとして利用する構成があります。
- Firestoreに業務データを保存する
- Elasticsearchには検索・絞り込み・ソートに適した形でデータを同期する
- 一覧画面や全文検索ではElasticsearchを利用する
この構成では、Firestoreへの書き込みがElasticsearchの検索結果へ反映されるまでに時間差が生じる可能性があります。そのため、Elasticsearchから取得したデータが、常にFirestoreの最新状態と一致するとは限りません。
この記事では、一般的な設計上の論点を説明するため、架空の在庫予約システムを例にします。
- 在庫切れの商品を予約可能と表示してしまう
- 作成直後の仮予約を「存在しない」と判定してしまう
- 仮予約の数量変更と予約確定が競合する
これらを題材に、FirestoreとElasticsearchの読み分け方と、状態遷移をトランザクションで守る考え方を整理します。
前提:検索用のRead Modelは正本と一致しない時間がある
この記事では、次のような構成を想定します。
Firestoreの変更をElasticsearchへ非同期で同期する場合、変更イベントの処理やインデックスの更新が完了するまでに時間差が生じます。さらに、Elasticsearchの検索はnear real-timeであり、ドキュメントへの変更はrefreshを経て検索可能になります。
つまり、Firestoreへの書き込みが成功した直後でも、Elasticsearchの検索結果には変更前の状態が残っている可能性があります。
この性質自体は不具合ではありません。重要なのは、アプリケーション側で次の2種類を区別することです。
- 少し古い結果でも成立する処理
- 最新状態でなければ安全性を保てない処理
起き得る問題1:在庫切れの商品を予約可能と判定してしまう
商品一覧では、Elasticsearchを使ってキーワード、カテゴリ、価格帯などで商品を検索します。ここで、予約対象を確定する処理にも同じ検索結果を使ったとします。
const products = await productSearchRepository.searchByIds(
requestedProductIds,
);
const reservableProductIds = products.map((product) => product.id);
通常時は問題なく見えます。しかし、Firestore上の在庫がなくなった後、その変更がElasticsearchへ反映される前に検索すると、古い在庫状態を取得する可能性があります。
1. Firestore上の在庫がなくなる
2. Elasticsearchには在庫ありの状態が残っている
3. Elasticsearchで予約候補を検索する
4. 在庫切れの商品を予約可能と判定する
5. 遅れてElasticsearchへ最新状態が反映される
一覧画面に「在庫あり」と一時的に表示されるだけなら、要件によっては許容できます。しかし、その検索結果だけを根拠に在庫を確保すると、在庫不足や二重予約につながります。
そこで、予約候補を検索する処理と、正本の状態に基づいて予約を確定する処理を分けます。
const products = await inventoryRepository.getCurrentProducts(
requestedProductIds,
);
const unavailableProduct = products.find((product) => {
const requestedQuantity = request.quantityByProductId[product.id];
return !product.isReservable || product.stock < requestedQuantity;
});
if (unavailableProduct) {
throw new Error("予約できない商品が含まれています");
}
この確認により、Elasticsearchの同期遅延による誤判定は避けやすくなります。
ただし、Firestoreから最新状態を読むだけでは、同時に実行された複数の予約による競合までは防げません。実際に在庫を減らして予約を確定する処理では、在庫の確認と更新を同じトランザクションに含める必要があります。
起き得る問題2:作成直後なのに404になる
同期遅延は、在庫の変更だけでなく、データの作成直後にも影響します。
たとえば、Firestoreに仮予約を作成した直後、続けて数量変更APIを呼ぶケースを考えます。更新前の存在確認をElasticsearchの検索で行うと、まだ同期されていない仮予約を「存在しない」と判定する可能性があります。
Firestore: 仮予約が存在する
Elasticsearch: まだ検索結果に現れない
APIの応答: 404 Not Found
これは、検索インデックスの状態を正本の存在判定に使ったことで発生する、正本の状態と一致しない404です。
対象の存在確認がそのまま更新可否の判断につながる場合は、検索用のRead ModelではなくFirestoreを参照します。
ただし、ここでも読み取りと更新を別々に実行すると、別の競合が残ります。
read → check → writeを分離すると状態遷移が競合する
仮予約には、次のルールがあるとします。
pending状態の仮予約だけ、数量変更または予約確定ができる。
トランザクションを使わずに実装すると、次のようになります。
const reservation = await reservationRepository.getCurrent(id);
if (!reservation) {
throw new NotFoundError();
}
if (reservation.status !== "pending") {
throw new InvalidStateError();
}
const updated = {
...reservation,
quantity: request.quantity,
status: request.confirm ? "confirmed" : "pending",
};
await reservationRepository.update(updated);
逐次実行では正しく見えます。しかし、「数量変更」と「予約確定」がほぼ同時に実行されると、両方が同じpending状態を読み、条件を通過する可能性があります。
数量変更 予約確定
│ │
├─ status=pendingを読む │
│ ├─ status=pendingを読む
├─ 条件を通過 ├─ 条件を通過
├─ pendingで保存 │
│ └─ confirmedで保存
▼
最後に完了した書き込みが残る
タイミングによっては、予約確定後に数量変更の書き込みが完了し、状態をpendingへ戻してしまう可能性があります。
問題はFirestoreを読んでいるかどうかだけではありません。判定に使った状態が、書き込み時点でも変わっていないことを保証できていない点にあります。
判定から保存までをトランザクションに入れる
存在確認、状態判定、更新を同じFirestoreトランザクションに含めます。
return transactionService.runTransaction(async (transaction) => {
const reservation = await reservationRepository.getForUpdate(
id,
transaction,
);
if (!reservation) {
throw new NotFoundError();
}
if (reservation.status !== "pending") {
throw new InvalidStateError();
}
const updated = {
...reservation,
quantity: request.quantity,
status: request.confirm ? "confirmed" : "pending",
};
await reservationRepository.save(updated, transaction);
return updated;
});
Firestoreのトランザクションでは、読み取ったドキュメントが並行処理によって変更された場合、トランザクションが再試行されます。
数量変更と予約確定が競合し、予約確定が先に完了した場合、数量変更側は再試行時にconfirmedを読みます。その結果、pendingではないことを検出し、更新を中止できます。
重要なのは、単にupdateだけをトランザクションに入れるのではなく、次の一連を同じトランザクションで扱うことです。
- 最新状態を読む
- 更新可能な状態か判定する
- 新しい状態を書き込む
状態遷移の前提条件と更新を、ひとつの原子的な操作として扱います。
在庫数を変更する場合も同様です。在庫数を読み、必要数が残っているかを判定し、在庫を減らすところまでを同じトランザクションに含めます。
トランザクション関数に外部副作用を入れない
Firestoreのトランザクション関数は再実行される可能性があります。そのため、コールバック内で決済や外部API呼び出しを行うと、同じ副作用が複数回発生する危険があります。
// 避けたい例
await runTransaction(async (transaction) => {
const reservation = await transaction.get(ref);
await capturePayment(reservation); // 再試行で重複する可能性がある
transaction.update(ref, { status: "confirmed" });
});
トランザクション内では、予約状態の更新と、後続処理を表すジョブの記録までに留めます。
await runTransaction(async (transaction) => {
const reservation = await transaction.get(reservationRef);
if (reservation.data()?.status !== "pending") {
throw new InvalidStateError();
}
transaction.update(reservationRef, { status: "confirmed" });
transaction.create(jobRef, {
type: "CAPTURE_PAYMENT",
reservationId: reservation.id,
idempotencyKey,
});
});
実際の決済や外部システムとの連携は、キューのConsumerなどで実行します。また、再配信やリトライが発生しても同じ処理を重複実行しないように、冪等性キーを持たせます。
トランザクションの外側で外部APIを呼ぶだけでは、Firestoreへの保存後、外部APIを呼ぶ前にプロセスが停止する可能性があります。「トランザクションから後続処理へどう確実に引き渡すか」も別途考える必要があります。
読み取り元は「画面かAPIか」ではなく目的で決める
読み取り元は、HTTPメソッドや画面の種類ではなく、その結果が何に使われるかで決めます。
| 処理 | 読み取り元 | 理由 |
|---|---|---|
| 商品一覧表示 | Elasticsearch | 検索・絞り込み・ソートを優先できる |
| 全文検索 | Elasticsearch | 検索エンジンの機能を利用できる |
| 件数集計 | Elasticsearch | 反映遅延を許容できる場合は効率がよい |
| 存在確認 | Firestore | 更新対象の正本での存在確認が必要 |
| 権限判定 | Firestore | 古い権限情報による許可を避ける必要がある |
| 状態遷移 | Firestoreのトランザクション | 判定と更新を原子的に扱う必要がある |
| 在庫の確認と減算 | Firestoreのトランザクション | 同時予約による在庫超過を防ぐ必要がある |
「GETならElasticsearch、POSTならFirestore」のように、HTTPメソッドだけで判断するのは不十分です。
たとえば、予約確認画面はGETであっても、そこで取得した情報を予約確定の根拠として扱うなら、確定処理では正本の再確認が必要です。逆に、管理画面の商品一覧や履歴検索は重要なデータでも、短時間の反映遅延を許容できるならElasticsearchが適しています。
判断軸は、その読み取り結果が、不可逆な処理や認可・状態遷移の判断に使われるかです。
リポジトリのAPIに整合性の意図を表す
FirestoreとElasticsearchへのアクセスを抽象化する場合、findByIdのような名前だけでは、読み取り元や期待する整合性が分かりません。
用途ごとにインターフェースを分けると、呼び出し側から意図を読み取りやすくなります。
interface ReservationSearchRepository {
// 一覧・検索用のRead Modelから取得する
searchByIds(ids: string[]): Promise<ReservationSearchView[]>;
}
interface ReservationRepository {
// 正本から取得する
getCurrent(id: string): Promise<Reservation | null>;
// 状態遷移用。トランザクションの読み取りに参加する
getForUpdate(
id: string,
transaction: Transaction,
): Promise<Reservation | null>;
save(
reservation: Reservation,
transaction: Transaction,
): Promise<void>;
}
このように分けると、次の点をコード上で確認しやすくなります。
- 検索結果を確定処理へ誤って使っていないか
- 正本を参照すべき処理になっているか
- 判定と更新が同じトランザクションに含まれているか
整合性要件はコメントだけに残さず、型やインターフェースでも表現すると安全です。
テストで確認したいケース
通常系だけでは、同期遅延や並行処理の問題は見つけにくいため、意図的に不一致や競合を作るテストが必要です。
同期遅延を前提にしたテスト
- Firestoreには存在するがElasticsearchの検索結果には存在しない
- Firestoreでは在庫切れだがElasticsearchでは在庫ありになっている
- FirestoreとElasticsearchでステータスが異なる
期待する結果は、検索結果が古くても、存在確認や確定処理はFirestoreの状態に従うことです。
競合を前提にしたテスト
- 同じ仮予約に対して数量変更と予約確定を並行実行する
- 同じ仮予約に対して予約確定を複数回実行する
- 同じ在庫に対して複数の予約を並行実行する
- 一方が状態を変更した後、もう一方が古い前提で更新しようとする
少なくとも、次のドメインルールは個別にテストしておきます。
-
pending以外の予約は数量変更できない -
pending以外からconfirmedへ遷移できない - 必要数を下回る在庫は確保できない
- 同じ冪等性キーの後続処理は重複実行されない
設計時のチェックリスト
FirestoreとElasticsearchを併用する機能では、読み取りを実装する前に次を確認します。
- 少し古い状態でも、その処理は安全か
- 結果が予約、課金、在庫、権限などの確定に使われないか
- 作成・更新直後に同じデータを読む導線がないか
- 正本の存在確認が必要な処理ではないか
-
read → check → writeの間に別リクエストが割り込めないか - 在庫などの共有資源を確認と同時に更新する必要がないか
- トランザクションの再試行で外部副作用が重複しないか
- 後続処理への引き渡しが途中で失われないか
- リポジトリのAPIから整合性の意図を読み取れるか
- 同期遅延と競合を再現するテストがあるか
ひとつでも不安があれば、検索結果だけで確定せず、正本の参照やトランザクションの利用を検討します。
おわりに
FirestoreとElasticsearchを併用する場合、どちらか一方を万能なデータストアとして扱うのではなく、構成上の役割を明確にすることが重要です。
この記事で想定した構成では、次のように役割を分けました。
- Elasticsearchは、一覧・検索のためのRead Model
- Firestoreは、存在確認や確定判断に使う正本
- Firestoreトランザクションは、状態遷移や共有資源の確認と更新を守る仕組み
一覧表示では検索性能を活かし、確定処理では正本の最新状態を確認する。そして、状態や在庫を確認してから更新する処理では、判定と保存を同じトランザクションに含めます。
この境界を意識することで、同期遅延による誤判定と、並行処理による上書きや二重処理を避けやすくなります。

