API

Пагінація та організація списків

Limit/offset, курсорна пагінація, мутабельні та іммутабельні списки, зворотна пагінація, фільтри та сортування у списках.

Пагінація та організація списків

Як тільки API починає повертати списки ресурсів, з'являється ціла низка проблем: що робити, якщо в списку мільйон елементів? Як гарантувати, що клієнт побачить усі дані? Що станеться, якщо дані змінюються під час перегляду? У цій статті ми розберемо всі підходи до організації списків.

1. Навіщо потрібна пагінація

Уявіть: ваш API повертає список замовлень. У маленькому сервісі їх 50 — все добре. Але через рік замовлень стає 500 000. Запит GET /v1/orders тепер:

ПроблемаНаслідок
Обсяг данихВідповідь займає 50 МБ — мобільний клієнт просто не зможе обробити
Час відповідіСеріалізація 500 000 об'єктів — секунди або навіть хвилини
Навантаження на БДSELECT * FROM orders без LIMIT — може «покласти» базу
Пам'ять сервераЗавантаження всіх записів у пам'ять — Out of Memory

Пагінація (pagination) — це механізм розбиття великого списку на «сторінки» фіксованого розміру. Клієнт запитує одну сторінку за раз і може переходити до наступної.


2. Offset-пагінація: простий, але проблемний підхід

Як працює

Клієнт передає два параметри: offset (скільки записів пропустити) і limit (скільки повернути):

GET /v1/orders?limit=20&offset=0    → записи 1-20
GET /v1/orders?limit=20&offset=20   → записи 21-40
GET /v1/orders?limit=20&offset=40   → записи 41-60

Це інтуїтивно зрозумілий підхід, знайомий кожному, хто писав SQL-запити з LIMIT і OFFSET.

Offset-пагінація
app.MapGet("/v1/orders", 
    (int? limit, int? offset) =>
{
    var safeLimit = Math.Clamp(limit ?? 20, 1, 100);
    var safeOffset = Math.Max(offset ?? 0, 0);
    
    var orders = db.GetOrders(safeLimit, safeOffset);
    return Results.Ok(new
    {
        items = orders,
        limit = safeLimit,
        offset = safeOffset,
        total = db.CountOrders()
    });
});

Чому offset — поганий вибір

Offset-пагінація має три фундаментальні проблеми:

Проблема 1: Пропуск або дублювання елементів

Уявіть: клієнт запросив першу сторінку (offset=0, limit=20), отримав записи 1-20. Поки він переглядає дані, інший користувач видаляє запис #3. Тепер у базі:

  • Було: 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22...
  • Стало: 1, 2, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22...

Клієнт запитує другу сторінку з offset=20. Але тепер на позиції 20 стоїть запис 21, а не 21-й елемент. Запис #21 «зсунувся» на позицію 20 — і ніколи не буде показаний клієнту. Він пропущений.

Аналогічна ситуація з додаванням: новий запис зсуває всі елементи вперед, і клієнт побачить один запис двічі.

Проблема 2: Продуктивність

База даних при виконанні OFFSET 100000 фактично читає та відкидає 100 000 записів. Чим далі ви «гортаєте» — тим повільніше працює запит:

OffsetЧас виконання (приблизно)
02 мс
10 00050 мс
100 000500 мс
1 000 0005+ секунд

Проблема 3: Неможливість паралельної вибірки

Offset прив'язаний до порядку записів. Якщо порядок змінюється (додавання, видалення, зміна сортування) — offset стає недійсним. Два паралельних запити з різними offset-ами можуть повернути перетинні або неповні дані.

Висновок: Offset-пагінація підходить лише для статичних даних, що не змінюються під час перегляду — наприклад, для документації, списку країн або архівних записів. Для динамічних даних (замовлення, повідомлення, стрічка новин) використовуйте курсорну пагінацію.

3. Курсорна пагінація: правильний підхід

Ідея: замість номера сторінки — «закладка»

Курсор (cursor) — це непрозорий маркер, що вказує на конкретну позицію у списку. Уявіть: замість того, щоб сказати «покажи сторінку 3», ви кажете «покажи все, що йде після цього запису».

