
OFFSET/LIMIT — простой способ разбить список на страницы. Но у него есть три дорогие проблемы:
Keyset‑пагинация решает все три проблемы: не пропускает записи зря, стабилизирует порядок и почти всегда работает по индексу.
Идея простая: вместо «номер страницы» мы запоминаем «курсором» последнюю виденную запись и при следующем запросе просим «записи строго новее/старее неё». Это называется seek‑сканированием по индексу.
Ключевые правила:
Пусть у нас таблица orders с колонками id (PK), created_at, amount, status, tenant_id.
Создадим индекс под сортировку:
-- PostgreSQL
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_orders_created_id_desc
ON orders (created_at DESC, id DESC);
Первая страница (без курсора):
-- Возьмём 50 последних заказов клиента
SELECT id, created_at, amount, status
FROM orders
WHERE tenant_id = $1
ORDER BY created_at DESC, id DESC
LIMIT $2; -- $2 = 50
Следующая страница (есть курсор с последним элементом из предыдущей):
-- Курсор содержит cursor_created_at, cursor_id
SELECT id, created_at, amount, status
FROM orders
WHERE tenant_id = $1
AND (created_at, id) < ($3, $4)
ORDER BY created_at DESC, id DESC
LIMIT $2;
Для MySQL эквивалент условия:
-- MySQL
WHERE tenant_id = ?
AND (
created_at < ? OR (created_at = ? AND id < ?)
)
ORDER BY created_at DESC, id DESC
LIMIT ?;
Почему это быстро: база идёт по индексу ровно до лимита и на этом останавливается. Нет «пропуска тысяч строк», как при OFFSET.
Не стоит отдавать курсор «как есть» с внутренними идентификаторами — его можно подделать или подобрать. Решение: кодируем полезную нагрузку и подписываем её HMAC‑подписью.
Пример на TypeScript (Node.js), токены в формате base64url.payload.signature:
import crypto from 'crypto';
const SECRET = process.env.CURSOR_SECRET || 'change_me_strong_secret';
type Dir = 'next' | 'prev';
interface CursorPayload {
createdAt: string; // ISO‑строка
id: number;
dir: Dir;
}
function sign(payload: string): string {
return crypto.createHmac('sha256', SECRET).update(payload).digest('base64url');
}
export function encodeCursor(p: CursorPayload): string {
const json = JSON.stringify(p);
const b64 = Buffer.from(json, 'utf8').toString('base64url');
const sig = sign(json);
return `${b64}.${sig}`;
}
export function decodeCursor(token: string): CursorPayload {
const [b64, sig] = token.split('.');
if (!b64 || !sig) throw new Error('Некорректный курсор');
const json = Buffer.from(b64, 'base64url').toString('utf8');
const expected = sign(json);
const ok = crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
if (!ok) throw new Error('Неверная подпись курсора');
const obj = JSON.parse(json);
if (typeof obj.id !== 'number' || typeof obj.createdAt !== 'string') {
throw new Error('Неверные поля в курсоре');
}
if (obj.dir !== 'next' && obj.dir !== 'prev') obj.dir = 'next';
return obj as CursorPayload;
}
С серверной стороны вы всегда можете менять «состав» курсора (например, добавлять фильтры), не ломая клиентов — достаточно поддерживать расшифровку старого формата на время миграции.
Keyset‑пагинация быстрая, когда условие фильтра поддержано индексом, и порядок сортировки совпадает с порядком в индексе.
Рекомендации:
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_orders_paid_created_id
ON orders (created_at DESC, id DESC)
WHERE status = 'paid';
Для «предыдущей страницы» используйте обратное сравнение и перевёрнутый порядок, затем разверните результат в приложении.
-- Предыдущая страница относительно курсора (последний элемент текущей страницы)
SELECT id, created_at, amount, status
FROM orders
WHERE tenant_id = $1
AND (created_at, id) > ($3, $4) -- меняем знак
ORDER BY created_at ASC, id ASC -- инвертируем порядок
LIMIT $2; -- берём N записей
На уровне кода:
Храните стек курсоров у клиента (или в сессии), чтобы «назад» работало без пересчётов.
Классический приём: запрашивайте на 1 запись больше лимита.
Это избавляет от отдельного «count(*)» запроса и споров с оптимизатором.
Абсолютный count(*) при больших объёмах данных может стоить миллисекунды на маленьких таблицах и секунды на больших. В лентах и каталогах он редко обязателен.
Практичные варианты:
-- Грубая оценка общего числа строк в таблице (не по фильтру!)
SELECT reltuples::bigint AS estimate
FROM pg_class
WHERE relname = 'orders';
Если между запросами появляются новые строки, keyset‑пагинация не ломается: курсор «смотрит назад» относительно стабильно отсортированного множества.
Что учесть:
{
"items": [
{"id": 5012, "created_at": "2024-12-09T10:11:22Z", "amount": 1490, "status": "paid"},
{"id": 5009, "created_at": "2024-12-09T10:10:07Z", "amount": 990, "status": "paid"}
],
"next_cursor": "eyJjcmVhdGVkQXQiOiIyMDI0LTEyLTA5VDEwOjEwOjA3WiIsImlkIjo1MDA5LCJkaXIiOiJuZXh0In0.yS2yQ2...",
"has_next": true
}
Эндпоинт может выглядеть так: GET /api/orders?limit=50&after=... Возвращайте next_cursor и, при необходимости, prev_cursor.
Пошаговый план:
Бонус: если у вас мультиарендность (tenant_id), обязательно включайте её в начало индекса — это резко уменьшает рабочий набор ключей на один запрос.
Keyset/seek‑пагинация — простой приём, который дает непропорционально большой выигрыш:
Внедряется поэтапно: зафиксируйте порядок, добавьте индексы, введите курсоры с подписью и аккуратно мигрируйте клиентов. В результате списки грузятся быстрее, пользователи дольше остаются в продукте, а счёт за базу предсказуемо снижается.