Сучасні застосунки часто працюють із наборами даних у мільйони записів. Чи то e‑commerce з великим каталогом, стрічка соцмережі чи аналітична панель — рано чи пізно виникає питання, як показувати великі обсяги даних, не перевантажуючи сервер і користувача. Пагінація — стандартне рішення, але методи пагінації поводяться по‑різному зі зростанням даних.
У цій статті порівняємо два підходи до пагінації в Laravel з MongoDB: offset‑пагінацію з skip() і limit() та cursor‑пагінацію, яка використовує вказівники на документи. Ви дізнаєтесь, як кожен підхід працює всередині, чому offset‑пагінація деградує на великих об’ємах і коли cursor‑пагінація буде кращим вибором. Наприкінці — практичні приклади реалізації й поради для вибору стратегії.
# Offset Pagination: механіка та проблеми з продуктивністю
Offset‑пагінація — традиційний підхід: ви говорите базі даних пропустити певну кількість записів і повернути наступну партію. Наприклад, для сторінки 5 з 20 елементами пропускаєте перші 80 записів і берете наступні 20.
# Як працює offset‑пагінація
У MongoDB offset‑пагінація використовує skip() для вказівки точки старту та limit() для кількості документів. У Laravel Eloquent є метод paginate(), що робить це автоматично, або можна вручну застосувати skip() і take() для контролю.
Ось базова реалізація:
use App\Models\Product;
class ProductController extends Controller
{
public function index(Request $request)
{
$page = max((int) $request->input('page', 1), 1);
$perPage = 20;
$skip = ($page - 1) * $perPage;
$products = Product::orderBy('created_at', 'desc')
->skip($skip)
->take($perPage)
->get();
return response()->json($products);
}
}
Обчислення skip просте: ($page - 1) * $perPage. Для сторінки 1 пропускаєте 0 записів, для сторінки 2 — 20, для сторінки 100 — 1,980.
Вбудований метод Laravel paginate() обгортає цю логіку й додає метадані, як‑от загальна кількість сторінок і посилання навігації:
$products = Product::orderBy('created_at', 'desc')->paginate(20);
Він повертає об’єкт paginator з результатами та інформацією про загальну кількість записів, поточну сторінку й URL для попередньої/наступної сторінок.
# Чому offset‑пагінація падає в продуктивності на великих даних
Проблема з skip() проявляється, якщо подивитись, що насправді робить MongoDB. Коли ви викликаєте skip(1000000), MongoDB не перескакує одразу до запису 1,000,001 — йому доводиться просканувати мільйон документів і відкинути їх, перш ніж повернути ваші результати. База читає й ігнорує кожен пропущений документ, тому сторінка 10,000 буде значно повільнішою за сторінку 1.
Час виконання зростає лінійно зі значенням offset. Якщо сторінка 1 займає 5 мс, сторінка 1,000 може займати 500 мс, а сторінка 10,000 — кілька секунд. Це відбувається незалежно від індексів, бо саме skip вимагає проходження документів.
Подивитися це можна за допомогою explain:
db.products.find().sort({ created_at: -1 }).skip(1000000).limit(20).explain("executionStats")
Поле docsExamined покаже понад один мільйон переглянутих документів, хоча повертається тільки 20.
# Проблема підрахунку
Offset‑пагінація часто показує «Сторінка X з Y», для чого потрібна загальна кількість документів. Отримати точний count у великих колекціях дорого: MongoDB мусить просканувати всю колекцію, і ця операція не завжди використовує індекси так ефективно, як фільтровані запити.
Кілька стратегій, щоб пом’якшити витрати:
- Кешуйте count: зберігайте загальну кількість окремо і оновлюйте періодично замість підрахунку на кожен запит
- Використовуйте оцінки:
estimatedDocumentCount()повертає приблизний count набагато швидше - Не показуйте тотали: відображайте тільки «Далі» і «Назад» без загальної кількості сторінок
// Fast estimated count
$estimatedCount = DB::connection('mongodb')
->collection('products')
->raw(function ($collection) {
return $collection->estimatedDocumentCount();
});
# Коли offset‑пагінація ще доречна
Незважаючи на обмеження, offset‑пагінація підходить у певних випадках:
- Малі та середні набори даних: колекції до ~100,000 записів рідко мають помітні проблеми
- Адмін‑панелі: внутрішні інструменти, де зручність важливіша за максимальну продуктивність, а дані часто фільтруються
- Потрібен довільний доступ до сторінок: коли користувачі мають стрибати на сторінку 50 або 200
Якщо ваш застосунок підпадає під ці умови, простота offset‑пагінації робить її хорошим вибором. Проблеми з продуктивністю з’являються лише при глибокій навігації в великих колекціях.
# Cursor Pagination: масштабована альтернатива
Cursor‑пагінація працює інакше: замість підрахунку, скільки записів пропустити, ви використовуєте вказівник на останній побачений документ і просите всі записи після нього. Ця техніка також відома як keyset або seek pagination.
# Що таке cursor‑пагінація
Курсор — значення, яке унікально визначає позицію в відсортованому наборі результатів. Щоб отримати наступну сторінку, ви передаєте курсор, і база відбирає документи, де поле сортування більше (або менше, залежно від напряму) за це значення.
Наприклад, у стрічці постів, відсортованих за датою створення, замість «пропусти перші 100 постів» ви говорите «дайте пости, створені після цього timestamp». База може скористатися індексом і перейти безпосередньо до цієї позиції без сканування попередніх документів.
# Як працює cursor‑пагінація
Типовий сценарій:
- Перший запит: отримуєте перші N записів і зберігаєте курсор останнього з них
- Наступні запити: отримуєте N записів, де поле сортування більше за курсор
- Повторюєте: у кожній відповіді повертаєте новий курсор для наступної сторінки
Курсором зазвичай є _id, timestamp як created_at або складне значення для сортування по неунікальних полях.
// First page - no cursor needed
$products = Product::orderBy('_id', 'asc')
->limit(20)
->get();
$lastId = $products->last()->_id;
// Second page - use the cursor
$products = Product::where('_id', '>', $lastId)
->orderBy('_id', 'asc')
->limit(20)
->get();
Оскільки _id завжди проіндексований у MongoDB, такі запити виконуються стабільно незалежно від глибини перегляду.
# Чому курсори ефективні
Різниця в тому, як база виконує запит. У випадку умови типу where('_id', '>', $lastId) MongoDB використовує індекс, щоб одразу перейти до початкової точки. Немає сканування й відкидання документів — перевіряються тільки ті, що повертаються.
Це дає cursor‑пагінації O(1) по часу для будь‑якої «сторінки». Час залежить лише від кількості повернутих документів, а не від позиції в наборі.
# Компроміси
Cursor‑пагінація має обмеження, що впливають на UX:
- Немає довільного стрибка по сторінках: користувачі можуть рухатись тільки послідовно вперед/назад
- Нема «Сторінка X з Y»: без offset‑системи немає ідеї номерованих сторінок
- Керування курсором: клієнти повинні зберігати курсор між запитами
Ці обмеження роблять cursor‑пагінацію непридатною для інтерфейсів, що вимагають традиційних номерів сторінок. Але багато сучасних застосунків не потребують нумерації: infinite scroll, кнопки «Load more» і API для мобільних додатків природно працюють з курсорами.
# Ідеальні випадки використання
Cursor‑пагінація краще підходить для:
- Infinite scroll: стрічки соцмереж, новинні сайти, discovery‑інтерфейси
- Мобільні та SPA: API для фронтендів з «Load more»
- Реального часу: логи, сповіщення, стріми подій з частими оновленнями
- Великі API: публічні API, де важлива захищеність від deep pagination атак
# Реалізація в Laravel MongoDB
Laravel має вбудовану підтримку cursor‑пагінації через cursorPaginate(), яка працює з Laravel MongoDB пакетом. Тут розглянемо як вбудований метод, так і кастомні реалізації для складніших випадків.
# Метод cursorPaginate у Laravel
Доступний з Laravel 8, cursorPaginate() автоматично кодує/декодує курсори й генерує посилання навігації:
use App\Models\Product;
class ProductController extends Controller
{
public function index()
{
$products = Product::orderBy('_id')->cursorPaginate(15);
return response()->json($products);
}
}
Відповідь містить метадані курсора, які клієнт може використати для навігації:
{
"data": [...],
"path": "http://example.com/products",
"per_page": 15,
"next_cursor": "eyJfaWQiOiI2NTBhYjEyMzQ1NiIsIl9wb2ludHNUb05leHRJdGVtcyI6dHJ1ZX0",
"next_page_url": "http://example.com/products?cursor=eyJfaWQiOiI2NTBhYjEyMzQ1NiIsIl9wb2ludHNUb05leHRJdGVtcyI6dHJ1ZX0",
"prev_cursor": null,
"prev_page_url": null
}
Курсор — це base64‑закодований JSON з полями сортування останнього документа. Laravel декодує його на наступних запитах і формує відповідний запит.
Для API зручніше віддавати дані в іншому форматі:
public function index(Request $request)
{
$products = Product::orderBy('created_at', 'desc')
->orderBy('_id', 'desc')
->cursorPaginate(20);
return response()->json([
'products' => $products->items(),
'meta' => [
'next_cursor' => $products->nextCursor()?->encode(),
'prev_cursor' => $products->previousCursor()?->encode(),
'has_more' => $products->hasMorePages(),
],
]);
}
# Кастомна cursor‑пагінація
Іноді потрібно більше контролю, ніж дає cursorPaginate(): складне сортування, певний формат курсора для фронтенду чи інтеграція з існуючим API.
Ось базова кастомна реалізація:
use App\Models\Order;
use Illuminate\Http\Request;
class OrderController extends Controller
{
public function index(Request $request)
{
$perPage = 20;
$cursor = $request->input('cursor');
$query = Order::orderBy('_id', 'asc');
if ($cursor) {
$decodedCursor = base64_decode($cursor);
$query->where('_id', '>', $decodedCursor);
}
// Fetch one extra to check if more pages exist
// then remove it before returning
$orders = $query->limit($perPage + 1)->get();
$hasMore = $orders->count() > $perPage;
if ($hasMore) {
$orders->pop();
}
$nextCursor = $hasMore
? base64_encode($orders->last()->_id)
: null;
return response()->json([
'orders' => $orders,
'next_cursor' => $nextCursor,
'has_more' => $hasMore,
]);
}
}
Хитрість отримати на одну запис більше ($perPage + 1) дозволяє визначити, чи є ще сторінки, без додаткового count‑запиту.
# Робота зі складним сортуванням (compound sort)
Якщо сортуєте по неунікальному полю, наприклад created_at, кілька документів можуть мати однакове значення. Щоб уникнути неоднозначності, додають унікальне поле як тай‑брейкер — зазвичай _id.
public function index(Request $request)
{
$perPage = 20;
$cursor = $request->input('cursor');
$query = Order::orderBy('created_at', 'desc')
->orderBy('_id', 'desc');
if ($cursor) {
$decoded = json_decode(base64_decode($cursor), true);
$query->where(function ($q) use ($decoded) {
$q->where('created_at', '<', $decoded['created_at'])
->orWhere(function ($q2) use ($decoded) {
$q2->where('created_at', '=', $decoded['created_at'])
->where('_id', '<', $decoded['_id']);
});
});
}
$orders = $query->limit($perPage + 1)->get();
$hasMore = $orders->count() > $perPage;
if ($hasMore) {
$orders->pop();
}
$nextCursor = null;
if ($hasMore & $orders->isNotEmpty()) {
$lastOrder = $orders->last();
$nextCursor = base64_encode(json_encode([
'created_at' => $lastOrder->created_at->toISOString(),
'_id' => (string) $lastOrder->_id,
]));
}
return response()->json([
'orders' => $orders,
'next_cursor' => $nextCursor,
'has_more' => $hasMore,
]);
}
Складена умова курсора каже: «Дайте документи, де created_at менше за timestamp курсора, АБО де created_at дорівнює timestamp і _id менше за ID курсора». Це гарантує стабільний порядок навіть при колізіях timestamp.
# Вибір підходу
Вибір між offset і cursor залежить від розміру даних, вимог інтерфейсу та обмежень по продуктивності.
# Порівняльна таблиця
| Фактор | Offset pagination | Cursor pagination |
|---|---|---|
| Продуктивність на великому масштабі | Погіршується з ростом offset | Стабільна незалежно від позиції |
| Складність реалізації | Проста | Помірна |
| Переходи на довільні сторінки | Підтримуються | Не підтримуються |
| Відображення «Сторінка X з Y» | Підтримується | Не підтримується |
| Підходящий розмір набору даних | До ~100K записів | Будь‑який розмір |
# Обирайте offset‑пагінацію коли
- Ваш набір невеликий‑середній (до ~100,000 записів)
- Користувачам потрібен прямий стрибок на конкретні сторінки
- Потрібно показувати «Сторінка 3 з 47»
- Будуєте адмін‑панель, де зручність важливіша за продуктивність
- Фільтрований результат завжди залишаєтьcя невеликим
# Обирайте cursor‑пагінацію коли
- Набір даних великий або безперервно зростає
- Реалізуєте infinite scroll або «Load more»
- Створюєте API для мобільних або SPA
- Важлива консистентність продуктивності, а не довільний доступ до сторінок
- Хочете захиститися від deep pagination атак на публічних API
# Гібридні підходи
Деякі застосунки виграють від поєднання методів:
- Offset для початкових сторінок, cursor для глибшої навігації: дозволяйте нумеровані сторінки для перших 10–20 сторінок, а потім переходьте на cursor‑«Load more»
- Кешований count з cursor: використовувати cursor для фетчингу, але підтримувати кешований загальний count, щоб давати користувачам уявлення про розмір набору без втрат в продуктивності
# Поради з оптимізації
Окрім вибору стратегії, кілька оптимізацій покращать продуктивність обох підходів.
# Індексуйте поля курсора
Cursor‑пагінація ефективна лише якщо поля курсора проіндексовані. Для сортування по created_at і _id створіть compound index:
use Illuminate\Support\Facades\Schema;
use MongoDB\Laravel\Schema\Blueprint;
Schema::create('orders', function (Blueprint $collection) {
$collection->index(['created_at' => -1, '_id' => -1]);
});
MongoDB автоматично створює ascending index на _id. Але для compound сортування, наприклад created_at desc + _id desc, потрібен явний compound index. MongoDB не може ефективно комбінувати окремі одно‑поле індекси для такого запиту.
Без відповідного індексу MongoDB повернеться до сканування документів навіть при cursor‑пагінації. Порядок індексу важливий: він має збігатися з порядком сортування.
Для фільтрованих запитів, що також пагінуються, подумайте про compound індекси з полями фільтру першими. Наприклад, якщо часто фільтруєте по status і пагінуєте по даті, індекс ['status' => 1, 'created_at' => -1, '_id' => -1] дозволить MongoDB ефективно задовольнити й фільтр, і курсор‑умову.
# Використовуйте проекції
Повертайте лише ті поля, що дійсно потрібні. Великі документи з вкладеними масивами чи об’єктами довше передавати й серіалізувати:
$products = Product::select(['name', 'price', 'category', 'created_at'])
->orderBy('_id')
->cursorPaginate(20);
Це зменшує мережевий трафік і навантаження на серіалізацію. Різниця помітна, коли документи містять великі текстові поля або глибоку вкладеність.
# Кешуйте стратегічно
Для часто запитуваних перших сторінок варто використовувати кеш:
$firstPage = Cache::remember('products:first_page', now()->addMinutes(5), function () {
return Product::orderBy('created_at', 'desc')
->limit(20)
->get();
});
Перші сторінки зазвичай отримують найбільше трафіку, тому їх кешування значно знижує навантаження. Глибші сторінки можна брати «свіжими».
Якщо показуєте загальні counts, кешуйте їх з відповідним TTL замість підрахунку на кожен запит:
$totalProducts = Cache::remember('products:count', now()->addHours(1), function () {
return Product::count();
});
Розгляньте інвалідацію кешу першої сторінки при вставці нових документів, бо помітність застарілого першого результату вища, ніж застарілого глибокого. Можна використовувати cache tags або event listeners:
// In your model or observer
protected static function booted()
{
static::created(function () {
Cache::forget('products:first_page');
});
}
# Висновок
Пагінація — базовий патерн для роботи з великими наборами даних, але вибір реалізації впливає на продуктивність і UX. Offset‑пагінація з skip() і limit() проста і підходить для невеликих наборів або адмін‑інструментів, де потрібен довільний доступ до сторінок. Проте її продуктивність деградує лінійно при глибокому перегляді.
Cursor‑пагінація забезпечує стабільну продуктивність, використовуючи індексовані пошуки замість сканування. Laravel MongoDB підтримує обидва підходи: paginate() для offset і cursorPaginate() для cursor.
При виборі враховуйте розмір даних і темп росту, потребу в довільному доступі до сторінок і критичність стабільності продуктивності. Для великих систем зазвичай кращий cursor. Для невеликих наборів з традиційним UI — offset лишається практичним.
Який би метод ви не обрали, індексуйте поля сортування, мінімізуйте передавані поля через проекції і тестуйте продуктивність на даних, близьких до production. Те, що виглядає прийнятно на 10K записів, може сильно змінитися при 10M. Якщо починаєте нову фічу для великих або зростаючих даних — почніть з cursorPaginate(), щоб уникнути складної міграції пізніше.