Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 14 из 25 ~20 мин +50 XP

Пагинация: LIMIT/OFFSET и keyset

sqlc пагинация — тема, на которой слой данных превращается в настоящий API: список на тысячу задач нельзя отдавать одним куском, а спрашивать у базы «ещё двадцать, начиная с позиции триста сорок» — значит платить за чужой скроллинг. Механику двух фраз — LIMIT и OFFSET — седьмой урок дал целиком и честно остановился на границе: OFFSET при глубоком смещении заставляет базу отсчитывать и выбрасывать строки, а курсорная схема keyset строится на WHERE поверх последней видной строки. Обещание выполняем. Тринадцатый урок научил трекер отчётам и напутствовал: тысяча строк тоже хочет листаться. Четырнадцатый урок отвечает на шесть вопросов. Первый: что генерирует sqlc для запроса с LIMIT $1 OFFSET $2 — два параметра сворачиваются в Params-структуру, имена Limit и Offset приедут из позиции плейсхолдеров. Второй: почему страницы по позициям врут — вставки и удаления сдвигают окно, а глубокий OFFSET стоит пропорционально глубине. Третий: как устроен keyset — составной ключ из created_at и id и сравнение кортежей. Четвёртый: где вывод имён генератора ломается на кортеже — и как написать курсорный запрос так, чтобы сигнатура вышла чистой. Пятый: как упаковать курсор в base64-строку для HTTP-клиента. Шестой: когда общее число строк действительно нужно и как узнать «есть ли дальше» без COUNT; сравнение подходов сведено в таблицу под конец урока. Схема прежняя: todos с привычными колонками, журнал spendings листается по тем же правилам. Формы :one и :many — в [седьмом уроке](/courses/sqlc/lessons/07-select/); count и его типы — в [тринадцатом](/courses/sqlc/lessons/13-agregacii/); план остальных тем — на странице [курса sqlc](/courses/sqlc/).

Содержание урока · 13 разделов

К седьмому уроку трекер научился показывать первую страницу, и это выглядело концом истории: LIMIT ограничил, OFFSET пропустил, ORDER BY разложил. Настоящая история начинается, когда списком начинают пользоваться: пользователь листает второй экран, третий, десятый; между его касаниями приходят новые задачи, старые закрываются и удаляются; фронтенд просит курсор, а не номер страницы; продукт хочет знать, есть ли вообще продолжение. Каждое из этих требований ломает наивную пару LIMIT/OFFSET по-своему.

Четырнадцатый урок строит пагинацию, которая выдерживает живые данные. Сначала доводим OFFSET-версию до ума: Params-структура с Limit и Offset, честная цена глубоких страниц, пример сдвига окна на вставке. Потом строим keyset: курсор из значений последней строки, составной ключ, сравнение кортежей — и разбираем сюрприз, который приготовил генератор: на кортежном сравнении вывод имён даёт CreatedAt_2, а тип второго параметра делает метод неработоспособным. Лечение — развёрнутая форма: чистые имена, рабочая сигнатура. Дальше — упаковка курсора в base64 для HTTP, вопрос о COUNT, который тринадцатый урок уже подготовил, и паттерн limit+1, отвечающий на «есть ли дальше» одним запросом.

sqlc пагинация: два подхода к одному списку#

Ключ «sqlc пагинация» — это два разных контракта под одним словом «страница». Первый — страницы по позициям: LIMIT/OFFSET режут отсортированную выборку на равные куски, и кусок номер N — это строки с позиции N умножить на размер. База не помнит, что было «на прошлой странице»: каждый запрос пересчитывает список с нуля и отматывает смещение. Второй контракт — страницы по ключу: каждая страница помнит последнюю отданную строку и просит «строго после неё». Позиция — свойство нумерации, ключ — свойство самой строки; отсюда все различия в устойчивости, цене и внешнем виде API.

