839の日記

業務とは関係ない趣味の話を書くブログです。

firestoreのページネーションプラクティス

以下のようなシンプルなデータセットに対してのページネーションを考える。

DocumentID likeCount
0 20
1 10
2 30
3 20
4 10
5 20
6 10

これを2件ずつlikeCountで降順ソートしたページネーションを実現したい場合は以下のような形になる。

Page DocumentID(likeCount)
1 2(30)
1 5(20)
2 3(20)
2 0(20)
3 6(10)
3 4(10)
4 1(10)

これを実現する手段で思い浮かぶのはオフセットだと思うが、firestoreではオフセットの利用が推奨されていない*1

オフセットは使用しないでください。その代わりにカーソルを使用します。オフセットを使用すると、スキップされたドキュメントがアプリケーションに返されなくなりますが、内部ではスキップされたドキュメントも引き続き取得されています。スキップされたドキュメントはクエリのレイテンシに影響し、このようなドキュメントの取得に必要な読み取りオペレーションは課金対象になります。

refs: Cloud Firestore のベスト プラクティス  |  Firebase

ここで出てくるカーソルは クエリカーソルを使用したデータのページ設定  |  Firebase で説明されている。 ページネーションを実現する上で気をつけないといけないことは

  • 同じデータが複数ページに跨って出ないようにする
  • 同じ値(e.g. likeCount)の違うデータがページを跨ぐときにスキップされないようにする

という点だと思う。 しかし上記のリファレンスからは2点を達成するための方法がやや気づきにくい。

解法の一つとして Firestore で いいね順(Score順)Sort + Paging するポイント - Qiita の記事では createdAt を使った複数のOrderByで解決する方法が記載されている。 最初に記載したデータセットcreatedAt のフィールドを追加すれば複数フィールドOrderByで大体のケースは正しいページネーションが行える。

今回は更新順の取得 => createdAt の値が下がっていく方向に進むので、開始日時を 1ms 減算

ただ複数フィールドのOrderByを行う方法はいくつかデメリットがある。

  • createdAt が1ms以内に収まるドキュメントが複数あった場合に期待通りの結果にならないケースがある(はず)
  • createdAt がないと期待したページネーションができない*2
  • 複数フィールドOrderByを行うために複合インデックスを明示的に作成する必要がある

このような問題はカーソルの指定にDocumentSnapshotを指定することで解決できる。

var lastDoc *firestore.DocumentSnapshot
for {
  client.Collection("collectionID").OrderBy("likeCount", firestore.Desc).Limit(2)
  if lastDoc != nil {
    q = q.StartAfter(lastDoc)
  }
  dss, _ := q.Documents(ctx).GetAll()
  if len(dss) == 0 {
    break
  }
  lastDoc = dss[len(dss)-1]
}

これをすると最初の表に記載した期待通りの順番でドキュメントが返ってくる。
likeCountを使うものに書き換えると以下のようになる。

var lastLikeCount int64 = -1
for {
  client.Collection("collectionID").OrderBy("likeCount", firestore.Desc).Limit(2)
  if lastLikeCount != -1 {
    q = q.StartAfter(lastLikeCount)
  }
  dss, _ := q.Documents(ctx).GetAll()
  if len(dss) == 0 {
    break
  }
  if v, ok := dss[len(dss)-1].Data()["likeCount"].(int64); ok {
    lastLikeCount = v
  }
}

これを実行すると以下のような結果が取得される。
赤字のデータは期待していたが実際には取得できなかったデータを示す。

Page DocumentID(likeCount)
1 2(30)
1 5(20)
- 3(20)
- 0(20)
2 6(10)
2 4(10)
- 1(10)

期待通りの結果からいくつかドキュメントが欠損してしまっていることがわかる。*3 ちなみにStartAfterからStartAtに変更すると無限ループが発生してしまうのでStartAfterを使っている。

likeCountをcursorに指定する場合と比べ、ドキュメントを指定する場合はgoクライアントのリクエストで以下のような差分がある。

   ...
   StartAt: &pb.Cursor{
     Values: []*pb.Value{
       {ValueType: &pb.Value_IntegerValue{IntegerValue: 20}},
+      {ValueType: &pb.Value_ReferenceValue{ReferenceValue: "projects/:projectID/databases/(default)/documents/collectionID/5"}},
     },
   },
   ...

上記の通り、ReferenceValueによるソートが入っている。 これは以下の点でメリットがある。

  • このようなケースに関しては複合インデックスが不要
  • 不要なフィールドを指定しなくて良い
  • 1ms以内にドキュメントが複数作成されていても期待通りの結果が返る

ReferenceValueは恐らくfirestoreの中で特別な立ち位置にいるため複合インデックス無しでこのような事ができるのだと思う。
少なくともReferenceValueはソートされていると思われるレスポンスが返ってきている。*4 事実上複数フィールドに対してOrderByをしているのだが、ReferenceValueに関しては複合インデックスの制約を受けないようだ。

このあたりはもしかしたらドキュメントにちゃんと書かれてるかもしれないので見つけた人がいたらぜひ教えて下さい。

*1:RDBでも大規模なデータではオフセットだとパフォーマンスは出にくい

*2:ページネーションのために本来不要なフィールドが必要、という意

*3:クライアント側で工夫すればlikeCountをcursorに指定する方式でもできるとは思うが、複雑度は高い

*4:検証例のDescの場合ではDocumentIDも降順になっているが、AscにするとDocumentIDも昇順になっている