Урок 03

Пагинация: offset vs cursor

Зачем пагинация

Проблема: Model::all() — миллион.

Что даёт:

  • Меньше данных.
  • Быстрее ответ.
  • Меньше памяти.
  • UI — страницы / бесконечная.

Правило: любая выборка — пагинация.

Offset pagination

Классика:

$posts = Post::paginate(20);

Что даёт:

  • Параметр ?page=2.
  • Метаданные total, per_page.
  • Ссылки в Blade.

Правило: paginate — по умолчанию.

Simple paginate

$posts = Post::simplePaginate(20);

Что даёт:

  • Без total count.
  • Быстрее (нет COUNT).
  • Только next/prev.

Правило: simplePaginate — когда total не нужен.

Проблема offset

Что происходит на page=10000:

SELECT * FROM posts LIMIT 20 OFFSET 200000;

БД должна:

  • Прочитать 200 000 строк.
  • Пропустить их.
  • Вернуть 20.

Что даёт: O(N) — медленно.

Правило: offset > 1000 — проблема.

Cursor pagination

Что это: курсор по id.

$posts = Post::cursorPaginate(20);

Что генерирует:

SELECT * FROM posts
WHERE id > 200000
ORDER BY id
LIMIT 20;

Что даёт: O(log N) — быстро.

Правило: cursor — для больших коллекций.

Cursor: параметры

URL:

/posts?cursor=eyJpZCI6MjAwMDAwfQ

Что важно: курсор — base64 от ID.

Правило: нельзя ?page=2 с cursor.

Cursor: сортировка

Post::orderBy('created_at', 'desc')->cursorPaginate(20);

⚠️ Нужна колонка сортировки с индексом.

Правило: уникальная колонка или комбинация.

Cursor: с фильтрами

Post::where('category_id', $id)
    ->orderBy('id')
    ->cursorPaginate(20);

Что даёт: фильтр + cursor.

Правило: фильтр должен быть в индексе.

Бесконечная прокрутка

Frontend:

let cursor = null;

async function loadMore() {
    const url = cursor ? `/api/posts?cursor=${cursor}` : '/api/posts';
    const res = await fetch(url);
    const data = await res.json();

    render(data.data);
    cursor = data.next_cursor;
}

Что даёт: классический infinite scroll.

Правило: cursor — идеально для infinite.

Offset vs Cursor

offsetcursor
СкоростьO(N)O(log N)
Total countЕстьНет
Страницы в UIДаНет
Infinite scrollПлохоОтлично
Прыгать по страницамДаНет
СтабильностьДрейфСтабильно

Правило:

  • Админка — offset.
  • API — cursor.
  • Infinite — cursor.

Дрейф offset

Проблема: на page=2 вставили 5 записей → page=1 показал их дважды.

Что даёт: дубли в пагинации.

Cursor решает.

Правило: публичный API — cursor.

Total count

Проблема: COUNT(*) на большой таблице — медленно.

Что делать:

  • simplePaginate без total.
  • Кэш total на 5 минут.
  • Примерная оценка.

Правило: total — кэшировать или не считать.

Кэш total

$total = Cache::remember('posts:total', 300, fn () => Post::count());

Что даёт: COUNT раз в 5 мин.

Правило: total — кэш обязателен.

Limit в API

Правило:

$perPage = min($request->integer('per_page', 20), 100);

Что даёт: клиент не запросит 10 000.

Правило: максимум per_page.

Ключи в JSON

Формат ответа:

{
    "data": [...],
    "links": {
        "first": "...",
        "last": "...",
        "prev": null,
        "next": "..."
    },
    "meta": {
        "current_page": 1,
        "total": 1234,
        "per_page": 20
    }
}

Правило: стандарт Laravel — не ломать.

Ресурсы и пагинация

return PostResource::collection(Post::paginate(20));

Что даёт: paginated коллекция в ресурсе.

Правило: ресурсы работают с paginated.

Cursor + ресурсы

return PostResource::collection(Post::cursorPaginate(20));

Что даёт: cursor-структура в JSON.

Offset + фильтры

Проблема: фильтры раздувают COUNT.

Что делать:

  • simplePaginate.
  • Кэш с ключом по фильтру.
  • Cursor.

Правило: сложные фильтры — cursor.

Индексы для пагинации

Что нужно:

  • Колонка в orderBy.
  • Колонка в where.
  • Композитный индекс.

Пример:

CREATE INDEX idx_posts_category_id ON posts (category_id, id);

Правило: (filter_column, order_column) — композит.

Cursor по двум колонкам

Post::orderBy('created_at', 'desc')
    ->orderBy('id', 'desc')
    ->cursorPaginate(20);

Что даёт: стабильная сортировка.

Правило: если created_at не уникален — + id.

Laravel 11+ cursor

Что нового:

  • cursorPaginate — основной.
  • Cursor в JSON.
  • next_cursor стабильно.

Правило: использовать нативный cursor.

Пагинация в GraphQL

Если GraphQL:

  • Relay cursor-подход.
  • edges + pageInfo.

Правило: GraphQL — всегда cursor.

Практика

1. Замените paginate на simplePaginate где total не нужен.

2. Добавьте cursorPaginate в 3 API-роута.

3. Настройте per_page максимум.

4. Кэшируйте total.

5. Добавьте композитные индексы.

6. Проверьте дрейф offset.

7. Перейдите на cursor в infinite scroll.

8. Замерьте page=10000 offset vs cursor.

9. Обновите фронт под cursor.

10. Прогоните тесты.

Частые ошибки

1. paginate с COUNT на 10M:

Медленно

Решение: simplePaginate / кэш.

2. page=10000 offset:

Секунды

Решение: cursor.

3. per_page=100000:

OOM

Решение: min(100).

4. Cursor без orderBy:

Ошибка

Решение: явно сортировать.

5. Cursor по не уникальной:

Дрейф

Решение: + id.

6. Нет индекса под фильтр:

Seq Scan

Решение: композит.

7. total без кэша:

COUNT каждый раз

Решение: кэш.

8. Смена схемы в API:

Ломает клиентов

Решение: стандарт Laravel.

Итог

  • paginate — с total.
  • simplePaginate — без.
  • cursorPaginate — для больших.
  • Offset — O(N).
  • Cursor — O(log N).
  • Infinite — cursor.
  • Админка — offset.
  • Индексы — обязательны.
  • per_page — максимум.
  • Total — кэш.

Дальше — кэш на уровне БД: материализованные представления.