巨大レコードのページネーション用gemを作った

Railsにおいて,kaminariwill_paginateのようなページネーションライブラリは非常に強力だ. というか,あまりページネーションというものを意識せずとも使えてしまうので,便利だ.

ページネーション時のカウントクエリが重い

便利ではあるのだが,すべての場合において無敵なわけではない. 複雑なJOIN を繰り返すことによりインデックスが効かなくなった状態で,レコード数が大量にあるような場合.そんなときに,これらのページネーションを使っていると,重くなる場合がある.

ページネーションライブラリは,ページの下部にページネーションのビューを表示している. そして,ここにはラストページの番号が書かれている.

f:id:h3poteto:20151219205701p:plain

この数字は,ページネーションライブラリ内部でカウントクエリを発行して,条件に合致するレコード数をカウントしている.

複雑で重いクエリの場合,このカウントクエリが非常に重くなってしまう場合がある.

大量のレコードがある場合のページネーション

gemにした

このような場合のページネーションを上手いこと解決してくれるgemを作った.

github.com

方針

そもそもそのページ数,正確である必要は?

カウントクエリが重くなるような状態では,たいていの場合はレコード数が多い.例えば4000ページくらいあったとしよう.果たして1ページ目を訪れる人のどのくらいが,最終ページにいくだろうか? そして最終ページにいくとき,そのページ数が正確である必要性はあるのだろうか?

これは俺の個人的意見なのだが,ページ数もレコード件数も,そこまで正確である必要はないと思っている.だいたい有効桁数3桁くらいまで出ていれば,その先はなんであろうと変わらない. そもそも天下のgoogleだって,検索件数を正確には出していないじゃないですか.

f:id:h3poteto:20151219205638p:plain

どう実現するか

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

使い方

READMEに書いてあることとほとんど同じですが.

Redisの設定

config/initalizers/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

per_page はわかると思います.ちなみにデフォルトで10が設定されているので,省略可能なオプションです. essential は,ページ数を適当に丸めて表示するときの有効桁数です.この例で言うと,ラストが4561ページだったら,4570と表示されることになります.この値も,デフォルト値として3が設定されているので,省略可能なオプションになります.

Views

<% @guess.records.each do |r| %>
  <%= r.hogehoge %>
<% end %>
<%= paging(@guess) %>

元のActiveRecord オブジェクトはrecordsというインスタンス変数に格納されています.ここから取り出せば,今までと同じように使えます.

Assets

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

//= require 'guess_paging'

これでこんな感じのビューデザインになります.

f:id:h3poteto:20151219205735p:plain

Helpers

  • ページネーションのビューを表示する

    <%= paging(@guess) %>

  • 現在のページ番号

    @guess.current_page

  • ラストページの番号(概数)

    @guess.max_page

  • レコード件数(概算)

    @guess.count

速くなるよ

あまり複雑なDBを即席で作れなかったので,適当に10万件くらいの軽いレコードなので,そこまで大きな差は出ていませんが.

  • kaminari f:id:h3poteto:20151219205819p:plain f:id:h3poteto:20151219205825p:plain

  • guess_paging f:id:h3poteto:20151219205833p:plain f:id:h3poteto:20151219205841p:plain

できていないこと

  • localeによるビューの変更 kaminari のようにlocaleの設定でページネーションビューの表示文字を変更することについては,対応していません.

  • デザイン変更 これは使う側で適当なcssを作ってもらえばもちろん上書きできます.デフォルトだと,//= require 'guess_paging' したものがそのまま適応されます.

思うところ

今回のgemはActiveRecord の拡張として作ってはいません.どうもページネーションの機能がActiveRecord に拡張されて入るというのは,違う気がしていてい,ページネーションはページネーションのオブジェクトを生成するように作りました. ただ,これにもちょっとだけ不便なところがあって,ActiveRecord の拡張として作ると,呼び出された時に初めてDBアクセスしてARのオブジェクトが生成されます. なので,コントローラでクエリを書いておくと,ビューで呼び出すときに初めてDBアクセスされますよね.そのため,kaminari のようなページネーションでは,ページネーション用のカウントクエリはビューで呼び出した際に初めてクエリが呼ばれます.

それに対して,guess_paging は,ARで書いたクエリを一度GuessPaging に渡して,そこでオブジェクト化しているので,guessメソッドを読んだ時点でARのオブジェクト生成が走り,必要なクエリが呼ばれます. なるほど,ARの拡張として作るというのにはそういう意味があるんですね.

もうひとつ考えたこととしては,おそらくこのgemってレコード件数が少ないページにはあんまり使い勝手が良くないんですよね. だって,勝手にページ数保持するじゃないですか,正確じゃないでしょ. これを10ページとかでやられたら,むしろ邪魔なわけですよ.

そうなると kaminariwill_paginate と同居するってことは十分に考えられて,その際にARの拡張にしているとメソッド名被りそうだな……と. であるならオブジェクトからして別の方が取り回しが楽な予感がしました.