Содержание урока · 10 разделов
Понедельник, десять утра: отчётная выборка упёрлась в необновлённую статистику и держит таблицу минуты. Приложение при этом живёт ровно столько, сколько у вас стоит таймаутов: без них горутины копятся на запросах, каждый держит коннект пула, пул из четвёртого урока исчерпывается, и падает уже весь сервис, а не один экран. В pgx ответ на эту историю встроен в сигнатуру каждого вызова: Query, Exec, QueryRow, Begin, Acquire — всё принимает контекст, и все потенциально блокирующие операции уважают его отмену. Документация pgconn формулирует поведение жёстко: при отмене контекста метод возвращается немедленно, а в большинстве случаев соединение за этим закрывается. Это не баг, а осознанный выбор драйвера: дешевле выбросить коннект, чем гадать, в каком состоянии остался протокол. Урок идёт по порядку: механика отмены и судьба коннекта, таймаут на запрос через context.WithTimeout, бюджеты на операцию целиком, особое поведение транзакций, у которых автоотката нет, и серверные настройки вроде statement_timeout для случаев, когда таймаут нужен на стороне базы.
Что происходит при отмене контекста#
Дедлайн истёк — и pgx делает три вещи почти одновременно. Первая: заблокированный вызов в Go разблокируется и вернёт ошибку — при таймауте это context.DeadlineExceeded с текстом context deadline exceeded, при ручной отмене — context.Canceled; пакеты могут оборачивать её своим текстом, поэтому проверяйте через errors.Is, а не сравнением строк. Вторая: соединение уводится из игры — драйвер по умолчанию ставит жёсткий дедлайн на сокет, после чего коннект признаётся негодным; пул при возврате такой коннект уничтожает и по требованию создаст новый. Третья: сервер тоже узнаёт об отмене — прерванное соединение Postgres увидит разрыв и прервёт выполнение запроса сам. Смысл тройки: приложение не ждёт, база не жуёт мёртвый запрос, ресурсы освобождаются с обеих сторон. Побочное следствие важно помнить: после отмены конкретный pgx.Conn мёртв, но живой же пул — недоступность на один запрос не ломает пул целиком, health-проверки из четвёртого урока поднимут замену. И раз коннект после отмены не возвращает нормальный ответ, цепочка rows.Next вернёт false, а rows.Err() из пятого урока покажет причину — пятый урок становится здесь не повторением, а инструментом. Механика по умолчанию устроена честно: обработчик отмены ставит дедлайн прямо на сокет — в дефолтной настройке задержки у него нет, и сокет закрывается. А вот делить один pgx.Conn между горутинами не стоит и рядом: вторая операция на занятом коннекте упадёт с ошибкой conn busy — в исходниках pgconn она честно помечена как возможная только при баге приложения; таймауты здесь не помогут, поможет владение коннектом по одному за раз.
Таймаут на запрос: WithTimeout и defer cancel
GOpackage main
import (
"context"
"errors"
"fmt"
"log"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
func main() {
ctx := context.Background()
pool, err := pgxpool.New(ctx,
"postgres://app:secret@localhost:5432/app")
if err != nil {
log.Fatal(err)
}
defer pool.Close()
qCtx, cancel := context.WithTimeout(ctx, 300*time.Millisecond)
defer cancel()
rows, err := pool.Query(qCtx,
"SELECT pg_sleep(5), 'готово'")
if err != nil {
log.Fatal(err)
}
defer rows.Close()
var done string
for rows.Next() {
var slept any
if err = rows.Scan(&slept, &done); err != nil {
break
}
}
if err = rows.Err(); err != nil {
if errors.Is(err, context.DeadlineExceeded) {
log.Print("таймаут: запрос живёт дольше 300 мс")
return
}
log.Fatal(err)
}
fmt.Println(done)
}Дедлайн 300 миллисекунд поставлен контекстом, а не драйвером: pg_sleep(5) гарантирует, что запрос его превысит. Ошибка context deadline exceeded приходит либо от Query, либо в rows.Err(), а errors.Is различит таймаут от других сбоев — в реальном сервисе эта ветка означает деградацию, а не катастрофу.
cancel: почему defer обязателен#
Пара WithTimeout и cancel объясняется устройством таймера: внутри контекста живёт таймер планировщика, и до вызова cancel он считается активным — держит ссылку, память и запись во внутреннем списке таймеров рантайма. Для одного запроса это мелочь, для обработчика, который ставит таймаут на каждый вызов, — утечка по десятку таймеров на запрос. Документация context прямо называет незакрытый контекст утечкой до завершения родителя, а go vet с проверкой lostcancel ругается на пропущенный cancel в коде — обе подсказки появляются на первой же неделе работы. Отсюда рефлекс: defer cancel() пишется сразу после WithTimeout, и между ними не должно быть ни одного return. Полезно знать поведение двойного вызова: cancel идемпотентен, повторный вызов безвреден, поэтому страховка «а вдруг я забыл в другой ветке» бесплатна. На слое HTTP-хендлеров партию таймеров экономит middleware: один WithTimeout на запрос вместо пяти на каждый вызов — вложенность контекстов делает это безопасным, а меньший дедлайн по-прежнему выигрывает. Это же место — правильная точка для логирования: middleware видит разницу между «запрос кончился» и «запрос убит дедлайном» и отдаёт её в метрики, не трогая бизнес-код.
Бюджет операции: таймауты на слой выше#
Дедлайны работают вложенно, и это главная мысль блока: контекст-потомок наследует родителя и не может его превысить. HTTP-хендлер получает контекст запроса с уже стоящим дедлайном сервера; внутри он ставит свой WithTimeout на обращение к базе — меньший из двух и есть реальный бюджет. На операцию из нескольких запросов — например, транзакция перевода — бюджет ставится один раз на всю группу: WithTimeout перед Begin, и все tx-вызовы носят этот же qCtx. Отдельный вопрос — освобождение: cancel, который возвращает WithTimeout, обязан вызваться, иначе таймер висит до конца приложения; defer cancel() сразу после создания — рефлекс, а не стилистика. На слое пула контекст работает и на ожидание соединения: pool.Begin и Acquire с истёкшим контекстом не дождутся свободного коннекта и вернут ошибку — при исчерпанном пуле это тот же deadline exceeded, что сбивает с толку, если забыть про очередь ожидания. У ожидания коннекта нет отдельного таймаута-настройки в пуле: бюджет ожидания — это бюджет вашего контекста, отдельной ошибки «не дождался» в pgx нет, есть context deadline exceeded. Практическая расстановка: глобальный фон без дедлайна, дедлайн на юзкейс, при необходимости — ещё один, сжатый, на тяжёлый запрос внутри; каждое звено проверяет errors.Is и отвечает своей ошибкой, а не голым таймаутом наружу.
Проверка: судьба коннекта
Контекст запроса истёк посреди выборки. Что станет с соединением из пула?
Контекст и транзакции: чего pgx не делает#
Здесь самое контринтуитивное место урока. В database/sql отмена контекста, с которым открыли транзакцию, откатывает её автоматически. В pgx это не так: документация Conn.Begin прямо говорит — контекст влияет только на саму команду BEGIN, автоматического отката при отмене нет. Механика такая: дедлайн рвёт выполнение текущего запроса, коннект обычно умирает — и вместе с ним транзакция, откатанная сервером при разрыве. Но разрыв не гарантирован во всех сценариях, и код, который полагается на «сбой — значит откатилось», пишет недетерминированную логику. Правильный каркас: defer tx.Rollback() из тринадцатого урока ставится всегда, независимо от контекста; Rollback на умершем коннекте вернёт ошибку — не страшно, цель в том, чтобы живой коннект никогда не остался с открытой транзакцией. Заметьте асимметрию с database/sql: там откат по отмене встроен в слой адаптера, здесь встроен только разрыв запроса — каркас из defer не опционален. Отдельная ловушка — pgx.BeginFunc из четырнадцатого урока: его контекст обслуживает BEGIN, COMMIT и ROLLBACK, но не ограничивает тело fn — если хотите бюджет на всю операцию, ставьте WithTimeout на контекст до вызова обёртки, и он унаследуется всеми запросами внутри. Оба факта сходятся в одно правило: контекст ограничивает отдельные вызовы, а каркас транзакции — ваша ответственность.
Бюджет на транзакцию целиком
GOfunc createOrder(ctx context.Context, pool *pgxpool.Pool,
order Order) error {
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
return pgx.BeginFunc(ctx, pool, func(tx pgx.Tx) error {
if _, err := tx.Exec(ctx,
"INSERT INTO orders (user_id, total) VALUES ($1, $2)",
order.UserID, order.Total); err != nil {
return err
}
_, err := tx.Exec(ctx,
"UPDATE accounts SET balance = balance - $1 WHERE id = $2",
order.Total, order.UserID)
return err
})
}Дедлайн на два секунды поставлен до BeginFunc, и обёртка унаследует его во все вызовы — и на Begin, и на оба Exec, и на финальный Commit. Тело колбэка само не добавляет таймаутов: помните из четырнадцатого урока, что BeginFunc не ограничивает fn — бюджет приносит контекст.
Что напечатает фрагмент?
Прочитайте код и выберите, что он выведет:
ctx, cancel := context.WithTimeout(context.Background(),
100*time.Millisecond)
defer cancel()
var n int
err := pool.QueryRow(ctx,
"SELECT count(*) FROM events WHERE payload LIKE '%x%'").
Scan(&n)
fmt.Println(errors.Is(err, context.DeadlineExceeded))Где какой таймаут живёт#
| Уровень | Механизм | Что ограничивает |
|---|---|---|
| Соединение | connect_timeout в DSN | Установление связи, урок третий |
| Запрос | context.WithTimeout перед вызовом | Один Exec, Query, QueryRow |
| Операция | WithTimeout до Begin или BeginFunc | Транзакцию целиком, все tx-вызовы |
| Ожидание пула | Тот же контекст в Acquire/Begin | Ожидание свободного коннекта |
| Сервер | statement_timeout, per-role | Любой запрос независимо от клиента |
| Бездействие | idle_in_transaction_session_timeout | Транзакцию, открытую и забытую |
| Наблюдение | QueryTracer из 24-го урока | Не ограничивает, но показывает медленных |
statement_timeout: дедлайн на стороне базы#
Контекст — дедлайн клиента, но Postgres умеет ставить лимит и сам: SET statement_timeout = '2s' ограничивает выполнение запроса на сервере, и команда, превысившая лимит, получает ошибку canceling statement due to statement timeout уже от базы. Зачем вторая страховка, если есть первая? Клиентский таймаут защищает приложение, но не базу: горутина без дедлайна гонит запрос, пока соединение живо; statement_timeout защищает базу от всех клиентов сразу — отчётов аналитиков, чужих сервисов, консольной сессии коллеги. Ставится по-разному: глобально в конфиге, на роль ALTER ROLE analytics SET statement_timeout, на сессию SET, и разумная практика — жёсткий потолок для отчётных ролей и щедрый или отсутствующий для сервисных. Рядом живут два соседних лимита: idle_in_transaction_session_timeout рвёт транзакцию, которая сидит без дела, — прямая защита от приложения, открывшего Begin и ушедшего в HTTP-вызов, — тот самый случай из живого примера тринадцатого урока; lock_timeout ограничивает ожидание блокировки, чтобы запрос не висел в очереди к чужой строке вечно. Оба дополняют statement_timeout: он режет долгое выполнение, они — долгое бездействие и долгое ожидание. Есть нюанс совместимости: таймаут на сервере не спасает, если затык в сети до базы — пакет едет, но долго; клиентский контекст в этой ситуации единственный, кто заметит. Взаимодополнение — правильный вывод: контекст режет затыки по дороге, statement_timeout режет тяжёлые планы на сервере, а лог медленных запросов из двадцать четвёртого урока покажет, кого из них вы недооценили.
Типичная ошибка: прототип стартует с context.Background() на каждом запросе «потому что проще», и через полгода сервис замирает целиком — база притормозила, горутины встали на SELECT, коннекты пула кончились, health-check тоже ждёт базу. Что сломалось бы: рестарт не спасает, новый экземпляр умирает так же. Лекарство до рефакторинга: дедлайн хотя бы на уровне хендлера — один WithTimeout в middleware закрывает девять из десяти затыков. Моё спорное мнение с условием: context.Background() правомерен в фоне — воркеры очередей, ночные миграции, слушатели NOTIFY — но и там нужен свой бюджет, просто щедрее.
Правила таймаутов одним списком#
- Дедлайны ставит код: pgx не добавляет таймаутов от себя
- context.WithTimeout + defer cancel() — пара без разрывов
- Ошибку различайте errors.Is(err, context.DeadlineExceeded), не по тексту
- Отмена убивает коннект: пул выбросит его сам — не пытайтесь «оздоровить»
- Бюджет операции — один WithTimeout до BeginFunc, он наследуется
- Транзакция не откатывается по отмене автоматически — defer Rollback обязателен
- statement_timeout — страховка базы от всех клиентов сразу
- Отчётные роли — жёсткий потолок, сервисные — щедрый
Проверка: транзакция и отмена
Транзакция открыта на контексте с дедлайном. Дедлайн истёк посреди второго Exec. Что произойдёт с транзакцией?
Соберите запрос с дедлайном
Восстановите постановку таймаута и различение его ошибки.
Соберите операцию с бюджетом
Расставьте строки: дедлайн, обёртка, запрос, различение ошибки.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: контекст и таймауты
Дефолт «отмена убивает коннект» — компромисс в пользу простоты, и у pgconn есть ручка: BuildContextWatcherHandler в конфиге позволяет заменить обработчик отмены. CancelRequestContextWatcherHandler шлёт серверу cancel request с настраиваемой задержкой CancelRequestDelay и ставит страховочный дедлайн DeadlineDelay на сокет — запрос прерывается мягче, соединение обычно выживает; DeadlineContextWatcherHandler проще — он лишь откладывает закрытие сокета на DeadlineDelay. Это имеет смысл при частых отменах на дорогих коннектах, но добавляет режимов отказа — включайте осознанно, не на первом месяце с pgx.
Живая проверка — локально: песочница урока покажет тематическую программу на чистом Go, а pg_sleep и дедлайны гоняйте на Postgres из первого урока. Дальше по курсу: в шестнадцатом — pgconn.PgError и коды SQLSTATE, в которые упакуются и серверные таймауты; в двадцать четвёртом — QueryTracer, который покажет, какие запросы недооценили ваш бюджет; в тринадцатом и четырнадцатом — каркас транзакций, на котором построена половина этого урока. Домашнее задание доводит навык до автоматизма: дедлайн, различение ошибки, откат транзакции при отмене — три приёма, которые в проде окупаются за первую деградацию базы.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Все блокирующие вызовы pgx принимают контекст; таймауты ставит код, драйвер не добавляет своих.
- При отмене вызов возвращается немедленно ошибкой DeadlineExceeded или Canceled — различайте через errors.Is.
- Отмена обычно закрывает соединение; пул уничтожает его и создаёт замену.
- pgx не откатывает транзакцию по отмене автоматически — defer Rollback обязателен.
- Контекст BeginFunc обслуживает управляющие команды и не ограничивает fn.
- Бюджет операции — один WithTimeout до Begin: он наследуется всеми вызовами.
- statement_timeout страхует базу от всех клиентов; контекст — приложение от дороги и базы.
- idle_in_transaction_session_timeout рвёт забытую транзакцию — страховка от Begin без финала.
Домашнее задание
Напишите запрос с pg_sleep(5) и дедлайном 200 миллисекунд: добейтесь context.DeadlineExceeded и различите его errors.Is. Оберните транзакцию перевода из тринадцатого урока в WithTimeout на две секунды, уроните середину таймаутом и проверьте логом, что defer Rollback отработал. Поставьте на тестовую роль SET statement_timeout = '1s' и убедитесь, что база сама рвёт медленный запрос ошибкой canceling statement; поставьте рядом idle_in_transaction_session_timeout и добейтесь его срабатывания, открыв транзакцию и не делая ничего. В конце посчитайте в комментарии, сколько горутин могло бы зависнуть без дедлайна при ваших MaxConns.
Личные заметкиПусто
Вопросы и ответы по уроку
Что вернёт rows.Err() после отмены контекста посреди цикла?
Ошибку отмены — ту же context.DeadlineExceeded или Canceled, возможно в обёртке драйвера. Цикл по rows.Next() завершится как при обычном конце выборки, поэтому проверка rows.Err() после цикла обязательна: без неё таймаут выглядит как пустой список — та же ловушка, что сетевые сбои из пятого урока. Тайминг тоже важен: проверять нужно именно после цикла — rows.Next может вернуть false и на пустой выборке, и на отмене, различит их только Err.
Нужен ли таймаут на каждый запрос или достаточно одного на хендлер?
Начните с одного на хендлер — он закрывает большинство затыков. Отдельный WithTimeout на тяжёлый запрос оправдан, когда бюджет запроса должен быть меньше бюджета хендлера, чтобы деградация базы не съедала весь юзкейс. На транзакции бюджет ставится на всю группу до Begin — отдельные таймауты на каждый Exec внутри избыточны.
statement_timeout или контекст — что выбрать?
Они про разное и дополняют друг друга. Контекст защищает приложение: не ждёт ни базу, ни дорогу, и освобождает коннект, который пул заменит. statement_timeout защищает базу от любого клиента, включая те, где дедлайны забыли. Рабочая связка: контекст везде в коде, statement_timeout — потолок на отчётных ролях в конфигурации.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
MongoDB в Go: официальный драйвер mongo-driver v2
Курс MongoDB для Go: подключение, bson-теги, CRUD, фильтры, агрегации, индексы, транзакции и Change Streams. 25 уроков по mongo-driver v2.
- Подключаться к MongoDB и настраивать клиента
- Моделировать документы структурами и bson-тегами
- Делать CRUD, сложные фильтры и проекции
- Строить агрегации $match/$group/$lookup