はじめに
最近携わっているプロジェクトでは、DBとしてFirestoreを利用しています。
これまでRDBを扱う機会のほうが多かったため、Firestoreでトランザクションや排他制御を実装する場合、RDBとどのような違いがあるのか気になったので、備忘録として残しておきます。
なお、RDBは製品によって挙動が異なるため、本記事ではPostgreSQLを例にします。また、FirestoreはStandard EditionのWeb SDKを中心に扱います。
先に結論
FirestoreとRDBの排他制御を大まかに比較すると、次のようになります。
ただし、次のように単純に分けられるわけではありません。
Firestore = 楽観ロック
RDB = 悲観ロック
FirestoreのサーバーSDKは、データベースに設定された同時実行モードに従います。また、RDBでもバージョンカラムを使った楽観ロックを実装できます。
本記事では、違いを分かりやすくするため、FirestoreのWeb SDKによる楽観的な制御と、PostgreSQLのFOR UPDATEによる悲観的な制御を比較します。
排他制御が必要になる例
残り枠が1件のデータを、2人が同時に更新するケースを考えます。
{
"remaining": 1
}
トランザクションを使わず、読み取りと更新を別々に実行するとします。
const snapshot = await getDoc(slotRef);
const remaining = snapshot.data()?.remaining;
if (remaining > 0) {
await updateDoc(slotRef, {
remaining: remaining - 1,
});
}
2人が同時に処理すると、どちらもremaining = 1を取得する可能性があります。
最終的な値は0ですが、2人とも予約に成功したと判断してしまいます。
読み取りから書き込みまでの間に、別の処理が割り込めることが原因です。
Firestoreの場合
Firestoreでは、現在値を基に更新内容を決める場合にトランザクションを使用します。
await runTransaction(db, async (transaction) => {
const snapshot = await transaction.get(slotRef);
if (!snapshot.exists()) {
throw new Error("予約枠が存在しません");
}
const remaining = snapshot.data().remaining;
if (remaining <= 0) {
throw new Error("空きがありません");
}
transaction.update(slotRef, {
remaining: remaining - 1,
});
});
FirestoreのWeb SDKでは、トランザクションが読み取ったドキュメントを別の処理が更新すると、競合を検知してトランザクション関数を最初から再実行します。
読み取りは書き込みより先に行う必要があり、トランザクションの書き込みはすべて成功するか、すべて反映されないかのどちらかになります。
先ほどの例では、先にコミットした処理は成功します。
後から書き込もうとした処理は競合を検知して再実行され、最新のremaining = 0を読み取るため、予約不可になります。
RDBの場合
PostgreSQLでは、SELECT ... FOR UPDATEを使って更新対象の行をロックできます。
BEGIN;
SELECT remaining
FROM reservation_slots
WHERE id = 1
FOR UPDATE;
-- 取得したremainingが0以下なら予約処理を中止する
UPDATE reservation_slots
SET remaining = remaining - 1
WHERE id = 1;
COMMIT;
先に処理を始めたトランザクションが対象行をロックすると、別のトランザクションによる同じ行への更新は、ロックが解放されるまで待機します。
FirestoreのWeb SDKが「競合したら最新の値でやり直す」のに対して、FOR UPDATEは「先に対象行をロックして、ほかの処理を待たせる」という違いがあります。
Firestoreで注意すること
Firestoreのトランザクション関数は、競合時に複数回実行される可能性があります。
そのため、トランザクション内でメール送信や外部APIの呼び出しなどを行うのは避けます。公式ドキュメントでも、トランザクション関数がアプリケーションの状態を直接変更しないよう案内されています。
await runTransaction(db, async (transaction) => {
const snapshot = await transaction.get(reservationRef);
transaction.update(reservationRef, {
status: "confirmed",
});
// 再実行される可能性があるため避ける
await sendEmail();
});
再実行されても問題のない処理に限定し、外部への副作用は避けます。
まとめ
FirestoreのWeb SDKでは、競合を検知するとトランザクションを自動で再実行します。
一方、PostgreSQLのFOR UPDATEでは、対象行を先にロックし、ほかの処理を待たせます。
つまり、両者の違いは、競合後にやり直すか、競合前に待たせるかという点でした。

