> Как устроена пагинация в Go проектах (Go)

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

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

Стек: Go

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

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

Пагинация в Go обычно реализуется через параметры запроса limit и offset или cursor-based подход. На уровне БД используется LIMIT/OFFSET в SQL или Find с Limit/Offset в GORM. Для API принято возвращать метаданные: total, page, per_page. В высоконагруженных системах предпочтителен cursor-based вариант, так как OFFSET деградирует на больших объёмах данных. Валидация параметров и дефолтные значения - обязательная часть реализации.

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

Существует два основных подхода к пагинации в Go-проектах:

Offset-based пагинация - классический вариант с page и per_page или limit и offset. Прост в реализации, позволяет переходить к произвольной странице. Минус - при больших значениях OFFSET база данных сканирует все пропущенные строки, что замедляет запрос. Подходит для небольших и средних объёмов данных.

Cursor-based пагинация - использует непрозрачный токен (обычно закодированный ID или timestamp последней записи). Запрос строится через WHERE id > $cursor ORDER BY id LIMIT $limit. Работает стабильно на любых объёмах, но не даёт перейти к произвольной странице. Часто применяется в realtime-системах, лентах, чатах.

В Go-проектах пагинация обычно реализуется в слое handler/repository. Типичная структура:

  • DTO с параметрами запроса (Limit, Offset, Cursor)
  • Валидация и установка дефолтов
  • Формирование запроса к БД
  • Ответ с данными и метаинформацией

Для GORM стандартный подход:

GO
db.Model(&User{}).Limit(limit).Offset(offset).Find(&users)

Для SQL-запросов через database/sql - подстановка параметров в LIMIT $1 OFFSET $2.

Важно учитывать: LIMIT и OFFSET принимают целые числа, поэтому параметры из query string нужно парсить через strconv.Atoi и проверять на отрицательные значения.

На практике

В реальных проектах пагинация редко ограничивается простым LIMIT/OFFSET. Часто добавляют:

  • сортировку (параметр sort с валидацией допустимых полей)
  • фильтрацию по датам, статусам
  • подсчёт общего количества записей через отдельный COUNT(*) запрос
  • защиту от слишком больших значений limit (например, максимум 100)
  • кэширование первых страниц

Для cursor-based подхода в Go часто используют base64-кодирование курсора, чтобы скрыть внутренний формат от клиента. Например:

GO
cursor := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%d:%s", id, timestamp)))

В REST API ответ обычно выглядит так:

JSON
{
"data": [...],
"pagination": {
"total": 100,
"page": 2,
"per_page": 20,
"has_more": true
}
}

Для cursor-based:

JSON
{
"data": [...],
"next_cursor": "abc123",
"has_more": true
}

Пример кода

Пример offset-based пагинации в Go с использованием net/http и GORM:

GO
type PaginationParams struct {
Page int
PerPage int
}
func parsePagination(r *http.Request) (PaginationParams, error) {
page, err := strconv.Atoi(r.URL.Query().Get("page"))
if err != nil || page < 1 {
page = 1
}
perPage, err := strconv.Atoi(r.URL.Query().Get("per_page"))
if err != nil || perPage < 1 {
perPage = 20
}
if perPage > 100 {
perPage = 100
}
return PaginationParams{Page: page, PerPage: perPage}, nil
}
func ListUsersHandler(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
params, _ := parsePagination(r)
offset := (params.Page - 1) * params.PerPage
var users []User
var total int64
db.Model(&User{}).Count(&total)
db.Order("id ASC").Limit(params.PerPage).Offset(offset).Find(&users)
response := map[string]interface{}{
"data": users,
"pagination": map[string]interface{}{
"total": total,
"page": params.Page,
"per_page": params.PerPage,
"has_more": offset+len(users) < int(total),
},
}
json.NewEncoder(w).Encode(response)
}
}

Пример cursor-based пагинации:

GO
func ListMessagesHandler(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cursor := r.URL.Query().Get("cursor")
limit := 50
query := db.Model(&Message{}).Order("id DESC").Limit(limit)
if cursor != "" {
decoded, _ := base64.StdEncoding.DecodeString(cursor)
cursorID, _ := strconv.Atoi(string(decoded))
query = query.Where("id < ?", cursorID)
}
var messages []Message
query.Find(&messages)
var nextCursor string
hasMore := len(messages) == limit
if hasMore {
lastID := messages[len(messages)-1].ID
nextCursor = base64.StdEncoding.EncodeToString([]byte(strconv.Itoa(lastID)))
}
json.NewEncoder(w).Encode(map[string]interface{}{
"data": messages,
"next_cursor": nextCursor,
"has_more": hasMore,
})
}
}

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

Начните с разграничения двух подходов: offset-based и cursor-based. Объясните, когда какой выбирать. Упомяните, что в Go нет встроенных средств пагинации - это всегда ручная работа с параметрами запроса и ORM/драйвером БД. Покажите понимание trade-off: offset прост, но медленный на больших данных; cursor быстрый, но не поддерживает произвольный доступ к страницам.

Расскажите про валидацию входных параметров, дефолтные значения, ограничение максимального limit. Упомяните, что в production-проектах часто добавляют подсчёт total через отдельный запрос, но на больших таблицах это может быть дорого - тогда используют приблизительные оценки или отказываются от total.

Если спросят про GraphQL - упомяните Relay-style connection с first/after, это тот же cursor-based подход. Если спросят про gRPC - обычно используют page token, аналогичный cursor.

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

Интервьюер оценивает:

  • понимание разницы между offset и cursor пагинацией
  • знание особенностей работы БД с OFFSET на больших объёмах
  • умение валидировать входные данные и устанавливать разумные ограничения
  • способность спроектировать ответ API с метаданными
  • понимание, когда пагинация не нужна (например, для маленьких справочников)
  • знание конкретных инструментов: GORM, sqlx, database/sql

Для senior-позиции важно показать, что вы думаете о производительности, консистентности данных (например, при вставке новых записей во время пагинации) и удобстве клиентского API.

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

  • не валидировать параметры - отрицательный limit или offset вызывает ошибку БД
  • не ограничивать максимальный limit - клиент может запросить миллион записей
  • использовать OFFSET для больших таблиц без осознания проблемы производительности
  • забывать про COUNT(*) запрос - без него клиент не знает общее количество страниц
  • возвращать next_cursor как сырой ID - лучше кодировать в base64
  • не учитывать сортировку при cursor-based пагинации - курсор должен быть связан с порядком сортировки
  • игнорировать гонки: при offset-based пагинации новые записи сдвигают страницы, при cursor-based - нет
  • парсить query параметры без обработки ошибок - strconv.Atoi вернёт ошибку на нечисловом значении

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

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