Файл для обоих подходов заводим по конвенции курса — одна тема выборок — один файл: db/queries/pagination.sql. Восьмой урок уже показывал TodoPage с LIMIT $1 OFFSET $2 на узкой выборке из четырёх колонок; здесь соберём полную картину: полная модель, Params-структура с двумя параметрами, проблемы позиций и альтернатива. Сразу полезно знать заранее: LIMIT и OFFSET — единственные две фразы SQL, для которых у sqlc есть зарезервированные имена параметров; всё остальное он выводит из сравнений с колонками, и именно это правило работает против нас в кортежном сравнении keyset.

Страница с параметрами: LIMIT $1 OFFSET $2

GO
-- db/queries/pagination.sql: страницы — новая тема выборок, новый файл

-- name: ListTodosPage :many
SELECT * FROM todos
WHERE deleted_at IS NULL
ORDER BY created_at DESC, id DESC
LIMIT $1 OFFSET $2;

// db/sqlc/pagination.sql.go — сгенерировано (pgx/v5, v1.27)
type ListTodosPageParams struct {
	Limit  int32 // плейсхолдер в LIMIT — зарезервированное имя limit
	Offset int32 // плейсхолдер в OFFSET — зарезервированное имя offset
}

func (q *Queries) ListTodosPage(ctx context.Context, arg ListTodosPageParams) ([]Todo, error)

Два параметра — значит Params-структура: правило восьмого урока о пороге в один плоский аргумент; там же мы видели имена Limit и Offset — sqlc резервирует их ровно для этих фраз, а тип integer превращается в int32. Порядок полей — порядок плейсхолдеров. ORDER BY собран из двух колонок: created_at DESC ставит свежее сверху, id DESC — тай-брейкер, который в седьмом уроке стоял по возрастанию; направления специально совпадают — ниже станет ясно, почему курсорному запросу это критично. SELECT * возвращает []Todo — полную модель: страницам карточек обычно нужны все поля. deleted_at IS NULL — дисциплина мягкого удаления, которую несёт каждый читающий запрос курса.

Сдвиг окна: OFFSET — позиция, а не ключ#

OFFSET — это не «начни с той задачи, которую я видел», а «пропусти столько-то строк отсортированного списка». Между страницами состав списка меняется — и окно уезжает. Пример с числами. Первая страница с LIMIT 3 показала задачи id 4, 3, 2 — created_at DESC ставит свежее сверху. Пока пользователь читал, кто-то создал задачу id 5. Список теперь 5, 4, 3, 2, 1; запрос OFFSET 3 LIMIT 3 пропускает 5, 4, 3 — и первым на второй странице показывает задачу id 2, замыкавшую первую. Строка продублируется в интерфейсе. Симметрично с удалением: закройте или удалите задачу id 4 между страницами — и задача id 2 проскочит мимо пользователя, вторая страница начнётся с id 1. Вставка сверху — сдвиг вниз и повторы; удаление — сдвиг вверх и пропуски.

Вторая проблема — цена. OFFSET 100000 заставляет базу построить и выбросить сто тысяч строк: они проходят сортировку, занимают память, считаются только ради позиции первой отдаваемой. Страница на такой глубине стоит как чтение ста тысяч строк плюс двадцать нужных. Листание от начала до конца платит сумму стоимостей всех страниц — счёт растёт квадратично с длиной списка, тогда как ключевая схема платит за страницу одинаково всегда. Третья проблема тише первых двух: две страницы — это два отдельных снимка базы в разные моменты времени; между ними не существует «окна», которое можно было бы назвать стабильным. Для админки с кнопками «страница 7» это терпимо; для ленты, в которой пользователь живёт, — нет.

Проверка: сдвиг окна на вставке

Первая страница (LIMIT 3, ORDER BY created_at DESC, id DESC) показала задачи id 4, 3, 2. Пока пользователь читал, создана задача id 5. Какую строку покажет первой запрос OFFSET 3 LIMIT 3?

keyset pagination: WHERE поверх последней строки#

