RubyOnRails
pagination

クラウドワークスを支えてないページネーション技術

More than 3 years have passed since last update.

この記事は Crowdworks Advent Calendar 2015 20日目の記事になります.

みなさんRailsのページネーションは何を使っていますか?

kaminari ? will_paginate ?

どちらも使い勝手の良いページネーションライブラリですが,僕が入った時,クラウドワークスでは will_paginate が使われていました.


ページネーション用のクエリが重い

ページネーションはページ下部にページング用のビューを出力していますが,その内部ではレコード件数のカウントをします.

kaminari pagination

このような見せ方をしているため,どうしても最終ページのページ番号を知る必要があります.

ページに表示するレコードを取得するクエリには,OFFSETLIMIT をつけていますが,最終ページを知るためにはどうしても OFFSETLIMIT のついていないクエリで COUNT する必要があります.

というわけで kaminariwill_paginate もカウントしています.


絞り込みが複雑なページが重くなる

クラウドワークス内にもページネーションをしている場所がいくつもあります.

そういうページで一部,やたら重いページが見受けられました.

これ,絞込のSQLクエリがやたら複雑で,それについてのカウントクエリを投げていて,現状一番のボトルネックになっているのはそこなんです.

もちろんそのカウントクエリとは,ページネーションの中で呼び出している COUNT でした.

もちろん表示する分のレコードを取得するのも重いわけですが,LIMITOFFSETがついているのでCOUNT ほどではない.


キャッシュする?

一番最初に思いつくのはキャッシュです.ページ数をキャッシュしてしまえば,速そうな気がします.

ただ,単純なページ数のキャッシュには問題もあります.


レコード数が増えた時にどうするか

レコード数が増えた時に,ページ数をキャッシュしてしまっていると,最後のページへのリンクが生成されずに,全件にアクセスできなくなってしまいます.

これはページに載せているコンテンツにもよるでしょうけど,あまり望ましくありません.

であるならば……


載せるコンテンツが増えた時にキャッシュを再生成する

これは確かに手段としてはありです.

ただし,結構な複雑度になります.なにせこれだけ重いページなのだから,同期処理でキャッシュの再生成はできない.レコードが増えた時に,非同期でキャッシュクリア&再生成をしなければならない.

さらに言うなら,こういうカウントクエリ原因で重いページはいくつもあります.

なので,対象になるレコードについてはすべて create 時にキャッシュの処理をしてやらなきゃいけない.

いや,めんどくさくないですか?

それ,表示側だけでなんとかならんの?


そもそもそのページ数,正確に出す必要あるの?

たいてい,そういう重いページネーションをしているページは,そもそもレコード件数が多く,強敵の中には4000ページを超える奴らが潜んでいます.

これって,1ページ目を見た時に最終ページが何ページなのか正確に知りたいですか?

それを知りたい人ってどのくらいいるんですか?

googleの検索結果などをみていただけるとわかると思うんですが,最終ページのページ数を正確に出す必要ってないんですよ,たぶん.概算の数を出しておいて,いざ最終ページアクセスがあったときに,「あ,ごめん,これしかなかったわ」と出せばいいんじゃないでしょうか.

スクリーンショット 2015-12-19 19.55.14.png

これは感覚なんですが,ページ数の表記において有効数字は3桁程度あればいいと思うんですよね.

4561ページだろうが,4562ページだろうが,そんな大差ないと思うんですよ.


じゃぁ概算のページ数だけ出しておこう


方針


  1. 本当に一番最初に1ページ目を表示したときに,ページ数をCOUNT クエリ発行してカウントする

  2. その値を(クエリをKeyとして)Redisに保存しておく

  3. ラストページの値は,最初にカウントした値を元に,多めに見積もって適当にceil して出しておく

  4. アクセスされたとき,もしレコード件数が0件だったら,もう一度COUNT クエリを発行して正確なラストページの値を算出し,ラストのレコードを表示する

  5. そのとき,Redisに保存されている値と違う値になっていたら,Redisの値を更新する

Redisじゃなくてmemcacheとかに保存してもいいんですが…….もちろんキャッシュの値が消えていた時はカウントしなおします.だけど,別に消える必要もなくて,expireを設定するようなものでもない.必要なときには値を更新していくので,勝手に消えてもらうとむしろパフォーマンスが下がるだけなんですね.なのでmemcacheはあんまり適切ではないです.

この方式でカウントクエリにより重くなるのは,本当に初回の一人目と,ラストページを見に来た人だけです.

Redisの値が吹っ飛ぶことを想定しないのであれば,稼働後はラストページを見に来るときだけ COUNT クエリを発行します.


Gemにした

というわけでこんな機能をもつページネーションライブラリを作りました.

https://github.com/h3poteto/guess_paging


使い方


redisの設定

config/initializers/redis.rb みたいなのを用意します.

GuessPaging::RedisClient.setup do |config|

config.redis_host = '127.0.0.1'
config.redis_port = 6379
end


controllers

class RecordsController < ApplicationController

def search
@guess = GuessPaging::Paginate.new(
query: Record.where(category_id: params[:category_id].to_i),
per_page: 10,
essential: 3)
@guess.guess(
page_params: params[:page]
)
end
end

kaminari のように ActiveRecord の拡張にはしていません.ページネーション用のオブジェクトなのに,ActiveRecord にいるのって,なんか変な感じしません?

per_page はわかると思うんですが,essential という設定は,ページ数を丸めるときの有効数字の桁数になります.この設定だど3桁なんで,ラストが4561ページだとすると,4570ページと表示されますね.


views

<% @guess.records.each do |record| %>

<%= record.hoge %>
<% end %>
<%= paging(@guess) %>

ActiveRecord の拡張にしていないので,GuessPaging のオブジェクトからレコードを取り出してやります.records で取り出されるのは,ActiveRecord のオブジェクトなので,この先はいつもどおりに使えます.

<%= @guess.count %>

これでレコードが何件あるのかを表示できますが,ここで表示するのはあくまで概算の数です.ページ数と同じで,カウントクエリを発行せずに概算を出すので,ラストページに行くまでは正確な数値はわかりません.


assets

app/assets/stylesheets/application.css に以下の行を追加します.

//= require 'guess_paging'


速い

ちょっと手元で複雑なJOIN をするレコードを構成するのがめんどくさかったので,適当に10万件くらいのレコードでページネーションしました.なので差が微妙ですが…….


  • kaminrai

    kaminari1.png

    kaminari2.png


  • guess_paging

    guess1.png

    guess2.png


というわけでクラウドワークスではこんなページネーションの技術が使われて・・・・・・いません!

タイトルにもある通り,全然支えてないです.今も will_paginate してます.毎日NewRelicに「重いよこれ」って怒られる日々です.

なにこれ.