GET /v1/orders?limit=20
→ items: [1..20], next_cursor: "eyJpZCI6MjB9"

GET /v1/orders?limit=20&cursor=eyJpZCI6MjB9
→ items: [21..40], next_cursor: "eyJpZCI6NDB9"

GET /v1/orders?limit=20&cursor=eyJpZCI6NDB9
→ items: [41..50], next_cursor: null  // кінець списку

Чому це працює краще

Проблема пропуску записів зникає: курсор прив'язаний не до позиції, а до конкретного запису. Навіть якщо хтось видалить записи перед курсором — ми все одно продовжимо з правильного місця.

Продуктивність стабільна: в SQL курсор перетворюється на WHERE id > @cursor_id ORDER BY id LIMIT 20. База використовує індекс, і запит виконується за сталий час незалежно від «номера сторінки».

ПідхідВидалення елементуДодавання елементуЧас запитуДовільна сторінка
Offset❌ Пропуск❌ Дублікат❌ Деградує✅ Так
Cursor✅ Коректно✅ Коректно✅ Стабільний❌ Тільки послідовно

Формат відповіді

Курсорна пагінація
app.MapGet("/v1/orders", 
    (int? limit, string? cursor) =>
{
    var safeLimit = Math.Clamp(limit ?? 20, 1, 100);
    var page = db.GetOrdersAfterCursor(
        cursor, safeLimit);
    
    return Results.Ok(new
    {
        items = page.Items,
        next_cursor = page.HasMore 
            ? page.LastCursor 
            : null,
        has_more = page.HasMore
    });
});
Що таке «непрозорий курсор»? Клієнт не повинен знати, як влаштований курсор всередині. Це може бути Base64-закодований ID, timestamp або будь-що інше. Клієнт отримує рядок і передає його назад у наступному запиті — без спроб парсити чи модифікувати.

4. Мутабельні та іммутабельні списки

Книга Константинова вводить важливе розрізнення: мутабельні та іммутабельні списки мають різну семантику пагінації.

Іммутабельний список

Це список, де нові елементи не додаються, а існуючі не змінюються. Приклади:

  • Історія транзакцій (кожна транзакція — факт, що не змінюється)
  • Лог подій (кожна подія зафіксована)
  • Архів замовлень (завершені замовлення не редагуються)

Для іммутабельного списку пагінація проста: порядок елементів стабільний, курсор завжди вказує на одну і ту ж позицію. Клієнт може спокійно «гортати» сторінки — дані не «їдуть».

Мутабельний список