Курсорная пагинация меняет вопрос. Вместо «пропусти триста сорок строк» — «отдай строки строго до вот этой, вот её ключ»: ключ — значения сортировочных колонок последней видимой строки, а WHERE превращает их в условие. Схема зовётся keyset pagination — «пагинация по набору ключей» — и известна ещё как seek method: база ищет начало следующей страницы, а не считает пропускаемое. Курсор — центральное понятие темы: значение, которое переживает сам запрос и путешествует между страницами; курсорная пагинация из плана курса — это она и есть.

Составной ключ возникает сразу: created_at у задач не уникален — две задачи, вставленные одной транзакцией, получают одно значение now(), ведь время фиксируется на старте транзакции. Разрез страницы посреди равных created_at должен опираться на вторую колонку — id. Условие «строго после» превращается в сравнение кортежей: (created_at, id) < ($1, $2) при ORDER BY created_at DESC, id DESC — порядок сортировки и направление сравнения обязаны смотреть в одну сторону, это единый контракт. Смешанные направления — излюбленная ошибка: DESC-сортировка с ASC-тай-брейкером требует зеркального сравнения во второй паре. В нашем запросе оба направления DESC — и сравнение симметрично. А сравнение подходов сведено в таблицу под конец урока; пока — как этот SQL выглядит для генератора.

Составной ключ: сравнение кортежей

GO
-- name: TodosAfterCursor :many
SELECT * FROM todos
WHERE deleted_at IS NULL
  AND (created_at, id) < ($1, $2) -- курсор: значения последней строки
ORDER BY created_at DESC, id DESC
LIMIT $3;

// сгенерировано (pgx/v5, v1.27) — и вот сюрприз
type TodosAfterCursorParams struct {
	CreatedAt   pgtype.Timestamptz // первый плейсхолдер: законно, created_at
	CreatedAt_2 pgtype.Timestamptz // второй плейсхолдер — тоже «created_at»!
	Limit       int32
}

Сравнение кортежей — документированная часть PostgreSQL: элементы сравниваются слева направо, до первой неравной пары; ROW(1,2,NULL) < ROW(1,3,0) — истина, потому что третья пара уже не рассматривается. Обе колонки ключа NOT NULL — NULL-сюрпризы кортежного сравнения нас не касаются. Но вывод имён генератор проваливает: резолвер сравнений ищет первую колонку в левой части выражения — левый кортеж начинается с created_at, и это имя с его типом получают оба плейсхолдера. Второму достаётся имя с суффиксом _2 — вам доводилось видеть такой суффикс у колонок, здесь он прилетел параметрам. Плохо не имя — тип. CreatedAt_2 имеет тип pgtype.Timestamptz, а в сравнении второй слот ждёт bigint: метод сгенерируется, скомпилируется и упадёт при первом вызове — pgx не сможет закодировать timestamptz там, где план запроса требует bigint. Именованные параметры не спасают: sqlc.arg внутри кортежа меняет только имя, тип всё равно приедет от колонки. Лечение — переформулировать сравнение так, чтобы у каждого плейсхолдера была своя колонка слева.

Развёрнутая форма: чистые имена, чистые типы#

Лечение не придумано, а выписано из той же документации: определение кортежного сравнения — скалярная формула created_at < $1 OR (created_at = $1 AND id < $2). Запишите её явно — и каждое сравнение станет отдельным, с собственной колонкой слева: оба вхождения $1 сравниваются с created_at и дают одно имя CreatedAt с типом pgtype.Timestamptz, $2 сравнивается с id и получает ID int64, $3 в LIMIT становится Limit int32. Правила вывода — те самые, что восьмой урок подтвердил на десятке запросов: имя и тип параметру даёт сравнение с колонкой, LIMIT резервирует своё имя. Семантика запроса не меняется ни на бит: это то же «строго после курсора», записанное без сокращений.

Здесь же живёт направление. ORDER BY created_at DESC, id DESC и сравнение «меньше» согласованы: свежее сверху, продолжение — строго меньший ключ. Если оставить тай-брейкер из седьмого урока — id по возрастанию — вторая пара сравнения перевернётся: created_at = $1 AND id > $2. Смешанные направления работают, но читаются хуже и ломаются чаще; единое направление дешевле в поддержке. Вывод урока: в sqlc пишите курсорное условие развёрнутой формой, а кортежную держите в голове как определение.

