Studyplus Engineering Blog

スタディプラスの開発者が発信するブログ

Railsで流量の多いデータをページネーションする

はじめまして、for School事業部のサーバーサイドエンジニアの冨山です。
今回はfor SchoolのリニューアルにおいてAPIでリアルタイムなデータをページネーションする上でJinraiというカーソルベースのページネーションライブラリを開発しました。
今回はその開発の経緯や直面した課題についてお話していきたいと思います。

TL:DR

  • ページベースのページネーションだとクライアントへ渡すデータに重複が発生する。
  • 基点となるレコードを示すカーソルをクライアントへ渡すことでOFFSETを意識することなく特定の範囲のデータを取得できる。
  • IDをクライアントに渡したくない場合、カーソルとして渡す値がユニークで無いとクライアントに渡らない情報が発生する。
  • ソートキーの値が重複する場合IDでもソートすることで上の問題が解決する

Jinraiの使い方

class Post < ApplicationRecord
  cursor_per 5 # 一度に取得する件数の指定
end

class PostsController < ApplicationController
  def index
    @posts = Post.cursor(since: params[:since], till: params[:till])
  end
end

posts = Post.cursor # 最新5件のレコード
since = posts.till_cursor # postsの最後のレコードを示すカーソル
Post.cursor(since: since) # postsの最後のレコード以前の5件のレコード

till = posts.since_cursor # postsの最初のレコードを示すカーソル
Post.cursor(till: till) # postsのレコードより最新のレコード

デフォルトではidでソートされますが、id以外のキーでソートしたい場合sort_atオプションを渡すことで実現できます。

Post.cursor(sort_at: :updated_at) #=> updated_atでソートされた上から5件のレコード

開発経緯

Studyplus for Schoolでは生徒の勉強記録の情報をタイムラインに表示するような機能があります。

当初はKaminariを使っていましたがそれだとデータの流量が増えたときにページ間でデータの重複が発生するためカーソルベースのページネーションを作ろうという話になりました。

課題1

最新データから一定件数ずつ取得するタイムラインでは常にデータの先頭に最新のレコードが挿入され続けるためページ型のページネーションだとデータの重複が発生します。
以下にその一例を示しますが、下のような投稿のテーブルがあるとします。

posts

id text updated_at
10 post10 2018/12/13 14:12
9 post09 2018/12/13 14:11
8 post08 2018/12/13 14:10
7 post07 2018/12/13 14:09
6 post06 2018/12/13 14:08
5 post05 2018/12/13 14:08
4 post04 2018/12/13 14:06
3 post03 2018/12/13 14:05
2 post02 2018/12/13 14:04
1 post01 2018/12/13 14:03

Kaminariで上のテーブルから最新のデータを5件づつデータを取得すると、

Post.order(id: :desc).page(1).per(5).pluck(:id) #=> [10, 9, 8, 7, 6]

発行されているSQLは

SELECT posts.* FROM posts ORDER BY posts.id DESC LIMIT 5 OFFSET 0

となります。

ここで新しい投稿があり、データ更新されるとテーブル内の情報は下のように更新されます。

posts

id text created_at
11 post11 2018/12/13 14:20
10 post10 2018/12/13 14:12
9 post09 2018/12/13 14:11
8 post08 2018/12/13 14:10
7 post07 2018/12/13 14:09
6 post06 2018/12/13 14:08
5 post05 2018/12/13 14:08
4 post04 2018/12/13 14:06
3 post03 2018/12/13 14:05
2 post02 2018/12/13 14:04
1 post01 2018/12/13 14:03

このタイミングで先程の方法で2ページ目を取得すると、発行されるSQLは

SELECT posts.* FROM posts ORDER BY posts.id DESC LIMIT 5 OFFSET 5

となるため

Post.order(id: :desc)page(2).per(5).pluck(:id) #=> [6, 5, 4, 3, 2]

は、id=6のデータが重複してしまいます。 ページベースのページネーションで上のようなリアルタイムデータを扱うと重複や削除処理などが入れば表示されないデータが発生します。

解決策

そこで最初と最後のレコードを一意に特定可能な値を渡すことで、そのレコードを基点に取得すれば上のような問題は発生しなくなります。 一般的な方法にIDを渡す方法があり、IDはユニークなのでクライアントへ直前に渡したデータの任意のIDをリクエストに含めて貰えれば重複なく前後のデータを返すことができます。

例えばkaminariでは2ページ目を取得する場合

curl -X GET http://example.com/users?page=2

といったリクエストを受けますが、Jinraiでは以下のような形でリクエストを受けます。

curl -X GET http://example.com/users?since=6

ここで発行されるSQLは以下のようになります。

SELECT  posts.* FROM posts WHERE posts.id < 6 ORDER BY posts.id DESC LIMIT 5

この方法であれば上の新しい投稿がされた場合であっても直前に受け取ったデータの最後のIDをリクエストのsinceパラメータに含めてあげることで、重複なくデータを取り出すことができます。

課題2

上でデータの重複は解決できますが、クライアントにIDを渡したくかったり、IDでなくupdated_atなどの異なる属性でソートされたデータが欲しいといったケースが出てくると思います。

下はpostsをupdated_at > idの順序でソートされたデータです。

posts

id text updated_at
10 post10 2018/12/13 14:12
9 post09 2018/12/13 14:11
8 post08 2018/12/13 14:10
7 post07 2018/12/13 14:09
5 post06 2018/12/13 14:08
6 post05 2018/12/13 14:08
4 post04 2018/12/13 14:06
3 post03 2018/12/13 14:05
2 post02 2018/12/13 14:04
1 post01 2018/12/13 14:03

こちらのテーブルから最初の5件を取得すると

Post.cursor(sort_at: :updated_at).pluck(:id) #=> [10, 9, 8, 7, 5]

となります。次にその次の5件を上と同じSQL取得すると、

SELECT posts.* FROM posts
WHERE posts.updated_at < '2018/12/13 14:08' ORDER BY posts.updated_at DESC LIMIT 5

という形ですが、直前に取得したデータの最後(id=5)のupdated_atが次のレコード(id=6)のupdated_atと同じ値の為id=6のレコードが取得できなくなってしまいます。

解決策

そこでJinraiではid以外の属性でソートするときは以下のようなSQLを発行します。

SELECT posts.* FROM posts
WHERE (posts.updated_at < '2018/12/13 14:08')
OR ((posts.updated_at = '2018/12/13 14:08') AND (posts.id > 6))
ORDER BY posts.updated_at DESC LIMIT 5

WHERE句でソートキーの値が重複する可能性を考慮して、同じだった場合より大きいidのデータを取得するようにしています。これを実装上でArelを使用してコードで以下のように表現しています。

if sort_at != primary_key
  condition_1 = arel_table[sort_at].send(rank, attributes[sort_at])
  condition_2 = arel_table.grouping(arel_table[sort_at].eq(attributes[sort_at])
    .and(arel_table[primary_key].send(rank_for_primary, id)))
  relation = where(condition_1.or(condition_2))
else
  relation = where(arel_table[primary_key].send(rank, id))
end

まとめ

以上のようにjinraiはページベースのものに比べて、全ページ数などが取得できないといったデメリットはありますが今回のようなケースでは過不足なくデータの受け渡しを行えます。
流量が多く最新からデータを取得したいようなケースに扱いやすい形でデータを提供できるような形で開発されています。

ソースは以下のGithubにあり弊社メンバー以外の方の開発参加も歓迎しています。
OSSの開発に関することはGitterにてやり取りされている為興味がある方は是非一度ルームで声をかけてください。
Github(studyplus/jinrai)
Gitter