
OFFSET/LIMIT привычны: быстро добавил LIMIT 50 OFFSET 5000 — и готова «101‑я страница». Проблемы обнаруживаются под нагрузкой:
Keyset‑пагинация решает все три пункта: скорость почти не зависит от «глубины» страницы, выдача стабильна, а нагрузка на базу предсказуемая.
Идея простая: мы не «перескакиваем» фиксированное число строк, а продолжаем чтение «с места, где остановились». Для этого используем сортировку по столбцу (или двум), а в следующем запросе добавляем условие «больше последнего значения на странице».
Так база делает быстрый индексный проход, а не пересчитывает тысячи строк.
Чтобы keyset был устойчивым и быстрым, нужен:
Пример для таблицы публичных постов:
-- Таблица
CREATE TABLE post (
id BIGSERIAL PRIMARY KEY,
author_id BIGINT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('draft','published','archived')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
title TEXT NOT NULL,
body TEXT NOT NULL
);
-- Индекс под пагинацию опубликованных по времени: сначала фильтр, затем сортировка+тайбрейкер
CREATE INDEX CONCURRENTLY idx_post_published_created_id
ON post (status, created_at, id);
С таким индексом запрос WHERE status='published' AND (created_at, id) > (...) ORDER BY created_at, id LIMIT 50 работает как лёгкий скан по индексу.
-- Первая страница без курсора
SELECT id, title, created_at
FROM post
WHERE status = 'published'
ORDER BY created_at ASC, id ASC
LIMIT 51; -- берём на один больше, чтобы узнать has_next
Если пришло 51 — значит есть ещё. Последние значения created_at и id упаковываем в курсор next.
-- last_created_at и last_id мы достали из курсора
SELECT id, title, created_at
FROM post
WHERE status = 'published'
AND (created_at, id) > ($1::timestamptz, $2::bigint)
ORDER BY created_at ASC, id ASC
LIMIT 51;
Назад делаем зеркально: меняем знак сравнения и переворачиваем результат на приложении.
-- Для «предыдущей» страницы относительно курсора
SELECT id, title, created_at
FROM post
WHERE status = 'published'
AND (created_at, id) < ($1::timestamptz, $2::bigint)
ORDER BY created_at DESC, id DESC
LIMIT 51; -- берём 51, разворачиваем на клиенте, отдаем 50
Фильтры должны идти префиксом в индексе. Если часто фильтруем по author_id и статусу, стоит такой индекс:
CREATE INDEX CONCURRENTLY idx_post_author_status_created_id
ON post (author_id, status, created_at, id);
И запрос:
SELECT id, title, created_at
FROM post
WHERE author_id = $1
AND status = 'published'
AND (created_at, id) > ($2::timestamptz, $3::bigint)
ORDER BY created_at ASC, id ASC
LIMIT 51;
Курсор — это не смещение и не номер страницы. Это компактное описания «где мы остановились». Чтобы клиент не мог подменить фильтры и нагрузить базу тяжёлыми запросами, курсор лучше подписывать.
Требования к курсору:
Пример на TypeScript (Node.js) — генерация и проверка курсора с HMAC‑подписью:
import crypto from 'crypto';
const SECRET = process.env.CURSOR_SECRET || 'change-me';
type CursorPayload = {
v: number; // версия формата
s: 'created_asc'; // схема сортировки
d: 'f' | 'b'; // направление (forward/backward)
l: [string, number]; // last tuple: [created_at ISO, id]
f?: Record<string, string | number | boolean>; // фильтры
exp: number; // unix timestamp истечения
};
function sign(data: string): string {
return crypto.createHmac('sha256', SECRET).update(data).digest('base64url');
}
export function encodeCursor(p: CursorPayload): string {
const json = JSON.stringify(p);
const b64 = Buffer.from(json).toString('base64url');
const mac = sign(b64);
return `${b64}.${mac}`;
}
export function decodeCursor(token: string): CursorPayload {
const [b64, mac] = token.split('.');
if (!b64 || !mac) throw new Error('bad cursor');
const expected = sign(b64);
if (!crypto.timingSafeEqual(Buffer.from(mac), Buffer.from(expected))) {
throw new Error('bad signature');
}
const json = Buffer.from(b64, 'base64url').toString('utf8');
const payload = JSON.parse(json) as CursorPayload;
if (payload.exp < Math.floor(Date.now() / 1000)) {
throw new Error('cursor expired');
}
return payload;
}
Сервер отвечает так:
{
"items": [ /* ... */ ],
"next": "eyJ2IjoxLCJzIjoiY3JlYXRlZF9hc2MiLCJkIjoiZiIsImwiOlsiMjAyNi0wMS0yOVQxMDozMDowMFoiLDUxMjM2XSwgImV4cCI6MTcwNjU5OTk5OX0.3bO...",
"prev": null,
"has_next": true
}
has_next вычисляем, выбирая на один элемент больше лимита.
Проверка плана:
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, title, created_at
FROM post
WHERE status = 'published'
AND (created_at, id) > ('2026-01-29T10:30:00Z'::timestamptz, 51236)
ORDER BY created_at ASC, id ASC
LIMIT 51;
Ищем Index Only Scan/Index Scan по нужному индексу и малое количество возвращённых строк.
Пример приближённой оценки для всей таблицы:
SELECT reltuples::bigint AS approx_count
FROM pg_class
WHERE relname = 'post';
Для фильтров надёжнее либо материализованные представления с обновлением по расписанию, либо отдельные счётчики.
Ошибки:
Чек‑лист тестов:
Что увидит бизнес:
import { Pool } from 'pg';
import { encodeCursor, decodeCursor } from './cursor';
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
export async function listPosts(params: {
limit?: number;
cursor?: string | null;
status?: 'published' | 'draft' | 'archived';
}) {
const client = await pool.connect();
try {
const limit = Math.min(Math.max(params.limit ?? 50, 1), 200);
const status = params.status ?? 'published';
let rows;
let nextCursor: string | null = null;
let prevCursor: string | null = null;
if (!params.cursor) {
const q = `
SELECT id, title, created_at
FROM post
WHERE status = $1
ORDER BY created_at ASC, id ASC
LIMIT $2
`;
const res = await client.query(q, [status, limit + 1]);
rows = res.rows;
} else {
const cur = decodeCursor(params.cursor);
if (cur.s !== 'created_asc') throw new Error('unsupported sort');
const [lastCreated, lastId] = cur.l;
const q = `
SELECT id, title, created_at
FROM post
WHERE status = $1 AND (created_at, id) > ($2::timestamptz, $3::bigint)
ORDER BY created_at ASC, id ASC
LIMIT $4
`;
const res = await client.query(q, [status, lastCreated, lastId, limit + 1]);
rows = res.rows;
}
const hasNext = rows.length > limit;
if (hasNext) rows = rows.slice(0, limit);
if (rows.length > 0) {
const last = rows[rows.length - 1];
nextCursor = encodeCursor({
v: 1,
s: 'created_asc',
d: 'f',
l: [last.created_at.toISOString(), Number(last.id)],
f: { status },
exp: Math.floor(Date.now() / 1000) + 3600
});
const first = rows[0];
prevCursor = encodeCursor({
v: 1,
s: 'created_asc',
d: 'b',
l: [first.created_at.toISOString(), Number(first.id)],
f: { status },
exp: Math.floor(Date.now() / 1000) + 3600
});
}
return { items: rows, next: nextCursor, prev: prevCursor, has_next: hasNext };
} finally {
client.release();
}
}
Этот пример отражает базовую схему: берём limit+1, строим next/prev курсоры, подписываем, соблюдаем TTL.
Keyset‑пагинация — простой в реализации (пара индексов и логика курсора) и мощный по эффекту инструмент. Выдача не «скачет», база не делает лишних проходов, пользователи и бизнес получают стабильную скорость и меньшие расходы. Внедрять лучше постепенно: начать с самого горячего списка, замерить выигрыш, затем распространить на другие эндпоинты.