みなさーん!
Ruby on Rails、使ってますかー!?
僕はもう3年以上使ってませーん!
こんにちは、俳優の海老原雄一郎です。
わたくし、3年前に Rails で SaaS プロダクトを開発する会社を退職して以来、ずっと俳優をやっていましたが、売れない役者と売れない芸人と売れないバンドマンはバイトで生計を立てるというのが定番なので、実は私もプログラマーのバイトをうすーく続けていました。
ちなみに VIVANT シーズン2 には出演できませんでした。
最近は、溜まりに溜まったプライベートの雑事をいったん片付けるために俳優の仕事を控えめにし、代わりにプログラマーの仕事を増やしているんですが、AIコーディングをやっているとそこそこ待ち時間が発生するということで、前職時代にアイディアだけは持っていたやつをスキマ時間で実装してみました。
誰かの何かの役に立つのかどうかは分かりませんが、プログラマー海老原はまだ生きてますよ!という名刺代わりに、紹介でもしておこうかと思います。
riffle が解決する課題
今回私が作った riffle は、Ruby on Rails 向けのページネーター gem です。
自分が前職で携わっていたプロダクトは、スモールビジネス向けの SoR(System of Record)系 SaaS でしたが、会計・経理や受発注などに関わる業務レコードの一覧機能は非常によく利用されていました。発生日・勘定科目・取引先などのフィルター条件を指定すると、条件に合致するレコードが一覧表示されて、たくさんヒットすると「1ページ目」「2ページ目」…… とページ分割されるやつです。
このようなページ分割や、分割されたページ間を移動できる UI のことを一般にページネーション、それを実現するライブラリや UI コンポーネントのことをページネーターと言いますが、自分が Rails 開発に携わっていた頃は Kaminari という gem がよく使われていたし、最近だと Pagy という gem が人気のようですね。
さて、大量データを効率よくナビゲートするのに便利なこのページネーション、対象データが大量になると性能問題が発生しやすいという課題があります。特に後ろのページに進めば進むほど画面表示が遅くなっていく、というのはよく聞く話です (Deep Pagination 問題) 。これは、Kaminari や Pagy の裏で内部的に実行されるクエリー (SQL) のことを考えると当たり前のことではあります(詳しくは後述)。
しかし私は前職在籍中にその課題を解決するアイディアを温めており、それを実現できなかったのが少し心残りであったのと、今回 AI を使って何か作ってみようかと考えた時にちょうど良いサイズ感のテーマのように思えたので、需要があるかどうかはともかく、作ってみることにしました。
riffle の仕組みを簡単に説明すると、初回検索時にヒットしたレコードの ID リストを Redis にキャッシュし、2ページ目以降は「どのレコードを表示すべきか」の特定を DB クエリーなしに Redis 上の ID リストだけで行います。該当レコードの詳細取得は主キー検索でできるため、ページ番号がどれだけ深くなっても速度が落ちません。また、この方式を採用することで副作用的に解決される他の課題もありますが、それらは後で詳述します。
riffle は Kaminari と Pagy(8系・9系・43系) のアドオンとして、既存アプリケーションに最小限の手間で導入できるほか、単独動作も可能となっています。
なお、なぜ前職でこれを実現しようとしなかったのかと言うと、私の担当プロダクトはデータ量がそこまで多くなかったのでページネーションの遅さが問題になっていなかったのと、もっと優先順位の高い課題がたくさんあったからです。
既存のページネーターの問題
先ほど「Deep Pagination が遅くなるのはクエリーのことを考えると当たり前」と書きました。通常のページネーターは、ページを移動するたびに次のようなクエリーを実行します。
-- 1ページ目
SELECT * FROM users ORDER BY name LIMIT 20 OFFSET 0; -- 比較的高速
SELECT COUNT(*) FROM users;
-- 500ページ目
SELECT * FROM users ORDER BY name LIMIT 20 OFFSET 9980; -- 9,980行をスキャンして捨てるので遅くなる
SELECT COUNT(*) FROM users;
後ろのページほど遅くなる Deep Pagination 問題の原因はこの LIMIT/OFFSET クエリーです。DB は読み飛ばす行も一度スキャンするため、後ろのページほど遅くなります。インデックスが効いていても、スキャン量そのものは減りません。
そしてページを移動するたびに常に2種類のクエリーが実行される点も、ページネーションにおける無駄と言えます。
ID リストキャッシュによる解決
この Deep Pagination 問題、既知の解法がいくつかあります。
-
Keyset(カーソル)方式(Pagy::Keysetなど):
WHERE (name, id) > (前ページの最後の値)で読む方式。性能は解決しますが、ページ番号でジャンプできません(next/prevのみ)。無限スクロールには最適でも、「500ページ目に飛ぶ」がある業務系リストビューでは採用しづらい。またソートキーの組が一意(全順序)であることが方式上の前提で、同値があるとページ境界で行が飛んだり重複したりするため、実務では末尾にidなどのタイブレーカーが必須です - Deferred Join: OFFSETをインデックスだけで処理してから本体をJOINするSQLパターン。改善はしますがOFFSETスキャン自体は残ります
-
Elasticsearch の scroll / PIT(Point in Time): riffle と同じ「検索時点のスナップショットに対してページングする」発想を、Elasticsearch 側が API として備えているものです(scroll は全件エクスポート向けの旧方式、PIT +
search_afterがその後継)。一貫性も性能も得られますが、ページ番号ジャンプは苦手(下の表の注記を参照)で、そもそもこの一機能のためだけに ES を導入・運用するのは重い
riffle はこれらのどれとも異なる、検索結果のID列全体を Redis に保持する方式を採用しました。
【初回リクエスト】
1. SELECT id FROM users WHERE ... ORDER BY name; -- 結果セットの全IDを取得(ActiveRecord なら pluck(:id))
2. IDリストをRedis Sorted Set に保存(score = 順序)、推測不可能な cursor_id を発行
3. 先頭ページ分のIDで SELECT * FROM users WHERE id IN (...);
【2ページ目以降】(?cursor_id=xxx&page=2)
1. RedisからIDリストの該当範囲をスライス(Redisへの往復1回、ページ番号に非依存)
2. SELECT * FROM users WHERE id IN (...); -- 主キー検索のみ
ここで、Redis に保存された ID リストをスナップショット、スナップショットにアクセスするためのポインターをカーソルと呼ぶことにします。手順2で発行する cursor_id がカーソルの識別子で、1つのカーソルが1つのスナップショットに対応します。
2ページ目以降へのアクセスではページリンクに ?cursor_id=xxx が乗ってブラウザとサーバーの間を往復し、リクエストが来たら riffle はその cursor_id で Redis のスナップショットを引き当てて、指定ページに含まれるレコードの ID リストを取り出します。どのスナップショットを見ているかをクライアント側が持ち歩くので、サーバーにセッション状態を持たせずに済み、ステートレスな HTTP のページングに乗るわけです。なお cursor_id は URL に出るため推測不可能なランダム値にしています。
2ページ目以降のページ取得は、1ページ目でも1000ページ目でも同じ速度になります。IDリストの該当範囲を切り出す部分の計算量が O(log N + ページサイズ)(N はキャッシュしたID数)で、ページ番号に依存しないからです。この計算量は Redis の Sorted Set に由来します(ZRANGE の計算量)。そこから先のレコード取得も主キー検索なので、やはりページ番号とは無関係です。LIMIT/OFFSET クエリーは消えます。
COUNTクエリも、1ページ目を含めて一度も発行しません。 手順1で全IDを引いた時点で件数は判明するので、それをスナップショットと一緒に記録しておき、以降はそこから答えます。ページネーターに総件数を渡すために払っていたコストが、riffle では初回からゼロになり、前述のページネーターの無駄の1つが解消されます。
代わりに初回リクエストでは上記の手順1、つまり条件に合致する全レコードのIDを一度に引くコストを払います。これは「重いソート・絞り込みを1回だけ払い、以降のページではその結果を再利用する」というトレードオフで、ページを行き来する使い方ほど効きます。
副産物:行スキップ・重複表示も消える
IDリストを凍結すると、ページネーション一般でよく発生する他の課題が構造的に解決されます。ページ1とページ2の表示の間にレコードが挿入・削除されるとページ境界がずれ、行が飛んだり同じ行が2回見えたりする——これは LIMIT/OFFSET 方式では避けられない現象です。riffle では全ページが同一のIDスナップショットを参照するため、ページ送り中のスキップと重複が起きません。
(ただしページ送り中に削除行が発見された場合には総件数が変動する他、逆順に移動している最中に削除が発生すると重複が起きる可能性はあります。詳しくは後述)
この性質は、既存の代替手法に対する大きな差別化ポイントになります。Keyset も Deferred Join も性能は改善しますが、スナップショット一貫性は持ちません。さらに riffle は初回クエリが返したID列をそのまま凍結するため、Keyset の「ソートキーは一意であること」という制約もなく、同値を含むソートや複雑な式によるソートでも、スナップショット後は順序が決定的になります。
| 方式 | Deep Pagination性能 | ページ番号ジャンプ | ページ間スキップ/重複なし | 追加インフラ不要 |
|---|---|---|---|---|
| 素のKaminari / Pagy (OFFSET) | ❌ | ✅ | ❌ | ✅ |
| Pagy::Countless | ⚠️ (COUNTなし、OFFSET残) | ✅ | ❌ | ✅ |
| Pagy::Keyset (カーソル方式) | ✅ | ❌ (next/prevのみ) | ❌ (スナップショットなし) | ✅ |
| Deferred Join | ✅ | ✅ | ❌ | ✅ |
| Elasticsearch scroll / PIT | ✅ | ⚠️ | ✅ | ❌ |
| riffle | ✅ | ✅ | ✅ (前方送り) | ❌ |
どの方式にも一長一短があります。riffle と Elasticsearch の「追加インフラ不要」が ❌ なのは、3つの性質を同時に満たす代償として、スナップショットを保持する場所(riffle なら Redis、ES なら ES 自身)が要るからです。純 SQL で完結する Keyset や Deferred Join にはこのコストがない代わりに、3つ全部は取れません。とはいえ Redis の運用は ES よりずっと軽く、セッションキャッシュや Sidekiq などで既に Redis を動かしている Rails アプリなら、追加負担は実質ほぼありません。
※ Elasticsearch の「ページ番号ジャンプ ⚠️」は、from/size での任意ページ移動が max_result_window(既定1万件)までに制限され、それより深いページは前方順次取得の PIT + search_after(from は 0 固定で任意ページへは飛べない)に切り替わる、という半端さを表しています。
※ riffle の「ページ間スキップ/重複なし ✅ (前方送り)」は、この保証が前方へページを送る場合に限られることを表しています。削除が起きた後に前のページへ戻ると重複が起こりえますし、総件数も固定されません。どちらも後述の「保証すること・しないこと」で詳しく書きます。
使い方
インストールと設定
Ruby >= 3.1、Rails >= 7.0 が必要です。Kaminari と Pagy のバージョンについては、そもそも gem の依存として要求しておらず、私が検証しているのが Kaminari 1.2 系と Pagy 8/9/43 系、というだけです。Redis サーバーのバージョンは基本コマンドしか使っていないので実質問いません。細かい条件は README を参照してください(今後変わる可能性もあるので、そちらを最新とします)。
riffle は Redis を使うので、まずアプリケーションから接続できる Redis を用意してください(Sidekiq やキャッシュで既に動かしていれば、それを流用できます)。
gem 自体はまだ rubygems.org には公開していないため、Gitタグから直接インストールします:
# Gemfile
gem 'riffle', github: 'yebihara/riffle', tag: 'v0.2.0'
# config/initializers/riffle.rb
Riffle.configure do |config|
config.redis = Redis.new(url: ENV['REDIS_URL']) # 必須
config.ttl = 30 * 60 # スナップショットの寿命(デフォルト30分)
config.max_ids = 100_000 # キャッシュする最大ID数
end
max_ids は1つのスナップショットに入れるIDの上限で、Redis のメモリを有界にするためのものです。これを超えるヒット件数だった場合の挙動は選べます。デフォルトは上限で切り詰めて警告ログを出し、そのカーソルに「切り詰め済み」の印を付けるので、アプリ側で「先頭10万件を表示中」と出すなどの対応が取れます。:raise にすれば例外になるので、ユーザーに検索条件を絞ってもらう作りにもできます。
なお20件ずつ表示するなら10万件は5,000ページぶんです。そこまでめくる人はいないので、実運用ではもっと小さくして構いません(スナップショット1つあたりのメモリも比例して小さくなります)。
Kaminari にアドオンして利用
モデル側で一度だけオプトインし:
class ApplicationRecord < ActiveRecord::Base
self.abstract_class = true
include Riffle::Model
end
呼び出し側はチェーンの最後に .riffle を足すだけです:
class UsersController < ApplicationController
def index
@users = User.order(:name)
.page(params[:page]).per(20)
.riffle(cursor: params[:cursor_id])
end
end
params の読み取りまで任せたい場合は riffle_page ヘルパーが使えます:
@users = riffle_page(User.order(:name), per: 20)
ビューは通常のKaminariと同じで、cursor_id はリンクに自動注入されます:
<%= paginate @users %>
なお .riffle はチェーンの終端に置きます。Kaminariは .page 実行時に独自の total_count をリレーションにミックスインし、Rubyは後から追加されたモジュールを先に解決するため、スナップショット由来の件数を優先させるには riffle が後である必要があります。順序を間違えると、黙って誤動作するのではなく、実行時エラーで教えてくれます。
Pagy にアドオンして利用
class UsersController < ApplicationController
include Pagy::Backend # Pagy 43では Pagy::Method
def index
@pagy, @users = pagy_riffle(User.order(:name))
end
end
Pagyは8系・9系・43系でAPIが大きく異なりますが(9で :items→:limit 改名、43は全面リライト)、riffleがバージョンを検出して適切なアダプタを選ぶため、pagy_riffle の使い方は共通です。
なお pagy_riffle はコントローラーのメソッドで、リレーションを引数として受け取りメタ情報を別の Pagy オブジェクトで返す形なので、Kaminari の場合と違ってモデル側の include Riffle::Model は不要です。
riffle 単独で利用
Kaminari / Pagy アダプターの実装はビュー層(ナビゲーションHTML、テーマ、i18n)への統合が中心です。Web API が返す JSON レスポンスでページネーションを提供する場合はビュー層の重要性が低下するので、riffle 単独のシンプルな構成でも十分便利に利用することができるはずです。モデル側の include Riffle::Model は Kaminari の場合と同様に必要で(.riffle はリレーションに生えるメソッドなので)、あとは page:/per: を .riffle に直接渡し、メタ情報を riffle_meta から返すだけです:
def index
users = User.order(:name)
.riffle(cursor: params[:cursor_id], page: params[:page], per: 20)
render json: { users: users.records, meta: users.riffle_meta }
end
riffle_meta はクライアントがページネーターを描画するのに必要な一式(cursor_id / page / per_page / total_count / total_pages / next_page / prev_page)を返し、クライアントは次のリクエストで ?cursor_id=xxx&page=2 を送るだけです。
すでにKaminariでシリアライズしている場合はそのままでも動きます。Kaminariのリーダーでmetaを組み立て、cursor_id を含めれば同じことです。
発行されるクエリの比較
ここまでの話を念頭に、実際に発行される SQL 文を確認してみましょう。まずは素のKaminari(ページを移動するたびに):
SELECT "users".* FROM "users" ORDER BY "users"."name" LIMIT 20 OFFSET 20;
SELECT COUNT(*) FROM "users";
riffle使用時(2ページ目以降):
SELECT "users".* FROM "users" WHERE "users"."id" IN (160, 186, ...) ORDER BY "users"."name";
2ページ目以降は主キーインデックスが利用可能なクエリーのみです。OFFSETスキャンもCOUNTもありません。includes や joins を含む元のスコープはページ移動後も保持されるので、N+1も発生しません。
ページ内の並び順保証は、この SQL の ORDER BY ではなく、Ruby 側での並べ直しで実現しています。この例において ORDER BY name が SQL に残っているのは元のリレーションをそのまま使っているためで、順序の保証はそれに依存していません。たとえば同名レコードのような同値があると ORDER BY name だけでは順序が一意に定まらず、初回に凍結した並びと食い違ってしまう可能性を回避しています。
性能検証
100万行のテーブルに対して、ページ番号を変えながらレコード取得時間を計測しました(各5回の中央値、単位ms、20件/ページ)。リポジトリ同梱の benchmark/deep_pagination.rb で、そのまま再現できます:
$ bundle exec ruby benchmark/deep_pagination.rb 1000000 1,100,1000,5000,25000,50000
数値は1ページを表示するのに実際にかかるコスト、つまりレコード取得と総件数取得の両方を含んでいます(ページネーターは「全◯件」を出すために総件数も必要なので、それを含めないと比較になりません)。Kaminari 側は毎回 COUNT クエリを払い、riffle 側はスナップショットから読むだけでゼロです。
| ページ番号 | Kaminari (ms) | riffle (ms) |
|---|---|---|
| 1(検索して最初に表示) | 0.33 | 309.6 |
| 100 | 0.32 | 0.21 |
| 1,000 | 0.59 | 0.21 |
| 5,000 | 1.33 | 0.21 |
| 25,000 | 5.54 | 0.21 |
| 50,000 | 11.37 | 0.21 |
riffle の特徴がはっきり出ています。1ページ目で 309.6ms という大きな前払いをして、以降は何ページ目だろうと 0.21ms 一定。対する Kaminari は1ページ目こそ 0.33ms と速いものの、後ろに進むほど比例して劣化し、5万ページ目では riffle の約54倍かかります。
なお今回の測定は SQLite のインメモリDBを利用したので Disk I/O が発生していないことと、非常にシンプルで低コストなクエリーで測定している点にご注意ください。これらは kaminari の測定値に対して非常に有利に働いており、実環境における1ページ目のクエリー性能はこれほど開かない可能性が高いです。
実際のユーザーが5万ページ目を表示する可能性は低いとしても、OFFSET による劣化は数十ページ目くらいから体感できることもあるでしょうから、もし既に問題が顕在化しているようであれば、riffle の導入を検討する価値はあるでしょう。
なお実際に測定はしていませんが、OFFSET のコストは gem ではなく DB 側で発生するため、Pagy でも同じ傾向になるものと推測されます。
削除レコード発見時の処理
riffle がスナップショット中のIDを持つレコードがDBから削除されていることに気付くと、riffle はそれをキャッシュから除去し、スナップショット内の次のIDでそのページを補填します。除去と補填が同じページ上で完結するので、以降のページ境界は連続したままです。
[Riffle] FETCH cursor_id=abc123 offset=20 limit=10 fetched=10 # キャッシュから10件切り出す
[Riffle] ZREM cursor_id=abc123 ids=["42", "57"] # DBで引いたら2件消えていた
[Riffle] DECR_COUNT cursor_id=abc123 by=2
[Riffle] FETCH cursor_id=abc123 offset=28 limit=20 fetched=20 # 次のIDで補填
1行目の fetched=10 は「キャッシュから切り出したID数」です。削除が判明するのはこの後、そのIDをDBで引いた時点になります。
再開オフセットが 30 ではなく 28(20 + 10 - 2)になっている点に注目してください。IDを2件除去すると以降の順位が2つ前に詰まるので、削除前のページサイズぶん進めると詰まった2件を飛び越してしまうため、このような調整を入れています。
なお値が更新された結果、検索条件(WHERE句)を満たさなくなった行は削除と同様に扱われます。
スナップショットの有効期限
デフォルトではカーソルの最後の利用から30分が経過すると、そのカーソル用のスナップショットは Redis から削除されます。ただしカーソル利用状況にかかわらず、カーソル作成から4時間を超えると、スナップショットの寿命はそれ以上延びません。これは、自動リロードするタブやページを巡回するクローラーが原因で、スナップショットが長時間に渡って居座り、メモリを消費し続けるのを防ぐための設計です。
期限切れのカーソルでリクエストが来たときの挙動は選べます。
-
:auto(デフォルト): 黙って新しいスナップショットを作り直して返します。画面は普通に出ますが、そこで一貫性は途切れています -
:strict:Riffle::CursorExpiredを投げるので、「再検索してください」と案内できます
なお有効期限設定はパラメーターで調整可能ですが、期限を伸ばすほど同時に生きているスナップショットが増え、Redis のメモリを食います。目安の計算式は README の「Redis のサイジング」にあります。
Rails 以外への適用可能性
riffle の本質は「結果セットのID列を順序付きで Redis に保存し、以降のページはそこからスライスする」という点にあります。riffle のコア(カーソル生成・ページ切り出し・削除時の補填)に Ruby 固有の要素はほぼないので、他フレームワークや他言語へも素直に移植できるはずです。
まとめ
-
LIMIT/OFFSETのリストビューは、データが蓄積されるサービスでは構造的に遅くなっていく - 初回検索の結果を「サーバー側カーソルキャッシュ」として保持し、以降のページをそこから返す発想を、Redis+IDリストでRailsのページネーターに落とし込んだのがriffle
- 2ページ目以降はOFFSETもCOUNTもない主キー検索1本になり、ページ取得コストはページ番号に依存しなくなる
- 前方にページを送る限り、ページ送り中の行スキップ・重複表示が発生しない
- Kaminari / Pagy に簡単にアドオンできるし、単独動作も可能
どこかの誰かの役に立つことを願っております。コメントや Issue によるフィードバックをお待ちしています。
