Help us understand the problem. What is going on with this article?

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

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に「重いよこれ」って怒られる日々です.

なにこれ.

Why do not you register as a user and use Qiita more conveniently?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away