Представьте запуск: внутри командного мессенджера появилась личная библиотека заметок — заголовок, тело, ярлыки вроде «работа» и «отпуск», флажок закрепить сверху, поиск по своему архиву. Фронт просит страницу списка с фильтрами и подгрузку следующей порции — классический crud postgres go-случай. Такой pgx проект собирается из курса целиком: пул из четвёртого урока, Exec и Query из пятого, CollectRows из седьмого, типы — uuid, text[], timestamptz — из десятого и двенадцатого, обёртка транзакции из четырнадцатого, дедлайны из пятнадцатого, PgError из шестнадцатого и семнадцатого. Нового в уроке мало, и это честно: на финале проверяется не знание свежих функций, а умение складывать знакомые в сервис, который не разваливается на границах слоёв. Порядок такой: схема и миграция, слои проекта, List с фильтрами и keyset, перевод ошибок в язык API, транзакция pinned, таймауты и выключение.
Схема: заметки, теги и два индекса#
Схема живёт в миграции 0001 — версионирование базы из двадцать первого урока, goose или golang-migrate, для проекта разницы нет. Колонки: id uuid с DEFAULT gen_random_uuid() — функция встроена в Postgres с тринадцатой версии; user_id uuid NOT NULL с внешним ключом на users — именно он поставит 23503, когда клиент пришлёт несуществующего пользователя; title и body — обычный text с NOT NULL и дефолтом у тела; tags text[] NOT NULL DEFAULT '{}' — короткий список ярлыков, который читается и пишется целиком вместе со строкой, как в двенадцатом уроке; pinned boolean NOT NULL DEFAULT false; updated_at timestamptz NOT NULL DEFAULT now() — каждая правка несёт updated_at = now() прямо в UPDATE, триггер здесь не обязателен. Индекса два. Btree по (user_id, updated_at DESC, id DESC) обслуживает и страницу списка, и keyset-условие — все три колонки запроса стоят в порядке индекса. GIN по tags разгружает пересечение массивов. Индекс pg_trgm для поиска оставлен в миграции закомментированным: включать его нужно осознанно, о цене разговора будет ниже.
Миграция 0001: таблица и индексы
GO-- 0001_notes.up.sql
CREATE TABLE notes (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title text NOT NULL,
body text NOT NULL DEFAULT '',
tags text[] NOT NULL DEFAULT '{}',
pinned boolean NOT NULL DEFAULT false,
updated_at timestamptz NOT NULL DEFAULT now()
);
-- страница списка и keyset: пользователь, свежее сверху, тай-брейкер
CREATE INDEX notes_user_updated_idx
ON notes (user_id, updated_at DESC, id DESC);
-- пересечение тегов
CREATE INDEX notes_tags_idx ON notes USING gin (tags);
-- включать, когда поиск стал горячим путём:
-- CREATE INDEX notes_title_trgm_idx ON notes USING gin (title gin_trgm_ops);
-- вниз: 0001_notes.down.sql — DROP TABLE notes;Миграция создаёт таблицу и два индекса одним файлом: btree по (user_id, updated_at DESC, id DESC) под страницу и keyset, GIN по tags под пересечение массивов. gen_random_uuid() генерирует id на сервере, поэтому INSERT не требует uuid.New() в Go. pg_trgm-индекс оставлен закомментированным: решение о нём принимается по объёму данных, а не по привычке.
Слои: models, repo, service#
Структура сервиса — три файла в internal/notes и один в cmd. models.go держит структуру Note: ID и UserID типа uuid.UUID из github.com/google/uuid, Title и Body string, Tags []string, Pinned bool, UpdatedAt time.Time. pgx v5 переваривает google/uuid напрямую: под типом лежит [16]byte, а драйвер понимает типы-надстройки над массивом байт — механику pgtype разобрали в десятом уроке. repo.go — структура с *pgxpool.Pool и методами Create, List, Update, TogglePinned; репозиторий не знает про HTTP и принимает доменные параметры. service.go — правила: собирать like-паттерн, нормализовать теги, транслировать ошибки; хендлеры в main.go только переводят query-параметры и JSON в вызовы сервиса. N+1 в выводе тегов не случится по построению: теги едут в массиве внутри строки, одна заметка — одна строка результата, и CollectRows из седьмого урока собирает срез без второго запроса. Отдельная таблица note_tags дала бы JOIN или агрегацию — для коротких личных ярлыков это цена без пользы.
List: фильтры, теги и keyset#
List собирает три фильтра и курсор. Теги: выбран один ярлык — условие вида ярлык = ANY(tags) из двенадцатого урока; несколько — пересечение tags && $2::text[], заметка подходит, если делит с фильтром хоть один тег. В коде ниже живёт универсальная ветка ($2::text[] = '{}' OR tags && $2::text[]): пустой массив отключает фильтр, не меняя текст запроса. Ловушка ровно одна: nil-срез из Go уезжает SQL NULL, а NULL при сравнении с '{}' даёт не истину и не ложь, а NULL — вся OR-ветка молча рассыпается; поэтому репозиторий нормализует nil в []string{} до запроса. Поиск: title ILIKE $3 OR body ILIKE $3, паттерн собирается в сервисе как процент-подстрока. Честно про производительность: подстрока в процентах не оставляет btree шансов, без pg_trgm это последовательный скан по строкам пользователя — на десятках и сотнях заметок он дешевле любого индекса, а GIN по gin_trgm_ops включайте, когда заметок десятки тысяч и поиск стал горячим путём. Пагинация двойная: первые страницы ходят LIMIT/OFFSET-ом, кнопка «показать ещё» — keyset по кортежу (updated_at, id) последней строки; развёрнутая пара сравнений вместо row-конструктора держит план прозрачным, а тай-брейкер id обязателен: updated_at не уникален, без него состав страниц плывёт.
List в репозитории: один запрос на страницу
GOtype ListCursor struct {
UpdatedAt time.Time
ID uuid.UUID
}
type ListParams struct {
UserID uuid.UUID
Tags []string // nil трактуем как «без фильтра»
Query string // подстрока для ILIKE
Cursor ListCursor
Limit int
}
func (r *Repo) List(ctx context.Context, p ListParams) ([]Note, bool, error) {
if p.Tags == nil {
p.Tags = []string{} // nil уехал бы NULL и сломал OR-ветку
}
if p.Limit <= 0 || p.Limit > 100 {
p.Limit = 20
}
like := "%" + p.Query + "%"
firstPage := p.Cursor.ID == uuid.Nil
rows, err := r.pool.Query(ctx, `
SELECT id, user_id, title, body, tags, pinned, updated_at
FROM notes
WHERE user_id = $1
AND ($2::text[] = '{}' OR tags && $2::text[])
AND (title ILIKE $3 OR body ILIKE $3)
AND ($4::bool OR updated_at < $5 OR (updated_at = $5 AND id < $6))
ORDER BY updated_at DESC, id DESC
LIMIT $7`,
p.UserID, p.Tags, like, firstPage,
p.Cursor.UpdatedAt, p.Cursor.ID, p.Limit+1)
if err != nil {
return nil, false, fmt.Errorf("notes list: %w", err)
}
notes, err := pgx.CollectRows(rows, pgx.RowToStructByName[Note])
if err != nil {
return nil, false, fmt.Errorf("collect notes: %w", err)
}
hasMore := len(notes) > p.Limit
if hasMore {
notes = notes[:p.Limit]
}
return notes, hasMore, nil
}Нормализация nil в пустой срез спасает OR-ветку: NULL вместо пустого массива отключил бы теги молча. like-паттерн из процента и подстроки при пустом запросе совпадает со всеми строками. Флаг $4 снимает keyset на первой странице, дальше работает пара сравнений по индексу (user_id, updated_at DESC, id DESC). LIMIT с единицей запаса даёт строку-маркер: hasMore — превышение длины, хвост срезается. CollectRows закрывает курсор сам.
Страница «все заметки» начала отвечать за двести миллисекунд после десятка тысяч документов. Профиль показал: рядом с SELECT страницы каждое листание гоняло SELECT count(*) с теми же фильтрами ради надписи «всего N заметок» — и счётчик заново читал все совпавшие строки. Если счётчик нужен на каждой странице, то он дублирует самую дорогую часть запроса и растёт вместе с выборкой. Замена: has_more через LIMIT+1 — признак продолжения вместо числа; «всего N» переехало на отдельный редкий эндпоинт с кэшем на несколько секунд. Если бизнес требует точного числа в каждом листании, то это осознанное решение, и за него платят по счётчику трафика.
Проверка: следующая страница keyset
Последняя заметка на экране: updated_at = 2025-05-01 10:00:00+00, id = 9f1c-... Порядок выдачи — ORDER BY updated_at DESC, id DESC. Какое условие в WHERE возьмёт следующую страницу?
Что напечатает подсчёт has_more?
Прочитайте код и выберите, что он выведет:
// в выборке пользователя 6 заметок, все старее курсора
const perPage = 5
notes, hasMore, err := repo.List(ctx, notes.ListParams{
UserID: uid, Cursor: cur, Limit: perPage,
})
// List делает LIMIT perPage + 1 и срезает хвост
fmt.Println(len(notes), hasMore)Ошибки базы в язык API#
Ошибки базы становятся ответами API в одном месте — функция translate в service.go. Шаг первый: errors.As распаковывает *pgconn.PgError, механика шестнадцатого урока; если As вернул false, это дорога, а не сервер, и наружу идёт 503 с ретраем на чтении. Код 23505 unique_violation: заголовок заметки уникален в рамках пользователя, констрейнт UNIQUE (user_id, title); ответ 409 и подсветка поля заголовка. Код 23503 foreign_key_violation: user_id, которого нет в users; ответ 422 с текстом «пользователь не найден» — валидация на границе всё равно стоит, а FK страхует гонку между проверкой и вставкой. pgx.ErrNoRows из QueryRow обновления — 404. Контекстные ошибки различаются errors.Is по context.DeadlineExceeded: истёкший дедлайн — 504 на чтении и 503 на записи. Правило языка API из семнадцатого урока: Detail и Message не покидают сервис — там имена таблиц и значения ключей; наружу идут свой код, своё сообщение, а имя констрейнта — только в лог.
Проверка: чужой user_id
Клиент прислал POST /notes с user_id, которого нет в users; на колонке висит внешний ключ. Что произойдёт и как ответит API?
Транзакция pinned, таймауты и выключение#
Инвариант заметок — закреплена максимум одна на пользователя. Переключение флага — две правки, и они обязаны жить или умирать вместе: pgx.BeginFunc из четырнадцатого урока открывает транзакцию, первый UPDATE ставит pinned = NOT pinned выбранной строке, второй снимает флаг со всех остальных заметок того же пользователя. Сбой второго откатывает первый: без транзакции между правками виден момент с двумя закреплёнными заметками, а конкурентные переключения усугубляют гонку. Собственная обёртка WithTx заменит BeginFunc, когда к каркасу добавится политика — метрика откатов или свой дедлайн на всю операцию. Таймауты из пятнадцатого урока: middleware ставит один context.WithTimeout на весь HTTP-запрос, вложенный дедлайн не может превысить родителя; для чтения бюджет — три секунды, для записи — две. Выключение по SIGTERM: сначала srv.Shutdown с дедлайном — сервер дописывает ответы и перестаёт принимать новые; затем pool.Close(). Деталь из исходников, которую легко проглядеть: Close пула не принимает контекст — в отличие от conn.Close(ctx) одиночного соединения — и блокируется, пока все коннекты не вернутся и не закроются; повторный вызов безвреден, внутри sync.Once.
main.go: пул, HTTP и выключение
GOfunc main() {
ctx := context.Background()
dsn := os.Getenv("DATABASE_URL") +
"?pool_max_conns=8&pool_min_conns=2"
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
log.Fatal(err)
}
if err := pool.Ping(ctx); err != nil {
log.Fatal(err)
}
svc := notes.NewService(pool)
mux := http.NewServeMux()
mux.Handle("GET /notes", httpTimeout(3*time.Second, svc.List))
mux.Handle("POST /notes", httpTimeout(2*time.Second, svc.Create))
mux.Handle("POST /notes/{id}/pin", httpTimeout(2*time.Second, svc.TogglePinned))
srv := &http.Server{Addr: ":8080", Handler: mux}
go func() {
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
log.Fatal(err)
}
}()
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
<-stop
shutCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutCtx); err != nil {
log.Printf("выключение сервера: %v", err)
}
pool.Close() // без контекста: дождётся возврата всех коннектов
}
func httpTimeout(d time.Duration, next http.HandlerFunc) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), d)
defer cancel()
next(w, r.WithContext(ctx))
})
}main владеет пулом и HTTP: pgxpool.New разбирает DSN с pool_max_conns и pool_min_conns, Ping на старте ловит недоступную базу там, где её видно. httpTimeout ставит дедлайн на каждый запрос — меньший из бюджетов выигрывает. По SIGTERM сначала srv.Shutdown дописывает ответы, затем pool.Close() без контекста дождётся возврата всех коннектов; порядок менять нельзя, закрытый пул при живом трафике даёт поток ошибок.
Сервис ставил дедлайны в два слоя: middleware — три секунды на запрос, метод сервиса — свой WithTimeout на секунду с тем же именем ctx. При сбое он проверял errors.Is(err, context.DeadlineExceeded) и отвечал «база медленная» — хотя в половине случаев сгорал клиентский таймер, и честный ответ был 504, а не 503. Потерянный cancel от собственного WithTimeout ещё и копил таймеры до конца запроса. Правило: если дедлайны живут на двух слоях, то у каждого один владелец; свой слой различает сгоревший таймер по своему контексту — qCtx.Err() != nil означает свой дедлайн, иначе виноват родитель. Тогда 503 и 504 расходятся без гаданий, а лог называет виновника.
Маршруты сервиса и их ошибки#
| Маршрут | Метод | Ошибки и статусы |
|---|---|---|
| GET /notes?tag=&q=&after= | repo.List | 200; пустая страница — пустой массив, не 404; after — кортеж курсора |
| POST /notes | repo.Create | 23505 → 409, 23503 → 422, дорога без PgError → 503 |
| PATCH /notes/{id} | repo.Update | pgx.ErrNoRows → 404; updated_at обновляет now() в самом UPDATE |
| POST /notes/{id}/pin | service.TogglePinned через pgx.BeginFunc | обе правки атомарны: сбой второй откатывает первую |
Чек-лист проекта перед выкаткой#
- Индекс (user_id, updated_at DESC, id DESC) создан миграцией — страница и keyset ходят по нему.
- List запрашивает LIMIT с запасом и возвращает has_more: COUNT(*) в цикле страниц не живёт.
- nil-теги нормализованы в []string{} до запроса: NULL в фильтре отключил бы ветку молча.
- Каждая ошибка базы проходит errors.As: 404, 409, 422, 503 — Detail и Message не покидают сервис.
- Один дедлайн на слой: middleware ставит бюджет запроса, транзакция получает его целиком.
- Выключение: srv.Shutdown с дедлайном, затем pool.Close — не наоборот.
- Тесты на живой базе: keyset, гонка двух TogglePinned и маппинг ошибок проверяются с testcontainers из двадцать третьего урока.
Соберите WHERE метода List
Восстановите четыре пропуска: пересечение тегов, регистронезависимый поиск, порядок и лимит страницы.
Соберите TogglePinned
Расставьте строки метода сервиса: обёртка транзакции, правка флага, снятие чужих закреплений, финал.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: проект целиком
Скелет готов к доращиванию. Соседние темы курса ложатся на него напрямую: массивы-теги и их запросы — в двенадцатом уроке, каркас транзакции и передача tx в репозиторий — в паттерне «с транзакцией», расстановка дедлайнов — в уроке про контекст и таймауты, тонкости маппинга 23505 на поля формы — в уроке про уникальность. Дальше по списку курса: миграции goose из двадцать первого, EXPLAIN из двадцать второго — проверьте, что List ходит по notes_user_updated_idx, тесты с testcontainers из двадцать третьего, QueryTracer из двадцать четвёртого. Проверять сборку нужно на живой базе: поднимите Postgres в Docker, залейте пятьсот заметок через Batch из восемнадцатого урока и прогоните список с фильтрами и листанием — расхождения с ожиданием ловятся на малых объёмах мгновенно. Последнее правило финала: считать цену каждой страницы — привычка, а не разовое упражнение.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Итоговый pgx проект — сервис заметок: слои main, service и repo, схема с uuid, text[] и timestamptz, два индекса.
- Фильтр тегов — tags && $2::text[] по GIN, точное совпадение одного — = ANY; nil нормализуется в пустой срез до запроса.
- Поиск ILIKE по подстроке без pg_trgm — последовательный скан: на личных архивах честно, на десятках тысяч — GIN по gin_trgm_ops.
- Пагинация: LIMIT/OFFSET для первых страниц, keyset по (updated_at, id) глубже; has_more через LIMIT+1 вместо COUNT(*).
- 23505 и 23503 распаковываются errors.As и становятся 409 и 422; pgx.ErrNoRows — 404, дедлайн — 504 или 503.
- TogglePinned держит инвариант одной закреплённой заметки внутри pgx.BeginFunc: сбой второй правки откатывает обе.
- Выключение: srv.Shutdown с дедлайном, затем pool.Close без контекста — он дождётся возврата всех коннектов.
Домашнее задание
Соберите сервис по блокам урока: миграция с двумя индексами, модель Note, репозиторий с Create, List и TogglePinned, HTTP-хендлеры с middleware дедлайна. Наполните таблицу пятьюстами заметками через Batch из восемнадцатого урока и замерьте страницу листания с COUNT(*) против has_more — разницу запишите цифрами. Снимите транзакцию с TogglePinned, ударьте по эндпоинту двумя конкурентными запросами и поймайте момент с двумя закреплёнными заметками, затем верните каркас на место. Уберите cancel у WithTimeout и пропустите go vet — проверка lostcancel подскажет, где таймер живёт лишнее. В конце откройте EXPLAIN ANALYZE метода List и убедитесь, что план ходит по notes_user_updated_idx, а не по Seq Scan.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему выдача не пагинируется OFFSET-ом, если это проще?
OFFSET читает и отбрасывает пропущенные строки: глубина страницы дорожает линейно, а состав страниц при вставках плывёт между запросами. Keyset по (updated_at, id) стоит одинаково на любой глубине и опирается на индекс списка. Первая страница у обоих способов одинаковая — различие проявляется к десятому листанию, когда OFFSET уже гоняет сотни лишних строк.
Когда подключать pg_trgm и что он даёт?
GIN-индекс с классом gin_trgm_ops режет строку на триграммы и делает ILIKE с подстрокой в процентах индексным. На персональных архивах в десятки и сотни заметок последовательный скан дешевле поддержки ещё одного индекса на записи. Подключайте, когда заметки измеряются десятками тысяч или поиск стал горячим путём, и сверяйте план через EXPLAIN.
Что вернёт API, если клиент отправит теги строкой, а не массивом?
Хендлер переводит JSON в []string до репозитория: строка вместо массива — 422 ещё на границе. Правило то же, что у любого crud postgres go-приложения: типы приезжают из HTTP в домен уже правильными, база не должна угадывать. pgx, в свою очередь, кладёт []string в text[] без единой обёртки — как в двенадцатом уроке.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
MongoDB в Go: официальный драйвер mongo-driver v2
Курс MongoDB для Go: подключение, bson-теги, CRUD, фильтры, агрегации, индексы, транзакции и Change Streams. 25 уроков по mongo-driver v2.
- Подключаться к MongoDB и настраивать клиента
- Моделировать документы структурами и bson-тегами
- Делать CRUD, сложные фильтры и проекции
- Строить агрегации $match/$group/$lookup