> Как реализовать эффективную пагинацию с курсорами и индексами (Go)

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

Компании: sferaplatform.ru

Стек: Go

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

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

Эффективная пагинация с курсорами - это постраничная выдача, где вместо номера страницы клиент передаёт непрозрачный токен, кодирующий позицию последней записи предыдущей страницы. Вместе с правильно построенным составным индексом это даёт O(log n) на запрос и стабильный порядок при вставках. Ключевые элементы: составной индекс по полям сортировки, фильтрация по (sort_field, id) > (cursor_value, cursor_id), и кодирование курсора в base64 или аналогичном формате.

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

Классическая offset-пагинация (LIMIT 20 OFFSET 100000) деградирует на больших объёмах: база сканирует и отбрасывает все пропущенные строки. Курсорная пагинация решает это через позиционный доступ.

Основная идея: сервер возвращает клиенту токен, который однозначно идентифицирует точку, после которой нужно продолжить выборку. Следующий запрос использует этот токен в WHERE вместо OFFSET.

Требования к курсору:

  • стабильность - позиция не должна меняться при вставках/удалениях между запросами;
  • непрозрачность - клиент не должен уметь подделывать или интерпретировать токен;
  • полнота - токен должен содержать все поля, участвующие в сортировке.

Составной индекс строится по правилу: (sort_field_1, sort_field_2, ..., id). Поле id добавляется последним для уникальности, так как сортировочные поля могут повторяться.

Запрос выглядит так:

SQL
SELECT * FROM items
WHERE (created_at, id) > ($1, $2)
ORDER BY created_at, id
LIMIT $3

Для обратной сортировки - меняем оператор на < и ORDER BY DESC.

Курсор кодируется как структура {created_at, id} в base64. Сервер декодирует, валидирует и подставляет в запрос.

На практике

В Go на практике используют:

  • time.Time для временных меток - но осторожно с точностью: если две записи имеют одинаковый created_at, нужен id как tiebreaker;
  • для UUID - не используйте uuid.UUID напрямую в курсоре, лучше строковое представление;
  • курсор должен быть подписан (HMAC) или зашифрован, если критична защита от подделки;
  • для больших таблиц - индекс должен покрывать запрос (covering index), чтобы избежать чтения таблицы;
  • при сортировке по нескольким полям - все они должны быть в индексе в том же порядке.

Типичный middleware-паттерн: функция encodeCursor / decodeCursor в отдельном пакете, с unit-тестами на граничные случаи (пустой курсор, битый base64, устаревший курсор).

Пример кода

GO
type Cursor struct {
CreatedAt time.Time
ID int64
}
func EncodeCursor(c Cursor) string {
raw := fmt.Sprintf("%d:%d", c.CreatedAt.UnixNano(), c.ID)
return base64.URLEncoding.EncodeToString([]byte(raw))
}
func DecodeCursor(s string) (Cursor, error) {
raw, err := base64.URLEncoding.DecodeString(s)
if err != nil {
return Cursor{}, err
}
parts := strings.SplitN(string(raw), ":", 2)
if len(parts) != 2 {
return Cursor{}, errors.New("invalid cursor")
}
ts, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil {
return Cursor{}, err
}
id, err := strconv.ParseInt(parts[1], 10, 64)
if err != nil {
return Cursor{}, err
}
return Cursor{
CreatedAt: time.Unix(0, ts),
ID: id,
}, nil
}
func GetItems(ctx context.Context, db *sql.DB, cursorStr string, limit int) ([]Item, string, error) {
var cur Cursor
if cursorStr != "" {
cur, err = DecodeCursor(cursorStr)
if err != nil {
return nil, "", err
}
}
query := `
SELECT id, created_at, name
FROM items
WHERE (created_at, id) > ($1, $2)
ORDER BY created_at, id
LIMIT $3
`
rows, err := db.QueryContext(ctx, query, cur.CreatedAt, cur.ID, limit+1)
// ... обработка rows
items := make([]Item, 0, limit)
for rows.Next() {
// ... сканирование
}
var nextCursor string
if len(items) > limit {
items = items[:limit]
last := items[len(items)-1]
nextCursor = EncodeCursor(Cursor{CreatedAt: last.CreatedAt, ID: last.ID})
}
return items, nextCursor, nil
}

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

Начните с проблемы offset-пагинации: рост времени запроса, нестабильность при вставках. Затем переходите к решению: курсор как позиция, составной индекс, WHERE (col, id) > (val, id). Обязательно упомяните trade-off: курсор не позволяет перейти на произвольную страницу, только "вперёд/назад".

Покажите понимание деталей: почему id в конце индекса, как обрабатывать дубликаты в сортировочном поле, как кодировать курсор безопасно. Если спросят про обратную пагинацию - скажите, что нужен второй курсор для предыдущей страницы, либо двусторонний курсор.

Хорошо добавить: "Для сортировки по нескольким полям индекс строится по тем же полям в том же порядке, и курсор содержит все эти поля".

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

  • понимание, почему offset не масштабируется;
  • умение строить составные индексы под конкретный запрос;
  • знание edge cases: одинаковые значения в сортировочном поле, точность времени, удаление записей;
  • способность объяснить, как курсор влияет на API дизайн;
  • практический опыт с Go: работа с time.Time, base64, sql.DB.

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

  • использование OFFSET в сочетании с курсором - это бессмысленно;
  • сортировка только по created_at без id - при совпадении времени записи теряются или дублируются;
  • курсор без подписи - клиент может подделать и получить чужие данные;
  • забывают про LIMIT + 1 для определения наличия следующей страницы;
  • не учитывают часовые пояса: time.Time в курсоре должен быть в UTC;
  • строят индекс только по полю сортировки, без id - тогда при дубликатах порядок нестабилен.

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

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