<? phpukraine СПІВБЕСІДИ
Пошук по платформі
SQL · MIDDLE

Чому OFFSET-пагінація повільна на великих таблицях і що таке keyset?

OFFSET не вміє «стрибати»: база читає й відкидає всі пропущені рядки, тому час зростає лінійно з номером сторінки. Keyset (seek method) замість номера сторінки передає ключ останнього прочитаного рядка й читає рівно LIMIT записів за постійний час.

Чому 1-ша сторінка списку відкривається миттєво, а 5000-та — три секунди?
Що таке seek method або курсорна пагінація і чим вона краща за LIMIT/OFFSET?
Чому користувач бачить той самий запис двічі, гортаючи сторінки?
Індекс на created_at є, EXPLAIN показує Index Scan — чому запит із OFFSET 200000 усе одно повільний?
пагінація keyset OFFSET індекси cursorPaginate

OFFSET — це не пошук, а відлік. Індекс дає базі впорядкований список записів, але не дає адресації «дай мені сотий тисячний елемент»: щоб дійти до потрібної позиції, планувальник читає й викидає всі рядки до неї. Тому ORDER BY created_at DESC LIMIT 20 OFFSET 100000 коштує 100 020 прочитаних записів заради двадцяти віддданих, і час зростає лінійно з номером сторінки. У плані це видно як Limit над Index Scan із великим rows removed, і саме тому перша сторінка списку відкривається за одиниці мілісекунд, а п'ятитисячна — за секунди. Якщо ще й індексу під сортування немає, до відліку додається повне сортування всієї вибірки.

Keyset-пагінація (вона ж seek method, вона ж курсорна) прибирає відлік: замість номера сторінки клієнт повертає значення ключа останнього прочитаного рядка, а запит формулюється як діапазон — «усе, що йде строго після цієї позиції». Для сортування created_at DESC, id DESC умова записується рядковим порівнянням (created_at, id) < (?, ?), і база через індекс (user_id, created_at, id) одразу стрибає в потрібну точку дерева та читає рівно двадцять записів. Вартість сторінки перестає залежати від її глибини. Ключовий момент — унікальність ключа сортування: created_at сам по собі не унікальний, тож без tie-breaker у вигляді id рядки з однаковою міткою часу розподіляються між сторінками недетерміновано. Так само не можна розписувати умову як created_at <= ? AND id < ?: це відкидає всі рядки з меншим created_at, але більшим id. Або рядкове порівняння, або явне a < ? OR (a = ? AND b < ?) — у MySQL 8.0 обидві форми оптимізуються в range scan, у старіших версіях надійніша друга.

Другий, часто важливіший за швидкість, аргумент — консистентність. Між запитом сторінки 3 і сторінки 4 хтось вставив запис на початок списку: вікно OFFSET зсувається, і користувач бачить той самий елемент двічі; при видаленні — навпаки, пропускає. Keyset прив'язаний до значення, а не до позиції, тому вставки та видалення на попередніх сторінках його не зсувають. Той самий принцип лежить в основі chunkById() і lazyById() в Laravel — вони обходять таблицю через WHERE id > ? саме тому, що chunk() з OFFSET ламається, коли обробка змінює рядки. Для HTTP-пагінації Laravel з версії 8.27 дає cursorPaginate(): курсор — це base64-JSON зі значеннями колонок з orderBy, який фреймворк розгортає в keyset-умову.

Ціна в keyset теж є, і на співбесіді її треба назвати першим. Немає переходу на довільну сторінку — позиція описується ключем, а не номером. Немає загальної кількості сторінок (COUNT(*) на великій таблиці й сам по собі коштує сканування, тому його або кешують, або замінюють на оцінку з reltuples у PostgreSQL чи rows з EXPLAIN у MySQL). Сортування має бути детермінованим, а колонки сортування — без NULL або з явним NULLS LAST і окремою гілкою курсора. Зміна сортування користувачем означає новий індекс під кожен варіант. Звідси практичний поділ: стрічки, нескінченний скрол, експорт і API з next_cursor — keyset; адмінка з номерами сторінок і глибиною в десятки сторінок — звичайний paginate(), він там ніколи не стане вузьким місцем. І в обох випадках рішення підтверджують через EXPLAIN (ANALYZE, BUFFERS), а не на око.

