Prisma 大量データの取得クエリを Cursor ベースのページネーションで改善する


はじめに

最近は Next.js がフルスタックアプリケーションフレームワークとして成熟してきたこともあり、Prisma を ORM として採用しています。
Prisma は直感的な API を提供する一方で、特定の使用パターンでパフォーマンスの問題を引き起こすことがあります。
この記事では、Prisma を使用した大量データの取得方法の改善方法について Cursor ベースのページネーションの実装について紹介します。

簡単な実装例

// ❌ 良くない例:全データの一括取得
const users = await prisma.user.findMany({
  skip: 100,  // 最初の 100 件をスキップ
  take: 10    // 次の 10 件を取得
});

// ✅ 最適化例:カーソルベースのページネーション
const users = await prisma.user.findMany({
  cursor: {
    id: 100  // ID が 100 より大きいレコードから
  },
  take: 10   // 10 件を取得
});

なぜ cursor が推奨されるのか

1. パフォーマンス

  • offset は指定した数のレコードを読み飛ばす必要がある

  • cursor は特定の位置から直接読み取りを開始できる

// 例:1000ページ目を取得する場合
// ❌ offset の場合:最初の 10000 レコードを読み込んでから破棄
await prisma.user.findMany({
  skip: 10000,
  take: 10
});

// ✅ cursor の場合:ID 10000 以降のレコードから直接読み取り
await prisma.user.findMany({
  cursor: { id: 10000 },
  take: 10
});

2. データの一貫性

  • offset の場合、データが追加/削除されると結果が変わる可能性がある

  • cursor は固定の基準点を使用するため、より一貫性がある

// app/api/users/route.ts
import { prisma } from '@/lib/prisma';
import { NextRequest, NextResponse } from 'next/server';

export async function GET(request: NextRequest) {
  try {
    // searchParams から cursor と pageSize を取得
    const { searchParams } = new URL(request.url);
    const cursor = searchParams.get('cursor');
    const pageSize = 10;

    const users = await prisma.user.findMany({
      // cursor が存在する場合、その ID 以降から取得
      ...(cursor
        ? {
            cursor: {
              id: parseInt(cursor),
            },
            skip: 1, // cursor として使用したレコードをスキップ
          }
        : {}),
      take: pageSize + 1, // 次ページの有無を確認するため 1 件多く取得
      orderBy: {
        id: 'asc',
      },
      // 必要なフィールドのみを取得
      select: {
        id: true,
        name: true,
        email: true,
        createdAt: true,
      },
    });

    // 次ページの有無を確認
    const hasNextPage = users.length > pageSize;
    // 実際に返すデータは pageSize 分まで
    const data = users.slice(0, pageSize);
    // 次の cursor は最後のレコードの ID
    const nextCursor = hasNextPage ? data[data.length - 1].id : null;

    return NextResponse.json({
      users: data,
      pagination: {
        nextCursor,
        hasNextPage,
      },
    });

  } catch (error) {
    console.error('Failed to fetch users:', error);
    return NextResponse.json(
      { error: 'Failed to fetch users' },
      { status: 500 }
    );
  }
}

// 検索条件付きの場合
export async function POST(request: NextRequest) {
  try {
    const body = await request.json();
    const { searchParams } = new URL(request.url);
    const cursor = searchParams.get('cursor');
    const pageSize = 10;

    const users = await prisma.user.findMany({
      where: {
        // 検索条件を追加
        name: {
          contains: body.searchTerm,
        },
        status: body.status,
        // 他の条件も追加可能
      },
      ...(cursor
        ? {
            cursor: {
              id: parseInt(cursor),
            },
            skip: 1,
          }
        : {}),
      take: pageSize + 1,
      orderBy: {
        id: 'asc',
      },
      select: {
        id: true,
        name: true,
        email: true,
        createdAt: true,
      },
    });

    const hasNextPage = users.length > pageSize;
    const data = users.slice(0, pageSize);
    const nextCursor = hasNextPage ? data[data.length - 1].id : null;

    return NextResponse.json({
      users: data,
      pagination: {
        nextCursor,
        hasNextPage,
      },
    });

  } catch (error) {
    console.error('Failed to search users:', error);
    return NextResponse.json(
      { error: 'Failed to search users' },
      { status: 500 }
    );
  }
}

いいなと思ったら応援しよう!