> Какие стратегии пагинации можно использовать вместо LIMIT OFFSET? (Go)

Уровень: senior · Роль: backend · Язык: Go · Категория: Технические вопросы

Компании: InDrive

Стек: Go

> Пример ответа

Короткий ответ

Основные альтернативы LIMIT OFFSET: keyset pagination (cursor-based), seek method, и pagination на основе индексов с фильтрацией по составному ключу. Keyset - самый надёжный для больших объёмов, так как использует стабильное условие WHERE по уникальному полю, не сканируя пропущенные строки. Также возможна pagination по времени (created_at) с дополнительным tie-breaker. Для Go-бэкенда часто применяют cursor в виде закодированного значения (base64) или отдельного параметра after_id. Выбор зависит от требований к стабильности порядка и возможности произвольного перехода по страницам.

Подробное объяснение

LIMIT OFFSET имеет фундаментальные проблемы: при больших OFFSET база данных всё равно сканирует и отбрасывает строки, что приводит к росту latency и нагрузки. Кроме того, при параллельной вставке/удалении записей страницы "съезжают" - пользователь видит дубликаты или пропуски.

Keyset pagination работает по принципу "верни строки, которые идут после последней видимой". Для этого используется условие вида:

SQL
WHERE (created_at, id) > ($last_created_at, $last_id)
ORDER BY created_at, id
LIMIT $page_size

Это позволяет использовать составной индекс, и запрос выполняется за O(log N + page_size) вместо O(offset + page_size).

Варианты keyset:

  • по автоинкрементному id (если порядок по id устраивает)
  • по составному ключу (например, (user_id, created_at)) для фильтрации по владельцу
  • по времени с tie-breaker на id, чтобы избежать нестабильности при одинаковых timestamp

Seek method - это обобщение keyset, когда клиент передаёт значение последнего элемента по каждому полю сортировки. В Go обычно это делается через query-параметры: ?after_id=123 или ?cursor=base64.

Есть также page-based с использованием WHERE id > $last_id - это частный случай keyset, но без сортировки по нескольким полям.

Для случаев, когда нужен произвольный доступ к странице (например, "перейти на страницу 5"), keyset не подходит - тогда остаётся offset, но с ограничениями (например, максимум 10000 строк) или используется поиск по индексу с предварительным подсчётом.

На практике

В Go-бэкенде типичная реализация:

  1. Клиент передаёт cursor (base64-encoded JSON или просто строку с последним id).
  2. Сервер декодирует cursor, валидирует формат.
  3. Формирует SQL с WHERE (sort_field, id) > ($sort_value, $id).
  4. Возвращает next_cursor в ответе, если строк больше, чем запрошено.

Важно: для keyset обязательно нужен составной индекс на (sort_field, id) или (filter_field, sort_field, id). Без индекса запрос будет деградировать.

Также стоит учитывать:

  • Если сортировка по нескольким полям, все они должны быть в условии WHERE.
  • Направление сортировки (ASC/DESC) меняет знак сравнения.
  • Для обратной навигации ("предыдущая страница") нужен отдельный cursor или параметр direction=prev.
  • При фильтрации по user_id индекс должен быть (user_id, sort_field, id).

Пример кода

GO
type Cursor struct {
SortValue time.Time `json:"sv"`
ID int64 `json:"id"`
}
func encodeCursor(c Cursor) string {
data, _ := json.Marshal(c)
return base64.URLEncoding.EncodeToString(data)
}
func decodeCursor(s string) (Cursor, error) {
data, err := base64.URLEncoding.DecodeString(s)
if err != nil {
return Cursor{}, err
}
var c Cursor
err = json.Unmarshal(data, &c)
return c, err
}
func ListItems(ctx context.Context, q *sql.DB, userID int64, cursor string, limit int) ([]Item, string, error) {
var c Cursor
if cursor != "" {
c, err = decodeCursor(cursor)
if err != nil {
return nil, "", err
}
}
query := `
SELECT id, title, created_at
FROM items
WHERE user_id = $1
AND (created_at, id) > ($2, $3)
ORDER BY created_at ASC, id ASC
LIMIT $4`
rows, err := q.QueryContext(ctx, query, userID, c.SortValue, c.ID, limit+1)
// ...
items := make([]Item, 0, limit)
for rows.Next() {
var it Item
// scan...
items = append(items, it)
}
var nextCursor string
if len(items) > limit {
items = items[:limit]
last := items[len(items)-1]
nextCursor = encodeCursor(Cursor{SortValue: last.CreatedAt, ID: last.ID})
}
return items, nextCursor, nil
}

Как отвечать на собеседовании

Начните с краткого упоминания проблемы OFFSET (сканирование пропущенных строк, нестабильность при изменениях). Затем перечислите основные альтернативы: keyset, seek, time-based. Объясните принцип keyset на примере условия (created_at, id) > (?, ?). Подчеркните необходимость составного индекса. Упомяните trade-off: нет произвольного перехода по страницам, но стабильность и производительность. Если спросят про Go - покажите, как кодировать cursor в base64 и передавать в query-параметре. Для senior-уровня добавьте: как обрабатывать обратную навигацию, фильтры, сортировку по нескольким полям, и когда всё же стоит оставить offset (например, для админок с малым объёмом данных).

Что проверяет интервьюер

  • Понимание внутренней работы LIMIT OFFSET и его ограничений.
  • Умение проектировать эффективные запросы с учётом индексов.
  • Знание практических деталей: составные ключи, tie-breaker, направление сортировки.
  • Способность объяснить trade-off между простотой и производительностью.
  • Для Go - умение реализовать cursor-based пагинацию на уровне API (кодирование, валидация, обработка граничных случаев).

Типичные ошибки

  • Предложение keyset без упоминания необходимости индекса - сразу видно отсутствие опыта.
  • Использование только id для сортировки, если порядок по времени - тогда теряется смысл.
  • Игнорирование проблемы одинаковых значений в поле сортировки (без tie-breaker на id).
  • Утверждение, что keyset полностью заменяет offset - на самом деле нет, если нужен произвольный доступ.
  • В Go - невалидация cursor, отсутствие обработки ошибок декодирования, утечка внутренних данных в cursor (например, не стоит класть туда чувствительные поля).
  • Путаница в направлении сравнения при DESC сортировке.

> ГОТОВЫ К СЛЕДУЮЩЕМУ СОБЕСЕДОВАНИЮ?

Запустите тренировочную сессию с ИИ и получите детальную обратную связь, чтобы увереннее проходить реальные интервью