-- OFFSET: щоб віддати 20 рядків, база читає й відкидає 100 000
SELECT id, created_at, total
FROM orders
WHERE user_id = 42
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 100000;

-- Keyset: передаємо не номер сторінки, а ключ останнього прочитаного рядка.
-- Рядкове порівняння читається як «усе, що йде строго після цієї позиції»
SELECT id, created_at, total
FROM orders
WHERE user_id = 42
  AND (created_at, id) < ('2026-02-01 10:15:00', 918273)
ORDER BY created_at DESC, id DESC
LIMIT 20;

-- Індекс під keyset: рівність попереду, обидві колонки сортування далі
CREATE INDEX orders_user_created_id
    ON orders (user_id, created_at DESC, id DESC);

-- ПОМИЛКА: так пропадуть рядки з іншим created_at, але меншим id
-- WHERE created_at <= '2026-02-01 10:15:00' AND id < 918273

-- Еквівалент рядкового порівняння вручну:
-- для MySQL до 8.0 і для змішаних напрямків сортування
SELECT id, created_at, total
FROM orders
WHERE user_id = 42
  AND (created_at < '2026-02-01 10:15:00'
       OR (created_at = '2026-02-01 10:15:00' AND id < 918273))
ORDER BY created_at DESC, id DESC
LIMIT 20;

-- Перевірка: у плані має бути range/Index Scan і ~20 прочитаних рядків,
-- а не сотні тисяч, як у варіанті з OFFSET
EXPLAIN (ANALYZE, BUFFERS)
SELECT id FROM orders
WHERE user_id = 42 AND (created_at, id) < ('2026-02-01 10:15:00', 918273)
ORDER BY created_at DESC, id DESC LIMIT 20;
Що OFFSET не є операцією пошуку: індекс дає впорядкованість, але позицію N база отримує лише прочитавши N рядків, тому OFFSET 100000 LIMIT 20 коштує 100020 прочитаних рядків замість 20.
Що keyset формулює умову через значення останнього рядка попередньої сторінки, а не через його порядковий номер, і завдяки цьому кожна сторінка коштує однаково.
Що ключ сортування має бути унікальним: до created_at додають id як tie-breaker, інакше рядки з однаковим часом губляться або дублюються на межі сторінок.
Що умову треба писати рядковим порівнянням `(created_at, id) < (?, ?)`, а не `created_at <= ? AND id < ?` — друге відкидає валідні рядки.
Що OFFSET дає ще й неконсистентність: вставка або видалення між запитами зсуває вікно, і користувач бачить дублі або пропуски, а keyset до цього стійкий.
Що керуються компроміси усвідомлено: keyset не вміє переходу на довільну сторінку й не дає загальної кількості, тому підходить для стрічок і нескінченного скролу, а не для адмінки з номерами сторінок.
Вважати, що індекс на колонці сортування «лікує» OFFSET: індекс прибирає Sort, але пропущені рядки все одно читаються один за одним.
Писати keyset-умову як `created_at <= ? AND id < ?` — рядки з іншим created_at і більшим id зникнуть зі стрічки.
Сортувати лише за created_at без унікального tie-breaker і дивуватися дублям на межі сторінок при однакових мітках часу.
Плутати `simplePaginate()` з keyset: він лише прибирає `COUNT(*)`, але запит лишається `LIMIT ... OFFSET ...`.
Робити keyset по колонці, яка може бути NULL, без явного `NULLS LAST` і окремої гілки умови — порівняння з NULL дає UNKNOWN і рядок не потрапляє в жодну сторінку.
Лишати `SELECT COUNT(*)` на кожен запит: на великій таблиці він сам по собі коштує повного сканування й з'їдає весь виграш.
ПОРАДА

Скажіть коротко: «OFFSET — це не пошук, це відлік; keyset перетворює пагінацію на звичайний range scan по індексу». І одразу назвіть ціну: немає стрибка на сторінку 500 і немає загальної кількості — тому в адмінці лишаємо OFFSET, у стрічці й API ставимо keyset.

оновлено 4 вересня 2026 · ліцензія CC-BY-SA-4.0 Знайшли неточність? Напишіть →
ПЕРЕВІРТЕ СЕБЕ

Індекс прибирає сортування, але не дає стрибнути на N-й рядок: OFFSET завжди відлічує пропущені записи по одному. Keyset замінює відлік умовою по значенню ключа, тому кожна сторінка коштує однаково.