シーク法とは
offset
を用いずにページネーションをする仕組みです。
offset
を使用する方法は、sqlがシンプルな反面、データ数が大きくなるにつれて処理が重くなるというデメリットがあります。
対してシーク法は、やや複雑なsqlになりますが、データ数が大きくなっても高速で取得できるというメリットがあります。
シーク法の概要については、以下の記事が非常にわかりやすいです。
offsetでページネーションは遅い。これからはシーク法だ!
Laravelで実装
LaravelのEloquent
でシーク法を実装します。
実装内容の概要は以下になります。
実装内容
-
posts
:投稿記事テーブル -
posts
を投稿日時(created_at
)の降順で取得する
本題のシーク法へ入る前に、まずは従来のオフセット法によるページネーションを見てみましょう。
オフセット法
従来のoffset
でのページネーションだと、以下のようになります。
public function index(Request $request): JsonResponse
{
$perPage = 10; // 1ページあたりの取得件数
// 現在のページ番号
$page = $request->get('page');
$posts = Post::select(['*'])
->orderByDesc('created_at')
->offset(($page - 1) * $perPage)
->limit($perPage)
->get();
// 以下省略
シーク法
続いてが本題です。これをシーク法で実装すると、次のようになります。
public function index(Request $request): JsonResponse
{
$perPage = 10; // 1ページあたりの取得件数
$postQuery = Post::select(['*'])
->orderByDesc('created_at')
->orderByDesc('id')
->limit($perPage);
// lastPostIdに前回取得時の最後尾のposts.idが入る
$lastPostId = $request->get('lastPostId');
if (isset($lastPostId)) {
$lastPost = Post::find($lastPostId);
$postQuery->where(function (\Illuminate\Database\Eloquent\Builder $query) use ($lastPost) {
$query
->where('created_at', '<', $lastPost->created_at)
->orWhere(function (\Illuminate\Database\Eloquent\Builder $query) use ($lastPost) {
$query
->where('created_at', $lastPost->created_at)
->where('id', '<', $lastPost->id);
});
});
}
$posts = $postQuery->get();
// 以下省略
これでシーク法によるページネーションが実現できました。
シーク法ではoffset
は使わず、where
でページネーションを行います。同じタイムスタンプの投稿が複数ある場合を考慮し、一意であるid
でもソートする必要があります。
そのためcreated_at
だけでなくid
でもorderBy
する必要がある点や、where
やorWhere
など多くてややこしいですね。
しかし一度理解してしまえば非常に有用なので、ぜひ覚えておきましょう。
なぜシーク法が早いのか?
ざっくり言うと、オフセット法の場合は100件目までスキップするイメージ、シーク法はカーソルの位置を記憶するイメージです。
オフセット法の場合はスキップ数が増えるほど処理も重くなりますが、シーク法はページ件数に関係なく取得ができるのです。
havingを用いる場合
シーク法のソート対象カラムについて、where
ではなくhaving
を用なければならない場合があります。(例:リレーション先のカウント数でのソートなど)
ここでは、以下の内容でhaving
によるシーク法を行います。
実装内容
-
posts
:投稿記事テーブル -
post_like_users
:投稿記事に「いいね」をしたユーザーを記録するテーブル -
posts
にはHasManyでpost_like_users
が紐づいている(リレーション名:likes
) - いいね数(
likes_count
)の降順で取得する
ソート対象のlikes_count
はwhere
で扱うことができず、having
を用いる必要があります。
これでページネーション実装すると、以下のようになります。
public function index(Request $request): JsonResponse
{
$perPage = 10; // 1ページあたりの取得件数
$postQuery = Post::select(['*', 'posts.id AS alias_id']) // idをhavingで使用するためにalias_idとする
->withCount('likes')
->orderByDesc('likes_count') // withCount('likes')により、likes_countが使用可能
->orderByDesc('id')
->limit($perPage);
// lastPostIdに前回取得時の最後尾のposts.idが入る
$lastPostId = $request->get('lastPostId');
if (isset($lastPostId)) {
$lastPost = Post::withCount('likes')->find($lastPostId);
$postQuery->having(function (\Illuminate\Database\Query\Builder $query) use ($lastPost) {
$query
->having('likes_count', '<', $lastPost->likes_count)
->orHaving(function (\Illuminate\Database\Query\Builder $query) use ($lastPost) {
$query
->having('likes_count', $lastPost->likes_count)
->having('alias_id', '<', $lastPost->id);
});
});
}
$posts = $postQuery->get();
// 以下省略
基本的にはwhere
の場合とあまり変わらないのですが、id
の比較部分がwhere
の場合と少し異なりますね。
->having('alias_id', '<', $lastPost->id);
where
ではなくhaving
を用いる必要があるので、select
で定義しておいたalias_id
で行っています。
まとめ
シーク法は、offset
を使用しないことで大量データでも高速にページネーションができる手法です。
特にデータ数が多いシステムでは、オフセット法に比べてパフォーマンス面で大きなメリットがあります。
本記事では、LaravelのEloquentでの基本的なシーク法の実装から、having
を使った応用例まで紹介しました。
ポイント
-
offset
を使わず、前回取得した最後のレコード情報(カーソル)を基準に次のデータを取得する -
created_at
だけでは重複を考慮しきれないため、id
でもソートする必要がある - ソート対象がリレーション先のカウントなどの場合は、
having
を使ったシーク法が必要
シーク法を使うことで、APIの応答速度やパフォーマンスが向上するため、大規模なデータを扱う場面では積極的に取り入れていきたいですね。
告知
最後にお知らせとなりますが、イーディーエーでは一緒に働くエンジニアを
募集しております。詳しくは採用情報ページをご確認ください。
みなさまからのご応募をお待ちしております。