ページネーションとフィルタリング — 大量データを扱う
タイムアウトの原因
「APIが遅い。というか、タイムアウトしてる」
パートナー企業B社からのクレームだった。GET /api/v1/articles が30秒かかっている。
「ちょっと待って、articleレコードが...250万件ある」
サクラは画面を凝視した。全件返そうとしていたのだ。250万件のJSONを一度に生成すれば、サーバーもクライアントも死ぬ。
ページネーションを今すぐ実装しなければならない。
ページネーションの種類
Loading diagram...
| 方式 | 向いているケース | 避けるべきケース |
|---|---|---|
| オフセット | 管理画面、件数が少ない | データが頻繁に追加される |
| カーソル | タイムライン、無限スクロール | 任意ページへのジャンプ |
| ページ | 検索結果、一般的なリスト | リアルタイムデータ |
オフセットベースのページネーション
kaminariを使った実装
# Gemfile
gem 'kaminari'
# インストール設定
rails generate kaminari:config# config/initializers/kaminari_config.rb
Kaminari.configure do |config|
config.default_per_page = 20
config.max_per_page = 100
config.page_method_name = :page
config.param_name = :page
end# app/controllers/api/v1/articles_controller.rb
module Api
module V1
class ArticlesController < BaseController
def index
@articles = Article.page(params[:page]).per(per_page_param)
render json: {
data: @articles.map { |a| ArticleSerializer.new(a).serializable_hash },
meta: pagination_meta(@articles),
links: pagination_links(@articles)
}
end
private
def per_page_param
[params.fetch(:per_page, 20).to_i, 100].min
end
def pagination_meta(collection)
{
pagination: {
current_page: collection.current_page,
per_page: collection.limit_value,
total_pages: collection.total_pages,
total_count: collection.total_count
}
}
end
def pagination_links(collection)
base_url = request.base_url + request.path
{
self: "#{base_url}?page=#{collection.current_page}&per_page=#{collection.limit_value}",
first: "#{base_url}?page=1&per_page=#{collection.limit_value}",
last: "#{base_url}?page=#{collection.total_pages}&per_page=#{collection.limit_value}",
prev: collection.first_page? ? nil :
"#{base_url}?page=#{collection.current_page - 1}&per_page=#{collection.limit_value}",
next: collection.last_page? ? nil :
"#{base_url}?page=#{collection.next_page}&per_page=#{collection.limit_value}"
}.compact
end
end
end
endレスポンス例:
{
"data": [...],
"meta": {
"pagination": {
"current_page": 1,
"per_page": 20,
"total_pages": 125,
"total_count": 2500000
}
},
"links": {
"self": "https://api.techbridge.jp/v1/articles?page=1&per_page=20",
"first": "https://api.techbridge.jp/v1/articles?page=1&per_page=20",
"last": "https://api.techbridge.jp/v1/articles?page=125&per_page=20",
"next": "https://api.techbridge.jp/v1/articles?page=2&per_page=20"
}
}WARNING
OFFSET 1000000 のような大きなオフセットは、DBが先頭から100万件読み飛ばすため非常に遅い。大量データにはカーソルベースを使いましょう。
カーソルベースのページネーション
リアルタイムデータや大量データに最適。最後に取得したレコードのID(またはタイムスタンプ)を基準にする。
# app/controllers/api/v1/feeds_controller.rb
module Api
module V1
class FeedsController < BaseController
def index
per_page = [params.fetch(:per_page, 20).to_i, 100].min
cursor = params[:cursor]
@articles = if cursor
Article.where("id < ?", decode_cursor(cursor))
.order(id: :desc)
.limit(per_page + 1) # 次ページ存在確認のため+1
else
Article.order(id: :desc).limit(per_page + 1)
end
has_next_page = @articles.size > per_page
articles_to_return = has_next_page ? @articles.first(per_page) : @articles
render json: {
data: articles_to_return.map { |a| ArticleSerializer.new(a).serializable_hash },
meta: {
has_next_page: has_next_page,
cursor: has_next_page ? encode_cursor(articles_to_return.last.id) : nil
}
}
end
private
def encode_cursor(id)
Base64.strict_encode64(id.to_s)
end
def decode_cursor(cursor)
Base64.strict_decode64(cursor).to_i
rescue ArgumentError
raise ApiError, "無効なカーソルです"
end
end
end
endクライアントの使い方:
# 最初のページ
GET /api/v1/feeds?per_page=20
# 次のページ(レスポンスのcursorを使う)
GET /api/v1/feeds?cursor=MTIz&per_page=20
フィルタリング
# app/controllers/api/v1/articles_controller.rb
def index
@articles = Article.all
@articles = apply_filters(@articles)
@articles = apply_sort(@articles)
@articles = @articles.page(params[:page]).per(per_page_param)
render json: { data: serialize_articles(@articles), meta: pagination_meta(@articles) }
end
private
def apply_filters(scope)
# ステータスフィルター
scope = scope.where(status: params[:status]) if params[:status].present?
# カテゴリフィルター(複数対応)
if params[:category_ids].present?
ids = params[:category_ids].split(",").map(&:to_i)
scope = scope.where(category_id: ids)
end
# 日付範囲フィルター
scope = scope.where("published_at >= ?", params[:from]) if params[:from].present?
scope = scope.where("published_at <= ?", params[:to]) if params[:to].present?
# 全文検索
scope = scope.where("title ILIKE ? OR body ILIKE ?",
"%#{params[:q]}%", "%#{params[:q]}%") if params[:q].present?
scope
endフィルター例:
GET /api/v1/articles?status=published
GET /api/v1/articles?category_ids=1,2,3
GET /api/v1/articles?from=2024-01-01&to=2024-12-31
GET /api/v1/articles?q=Rails&status=published&page=2
Ransackを使った高度なフィルタリング
# Gemfile
gem 'ransack'
# コントローラー
def index
@q = Article.ransack(ransack_params)
@articles = @q.result.page(params[:page]).per(20)
render json: { data: serialize_articles(@articles) }
end
private
def ransack_params
params.fetch(:q, {}).permit(
:title_cont, # タイトルに含む
:status_eq, # ステータス完全一致
:published_at_gteq, # 公開日以降
:published_at_lteq, # 公開日以前
:author_name_cont # 著者名に含む
)
endGET /api/v1/articles?q[title_cont]=Rails&q[status_eq]=published
ソート
ALLOWED_SORT_COLUMNS = %w[created_at published_at title view_count].freeze
def apply_sort(scope)
sort_by = params.fetch(:sort_by, "created_at")
order = params.fetch(:order, "desc").downcase
# バリデーション(インジェクション対策)
sort_by = "created_at" unless ALLOWED_SORT_COLUMNS.include?(sort_by)
order = "desc" unless %w[asc desc].include?(order)
scope.order(sort_by => order)
endGET /api/v1/articles?sort_by=published_at&order=asc
GET /api/v1/articles?sort_by=view_count&order=desc
スパースフィールドセット
クライアントが必要なフィールドだけをリクエストできる仕組み。
def show
fields = params[:fields]&.split(",")
serialized = UserSerializer.new(@user)
result = if fields.present?
serialized.serializable_hash(fields: { user: fields.map(&:to_sym) })
else
serialized.serializable_hash
end
render json: { data: result }
end# 全フィールド
GET /api/v1/users/1
# 必要なフィールドだけ
GET /api/v1/users/1?fields=name,email
ElasticSearchとの連携
全文検索が必要な場合はElasticSearchを使う。
# Gemfile
gem 'elasticsearch-model'
gem 'elasticsearch-rails'
# app/models/article.rb
class Article < ApplicationRecord
include Elasticsearch::Model
include Elasticsearch::Model::Callbacks
settings index: { number_of_shards: 1 } do
mappings dynamic: false do
indexes :title, type: :text, analyzer: :kuromoji
indexes :body, type: :text, analyzer: :kuromoji
indexes :status, type: :keyword
indexes :published_at, type: :date
end
end
end
# 検索
def search_articles
Article.search(
query: {
bool: {
must: [
{ match: { title: params[:q] } }
],
filter: [
{ term: { status: "published" } }
]
}
},
from: (params[:page].to_i - 1) * 20,
size: 20
).records
endAWS CloudFrontでのキャッシュ最適化
ページネーションされたレスポンスはキャッシュが効く。
{
"CloudFrontCacheBehaviors": {
"Items": [
{
"PathPattern": "/v1/articles*",
"DefaultTTL": 60,
"MaxTTL": 300,
"MinTTL": 0,
"ForwardedValues": {
"QueryString": true,
"QueryStringCacheKeys": {
"Items": ["page", "per_page", "status", "category_ids", "sort_by", "order"]
}
}
}
]
}
}ページパラメーターがキャッシュキーに含まれるので、同じクエリのリクエストはCloudFrontがキャッシュから返す。
INFO
フィルター条件が多いと組み合わせ爆発でキャッシュが効かなくなります。よく使われるフィルターのみキャッシュキーに含めるのが実用的です。
サクラの成果
250万件のタイムアウトが解決した。デフォルト20件、最大100件に制限したことで、レスポンスタイムが30秒から150ミリ秒になった。
「データが増えても、ユーザーが感じる速度は変わらない。それがページネーションの力」
次章では、APIを攻撃や乱用から守るレート制限を実装する。