PrismaMiddleTechnical

Как работает пагинация в Prisma? В чём разница между offset-based и cursor-based пагинацией?

Prisma поддерживает offset-пагинацию через skip/take (аналог SQL LIMIT/OFFSET) и cursor-пагинацию через cursor/take (поиск по индексу, без OFFSET). Cursor быстрее на больших таблицах, offset удобнее для постраничной навигации.

Пагинация в Prisma

Prisma поддерживает два вида пагинации: классическую offset-based (через skip и take) и cursor-based (через cursor и take). Выбор между ними зависит от требований к производительности и UX.

Offset-based пагинация

Параметры skip (сколько строк пропустить) и take (сколько взять) — прямой аналог SQL OFFSET ... LIMIT ...:

const PAGE_SIZE = 20;

async function getPostsPage(page: number) {
  const [posts, total] = await prisma.$transaction([
    prisma.post.findMany({
      skip: (page - 1) * PAGE_SIZE,
      take: PAGE_SIZE,
      orderBy: { createdAt: 'desc' },
    }),
    prisma.post.count(),
  ]);

  return {
    posts,
    total,
    totalPages: Math.ceil(total / PAGE_SIZE),
    currentPage: page,
  };
}

// использование
const result = await getPostsPage(3); // страница 3

Плюсы: простота, возможность прыгнуть на любую страницу, счётчик общего числа записей. Минусы: при большом OFFSET PostgreSQL всё равно перебирает пропускаемые строки — запрос замедляется при page > 1000.

Cursor-based пагинация

Cursor указывает на конкретную запись (обычно по id или createdAt). Вместо OFFSET база ищет строки после курсора через индекс — это O(log n) вместо O(n):

async function getPostsCursor(cursor?: number, take = 20) {
  const posts = await prisma.post.findMany({
    take,
    // пропустить сам курсор и взять следующие записи
    ...(cursor ? { skip: 1, cursor: { id: cursor } } : {}),
    orderBy: { id: 'asc' },
  });

  const nextCursor =
    posts.length === take ? posts[posts.length - 1].id : undefined;

  return { posts, nextCursor };
}

// первая страница
const page1 = await getPostsCursor();
// следующая страница
const page2 = await getPostsCursor(page1.nextCursor);

Двунаправленная cursor-based пагинация

async function getPostsBidirectional({
  cursor,
  direction = 'forward',
  take = 20,
}: {
  cursor?: number;
  direction?: 'forward' | 'backward';
  take?: number;
}) {
  const posts = await prisma.post.findMany({
    take: direction === 'backward' ? -take : take,
    ...(cursor ? { skip: 1, cursor: { id: cursor } } : {}),
    orderBy: { id: 'asc' },
  });

  return {
    posts,
    prevCursor: posts[0]?.id,
    nextCursor: posts[posts.length - 1]?.id,
  };
}

Когда что использовать

  • Offset: таблицы с кнопками страниц, административные панели, отчёты, поиск с фиксированным числом результатов. Удобно, когда пользователь хочет перейти на страницу 5 из 20.
  • Cursor: бесконечная прокрутка (infinite scroll), real-time фиды (Twitter, Instagram), API с высокой нагрузкой, таблицы с миллионами строк.

Подводные камни

  • При offset-based пагинации в активно меняющейся таблице возможны дубликаты или пропуски строк между страницами: пока пользователь читает страницу 2, новая запись вставляется в начало и смещает всё.
  • Cursor должен быть уникальным и монотонным. Использование createdAt как курсора без дополнительного уникального поля может привести к дубликатам при одинаковых временных метках.
  • Параметр skip: 1 при cursor-запросе обязателен, чтобы не включать саму курсорную запись в результат. Без него курсор-запись будет возвращаться дважды.
  • Отрицательный take (для листания назад) работает только совместно с cursor и меняет направление обхода, что может сбить с толку.
  • Cursor-based пагинация не позволяет узнать общее число страниц без дополнительного count() запроса.
  • Cursor по составному уникальному ключу в Prisma требует передавать объект с несколькими полями: cursor: { username_tenant: { username, tenant } } — синтаксис неочевиден.
  • Если между запросами страниц удаляется курсорная запись, Prisma выбросит ошибку или вернёт пустой результат в зависимости от версии. Нужна обработка этого случая.

Common mistakes

  • Путает Prisma Client API с гарантиями базы данных: индексы, блокировки и isolation level не создаются магически.
  • Не объясняет, где в lifecycle находится offset и cursor pagination.
  • Не разделяет validation, authorization, business logic и persistence.
  • Игнорирует ошибки, лимиты входных данных, observability и тестирование.

What the interviewer is testing

  • Может объяснить offset и cursor pagination на примере кода.
  • Называет ключевые API: skip, take, cursor.
  • Отделяет ORM/query builder поведение от реального поведения СУБД.
  • Видит production-риски: безопасность, отказоустойчивость, логирование и тесты.

Sources

Related topics