この記事は Crowdworks Advent Calendar 2015 20日目の記事になります.
みなさんRailsのページネーションは何を使っていますか?
kaminari
? will_paginate
?
どちらも使い勝手の良いページネーションライブラリですが,僕が入った時,クラウドワークスでは will_paginate
が使われていました.
ページネーション用のクエリが重い
ページネーションはページ下部にページング用のビューを出力していますが,その内部ではレコード件数のカウントをします.
このような見せ方をしているため,どうしても最終ページのページ番号を知る必要があります.
ページに表示するレコードを取得するクエリには,OFFSET
や LIMIT
をつけていますが,最終ページを知るためにはどうしても OFFSET
や LIMIT
のついていないクエリで COUNT
する必要があります.
というわけで kaminari
も will_paginate
もカウントしています.
絞り込みが複雑なページが重くなる
クラウドワークス内にもページネーションをしている場所がいくつもあります. そういうページで一部,やたら重いページが見受けられました.
これ,絞込のSQLクエリがやたら複雑で,それについてのカウントクエリを投げていて,現状一番のボトルネックになっているのはそこなんです.
もちろんそのカウントクエリとは,ページネーションの中で呼び出している COUNT
でした.
もちろん表示する分のレコードを取得するのも重いわけですが,LIMIT
とOFFSET
がついているのでCOUNT
ほどではない.
キャッシュする?
一番最初に思いつくのはキャッシュです.ページ数をキャッシュしてしまえば,速そうな気がします. ただ,単純なページ数のキャッシュには問題もあります.
レコード数が増えた時にどうするか
レコード数が増えた時に,ページ数をキャッシュしてしまっていると,最後のページへのリンクが生成されずに,全件にアクセスできなくなってしまいます. これはページに載せているコンテンツにもよるでしょうけど,あまり望ましくありません.
であるならば……
載せるコンテンツが増えた時にキャッシュを再生成する
これは確かに手段としてはありです.
ただし,結構な複雑度になります.なにせこれだけ重いページなのだから,同期処理でキャッシュの再生成はできない.レコードが増えた時に,非同期でキャッシュクリア&再生成をしなければならない.
さらに言うなら,こういうカウントクエリ原因で重いページはいくつもあります.
なので,対象になるレコードについてはすべて create
時にキャッシュの処理をしてやらなきゃいけない.
いや,めんどくさくないですか? それ,表示側だけでなんとかならんの?
そもそもそのページ数,正確に出す必要あるの?
たいてい,そういう重いページネーションをしているページは,そもそもレコード件数が多く,強敵の中には4000ページを超える奴らが潜んでいます. これって,1ページ目を見た時に最終ページが何ページなのか正確に知りたいですか? それを知りたい人ってどのくらいいるんですか?
googleの検索結果などをみていただけるとわかると思うんですが,最終ページのページ数を正確に出す必要ってないんですよ,たぶん.概算の数を出しておいて,いざ最終ページアクセスがあったときに,「あ,ごめん,これしかなかったわ」と出せばいいんじゃないでしょうか.
これは感覚なんですが,ページ数の表記において有効数字は3桁程度あればいいと思うんですよね. 4561ページだろうが,4562ページだろうが,そんな大差ないと思うんですよ.
じゃぁ概算のページ数だけ出しておこう
方針
- 本当に一番最初に1ページ目を表示したときに,ページ数を
COUNT
クエリ発行してカウントする - その値を(クエリをKeyとして)Redisに保存しておく
- ラストページの値は,最初にカウントした値を元に,多めに見積もって適当に
ceil
して出しておく - アクセスされたとき,もしレコード件数が0件だったら,もう一度
COUNT
クエリを発行して正確なラストページの値を算出し,ラストのレコードを表示する - そのとき,Redisに保存されている値と違う値になっていたら,Redisの値を更新する
Redisじゃなくてmemcacheとかに保存してもいいんですが…….もちろんキャッシュの値が消えていた時はカウントしなおします.だけど,別に消える必要もなくて,expireを設定するようなものでもない.必要なときには値を更新していくので,勝手に消えてもらうとむしろパフォーマンスが下がるだけなんですね.なのでmemcacheはあんまり適切ではないです.
この方式でカウントクエリにより重くなるのは,本当に初回の一人目と,ラストページを見に来た人だけです.
Redisの値が吹っ飛ぶことを想定しないのであれば,稼働後はラストページを見に来るときだけ COUNT
クエリを発行します.
Gemにした
というわけでこんな機能をもつページネーションライブラリを作りました.
使い方
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
guess_paging
というわけでクラウドワークスではこんなページネーションの技術が使われて・・・・・・いません!
タイトルにもある通り,全然支えてないです.今も will_paginate
してます.毎日NewRelicに「重いよこれ」って怒られる日々です.
なにこれ.