Це список, де елементи постійно додаються та змінюються. Приклади:

  • Активні замовлення (можуть з'являтися, скасовуватися)
  • Список користувачів онлайн (постійно змінюється)
  • Стрічка новин (нові пости з'являються щосекунди)

Для мутабельного списку пагінація складніша, бо стан змінюється між запитами.

Проблема: «зсув стрічки»

Уявіть стрічку новин: 50 нових постів за хвилину. Клієнт запитує першу сторінку, бачить пости 1-20. Поки він читає — з'являється 10 нових постів. Коли клієнт запитує другу сторінку з cursor — він побачить пости 21-40 від моменту першого запиту, а 10 нових (від моменту між запитами) будуть на «сторінці 0», яку клієнт вже переглянув.

Рішення: для мутабельних списків обов'язково передавайте в курсорі anchor (якір) — ідентифікатор стану списку на момент початку пагінації. Клієнт «бачить знімок» списку, а нові елементи побачить при наступному оновленні.

З книги: Розрізнення мутабельних і іммутабельних списків — не академічна вправа, а практична необхідність. Клієнт повинен знати, чи дані, які він пагінує, можуть змінитися між запитами, і відповідно обирати стратегію оновлення.

5. Зворотна пагінація

Іноді потрібно «гортати» список у зворотному напрямку — від новіших до старіших. Наприклад, чат: показуємо останні повідомлення, і при підтягуванні вверх — завантажуємо старіші.

Для цього API надає два параметри курсора:

ПараметрНапрямокВикористання
cursor (або after)ВпередПоказати елементи після курсора
beforeНазадПоказати елементи до курсора
Зворотна пагінація для чату
app.MapGet("/v1/chats/{chatId}/messages", 
    (int chatId, string? after, string? before, 
     int? limit) =>
{
    var safeLimit = Math.Clamp(limit ?? 50, 1, 100);
    
    PagedResult<Message> page;
    
    if (before is not null)
    {
        // Завантажити повідомлення СТАРІШІ за курсор
        page = db.GetMessagesBefore(
            chatId, before, safeLimit);
    }
    else
    {
        // Завантажити повідомлення НОВІШІ за курсор
        page = db.GetMessagesAfter(
            chatId, after, safeLimit);
    }
    
    return Results.Ok(new
    {
        items = page.Items,
        next_cursor = page.NextCursor,
        prev_cursor = page.PrevCursor,
        has_more = page.HasMore
    });
});

6. Фільтри та сортування

Фільтри через query

Прості фільтри передаються через query-параметри:

GET /v1/orders?status=active&recipe=lungo&limit=20

Правила:

  • Кожен фільтр — окремий query-параметр
  • Значення за замовчуванням — документуйте їх! Якщо status не передано — повертаються всі чи тільки активні?
  • Множинні значення?status=active&status=completed або ?status=active,completed

Сортування

GET /v1/orders?sort_by=created_at&sort_order=desc
З книги: Дозволяйте сортування тільки по індексованих полях. Якщо клієнт запросить сортування по неіндексованому полю на таблиці з мільйоном записів — це фактично DDoS на вашу базу даних.

Складні фільтри — через POST

Коли фільтри стають складними (діапазони дат, геолокація, вкладені умови), query-параметрів не вистачає:

app.MapPost("/v1/orders/search", (SearchRequest req) =>
{
    var safeLimit = Math.Clamp(req.Limit, 1, 100);
    var results = db.Search(req with { Limit = safeLimit });
    
    return Results.Ok(new
    {
        items = results.Items,
        next_cursor = results.NextCursor,
        total_estimate = results.TotalEstimate
    });
});

record SearchRequest(
    string? Recipe,
    string[]? Statuses,
    DateOnly? CreatedAfter,
    DateOnly? CreatedBefore,
    string? SortBy,
    string SortOrder = "desc",
    string? Cursor = null,
    int Limit = 20);

7. Метадані списку

Відповідь зі списком повинна містити метаінформацію, яка допомагає клієнту зрозуміти, чи є ще дані:

Повна відповідь зі списком
{
  "items": [
    {"id": 1, "recipe": "lungo"},
    {"id": 2, "recipe": "latte"}
  ],
  "next_cursor": "eyJpZCI6Mn0",
  "has_more": true,
  "total": 156,
  "limit": 20
}
ПолеОбов'язковістьОпис
items✅ Обов'язковеМасив елементів поточної сторінки
next_cursor✅ Обов'язковеКурсор для наступної сторінки (null якщо остання)
has_moreРекомендованоБулевий прапор: чи є ще елементи
totalОпціональноЗагальна кількість елементів (може бути дорогою операцією!)
limitРекомендованоФактичний ліміт (може відрізнятися від запрошеного)
Обережно з total! В деяких базах даних COUNT(*) на великих таблицях — дуже повільна операція. Якщо загальна кількість не критична для UI — краще повертати total_estimate (приблизну оцінку) або не повертати взагалі, покладаючись на has_more.

8. Практичні завдання

Рівень 1: Базовий

Рівень 2: Проєктування


9. Резюме

Offset ≠ пагінація

Offset-пагінація пропускає та дублює записи при зміні даних. Використовуйте тільки для статичних списків.

Курсор — золотий стандарт

Непрозорий маркер, прив'язаний до конкретного запису. Стабільна продуктивність, коректна робота при змінах.

Мутабельні vs іммутабельні

Іммутабельні списки (лог, архів) пагінуються просто. Мутабельні (стрічка, замовлення) потребують anchor-стратегії.

total — не безкоштовний

COUNT(*) на мільйонах записів — повільна операція. Використовуйте has_more або total_estimate.

Далі: у наступній статті ми розберемо безпеку API, автентифікацію та кешування — TLS, UUID, Cache-Control, та інтернаціоналізацію.

Copyright © 2026