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

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

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

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

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

f:id:h3poteto:20200327214209p:plain

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

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

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

クラウドワークス内にもページネーションをしている場所がいくつもあります. そういうページで一部,やたら重いページが見受けられました.

これ,絞込のSQLクエリがやたら複雑で,それについてのカウントクエリを投げていて,現状一番のボトルネックになっているのはそこなんです. もちろんそのカウントクエリとは,ページネーションの中で呼び出している COUNT でした.

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

キャッシュする?

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

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

レコード数が増えた時に,ページ数をキャッシュしてしまっていると,最後のページへのリンクが生成されずに,全件にアクセスできなくなってしまいます. これはページに載せているコンテンツにもよるでしょうけど,あまり望ましくありません.

であるならば……

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

これは確かに手段としてはありです. ただし,結構な複雑度になります.なにせこれだけ重いページなのだから,同期処理でキャッシュの再生成はできない.レコードが増えた時に,非同期でキャッシュクリア&再生成をしなければならない. さらに言うなら,こういうカウントクエリ原因で重いページはいくつもあります. なので,対象になるレコードについてはすべて create 時にキャッシュの処理をしてやらなきゃいけない.

いや,めんどくさくないですか? それ,表示側だけでなんとかならんの?

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

たいてい,そういう重いページネーションをしているページは,そもそもレコード件数が多く,強敵の中には4000ページを超える奴らが潜んでいます. これって,1ページ目を見た時に最終ページが何ページなのか正確に知りたいですか? それを知りたい人ってどのくらいいるんですか?

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

f:id:h3poteto:20200327214223p:plain

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

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

方針

  1. 本当に一番最初に1ページ目を表示したときに,ページ数をCOUNT クエリ発行してカウントする
  2. その値を(クエリをKeyとして)Redisに保存しておく
  3. ラストページの値は,最初にカウントした値を元に,多めに見積もって適当にceil して出しておく
  4. アクセスされたとき,もしレコード件数が0件だったら,もう一度COUNT クエリを発行して正確なラストページの値を算出し,ラストのレコードを表示する
  5. そのとき,Redisに保存されている値と違う値になっていたら,Redisの値を更新する

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

この方式でカウントクエリにより重くなるのは,本当に初回の一人目と,ラストページを見に来た人だけです. Redisの値が吹っ飛ぶことを想定しないのであれば,稼働後はラストページを見に来るときだけ COUNT クエリを発行します.

Gemにした

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

github.com

使い方

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 f:id:h3poteto:20200327214250p:plain f:id:h3poteto:20200327214258p:plain

  • guess_paging f:id:h3poteto:20200327214311p:plain f:id:h3poteto:20200327214320p:plain

というわけでクラウドワークスではこんなページネーションの技術が使われて・・・・・・いません! タイトルにもある通り,全然支えてないです.今も will_paginate してます.毎日NewRelicに「重いよこれ」って怒られる日々です.

なにこれ.