TodosAfterCursor: рабочая версия

GO
-- name: TodosAfterCursor :many
SELECT * FROM todos
WHERE deleted_at IS NULL
  AND (created_at < $1 OR (created_at = $1 AND id < $2))
ORDER BY created_at DESC, id DESC
LIMIT $3;

// сгенерировано (pgx/v5, v1.27)
type TodosAfterCursorParams struct {
	CreatedAt pgtype.Timestamptz // сравнение с колонкой created_at
	ID        int64              // сравнение с колонкой id
	Limit     int32              // плейсхолдер в LIMIT
}

func (q *Queries) TodosAfterCursor(ctx context.Context, arg TodosAfterCursorParams) ([]Todo, error)

Три параметра — Params-структура по правилам восьмого урока. Поле CreatedAt — без указателя и без обёртки: колонка NOT NULL, nullability параметра повторяет её. ID int64 — от bigint первичного ключа. Пустой ответ честный, как у всякого :many: лента кончилась — срез нулевой длины, ошибки нет, контракт седьмого урока. Обратите внимание на порядок полей: он следует порядку плейсхолдеров $1, $2, $3, а не порядку колонок ORDER BY — при чтении сгенерированной структуры сверяйтесь с SQL, а не с сортировкой.

Составной ключ: почему одного created_at мало#

Две задачи, вставленные одной транзакцией, получают одно время now(): оно фиксируется один раз на старте транзакции, и обе строки делят его. Режьте страницу посреди этой пары одной колонкой created_at — и вы в плену негарантированного порядка: база вправе вернуть равные строки как угодно, страница задублирует одну и потеряет другую. id спасает не потому, что «вторая колонка вообще полезна», а потому, что делает ключ уникальным: после (created_at, id) любые две строки сравнимы строго, и курсор разрезает список ровно в одном месте. Требование к тай-брейкеру одно — уникальность; направление — часть контракта с ORDER BY.

Курсор — это значения, а не ссылка на строку: строку с этим ключом удалят — курсор продолжит работать, условие «строго меньше» от найденного значения не зависит. Единственный способ потерять строки — переписать сортировочную колонку у ещё не показанных задач; created_at с DEFAULT now() никто не трогает, поэтому лента в порядке. И всё это хозяйство держится на индексе: составной индекс по (created_at, id) обслуживает и сортировку, и курсорное условие — планировщик ищет начало страницы по индексу, а не перебирает таблицу. Без него и OFFSET, и keyset платят сканом, просто keyset платит одинаково на любой глубине, а OFFSET — растуще. Направление DESC в определении индекса необязательно: PostgreSQL умеет читать B-tree задом наперёд. Индексы заслуживают отдельного разговора, но в этом курсе его нет — проверяйте планы через EXPLAIN на живой базе.

Проверка: зачем тай-брейкер

Две задачи вставлены одной транзакцией: обе получили created_at 10:00. Первая страница (LIMIT 4, ORDER BY created_at DESC) закончилась ровно между ними. Что сломается, если тай-брейкера id нет вовсе?

Предскажите вторую страницу

Прочитайте код и выберите, что он выведет:

-- живые задачи (created_at, id):
--   (10:03, 1), (10:02, 5), (10:01, 7), (10:01, 3)
-- первая страница: LIMIT 2 → (10:03, 1), (10:02, 5)

-- name: TodosAfterCursor :many
SELECT id, title FROM todos
WHERE deleted_at IS NULL
  AND (created_at < $1 OR (created_at = $1 AND id < $2))
ORDER BY created_at DESC, id DESC
LIMIT $3;

-- вызов: $1 = '10:02', $2 = 5, $3 = 2 — курсор второй строки

Первая страница: с чего начать курсор#

