Database
Firebase
Transaction
CloudFirestore

Firebase Cloud FirestoreのTransactionについて考える

みなさんこんにちは@1amageekです。

年末からいろいろやって、ひと段落したのでまとめました。

https://twitter.com/1amageek/status/1092774482563887107


なぜTransactionが必要なのか

まず、Transactionがどういった機能なのかを簡単に説明します。

TwitterやInstagramのフォロー機能を参考に説明します。

Firestoreでこの機能を実現するならば、以下のように構成するのがもっとも効率的だと思います。

ユーザーAとユーザーBにそれぞれカウントをもたせる

/user/a

{
followerCount: 0
followeeCount: 0
}

/user/b
{
followerCount: 0
followeeCount: 0
}

さらに誰が誰をフォローしてるかを記録するためにそれぞれにSubCollectionをもたせる

/user/a/follower/

/user/a/followee/

/user/b/follower/
/user/b/followee/

これで準備は完了です。ここでABをフォローした時の状態を考えてみます。

/user/a

{
followerCount: 1
followeeCount: 0
}

/user/b
{
followerCount: 0
followeeCount: 1
}

/user/a/follower/b
/user/a/followee/

/user/b/follower/
/user/b/followee/a

このようになると思います。この時点ではまだトランザクションは出てきてませんね。

では、Bがとても人気のユーザーになって一瞬のうちにC D E Fにフォローされたとき、どうなるか考えてみましょう。

トランザクションを考慮せずにコーディングすると次のようになります。

// ※なんとなく理解しやすいようにデフォルメしてます。このコードは動きません。

const b: User = new User("b")
const c: User = new User("c") // C or D or E or F
b.followerCount += 1
b.follower.insert(c)
c.followeeCount += 1
c.followee.insert(b)

const batch: Batch = new Batch()
batch(BatchTyep.update, b)
batch(BatchTyep.update, c)
batch.commit()

これをCだけが実行するならば問題ないですが集中的に実行されると次のような結果になります。

/user/b

{
followerCount: 0
followeeCount: 2  // あれれれれ??
}

/user/b/followee/a
/user/b/followee/c
/user/b/followee/d
/user/b/followee/e
/user/b/followee/f

/user/a/follower/b
/user/c/follower/b
/user/d/follower/b
/user/e/follower/b
/user/f/follower/b

BfolloweeCountは増えてませんね。

実はここに問題があります。

b.followerCount += 1

Aからフォローされていることを考えるとb.followerCountには1が入ってるはずですね?

C D E Fは同時にこのコードを実行したために、次のようなことが起こっています。

console.log(b.followerCount) // 1

b.followerCount += 1
console.log(b.followerCount) // 2

全員が2で上書きしていた。😇

ここで登場するのがTransactionです。


Transactionについて理解する

FirebaseのTransactionでは次のようにこの問題を回避します。

これはFirebaseのTransactionの例です。

const b = new User("b")

await db.runTransaction(function(transaction) {
// bのデータを読み込む
await b.fetch(transaction)
// countアップさせる
const followerCount: number = b.followerCount + 1
// データを上書きする
transaction.set(b.reference,
{ "followerCount": followerCount },
{ merge: true })
})

公式ドキュメントの中には次のように説明があります。


トランザクションが、トランザクション外部で変更されたドキュメントを読み取る。この場合、トランザクションは自動的に再実行されます。トランザクションは一定の回数で再試行されます。


https://firebase.google.com/docs/firestore/manage-data/transactions?hl=ja

runTransactionの中のコードは、外から変更された場合再試行されます。

スクリーンショット 2019-02-08 16.21.45.png

こうすることで、followerCountの値を最新に保ち一貫性を保つことが可能です。Firebaseでは通常5回再試行され、5回失敗するとそのトランザクションは失敗になります。

これで安全に開発可能かなと思いきやそうではありません。Firebaseの書き込みには時間的な制限があります。

1秒に1回しか書き込みを行うことができません。

Bがとても人気のユーザーになっても1秒に1フォロワーしか増やせない。


FirebaseのTransactionの負荷分散

集中アクセスがありかつトランザクションが必要な場合、負荷分散をする必要があります。これはFirebaseに限らず他のデータベースであっても同じで集中的なアクセスはいつか捌くことができなくなります。


分散カウンタ

負荷分散を行う代表として分散カウンタがあります。

FirebaseではSubCollectionへShardを定義してShard数分負荷を分散させることが可能です。

Shardの選択はランダムに行われます。なぜランダムに選択されるのかに関しては後に説明します。

https://firebase.google.com/docs/firestore/solutions/counters?hl=ja

スクリーンショット 2019-02-08 17.41.31.png

また、分散カウンタのデメリットとして、Documentに静的な数値を持っていないため、毎回全てのカウンタを合計して数値を算出する必要があります。


負荷分散の性能は初期値に依存してしまう

負荷分散の性能は最初に設定したShardの数に依存してしまうため、最初にShard数を多めに設定しすぎたり、少なめに設定すると余計な算出コストが必要であったり、分散性能が足りなくなることを意味します。

分散性能に関しては動的に変更する仕組みが必要かも知れません。


FirebaseのTransactionの応用

さて、フォロー機能に関しては分散カウンタを活用することで、余計なコストを払う可能性は出たものの十分に機能しそうであることがわかりました。次にトランザクションの応用を考えてみましょう。

僕はこのCloud Firestoreのトランザクションを用いてECに応用できないか考えてみました。

ECでは在庫管理機能が必要になります。

分散カウンタさえあれば、うまく行きそうな気がしますよね?実はうまく行きません。その理由を説明します。


負荷分散はRead負荷に弱い

ここではiPhoneの予約販売を想像してください。1万台の在庫を本日16時から一斉に予約開始としましょう。

これを100個のShardで捌くことにしましょう。そしてもちろんカウンタが10,000になれば受付終了です。

コードで書くならばこんな感じです。

const shardID = Math.floor(Math.random() * num_shards).toString()

const shard = new Shard(shardID)
await db.runTransaction(function(transaction) {
// countを初期化
let count = 0
let shardCount = 0
// 100個のShardから現在のCountを取得する
let tasks = []
for(let i=0; i<100; i++) {
const shard = new Shard(i.toString())
const task = async () => {
await shard.fetch(transaction)
count += shard.count
if (shard.id === shardID {
shardCount = shard.count
}
}
tasks.push(task)
}
await Promise.all(tasks)
// 10,000を超えてたら処理を終了させる
if (count >= 10000) {
throw new Error("Out of stock")
}
shardCount += 1
// データを上書きする
transaction.set(shard.reference,
{ "count": shardCount },
{ merge: true })
})

残念ながら動きません。なぜ動かないのかを説明します。16時に一斉に予約がスタートした時のことを考えてください。

スクリーンショット 2019-02-08 20.27.36.png

各デバイスは一斉に100個のShardをReadし始めます。Device AがWriteに成功したとしましょう。そうするとその変更をキャッチした全てのDeviceの全てのShardがまた100個のShardをReadし始めます。これが繰り返されるとなるととんでもない負荷がかかることが想像できます。

ではどのようにすれば解決できるでしょうか?

つづく。

記事をストックしてもらえると変更通知が届きます。続きが気になる方はぜひストックをしておいてください!🙏🏻

Firebaseのイベントあるからきてくださーい。

https://stamp.connpass.com/event/115477/