問題点
Rails でデファクトスタンダードとなっているページネーション gem に Kaminari というのがある。
めっちゃ最高便利で大好きなのだけど、巨大なテーブルに対して COUNT 文を投げると遅いという問題にぶち当たった。このような巨大なテーブルで Kaminari を使うために COUNT 文を発行しない without_count
というメソッドが用意されている( Kaminari 1.0.0 でやってくる 5 つの大きな変更 - Qiita )が、これを使うと next_page
や prev_page
、 total_pages
が取れなくなる(当たり前)。次のページがあるかどうかはばくち状態になってしまう。
本当は DB のスキーマを見直すべき(インデックスがちゃんと効くようにスキーマ変更するべき)だが、 Rails からもレガシーアプリからも同時に同じ DB にアクセスしており、並行運用しているような状況ではなかなか大胆な変更は実行できない。
DB 構造をなおせないとなるとキャッシュを思いつく。 HTML も Rails でレンダリングするのであれば partial cache などでページャー部分だけをキャッシュすれば良いが、 API 選任野郎と化した Rails ではビューのキャッシュはできない。
どうしたか
total_count
をキャッシュする。公開範囲を設定できるようなリソースだと全員同一のキーでキャッシュするわけにはいかないのでユーザーごとにキーを作ってキャッシュする必要あり。全ユーザーの全リクエストでスロークエリになってたやつが 5 分に一回スロークエリになるくらいだったら何とか許容できる。
例えば以下のようなコントローラーがあったとする。 HeavyModel
には数千万レコードあって、普通に COUNT
文を投げると遅い。 Paginatable
という名前でモジュールを定義して、 render
メソッドを上書きし、ページネーションを間に挟み込む。
class HeavyModelController < ApplicationController
include Paginatable
before_action :login_required
def index
resources = HeavyModel.all
render json: resources, paginate: true
end
end
モジュールはこんな感じ。車輪の再発明をしている感はあるが、 COUNT 文の結果が current_user
、コントローラーのクラス名、アクション名のそれぞれをつなげたものをキーにしてキャッシュされる。
module Paginatable
def render(*args)
options = args.extract_options!
resources = options[:json]
if options[:paginate]
resources, meta = paginate(resources, cache_total_count: options[:cache_total_count])
options[:json] = resources
options[:meta] ||= {}
options[:meta].merge!(meta)
end
args << options
super(*args)
end
def paginate(resources, options = {})
parse_params_for_pagination
paginator = Paginator.new(
resources: resources,
page: @page,
per: @per,
cache_total_count: options[:cache_total_count],
cache_key: total_count_cache_key
)
[paginator.resources, paginator.meta]
end
def total_count_cache_key
@total_count_cache_key ||= "#{current_user&.id}_#{self.class.name}_#{action_name}_count"
end
class Paginator
attr_reader :cache_total_count
UNCOUNTABLE = -1
def initialize(resources:, page:, per:, cache_total_count: false, cache_key: nil)
@_resources = resources
@page = page.to_i
@per = per.to_i
@cache_total_count = cache_total_count
@cache_key = cache_key
end
def resources
@resources ||= if cache_total_count?
@_resources.page(page).per(per).without_count
else
@_resources.page(page).per(per)
end
end
def page
@page.zero? ? 1 : @page
end
def per
@per.zero? ? Kaminari.config.default_per_page : @per
end
def meta
{
current_page: current_page,
next_page: next_page,
prev_page: prev_page,
total_pages: total_pages,
total_count: total_count
}
end
alias cache_total_count? cache_total_count
def paginatable?
!cache_total_count? && resources.respond_to?(:total_count)
end
def current_page
resources.current_page || page
end
def next_page
paginatable? ? (resources.next_page || UNCOUNTABLE) : next_page_fallback
end
def next_page_fallback
return UNCOUNTABLE if page < 1
return UNCOUNTABLE if per > resources.length
total_count_fallback > current_page * per ? current_page + 1 : UNCOUNTABLE
end
def prev_page
paginatable? ? (resources.prev_page || UNCOUNTABLE) : prev_page_fallback
end
def prev_page_fallback
return UNCOUNTABLE if page < 2
(total_count_fallback.to_f / per).ceil >= page ? current_page - 1 : UNCOUNTABLE
end
def total_pages
paginatable? ? resources.total_pages : (total_count.to_f / per).ceil
end
def total_count
paginatable? ? resources.total_count : total_count_fallback
end
def total_count_fallback
@total_count_fallback ||=
begin
cached_total_count = Rails.cache.read(@cache_key)
if cached_total_count
cached_total_count
else
real_total_count = @_resources.page(page).total_count
Rails.cache.write(@cache_key, real_total_count, expires_in: 5.minutes)
real_total_count
end
end
end
end
end
これで 5 分間はキャッシュが効くようになる。