У первой страницы нет «последней видимой строки» — первое практическое затруднение keyset. Дороги две. Первая — отдельный запрос: TodosFirstPage с единственным LIMIT $1 и тем же ORDER BY; следующие страницы идут через курсор. Два метода вместо одного — небольшая плата, а контракт читается честно: «первый лист» и «продолжение». Вторая дорога — значение-бесконечность: PostgreSQL понимает 'infinity'::timestamptz, а максимум bigint известен — 9223372036854775807; подставьте их в первый вызов, условие «строго меньше» отберёт весь список, и LIMIT обрежет его до страницы. В pgtype.Timestamptz бесконечность выражается полем InfinityModifier — pgx умеет её кодировать и читать. Честное сравнение: бесконечность экономит один запрос, но добавляет в код магическую пару значений, которую каждый читатель обязан расшифровать; два запроса скучнее и надёжнее. Дальше в примерах — два запроса.

Попутно о направлении. Лента «свежее сверху» листается вниз сравнением «меньше»; лента «старее сверху» — зеркальным запросом с «больше» и ASC-парами. Кнопка «назад» — это курсор первой строки текущей страницы и то же зеркальное условие: если интерфейс позволяет шагать в обе стороны, храните оба курсора страницы — последний для «вперёд», первый для «назад».

Первая страница и продолжение

GO
-- db/queries/pagination.sql — продолжение файла

-- name: TodosFirstPage :many
SELECT * FROM todos
WHERE deleted_at IS NULL
ORDER BY created_at DESC, id DESC
LIMIT $1;

// main.go: цепочка «первый лист → курсор → продолжение»
first, err := queries.TodosFirstPage(ctx, 20)
if err != nil {
        return err
}

// курсор — из последней строки первой страницы
next, err := queries.TodosAfterCursor(ctx, TodosAfterCursorParams{
        CreatedAt: last.CreatedAt, // pgtype.Timestamptz из модели Todo
        ID:        last.ID,        // int64 из модели Todo
        Limit:     20,
})

TodosFirstPage приносит ровно один параметр — плоский аргумент int32, привилегия одиночки из восьмого урока. Продолжение собирает Params-структуру из последней строки: last.CreatedAt уже pgtype.Timestamptz — тип поля модели и тип параметра совпали, значение переезжает без приведения; last.ID — int64. Клиенту возвращают страницу вместе с курсором последней строки, и следующий запрос начинается строго после неё. Заметьте, как мало в этом коде специфики: тот же паттерн листает журнал spendings из одиннадцатого урока и любую другую таблицу с парой «время, ключ».

base64-курсор: упаковка ключа для клиента#

HTTP-клиент не должен знать, что лента сортируется по (created_at, id): это деталь слоя данных, а деталь, протёкшая в API, становится обязательством навсегда. Поэтому курсор упаковывают в одну непрозрачную строку: клиент присылает её как получил, сервер раскрывает. Внутри — пара значений и разделитель: UnixNano времени и id, склеенные через «|». base64 здесь — кодирование байтов в текст, безопасный для адресов: вариант URLEncoding из стандартной библиотеки заменяет символы «+» и «/», которые в URL живут своей жизнью, на «-» и «_»; паддинг «=» остаётся. Курсор едет в query-параметре или теле запроса, не боясь экранирования.

Две честные оговорки. Кодирование — не шифрование: любой клиент раскодирует base64 и увидит id и наносекунды — не кладите в курсор ничего, что жаль показать; нужна защита — добавляйте подпись, а не надежду. И валидация: курсор приходит снаружи, значит он может быть чем угодно — DecodeString отвалится на чужой строке, разбор формата отвалится на распавшемся payload, и хендлер обязан ответить вежливым 400, а не паникой. Старые курсоры умирают при смене схемы сортировки: если лента живёт долго, вшейте версию payload и научитесь отказывать устаревшим курсорам.

encodeCursor и decodeCursor

GO
// encodeCursor прячет ключ пагинации в непрозрачную строку
func encodeCursor(t time.Time, id int64) string {
        payload := fmt.Sprintf("%d|%d", t.UnixNano(), id)
        return base64.URLEncoding.EncodeToString([]byte(payload))
}

