open atlas
↑ К треку
API API · 03 · 09

Pagination: чтение SQL и кода

Читайте реальный SQL, строку EXPLAIN и cursor-обработчик, предсказывайте поведение pagination и выбирайте исправление с наибольшим рычагом.

API Senior ◷ 14 min
Уровень
ОсновыJuniorMiddleSenior

Баги пагинации живут в плане запроса и 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 (многоверсионным управлением конкурентностью), затмевающий быструю страницу — оценивайте или убирайте. Читайте план, чините корневую причину, затем перепроверяйте план. Теперь, открывая медленный list-эндпоинт в продакшене, запускайте EXPLAIN ANALYZE и ищите эти четыре сигнатуры — план скажет, в каком из сниппетов вы находитесь.

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.