> Как реализовать эффективную пагинацию с курсорами и индексами (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 добавляется последним для уникальности, так как сортировочные поля могут повторяться.
Запрос выглядит так:
SQLSELECT * FROM itemsWHERE (created_at, id) > ($1, $2)ORDER BY created_at, idLIMIT $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, устаревший курсор).
Пример кода
GOtype Cursor struct {CreatedAt time.TimeID 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 Cursorif cursorStr != "" {cur, err = DecodeCursor(cursorStr)if err != nil {return nil, "", err}}query := `SELECT id, created_at, nameFROM itemsWHERE (created_at, id) > ($1, $2)ORDER BY created_at, idLIMIT $3`rows, err := db.QueryContext(ctx, query, cur.CreatedAt, cur.ID, limit+1)// ... обработка rowsitems := make([]Item, 0, limit)for rows.Next() {// ... сканирование}var nextCursor stringif 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- тогда при дубликатах порядок нестабилен.
> Похожие задачи по backend
Как гарантировать, что заказ не будет выполнен без исполнителя после резерва товара
Напиши функцию на Go, которая принимает слайс каналов int и возвращает канал с суммами чисел из каждого канала
Как устроена пагинация в Go проектах
Как бороться с большими объемами данных в запросах, например шардирование
> ГОТОВЫ К СЛЕДУЮЩЕМУ СОБЕСЕДОВАНИЮ?
Запустите тренировочную сессию с ИИ и получите детальную обратную связь, чтобы увереннее проходить реальные интервью