API
Pagination: чтение SQL и кода
Баги пагинации живут в плане запроса и cursor-обработчике, а не в спецификации. Читайте каждый сниппет, предсказывайте, что произойдёт на глубине и при конкурентных записях, и выбирайте исправление, которое senior-инженер делает первым.
Отработайте цикл, который вы запускаете на каждом медленном или нестабильном list-эндпоинте: читаете SQL и план, предсказываете, откуда берётся стоимость или дрейф, и тянетесь к изменению, исправляющему корневую причину.
Сниппет 1 — план глубокого offset
EXPLAIN ANALYZE
SELECT id, title, created_at
FROM articles
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 200000;
-- Limit (rows=20) (actual rows=20 loops=1)
-- -> Index Scan ... on articles (actual rows=200020 loops=1)
План использует индекс, но всё равно занимает секунды. Что говорит 'actual rows=200020' на Index Scan и каково исправление?
Сниппет 2 — cursor-обработчик
// GET /articles?after=<cursor>&limit=20
async function listArticles({ after, limit = 20 }) {
const { createdAt, id } = decodeCursor(after); // Base64 JSON -> {createdAt, id}
const rows = await db.query(`
SELECT id, title, created_at
FROM articles
WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT $3
`, [createdAt, id, limit]);
const last = rows[rows.length - 1];
return {
items: rows,
nextCursor: encodeCursor({ createdAt: last.created_at, id: last.id }),
hasNextPage: true,
};
}
Seek и ordering корректны, но hasNextPage неверен. В чём баг и каково стандартное исправление?
Сниппет 3 — рассогласование ORDER BY
CREATE INDEX idx_articles_feed ON articles (created_at DESC, id DESC);
-- обработчик выполняет:
SELECT id, title, created_at
FROM articles
WHERE (created_at, id) < ('2026-05-01 09:00:00', 918273)
ORDER BY created_at ASC, id ASC -- внимание: ASC
LIMIT 20;
Составной индекс есть, сравнение кортежа выглядит верным, но лента показывает не те строки и задержка не плоская. Что не так?
Сниппет 4 — подвал с подсчётом
const [{ total }] = await db.query(`SELECT COUNT(*) AS total FROM articles`);
const items = await listArticles({ after, limit: 20 });
return { items, total }; // рендерит "Показано 1–20 из {total}"
Keyset-страница ~10 мс, но p99 запроса в секундах. Виновник это COUNT(*). Какое изменение с наибольшим рычагом?
Пагинация диагностируется в плане и обработчике: ‘actual rows’ сильно выше размера страницы это сигнатура offset scan-and-discard, и keyset заменяет пропуск seek по кортежу; cursor-обработчик обязан брать n+1, чтобы вывести hasNextPage, а не жёстко его задавать; направление ORDER BY, оператор сравнения и составной индекс должны совпадать, иначе теряются и корректность, и плоская задержка; а точный COUNT(*) на каждый запрос это полный скан под MVCC, затмевающий быструю страницу — оценивайте его или убирайте. Читайте план, чините корневую причину, затем перепроверяйте план.