> Какие стратегии пагинации можно использовать вместо 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 работает по принципу "верни строки, которые идут после последней видимой". Для этого используется условие вида:
SQLWHERE (created_at, id) > ($last_created_at, $last_id)ORDER BY created_at, idLIMIT $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-бэкенде типичная реализация:
- Клиент передаёт
cursor(base64-encoded JSON или просто строку с последним id). - Сервер декодирует cursor, валидирует формат.
- Формирует SQL с
WHERE (sort_field, id) > ($sort_value, $id). - Возвращает
next_cursorв ответе, если строк больше, чем запрошено.
Важно: для keyset обязательно нужен составной индекс на (sort_field, id) или (filter_field, sort_field, id). Без индекса запрос будет деградировать.
Также стоит учитывать:
- Если сортировка по нескольким полям, все они должны быть в условии WHERE.
- Направление сортировки (ASC/DESC) меняет знак сравнения.
- Для обратной навигации ("предыдущая страница") нужен отдельный cursor или параметр
direction=prev. - При фильтрации по
user_idиндекс должен быть(user_id, sort_field, id).
Пример кода
GOtype 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 Cursorerr = 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 Cursorif cursor != "" {c, err = decodeCursor(cursor)if err != nil {return nil, "", err}}query := `SELECT id, title, created_atFROM itemsWHERE user_id = $1AND (created_at, id) > ($2, $3)ORDER BY created_at ASC, id ASCLIMIT $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 stringif 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 сортировке.
> Похожие задачи по Go
Зачем добавлять контекст в запросы к базе данных?
Что такое пагинация?
Что делать, если после добавления индекса селективность низкая и запрос остается медленным?
Приходилось ли менять уровни изоляции транзакций?
> Похожие задачи по backend
Зачем добавлять контекст в запросы к базе данных?
Что такое пагинация?
Что делать, если после добавления индекса селективность низкая и запрос остается медленным?
Приходилось ли менять уровни изоляции транзакций?
> ГОТОВЫ К СЛЕДУЮЩЕМУ СОБЕСЕДОВАНИЮ?
Запустите тренировочную сессию с ИИ и получите детальную обратную связь, чтобы увереннее проходить реальные интервью