Сучасні застосунки часто працюють із наборами даних у мільйони записів. Чи то e‑commerce з великим каталогом, стрічка соцмережі чи аналітична панель — рано чи пізно виникає питання, як показувати великі обсяги даних, не перевантажуючи сервер і користувача. Пагінація — стандартне рішення, але методи пагінації поводяться по‑різному зі зростанням даних.
У цій статті порівняємо два підходи до пагінації в Laravel з MongoDB: offset‑пагінацію з skip() і limit() та cursor‑пагінацію, яка використовує вказівники на документи. Ви дізнаєтесь, як кожен підхід працює всередині, чому offset‑пагінація деградує на великих об’ємах і коли cursor‑пагінація буде кращим вибором. Наприкінці — практичні приклади реалізації й поради для вибору стратегії.
Offset‑пагінація — традиційний підхід: ви говорите базі даних пропустити певну кількість записів і повернути наступну партію. Наприклад, для сторінки 5 з 20 елементами пропускаєте перші 80 записів і берете наступні 20.
У 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 для попередньої/наступної сторінок.
Проблема з 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 мусить просканувати всю колекцію, і ця операція не завжди використовує індекси так ефективно, як фільтровані запити.
Кілька стратегій, щоб пом’якшити витрати:
estimatedDocumentCount() повертає приблизний count набагато швидше// Fast estimated count
$estimatedCount = DB::connection('mongodb')
->collection('products')
->raw(function ($collection) {
return $collection->estimatedDocumentCount();
});
Незважаючи на обмеження, offset‑пагінація підходить у певних випадках:
Якщо ваш застосунок підпадає під ці умови, простота offset‑пагінації робить її хорошим вибором. Проблеми з продуктивністю з’являються лише при глибокій навігації в великих колекціях.
Cursor‑пагінація працює інакше: замість підрахунку, скільки записів пропустити, ви використовуєте вказівник на останній побачений документ і просите всі записи після нього. Ця техніка також відома як keyset або seek pagination.
Курсор — значення, яке унікально визначає позицію в відсортованому наборі результатів. Щоб отримати наступну сторінку, ви передаєте курсор, і база відбирає документи, де поле сортування більше (або менше, залежно від напряму) за це значення.
Наприклад, у стрічці постів, відсортованих за датою створення, замість «пропусти перші 100 постів» ви говорите «дайте пости, створені після цього timestamp». База може скористатися індексом і перейти безпосередньо до цієї позиції без сканування попередніх документів.
Типовий сценарій:
Курсором зазвичай є _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:
Ці обмеження роблять cursor‑пагінацію непридатною для інтерфейсів, що вимагають традиційних номерів сторінок. Але багато сучасних застосунків не потребують нумерації: infinite scroll, кнопки «Load more» і API для мобільних додатків природно працюють з курсорами.
Cursor‑пагінація краще підходить для:
Laravel має вбудовану підтримку cursor‑пагінації через cursorPaginate(), яка працює з Laravel MongoDB пакетом. Тут розглянемо як вбудований метод, так і кастомні реалізації для складніших випадків.
Доступний з 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(),
],
]);
}
Іноді потрібно більше контролю, ніж дає 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‑запиту.
Якщо сортуєте по неунікальному полю, наприклад 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 записів | Будь‑який розмір |
Деякі застосунки виграють від поєднання методів:
Окрім вибору стратегії, кілька оптимізацій покращать продуктивність обох підходів.
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(), щоб уникнути складної міграції пізніше.
Встановлення Xdebug може бути складним завданням, але в цій статті ми розкриємо, як швидко та просто налаштувати його за допомогою Docker на прикладі Laravel. Дочитайте до кінця, щоб дізнатися, як за кілька хвилин зробити Xdebug вашим надійним помічником у розробці
Laravel Livewire випустив нову версію 3.6, яка приносить з собою цікаві HTML-директиви для управління видимістю DOM-елементів і JavaScript-діями. Досліджуйте нові можливості Livewire, що допоможуть вам створити ще більш інтерактивні користувацькі інтерфейси!
Використання Vite для створення фронтенд-ресурсів у вашому додатку Laravel може бути захоплюючим, але іноді ви можете стикнутися з певними помилками. У цій статті ми розглянемо чотири поширені помилки, з якими ви можете зіткнутися, а також підкажемо способи їх усунення, щоб ви могли знову зосередитися на розробці вашого додатку