Firestore でローコストなタイムライン機能を実装してみる


エンジニア(プログラマー?)として仕事に就いて早8ヶ月、いつもお世話になってるQiitaへの初投稿です。

SNSアプリケーションを作成するときにもはや必須とも言えるフォローしている人の投稿を表示する、タイムライン機能をFirestoreで実装する方法について書いていきます。コストが1/100以下になったかもって結論の記事です。

(計算式間違ってんぞ! 意味わからん! こういう要望どうすんじゃ! とかあったら教えてください )

はじめに

Firestoreは書き込み、読み込みなどの通信回数によって課金されるため、如何に通信回数を少なくするか頭を悩ませながら開発されているかと思います。
タイムライン機能における通信回数は、(自分が調べた限りでは)フォロワーの数が多くなるにつれて一投稿あたりのコストが大きくなってしまいます。

(コストについて、Ninoさんによるこちらの投稿が非常にわかりやすい説明を掲載してくれています。)
https://note.com/deerboy/n/n6fb4e57d30c6

以下のようなフォロー・フォロワー構造を取り、投稿を行うたびにフォローしている人たち各々のタイムラインにドキュメントを作成するといった事を行なっております。
つまり、100万人のフォロワーがいる人物が投稿すると、100万ものドキュメントの作成が必要になってしまうということです。

データ構造
users: [ // user collection
  userId: { // doc
    follow:   [ userId: { created_at } ],// follow collection
    followed: [ userId: { created_at } ],// followed collection
    timeline: [ id: { userId: id, postDatas: {} },// timeline collection
  }
]

掲載していただいていた試算を紹介しますと、

計算条件
[平均300人のフォロワー]
[ユーザー4500万人]
[3ツイート(/日)]
[タイムライン表示10回(/日)]

以上の条件での一日のコストは730万円を超えてしまうようです。
この安くないコストを、なんとか小さくできないかと考えたものが以下に記す内容です。

目的

ローコストなタイムライン機能の実装

実装

データモデル

まず、フォローフォロワーモデルを以下のように設定しました。

データモデル
users: [ // user collection
  userId: { // doc
    follow:   [ userId: { created_at } ],
    followed: [ userId: { created_at } ],
    listsForTimeline: [
      id: { userlist: ['userId', ......] } // 配列
    ],
    timeline: [
      id {
        userId: id,
        postData: {},
        allowed_users: ['userId', ......], // 配列
        created_at: time
      }
    ]
  }
] 

先述と異なる主な点はlistsForTimelineと、timeline.allowes_usersを追加した点です。
ユーザーのフォローが成立した場合など、タイムライン配信させたい場合に、listsForTimelinearrayUnionで配列の要素として追加していきます

投稿

ユーザーによる投稿アクションが発生したとき、listsForTimelineから配列をコピーし、timelineに新しいドキュメントを作成します。

リストのコピー
listsForTimeline: [
  id: { userlist: ['myUserId', 'user2', 'user3'] } // timelineにコピー (自分にも表示したいのでmyUserIdいれます)
]
/* ↓投稿↓ (タイムライン以外の使用するなら、別途postを作成したり) */
timeline: [
  id {
    userId: id, // 投稿したUserのID
    postData: {hoges: fugas}
    allowed_users: ['myUserId', 'user2', 'user3'], // listsForTimelineからコピー
    created_at: time
  }
]

このようにする事で、次に説明するクエリでタイムラインを取得できるようになりました。

タイムラインの取得

ユーザーが閲覧可能なタイムラインを取得します。(collectionGroupへのindex作成は適宜必要です)

タイムラインの取得
const posts = []
db.collectionGroup('timeline')
  .where('allowed_users', 'array-contains', userId)
  .orderBy('created_at', 'desc')
  .get()
  .then(snapshot => {
    snapshot.forEach(postRef => {
      posts.push(postRef.data().postData)
    })
  })

これでタイムライン内の投稿を取得することができました。

効果

今回、投稿に伴うタイムライン作成の際にタイムライン用のリストを利用することにより通信回数の削減を試みました。
実際どの程度の効果が生じるのか概算してみました。

先述したサイトと同じ以下の条件で考えてみます。

計算条件
[平均300人のフォロワー]
[ユーザー4500万人]
[3ツイート(/日)]
[タイムライン表示10回(/日)]

読み取りアクションの数は、

// 投稿時[`listForTimeline`からの読み取り] + タイムライン表示時[`timeline`の読み取り]
// (リストの数 * ツイート数 * ユーザー数) + (クエリ発行数 * 読み込み回数 * ユーザー数)
(ceil(300/10000) * 3 * 45,000,000) + (1 * 10 * 45,000,000) = 585000000

書き込みアクションの数は、

// 投稿時の書き込み
// リストの数 * ツイート数 * ユーザー数
(ceil(300/10000) * 3 * 45,000,000) = 135000000

すなわちかかるコストは

// 読み取り回数 * $0.06(/10万回) + 書き込み回数 * $0.18(/10万回)
(585000000 * 0.06*10^(-5)) + (135000000 * 0.18*10^(-5)) => $594 => 64650

731万円 => 6.5万円と 1/100 以上に節約できました!!

とっても満足。

補足

実際は以下のような理由によりもう少しコストはかかってしまいます。

  • リストの登録のために1クエリ必要
  • クライアントに最大1万要素の配列なんか送りたくないんでfunctoins使います
  • ruleの設定もしなきゃいけないんじゃあ

それでもだいぶ安くなるんじゃないかな。きっと。多分。

おわりに

はじめにも言いましたが...
(計算式間違ってんぞ! 意味わからん! こういう要望どうすんじゃ! とかあったら教えてください )

あと、もっといいやり方あるぞ! っていうのも教えてください。 何卒。

ありがとうございました。