Как работает пагинация в 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-риски: безопасность, отказоустойчивость, логирование и тесты.