// decodeCursor возвращает ключ обратно; чужая строка — ошибка
func decodeCursor(s string) (time.Time, int64, error) {
        raw, err := base64.URLEncoding.DecodeString(s)
        if err != nil {
                return time.Time{}, 0, err
        }
        parts := strings.SplitN(string(raw), "|", 2)
        if len(parts) != 2 {
                return time.Time{}, 0, errors.New("неверный формат курсора")
        }
        nano, err := strconv.ParseInt(parts[0], 10, 64)
        if err != nil {
                return time.Time{}, 0, err
        }
        id, err := strconv.ParseInt(parts[1], 10, 64)
        if err != nil {
                return time.Time{}, 0, err
        }
        return time.Unix(0, nano), id, nil
}

Пара функций — вся упаковка. encodeCursor склеивает payload и возвращает base64-строку; decodeCursor идёт обратной дорогой: DecodeString, проверка формата, два ParseInt, time.Unix(0, nano). UnixNano даёт int64, обратимый без потерь. Ошибка на каждом шаге — не пессимизм, а контракт: снаружи приходит произвольная строка, и единственный честный ответ — вернуть ошибку, которую хендлер отмапит в 400. Формат «%d|%d» приватный: он живёт в одном файле и может меняться вместе с версией payload.

Соберите упаковщик курсора

Шесть строк: сигнатура, payload из наносекунд и id, base64-кодирование с URL-безопасным вариантом.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Проверка: чужой курсор

Клиент отправил курсор, в котором вручную заменил один символ base64-алфавита. Что произойдёт при обработке?

COUNT для общего числа: когда он вообще нужен#

Тринадцатый урок отдал count особый статус: единственный агрегат без NULL, ноль на пустом наборе, честный int64. Осталось ответить на вопрос, который пагинация ставит ребром: а нужен ли «1–20 из 4 312» вообще? Общий счётчик — это отдельный запрос count(*) с тем же WHERE, что у страницы, на каждую отрисовку списка — либо на первую страницу с кэшем и честной пометкой «приблизительно». На большой таблице точный count платит сканом в момент, когда его, возможно, никто не читает. Бесконечная лента от слова «сколько всего» отказывается сама: пользователю важен следующий экран, а не знаменатель. Для keyset нумерация страниц не существует по построению — от неё сознательно уходят.

Вопрос, который действительно нужно отвечать, — «есть ли продолжение». Ответ дешевле счётчика: запросите на одну строку больше, чем показываете. Пришло limit+1 — продолжение есть, лишнюю строку отрезаем, и клиент её не увидит; пришло меньше — лента кончилась, кнопка «ещё» уходит. Один запрос вместо двух: ни отдельного count, ни синхронизации двух чисел, которые любят расходиться. Если продукт требует знаменатель — считайте на первой странице, кэшируйте и не обновляйте на следующих: точность здесь иллюзорна с момента, как база изменилась после отрисовки.

hasMore: страница длиной limit+1

GO
// pageSize int32 — размер страницы; просим на одну строку больше:
// лишняя строка — сигнал «есть ли продолжение»
params := ListTodosPageParams{Limit: pageSize + 1, Offset: 0}
rows, err := queries.ListTodosPage(ctx, arg)
if err != nil {
        return err
}

hasMore := len(rows) > int(pageSize)
if hasMore {
        rows = rows[:pageSize] // лишнюю строку клиент не увидит
}

Паттерн limit+1 — вся «математика» урока: len(rows) сравнивается с размером страницы, лишняя строка срезается. Запрос тот же ListTodosPage — параметр Limit просто на единицу больше желаемого. Обратите внимание на порядок: hasMore вычисляется до среза, иначе сигнал потеряется. Для keyset трюк работает так же — лишняя строка после курсора говорит «есть ещё».

Предскажите вывод

Прочитайте код и выберите, что он выведет:

// страница запрошена длиной pageSize+1, из базы пришло 4 строки
rows := []string{"Аня", "Боря", "Вера", "Гоша"}
pageSize := 3

hasMore := len(rows) > pageSize
if hasMore {
        rows = rows[:pageSize]
}
fmt.Println(len(rows), hasMore)

