Пагінація та організація списків
Пагінація та організація списків
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.
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 | Час виконання (приблизно) |
|---|---|
| 0 | 2 мс |
| 10 000 | 50 мс |
| 100 000 | 500 мс |
| 1 000 000 | 5+ секунд |
Проблема 3: Неможливість паралельної вибірки
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
});
});
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
Складні фільтри — через 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: Базовий
Реалізуйте GET /v1/products з курсорною пагінацією:
- Параметри:
limit(за замовчуванням 20, макс 100),cursor(опціональний) - Курсор — Base64-закодований ID останнього елементу
- Відповідь:
items,next_cursor,has_more - Якщо
cursorневалідний — повертайте400
Використайте List<Product> як сховище, відсортований по ID.
Додайте до ендпоінту з завдання 11.1:
- Фільтр
?category=electronics - Фільтр
?min_price=100&max_price=500 - Сортування
?sort_by=price&sort_order=asc - Всі фільтри повинні працювати разом із пагінацією
Рівень 2: Проєктування
Реалізуйте API для чату зі зворотною пагінацією:
GET /v1/chats/{id}/messages— останні N повідомлень- Параметр
before— завантажити старіші повідомлення - Параметр
after— завантажити новіші (для оновлення) - Відповідь:
items,next_cursor,prev_cursor - Порядок: від старіших до новіших (як у чаті)
9. Резюме
Offset ≠ пагінація
Курсор — золотий стандарт
Мутабельні vs іммутабельні
total — не безкоштовний
Далі: у наступній статті ми розберемо безпеку API, автентифікацію та кешування — TLS, UUID, Cache-Control, та інтернаціоналізацію.
Ідемпотентність та синхронізація стану
Токени ідемпотентності, паттерн чернетка-підтвердження (draft-commit), безпечне повторення запитів, Content-Location, оптимістичний контроль та синхронізація стану між клієнтом і сервером.
Безпека API, кешування та інтернаціоналізація
TLS, UUID як ідентифікатори, JWT-безпека, Cache-Control, E-Tag-стратегії, rate limiting, інтернаціоналізація та локалізація відповідей.