mybook

ページネーションとフィルタリング — 大量データを扱う

タイムアウトの原因

「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     # 著者名に含む
  )
end
GET /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)
end
GET /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
end

AWS 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を攻撃や乱用から守るレート制限を実装する。