Сравнение подходов: OFFSET против keyset#

КритерийLIMIT/OFFSETkeyset
Контрактпозиция: «пропусти N строк»ключ: «строго после курсора»
Вставки между страницамиокно сдвигается — повторыстабильно: курсор — значения, не позиции
Удаления между страницамиокно сдвигается — пропускистабильно
Глубокие страницыбаза строит и выбрасывает N строкначало страницы ищется по индексу
Переход на страницу Nда, прямой: OFFSET Nнет: только по цепочке курсоров
«Всего N строк»COUNT на каждую или первую страницуне существует; есть hasMore через limit+1
Курсор в APIне нужен: номера страницнепрозрачная строка (base64)
Где уместенадминки, отчёты, малые объёмыленты, бесконечный скролл, публичные API

Восстановите курсорный запрос

Четыре пропуска: таблица, дисциплина мягкого удаления, вторая колонка составного ключа, направление тай-брейкера.

-- name: TodosAfterCursor :many SELECT * todos WHERE IS NULL AND (created_at < $1 OR (created_at = $1 AND < $2)) ORDER BY created_at DESC, id LIMIT $3;

Карточки: пагинация

Термин

LIMIT/OFFSET в sqlc

нажмите, чтобы перевернуть
Определение

Два параметра сворачиваются в Params-структуру ListTodosPageParams: поля Limit и Offset, тип int32. У LIMIT и OFFSET зарезервированные имена — единственные две фразы SQL со своими именами параметров. Страницы надёжны только в паре с ORDER BY по уникальному ключу.

1 / 6

Что дальше#

Пагинация встала на место: LIMIT/OFFSET с Params-структурой Limit и Offset — быстрый инструмент с честными границами; keyset с составным ключом (created_at, id) — контракт, устойчивый к живым данным; развёрнутая форма курсорного условия против кортежной — разница между неработающей сигнатурой и чистой; base64-курсор прячет ключ от клиента; COUNT оказался вопросом продукта, а не техники, а «есть ли дальше» отвечает limit+1. Трекер отдаёт страницы, которые не врут.

Страница, впрочем, редко приходит без условий: пользователь выбрал приоритет, срок, исполнителя — и каждый выбор появляется или исчезает в WHERE. Пятнадцатый урок «Динамические запросы: подходы» разберёт условные фильтры: как собирать WHERE, части которого опциональны, и где упирается в границы генератор. Механику LIMIT/OFFSET и контракт :many можно освежить в седьмом уроке; count и агрегаты — в тринадцатом; план остальных тем — на странице курса sqlc.

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

Коротко: что запомнить

  • LIMIT $1 OFFSET $2 сворачиваются в Params-структуру с полями Limit и Offset (int32): у LIMIT и OFFSET зарезервированные имена параметров, тип integer даёт int32; страницы надёжны только поверх ORDER BY с уникальным тай-брейкером.
  • OFFSET — позиция, а не ключ: вставки сдвигают окно вниз и дают повторы, удаления — вверх и дают пропуски; глубокая страница стоит построения и выбрасывания N строк; каждая страница — отдельный снимок базы.
  • keyset pagination листает по ключу: курсор — значения сортировочных колонок последней строки; составной ключ (created_at, id) делает порядок детерминированным; ORDER BY и сравнение обязаны смотреть в одну сторону; синоним — seek method.
  • Кортежное сравнение (created_at, id) < ($1, $2) генератор называет и типирует по первой колонке левого кортежа: CreatedAt и CreatedAt_2, а второй слот сравнения ждёт bigint — вызов падает; рабочая форма развёрнута: created_at < $1 OR (created_at = $1 AND id < $2).
  • Первая страница — отдельным запросом с LIMIT $1 или значением-бесконечностью ('infinity'::timestamptz, максимум bigint); курсор — значения, а не ссылка на строку: удаление курсорной строки ничего не ломает.
  • base64-курсор: URLEncoding безопасен в URL; кодирование — не шифрование; декод с валидацией отвечает 400; смена схемы сортировки убивает старые курсоры.
  • COUNT нужен, когда продукт требует «всего N» — и то лучше на первой странице с кэшем; «есть ли дальше» дешевле узнать паттерном limit+1: пришла лишняя строка — продолжение есть, hasMore считается до среза.

