
OFFSET/LIMIT кажется удобным: «дай мне 20 записей, пропустив 1000». Но база не «знает» как мгновенно перепрыгнуть тысячу строк — ей приходится сканировать, отсортировать и лишь затем отбросить первые N. На больших таблицах это превращается в лишние мегабайты чтения, рост p95/p99, а под нагрузкой — в время ожидания и тайм‑ауты.
Симптомы:
Для бизнеса это значит: падает конверсия в каталоге и поиске, отчёты ждут дольше, инфраструктура дороже, а инцидентов — больше. Решение — не «железом прикрыть», а поменять подход к пагинации.
Вместо «пропусти N строк» мы говорим «дай следующие 20 после вот этого ориентира». Ориентир — это курсор: значение ключевых полей последней (или первой) видимой записи. Типичный набор — пара из поля сортировки и уникального идентификатора, например (created_at, id) или (price_cents, id).
Запрос превращается из:
в
База прыгает сразу к нужному месту по индексу — без линейного перебора.
Чтобы не было «дрожания», порядок должен быть строгим и однозначным. Если сортируем по времени, добавляем вторым ключом уникальный id. Если по цене — аналогично: (price_cents, id). Тогда любая запись занимает фиксированное место в порядке, и курсор всегда «указывает» однозначно.
Нам нужны составные индексы под каждый сценарий сортировки и частые фильтры. Часто достаточно пары:
-- Основная сортировка: новые сверху
CREATE INDEX IF NOT EXISTS idx_products_active_created
ON products (created_at DESC, id DESC)
WHERE is_active = TRUE;
-- Для сортировки по цене (дешёвые сверху)
CREATE INDEX IF NOT EXISTS idx_products_active_price
ON products (price_cents ASC, id ASC)
WHERE is_active = TRUE;
Если почти все запросы фильтруются по категории, продублируйте категорию в начале индекса:
CREATE INDEX IF NOT EXISTS idx_products_cat_created
ON products (category_id, created_at DESC, id DESC)
WHERE is_active = TRUE;
Чтобы сократить чтение таблицы, можно сделать «перекрывающий» индекс и выбирать только нужные поля прямо из индекса (Index‑Only Scan):
-- PostgreSQL 11+: INCLUDE добавляет неключевые столбцы
CREATE INDEX IF NOT EXISTS idx_products_active_created_cover
ON products (created_at DESC, id DESC)
INCLUDE (title, price_cents)
WHERE is_active = TRUE;
Пусть у нас таблица:
CREATE TABLE IF NOT EXISTS products (
id BIGSERIAL PRIMARY KEY,
title TEXT NOT NULL,
price_cents INTEGER NOT NULL,
category_id INTEGER,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Первая страница (нет курсора):
SELECT id, title, price_cents, created_at
FROM products
WHERE is_active = TRUE
ORDER BY created_at DESC, id DESC
LIMIT 20;
Следующая страница после курсора (created_at = $1, id = $2):
SELECT id, title, price_cents, created_at
FROM products
WHERE is_active = TRUE
AND (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT 20;
Назад (получить предыдущие):
SELECT id, title, price_cents, created_at
FROM products
WHERE is_active = TRUE
AND (created_at, id) > ($1, $2)
ORDER BY created_at ASC, id ASC
LIMIT 20; -- потом развернуть результаты в приложении
Для сортировки по цене — просто меняем пары полей и операторы на согласованные с направлением.
Курсор — это компактный сериализованный объект с ключами сортировки и метаданными (вариант сортировки, направление). Чтобы пользователь не мог его подделать и получить неожиданно дорогой запрос, подпишем курсор HMAC‑подписью и закодируем в base64url.
Структура курсора:
Ниже полноценный пример на Express + pg. Он поддерживает фильтры, сортировку, курсоры вперёд/назад и защищённую подпись курсора.
const express = require('express');
const { Pool } = require('pg');
const crypto = require('crypto');
const app = express();
const pool = new Pool({
connectionString: process.env.DATABASE_URL || 'postgres://postgres:postgres@localhost:5432/app'
});
const CURSOR_SECRET = process.env.CURSOR_SECRET || 'dev-secret-change-me';
function encodeCursor(obj) {
const payload = Buffer.from(JSON.stringify(obj)).toString('base64url');
const sig = crypto.createHmac('sha256', CURSOR_SECRET).update(payload).digest('base64url');
return `${payload}.${sig}`;
}
function decodeCursor(cursor) {
const [payload, sig] = String(cursor).split('.');
if (!payload || !sig) throw new Error('Некорректный курсор');
const expected = crypto.createHmac('sha256', CURSOR_SECRET).update(payload).digest('base64url');
if (sig !== expected) throw new Error('Подпись курсора невалидна');
return JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
}
app.get('/products', async (req, res) => {
try {
const limit = Math.min(Math.max(parseInt(req.query.limit || '20', 10), 1), 100);
const sort = (req.query.sort || 'created_desc'); // created_desc | price_asc
// Выбор порядка и операторов для seek
let orderBy = 'created_at DESC, id DESC';
let seekOp = '<'; // для направления next при DESC
let sortFields = ['created_at', 'id'];
if (sort === 'price_asc') {
orderBy = 'price_cents ASC, id ASC';
seekOp = '>';
sortFields = ['price_cents', 'id'];
}
const categoryId = req.query.category_id ? parseInt(req.query.category_id, 10) : null;
const minPrice = req.query.min_price ? parseInt(req.query.min_price, 10) : null;
const maxPrice = req.query.max_price ? parseInt(req.query.max_price, 10) : null;
const filters = ['is_active = TRUE'];
const params = [];
if (Number.isFinite(categoryId)) {
params.push(categoryId);
filters.push(`category_id = $${params.length}`);
}
if (Number.isFinite(minPrice)) {
params.push(minPrice);
filters.push(`price_cents >= $${params.length}`);
}
if (Number.isFinite(maxPrice)) {
params.push(maxPrice);
filters.push(`price_cents <= $${params.length}`);
}
let cursor = null;
let direction = 'next';
if (req.query.cursor) {
cursor = decodeCursor(req.query.cursor);
if (cursor && cursor.dir === 'prev') direction = 'prev';
if (cursor && cursor.sort !== sort) {
// Сортировку сменили — курсор невалиден для нового порядка
cursor = null;
}
}
let whereSeek = '';
if (cursor) {
// Для created_at храним миллисекунды в курсоре, конвертируем в timestamptz
let key1 = cursor.key1;
let key2 = cursor.key2;
const tuple = `(${sortFields[0]}, ${sortFields[1]})`;
// Подготовим параметры под типы
if (sort === 'created_desc') {
// key1 — миллисекунды, приводим к timestamptz
params.push(new Date(key1));
} else {
params.push(key1);
}
params.push(key2);
const a = `$${params.length - 1}`;
const b = `$${params.length}`;
const tupleVal = `(${a}, ${b})`;
const op = (direction === 'next') ? seekOp : (seekOp === '<' ? '>' : '<');
whereSeek = `${tuple} ${op} ${tupleVal}`;
}
const whereClause = [whereSeek, ...filters].filter(Boolean).join(' AND ');
const sql = `
SELECT id, title, price_cents, created_at
FROM products
WHERE ${whereClause}
ORDER BY ${orderBy}
LIMIT $${params.push(limit)}
`;
const { rows } = await pool.query(sql, params);
// Курсы следующей/предыдущей страниц
let nextCursor = null;
let prevCursor = null;
if (rows.length > 0) {
const first = rows[0];
const last = rows[rows.length - 1];
const toKey = (r) => {
if (sort === 'price_asc') {
return { key1: r.price_cents, key2: r.id, sort, dir: 'next' };
}
// created_desc: храним время в миллисекундах UTC
return { key1: Date.parse(r.created_at), key2: r.id, sort, dir: 'next' };
};
nextCursor = encodeCursor(toKey(last));
const prevObj = toKey(first);
prevObj.dir = 'prev';
prevCursor = encodeCursor(prevObj);
}
// Если шли назад — разворачиваем, чтобы вернуть в привычном порядке
let items = rows;
if (direction === 'prev') {
items = [...rows].reverse();
// Перекидываем курсоры местами, чтобы UI было проще
const tmp = nextCursor;
nextCursor = prevCursor;
prevCursor = tmp;
}
res.json({ items, next_cursor: nextCursor, prev_cursor: prevCursor, limit, sort });
} catch (e) {
res.status(400).json({ error: e.message });
}
});
app.listen(process.env.PORT || 3000, () => {
console.log('Server listening');
});
Совет: курсор лучше привязывать к текущим фильтрам/сортировке (включать их хэш в подписываемый payload) — так вы гарантируете, что курсор не «переедет» в другой контекст и не вызовет дорогой план.
Не пытайтесь угодить всем сортировкам одним индексом. В реальном продукте 1–2 варианта покрывают 95% трафика: «новые сверху» и «дешёвые сверху». Для них делают отдельные индексы (см. выше) и при необходимости — варианты с категорией или статусом. Остальные редкие сортировки можно оставить на OFFSET/LIMIT или ограничить.
Правила подбора:
Полный COUNT(*) с тяжёлыми фильтрами дорог. В каталоге это редко нужно. Подходы:
Пример грубой оценки:
SELECT reltuples::bigint AS approx
FROM pg_class
WHERE oid = 'products'::regclass;
Для фильтров лучше вести отдельные счётчики по популярным сегментам (например, активные по категории) и обновлять триггером или периодической задачей.
Обе модели хорошо работают с курсорами. «Показать ещё» — проще, понятнее и дружелюбнее к аналитике. Бесконечная прокрутка — внимательнее к состоянию и кнопке «Назад», но с keyset это решаемо: храните курсоры в истории навигации.
Курсоры удобны пользователям и серверам, но поисковики любят стабильные адреса с параметрами страниц. Компромисс:
Если порядок строгий (поле сортировки + уникальный id), дубликатов и пропусков не будет: «seek» позиционируется строго после/до полученной ранее записи. Но при очень высокой скорости вставок на «первой» странице пользователь может видеть обновление витрины — это нормально.
Для «prev» инвертируем оператор, сортируем в обратном порядке и разворачиваем результат в приложении. Обязательно возвращайте оба курсора (next и prev) на каждой странице — это экономит логику на фронте и делает поведение предсказуемым.
Любое изменение фильтров или сортировки обнуляет курсор. Добавьте в курсор хэш текущих параметров и проверяйте его при декодировании — так вы избежите невалидных запросов.
Из практики внедрений в интернет‑витринах и лентах:
На больших коллекциях (10+ млн строк) выгода растёт ещё сильнее: OFFSET становится просто неприемлемым.
Переход на keyset‑пагинацию — не «магия разработчиков», а понятная бизнес‑инициатива: быстрее списки — выше конверсия, меньше инцидентов — спокойнее релизы, меньше I/O — ниже счёт за инфраструктуру.