
• Оглавление
Offset/limit знаком каждому: передали offset=100000, limit=50 — получили сотую первую страницу. Проблема в том, что базе приходится «пролистать» первые 100000 строк, прежде чем отдать нам 50. На высоких offset такие запросы прожигают CPU, память и диск, замедляют все остальные запросы и растят чек за инфраструктуру. К тому же данные успевают измениться между страницами: уехали записи, появились новые — страницы «прыгают», аналитика и экспорт становятся неточными.
Курсорная пагинация (keyset) читает не «с начала», а «от последней известной позиции». Она использует строгий порядок и ключ «где мы остановились». За счёт индексов и простых условий «меньше/больше» база делает точный seek по дереву и возвращает данные практически мгновенно, независимо от «номера» страницы.
Бизнесу это даёт: быстрее ответы API, меньше аварий из‑за нагрузок на базу, предсказуемые отчёты и экспорт, стабильную работу интеграций.
Плюсы:
Минусы:
Идея: есть детерминированный порядок (например, по времени создания убыв.), и «курсор» — позиция последней записи на странице. Следующая страница — это «все записи строго меньше этой позиции» (при сортировке по убыванию).
Плюсы:
Минусы:
Курсор — это технический токен, который клиент передаёт обратно серверу. Обычно это Base64‑строка с закодированным порядком и значением «последней записи» (например {"created_at":"2025-01-01T00:00
","id":"..."}). Чтобы защититься от подделки и тяжёлых запросов (например, кто‑то подставит «дальний» курсор), можно подписывать токен HMAC‑подписью.Рекомендуемый интерфейс:
Чтобы при одинаковом значении поля (например, created_at) порядок не «плясал», добавляем дополнительный ключ — как правило, монотонный id. Итого порядок: created_at DESC, id DESC. Курсор содержит оба поля.
Курсор кодируем в Base64 (лучше URL‑безопасный вариант). При смене фильтров или сортировки курсор становится недействителен и должен игнорироваться.
Создайте составной индекс под порядок выборки и фильтры:
-- Порядок: сначала по времени (убывание), затем по id (убывание)
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_items_created_id_desc
ON public.items (created_at DESC, id DESC);
-- Если есть часто используемый фильтр по статусу:
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_items_status_created_id_desc
ON public.items (status, created_at DESC, id DESC);
Так Postgres сможет пройти по индексу, не делая дорогостоящих сортировок и сканирований.
Выбираем следующую страницу после курсора (created_at_cur, id_cur):
SELECT id, created_at, payload
FROM public.items
WHERE (created_at, id) < ($1, $2) -- строгая позиция после курсора
ORDER BY created_at DESC, id DESC
LIMIT $3; -- limit+1 для has_more
Row‑сравнение (created_at, id) < (...) поддерживается Postgres и отлично сочетается с составными индексами. Если хотите явную форму:
WHERE created_at < $1 OR (created_at = $1 AND id < $2)
Для первой страницы курсор не передаём, условие WHERE опускаем.
SELECT reltuples::bigint AS estimate
FROM pg_class
WHERE oid = 'public.items'::regclass;
Это оценка, её точность зависит от ANALYZE. Для «кнопки экспорт» лучше выполнить отдельный точный подсчёт в фоне и выдать файл, а не держать HTTP‑соединение.
При курсорной пагинации у нас монотонный просмотр «в прошлое» (при убывающем порядке). Новые записи не ломают просмотр — они появляются «раньше» и просто не попадут на уже просмотренные страницы. Удаления и правки:
Для полностью стабильной «снимка на момент времени» используйте фиксированную «границу»: первая страница берётся «до now()», и все последующие — «до той же границы». Это можно реализовать, сохранив watermark (верхнюю границу created_at/id) в первом курсоре.
Ниже — минимальный, но рабочий пример. Он:
// file: server.js
// Запуск:
// DATABASE_URL=postgres://user:pass@host:5432/db
// CURSOR_SECRET=your_hmac_secret
// PORT=3000 node server.js
const express = require('express');
const crypto = require('crypto');
const { Pool } = require('pg');
const app = express();
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const PORT = process.env.PORT || 3000;
const SECRET = process.env.CURSOR_SECRET || 'dev-secret-change-me';
function base64urlEncode(buf) {
return Buffer.from(buf).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}
function base64urlDecode(str) {
str = str.replace(/-/g, '+').replace(/_/g, '/');
while (str.length % 4) str += '=';
return Buffer.from(str, 'base64');
}
function sign(data) {
return crypto.createHmac('sha256', SECRET).update(data).digest('hex');
}
function encodeCursor(payload) {
const json = JSON.stringify(payload);
const sig = sign(json);
const pack = JSON.stringify({ p: payload, s: sig });
return base64urlEncode(pack);
}
function decodeCursor(token) {
try {
const json = base64urlDecode(token).toString('utf8');
const { p, s } = JSON.parse(json);
if (!p || !s) throw new Error('bad cursor');
const calc = sign(JSON.stringify(p));
if (!crypto.timingSafeEqual(Buffer.from(s), Buffer.from(calc))) {
throw new Error('bad signature');
}
return p; // { created_at, id, watermark? }
} catch (e) {
return null;
}
}
app.get('/items', async (req, res) => {
const { limit: limitStr, cursor: cursorStr, status } = req.query;
let limit = Number(limitStr) || 50;
if (limit < 1) limit = 1;
if (limit > 500) limit = 500; // защитный порог
let cursor = null;
if (cursorStr) cursor = decodeCursor(String(cursorStr));
// Валидация фильтров
const allowedStatuses = ['new', 'processing', 'done'];
let statusFilter = null;
if (typeof status === 'string') {
if (!allowedStatuses.includes(status)) return res.status(400).json({ error: 'invalid status' });
statusFilter = status;
}
// Формируем SQL
const params = [];
const whereParts = [];
if (statusFilter) {
params.push(statusFilter);
whereParts.push(`status = $${params.length}`);
}
// Сортировка всегда одна и та же: created_at DESC, id DESC
if (cursor && cursor.created_at && cursor.id) {
params.push(cursor.created_at, cursor.id);
// Используем row-сравнение для keyset
whereParts.push(`(created_at, id) < ($${params.length - 1}, $${params.length})`);
}
const whereSql = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : '';
// +1 к лимиту для определения has_more
const qLimit = limit + 1;
params.push(qLimit);
const sql = `
SELECT id, created_at, payload
FROM public.items
${whereSql}
ORDER BY created_at DESC, id DESC
LIMIT $${params.length}
`;
try {
const client = await pool.connect();
try {
const { rows } = await client.query(sql, params);
let hasMore = false;
let slice = rows;
if (rows.length > limit) {
hasMore = true;
slice = rows.slice(0, limit);
}
let nextCursor = null;
if (hasMore && slice.length) {
const last = slice[slice.length - 1];
nextCursor = encodeCursor({ created_at: last.created_at.toISOString(), id: String(last.id) });
}
const baseUrl = `${req.protocol}://${req.get('host')}${req.path}`;
const search = new URLSearchParams();
if (statusFilter) search.set('status', statusFilter);
search.set('limit', String(limit));
if (nextCursor) search.set('cursor', nextCursor);
const nextUrl = nextCursor ? `${baseUrl}?${search.toString()}` : null;
res.json({
items: slice,
has_more: hasMore,
next_cursor: nextCursor,
next_url: nextUrl,
});
} finally {
client.release();
}
} catch (e) {
console.error(e);
res.status(500).json({ error: 'internal_error' });
}
});
app.listen(PORT, () => {
console.log(`Listening on http://localhost:${PORT}`);
});
SQL для таблицы и индексов (минимально):
CREATE TABLE IF NOT EXISTS public.items (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
created_at timestamptz NOT NULL DEFAULT now(),
status text NOT NULL DEFAULT 'new',
payload jsonb NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_items_created_id_desc
ON public.items (created_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS idx_items_status_created_id_desc
ON public.items (status, created_at DESC, id DESC);
Важно: в Node.js‑обработчике мы сериализуем created_at как ISO‑строку — это удобно для курсора. Если у вас bigint‑id и не нужен timestamptz, можно сделать порядок только по id DESC — будет ещё проще.
Курсорная пагинация даёт предсказуемое время ответа и снижает нагрузку на базу. Бизнес получает более стабильные отчёты и экспорт, меньше инцидентов из‑за «тяжёлых страниц» и экономию на инфраструктуре. Ключ к успеху — стабильный порядок сортировки, правильные индексы и аккуратный дизайн курсора с валидацией.