awesome-everything EN
↑ Обратно к восхождению

API

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

Суть Читайте реальный SQL, строку EXPLAIN и cursor-обработчик, предсказывайте поведение pagination и выбирайте исправление с наибольшим рычагом.
Высота — путь к senior
НольJuniorMiddleSenior
Ты на senior-высоте — в орбите
◷ 14 min

Баги пагинации живут в плане запроса и 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, затмевающий быструю страницу — оценивайте его или убирайте. Читайте план, чините корневую причину, затем перепроверяйте план.

Продолжить восхождение ↑Pagination: миграция offset-ленты на keyset
хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.