Домашнее задание

Проведите трекер через пагинацию. Первое: соберите db/queries/pagination.sql с ListTodosPage (LIMIT $1 OFFSET $2, сортировка created_at DESC, id DESC), прогоните sqlc generate и сверьте ListTodosPageParams поле за полем. Второе: воспроизведите сдвиг окна на живой базе — прочитайте первую страницу, вставьте новую задачу, запросите вторую и найдите повторившуюся строку; опишите своими словами, почему keyset от этого защищён. Третье: напишите TodosAfterCursor кортежем (created_at, id) < ($1, $2), сгенерируйте и найдите CreatedAt_2 в Params-структуре; объясните, какой тип ждёт второй слот сравнения; перепишите развёрнутой формой и сверьте чистую сигнатуру. Четвёртое: соберите TodosFirstPage и склейте в Go цепочку «первая страница → курсор → продолжение» на данных, где у двух задач совпадает created_at, и убедитесь, что страница разрезается ровно один раз. Пятое: реализуйте encodeCursor и decodeCursor, прогоните round-trip — закодируйте time.Unix(0, 5) с id 42, раскодируйте и проверьте равенство; затем подмените символ в курсоре и опишите, на каком шаге приедет ошибка. Шестое: добавьте hasMore через limit+1 к ListTodosPage и к TodosAfterCursor; на последней странице убедитесь, что лишней строки в ответе нет и hasMore ложен. Седьмое: соберите CountTodos с тем же WHERE, что у страницы, замерьте его время на большой таблице против стоимости первой страницы и решите для своего продукта, нужен ли знаменатель. Восьмое: создайте индекс CREATE INDEX ON todos (created_at, id) и сравните EXPLAIN курсорного запроса до и после.

Личные заметкиПусто
Хранятся только в вашем браузере (sqlc · Пагинация: LIMIT/OFFSET и keyset)

Вопросы и ответы по уроку

Почему у Limit и Offset в Params-структуре тип int32, а не int64?

Эти два параметра — особые: sqlc видит плейсхолдер во фразе LIMIT и называет его limit с типом integer, а integer в его таблице отображений — int32. То же с offset. Для размеров страниц и смещений int32 хватает с большим запасом; если по какой-то причине нужен другой тип, работает кастомизация из четвёртого урока, но на практике граница int32 для лимитов страниц недостижима.

Можно ли вернуться на предыдущую страницу в keyset?

Да — зеркальным запросом. Для ленты «свежее сверху» шаг назад означает «строго после первой строки текущей страницы» по возрастанию: условие с «больше» и ASC-парами. Хранить нужно оба курсора страницы — последний для «вперёд», первый для «назад». Альтернатива — стек курсоров на клиенте: каждый лист помнит курсор предыдущего, и «назад» — это просто возвращение к нему.

OFFSET или keyset — что выбрать?

Зависит от интерфейса. Админкам с номерами страниц и переходом «на страницу 7» OFFSET достаётся бесплатно и на малых объёмах работает честно. Лентам, бесконечному скроллу и публичным API нужен keyset: стабильность при живых данных, одинаковая цена любой страницы и непрозрачный курсор вместо протекающих номеров. Сравнение подходов — таблицей в этом уроке; в одном продукте спокойно живут оба.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Сравните с альтернативами

Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.

Следующая ступень учебного пути

ent25 уроков~13 чпрактика25 уроков уже доступны

ent: графовая ORM с кодогенерацией

Курс ent: схемы, поля, связи, запросы, хуки, privacy, миграции и GraphQL. 25 уроков с примерами и проектом микроблога.

  • Проектировать схемы и связи в ent
  • Генерировать код и жить с кодогенерацией
  • Писать запросы builder-стилем с типами
  • Строить хуки, интерсепторы и privacy-правила