Пошук уроків, статей та іншого контенту
Два підходи до розбиття великих списків на сторінки та компроміси кожного.
Пагінація — це спосіб повертати великий список частинами, а не завантажувати всі записи одним запитом.
Наприклад, замість кількох мільйонів замовлень API повертає лише 20:
{
"items": [],
"page": 2,
"pageSize": 20,
"total": 125430
}Пагінація допомагає:
зменшити навантаження на базу даних;
скоротити обсяг відповіді API;
пришвидшити рендеринг на клієнті;
контролювати використання пам’яті та мережі;
реалізувати нескінченне прокручування або кнопки переходу між сторінками.
Найпоширеніші підходи:
Offset pagination — сторінка визначається кількістю пропущених записів.
Cursor pagination — наступна сторінка визначається позицією останнього отриманого запису.
Offset-пагінація використовує параметри page і pageSize або безпосередньо offset і limit.
Для сторінки з номером page:
offset = (page - 1) * pageSizeНаприклад, для сторінки 3 із 20 записами:
offset = (3 - 1) * 20 = 40Запит до бази даних може мати такий вигляд:
SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 40;API зазвичай виглядає так:
GET /posts?page=3&pageSize=20Або:
GET /posts?offset=40&limit=20Клієнту легко працювати з номерами сторінок:
GET /products?page=1
GET /products?page=2
GET /products?page=3Це зручно для:
адміністративних панелей;
таблиць із номерами сторінок;
звітів;
результатів пошуку;
сценаріїв, де користувач має перейти безпосередньо на конкретну сторінку.
Якщо API повертає загальну кількість записів, клієнт може обчислити кількість сторінок:
totalPages = ceil(total / pageSize)Наприклад:
{
"items": [],
"page": 3,
"pageSize": 20,
"total": 125430,
"totalPages": 6272
}URL сторінок є стабільними, тому відповідь для page=3 легко кешувати на рівні клієнта або HTTP-кешу.
Для запиту:
SELECT id, title
FROM posts
ORDER BY created_at DESC
LIMIT 20 OFFSET 1000000;База даних може бути змушена знайти або переглянути значну кількість попередніх рядків, щоб пропустити їх. Навіть якщо клієнту потрібні лише 20 записів, робота з першими мільйонами може залишатися необхідною.
Точна поведінка залежить від бази даних, плану виконання та індексів, але великий OFFSET часто стає вузьким місцем.
Уявімо, що користувач завантажив першу сторінку:
A, B, CПісля цього перед цими записами додався новий елемент:
NEW, A, B, CПід час завантаження другої сторінки з OFFSET 3 користувач може отримати:
C, D, EЗапис C повторився, а межа сторінок змістилася.
Так само видалення запису між двома запитами може призвести до пропуску елемента.
Offset-запити зазвичай виконуються окремо. Якщо дані змінюються між запитами, сторінки можуть бути непослідовними.
Транзакція або спеціальний знімок даних можуть вирішити цю проблему, але часто це складно чи дорого для довгих сесій перегляду.
Cursor-пагінація не використовує номер сторінки. API повертає спеціальний курсор, який описує позицію останнього елемента у поточній вибірці.
Приклад запиту:
GET /posts?limit=20Відповідь:
{
"items": [
{
"id": 101,
"title": "Перший запис"
}
],
"nextCursor": "eyJjcmVhdGVkQXQiOiIyMDI2LTA4LTIwVDEwOjAwOjAwWiIsImlkIjoxMDF9"
}Наступний запит використовує цей курсор:
GET /posts?limit=20&after=eyJjcmVhdGVkQXQiOiIyMDI2LTA4LTIwVDEwOjAwOjAwWiIsImlkIjoxMDF9Курсор може бути:
простим ідентифікатором;
значенням поля сортування;
комбінацією кількох значень;
закодованим і підписаним JSON-об’єктом.
Курсор не обов’язково має бути зрозумілим клієнту. Зазвичай його вважають непрозорим значенням: клієнт зберігає курсор і передає його назад, але не намагається змінювати.
Cursor-пагінація часто реалізується за допомогою keyset pagination. Її суть — знаходити наступні записи через умову за значенням останнього запису, а не пропускати попередні рядки.
Припустімо, записи сортуються за created_at і id:
SELECT id, title, created_at
FROM posts
WHERE (created_at, id) < ('2026-08-20T10:00:00Z', 101)
ORDER BY created_at DESC, id DESC
LIMIT 20;Тут created_at використовується як основне поле сортування, а id — як додатковий унікальний tie-breaker.
Спрощений варіант, якщо сортування відбувається лише за унікальним ідентифікатором:
SELECT id, title
FROM posts
WHERE id < 101
ORDER BY id DESC
LIMIT 20;База даних може перейти безпосередньо до позиції в індексі та вибрати наступні записи:
WHERE id < :lastId
ORDER BY id DESC
LIMIT :limitВартість запиту не повинна лінійно зростати разом із номером сторінки, як у випадку з великим OFFSET.
Якщо нові записи додаються на початку списку, уже завантажені сторінки зазвичай не зміщуються. Курсор продовжує вибірку від попередньої позиції.
Це особливо важливо для:
стрічок новин;
чатів;
журналів подій;
історії транзакцій;
нескінченного прокручування;
великих таблиць.
Клієнту не потрібно знати номер сторінки:
{
"items": [],
"nextCursor": "opaque-cursor",
"hasNextPage": true
}Поки hasNextPage дорівнює true, клієнт надсилає наступний курсор.
Курсор описує позицію в послідовності, а не номер сторінки. Щоб дістатися далекої сторінки, зазвичай потрібно послідовно отримати попередні курсори.
Потрібно визначити:
стабільне сортування;
формат курсора;
правила для першої та наступної сторінки;
напрямок руху;
поведінку при зміні або видаленні записів;
перевірку некоректних або прострочених курсорів.
Сортування лише за полем, яке не є унікальним, може спричинити пропуски або дублювання.
Небезпечний приклад:
ORDER BY created_at DESCЯкщо кілька записів мають однакове значення created_at, база даних не зобов’язана повертати їх у стабільному порядку.
Краще використовувати складене сортування:
ORDER BY created_at DESC, id DESCІ відповідний складений курсор:
(created_at, id)даних небагато або offset не буде великим;
користувачам потрібні номери сторінок;
потрібен перехід на довільну сторінку;
сторінки змінюються рідко;
це внутрішня адміністративна форма або звіт;
важливі простота API та реалізації.
таблиця містить багато записів;
користувачі переважно переходять до наступної сторінки;
дані часто додаються або змінюються;
потрібна стабільна продуктивність;
реалізується infinite scroll;
API повертає стрічку, журнал або історію подій.
Іноді обидва підходи можна поєднати. Наприклад, для невеликих довідників використовувати offset, а для основної стрічки — cursor.
Пагінація тісно пов’язана із сортуванням. Без явного ORDER BY порядок записів не гарантований.
Поганий варіант:
SELECT id, title
FROM posts
LIMIT 20 OFFSET 40;Кращий варіант:
SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 40;Для cursor-пагінації індекс має відповідати типовому фільтру та сортуванню. Наприклад, для запитів за created_at і id може бути потрібен складений індекс:
CREATE INDEX posts_created_at_id_idx
ON posts (created_at DESC, id DESC);Конкретний синтаксис і ефективність залежать від СУБД. Рішення варто перевіряти через план виконання та реальні дані.
Важливо також обмежувати розмір сторінки на сервері:
requestedLimit = 10000
actualLimit = min(requestedLimit, 100)Інакше клієнт може випадково або навмисно запитати надто великий обсяг даних.
Приклад відповіді:
{
"items": [
{
"id": 101,
"title": "Перший запис"
}
],
"pageInfo": {
"hasNextPage": true,
"hasPreviousPage": false,
"startCursor": "cursor-for-first-item",
"endCursor": "cursor-for-last-item"
}
}Для простої навігації достатньо:
{
"items": [],
"nextCursor": "opaque-cursor",
"hasNextPage": true
}totalПідрахунок загальної кількості записів може бути дорогим для великих таблиць. Тому cursor API часто повертає hasNextPage, а не total.
Якщо точна кількість потрібна бізнес-логіці, її можна отримувати окремим запитом або кешувати. Але не варто автоматично додавати дорогий COUNT(*) до кожного запиту пагінації.
Курсор можна передавати у простому вигляді, але закодований формат має переваги:
приховує внутрішню структуру;
зменшує ризик ручного редагування;
дозволяє зберігати кілька полів;
може містити версію формату або строку дії.
Кодування саме по собі не є шифруванням. Якщо курсор містить конфіденційні дані, потрібен відповідний захист, а не лише Base64.
Сервер має перевіряти:
коректність формату;
відповідність курсора потрібному ресурсу;
допустимий напрямок;
строк дії, якщо він використовується;
підпис або цілісність даних.
Для наступної сторінки потрібна умова після останнього елемента. Для попередньої — умова перед першим елементом.
Наприклад, прямий порядок:
ORDER BY created_at ASC, id ASCНаступні записи:
WHERE (created_at, id) > (:createdAt, :id)
ORDER BY created_at ASC, id ASC
LIMIT :limitПопередні записи:
WHERE (created_at, id) < (:createdAt, :id)
ORDER BY created_at DESC, id DESC
LIMIT :limitРезультат для попередньої сторінки після вибірки часто потрібно розвернути перед поверненням клієнту, щоб зберегти очікуваний порядок.
У багатьох API реалізують лише nextCursor, бо рух уперед простіший і достатній для нескінченного списку.
Жоден підхід автоматично не створює повний незмінний знімок даних.
Cursor добре захищає від зміщення через нові записи на початку, але можливі інші сценарії:
запис змінив поле сортування;
запис було видалено;
запис змінив видимість через права доступу;
фільтри між запитами стали іншими;
курсор використовується занадто довго.
Практичні рекомендації:
сортувати за незмінним або майже незмінним полем;
додавати унікальний tie-breaker;
зберігати однакові фільтри для всіх запитів;
не змінювати порядок сортування під час проходження сторінок;
за потреби фіксувати верхню межу вибірки, наприклад час початку запиту;
для критичних звітів використовувати окремий знімок або узгоджену транзакцію.
ORDER BY score DESCЯкщо значення score повторюються, порядок може бути нестабільним.
Надійніше:
ORDER BY score DESC, id DESCНе слід безконтрольно приймати значення:
GET /items?limit=1000000&offset=50000000Сервер повинен мати максимальний limit, перевіряти offset і за потреби використовувати cursor-пагінацію для великих зміщень.
Умова id < lastId коректна лише тоді, коли список сортується за id. Якщо сортування відбувається за датою, курсор має містити дату та додатковий ідентифікатор.
Курсор не можна вважати автоматично коректним. Його потрібно парсити, валідувати та, за потреби, підписувати.
Перший запит:
GET /posts?status=published&after=...Наступний запит без status=published може повернути інший набір даних. Клієнт має зберігати всі параметри, крім самого курсора, або сервер може включати параметри фільтра в курсор.
total у кожному запитіВеликий список не завжди потребує точної кількості записів. Якщо інтерфейсу достатньо кнопки «Завантажити ще», hasNextPage може бути дешевшим і практичнішим за total.
hasNextPageПоширений прийом — запитувати limit + 1 записів:
SELECT ... LIMIT 21Якщо знайдено 21 запис для сторінки розміром 20, то hasNextPage = true, а зайвий запис не повертається клієнту.
Вибір можна звести до простого правила:
потрібні номери сторінок і перехід на довільну сторінку — offset;
потрібні продуктивність на великих даних і стабільна навігація вперед — cursor;
список сортується за кількома полями — використовуйте keyset cursor із усіма полями сортування;
дані часто змінюються — віддавайте перевагу cursor і детермінованому порядку.
Offset-пагінація проста, зрозуміла та добре підходить для таблиць, звітів і сценаріїв із переходом на конкретну сторінку. Її головний недолік — погіршення продуктивності на великих offset і нестабільність під час змін у наборі даних.
Cursor-пагінація складніша в реалізації, зате краще масштабується та природно підходить для стрічок, журналів і нескінченного прокручування. Її основа — стабільне сортування, унікальний tie-breaker, відповідні індекси та коректна обробка курсорів.
Найважливіше — не обирати підхід лише за формою API. Потрібно враховувати розмір таблиці, частоту змін, вимоги до навігації, індекси та допустиму консистентність результатів.