database/sql — это пул: ручки, арифметика размера и утечка rows.Close, которая его душит
sql.DB — ленивый пул соединений: Open ничего не набирает, Ping проверяет. Четыре ручки ограничивают его: безлимит топит Postgres, вечный lifetime пиннит мёртвый таргет после failover. Размер — от max_connections на все поды; каждый Rows закрыт.
Учения по failover должны были пройти незаметно: RDS повышает standby, endpoint переключается, тридцать секунд ошибок записи — и всё. Вместо этого сервис заказов 41 минуту кидал cannot execute INSERT in a read-only transaction. Новый primary был здоров. DNS-запись — верной. Каждый свежий psql с ноутбука попадал в нужный узел. Но сервис собрал пул через sql.Open и ни разу не выставил ConnMaxLifetime — а установленное TCP-соединение не перечитывает DNS. Восемьдесят внешне здоровых сокетов остались пришпилены к разжалованному узлу, теперь read-only реплике, и пул не видел причин их закрывать: они были живы, отвечали на SELECT и падали только на записи. Лекарство, которое в итоге сработало, — редеплой сервиса — сработало по самой глупой причине: новые процессы набирают новые соединения. Одна строка, db.SetConnMaxLifetime(5 * time.Minute), ограничила бы аварию пятью минутами.
Пул, который набирает лениво
sql.DB — не соединение. Это потокобезопасный пул соединений, который создают один раз и делят между всеми горутинами на всю жизнь процесса — один на базу, никогда не по одному на запрос. И он ленив:
db, err := sql.Open("pgx", dsn)
if err != nil {
return err // сюда приходят только ошибки DSN/драйвера — ничего ещё не набрано
}
db.SetMaxOpenConns(25) // жёсткий потолок одновременных соединений
db.SetMaxIdleConns(25) // сколько переживает паузы между всплесками
db.SetConnMaxLifetime(5 * time.Minute) // ротация: переживает failover и перенацеливание LB
db.SetConnMaxIdleTime(2 * time.Minute) // ужать простаивающий пул без шторма редиалов
if err := db.PingContext(ctx); err != nil { // ПЕРВЫЙ настоящий дозвон происходит здесь
return err // падаем быстро на старте, а не на первом запросе пользователя
}Каждый запрос одалживает соединение у пула по одному и тому же пути: переиспользовать свободное, если есть; набрать новое, если пул ниже MaxOpenConns; иначе встать в очередь ожидания, пока кто-то не освободит. Каждая ручка существует потому, что её отсутствие — конкретная производственная авария:
MaxOpenConnsне задан — значит безлимит. Каждое соединение Postgres — это форкнутый backend-процесс, держащий несколько мегабайт ещё до полезной работы, а сверхуwork_mem(4 МБ по умолчанию) может выделяться на каждый узел сортировки или хеша. Всплеск трафика без потолка превращается в сотни backend-процессов, нехватку памяти на сервере иFATAL: sorry, too many clients alreadyдля всех остальных сервисов, делящих базу. Потолок переводит перегрузку в ограниченную очередь внутри вашего процесса, где с ней справится дедлайн контекста.MaxIdleConnsслишком мал — а дефолт равен 2. При стабильной нагрузке в 25 одновременных запросов и 2 idle-слотах пул постоянно закрывает и заново набирает соединения: TCP плюс TLS плюс аутентификация — пара сетевых раундтрипов, миллисекунды поверх запросов, которые должны занимать сотни микросекунд, и видимая текучка вpg_stat_activity. Для стабильных сервисов idle ставят равным open.ConnMaxLifetimeне задан — это история из крючка. Установленные соединения не перечитывают DNS, поэтому после failover или перенацеливания балансировщика они продолжают говорить со старым узлом, пока тот отвечает. Ограниченный lifetime гарантирует, что каждое соединение передоберётся за считаные минуты — заодно это перебалансирует нагрузку после масштабирования базы за прокси.ConnMaxIdleTimeпозволяет пулу, рассчитанному на дневной пик, ужаться за ночь, вместо того чтобы держать в заложниках восемьдесят серверных backend-процессов без дела.
Сервис вызывает sql.Open с DSN, указывающим на несуществующий хост. Когда код об этом узнает?
Арифметика размера
Когда выставляешь размер пула в изоляции — «положим, 25 соединений на под» — ты даёшь обещание, которое придётся держать всему флоту. Бюджет общий на весь флот, и платит по нему база. Ограничение: поды × MaxOpenConns, плюс джобы миграций, кроны и коллеги с psql, должны умещаться в max_connections минус superuser_reserved_connections (по умолчанию 3). Postgres из коробки даёт max_connections = 100; у managed-провайдеров дефолт обычно пара сотен. Теперь прогоните сценарий всплеска: 30 подов с потолком 25 на каждом легитимно хотят 750 соединений против сервера на 200. Это не план мощностей, это самострел: каждый дозвон сверх серверного лимита падает с sorry, too many clients already, и ошибки случайным образом ложатся на все сервисы, делящие базу. Примерно после десяти подов делайте деление честно (200 минус резерв, делённое на 60 подов на максимуме HPA (Horizontal Pod Autoscaler), — это 3 соединения на под) — или признайте, что серверный пулер вроде PgBouncer стал инфраструктурой, а не оптимизацией.
30 подов работают с MaxOpenConns=25 против Postgres с max_connections=200. Трафик растёт, все поды заняты. Что произойдёт на самом деле?
Rows: утечка и полупрочитанный результат
Спроси себя: что держит соединение открытым между первой и последней строкой результата? Итератор *Rows — и каждый ранний выход без Close это соединение, которое пул никогда не получит обратно. Это самый распространённый способ, которым Go-сервисы исчерпывают пулы — по одному утёкшему итератору за раз:
rows, err := db.QueryContext(ctx, `SELECT id, email FROM users WHERE org_id = $1`, org)
if err != nil {
return nil, err
}
defer rows.Close() // возвращает соединение в пул; без него ранний return утекает соединение
var users []User
for rows.Next() {
var u User
if err := rows.Scan(&u.ID, &u.Email); err != nil {
return nil, err
}
users = append(users, u)
}
return users, rows.Err() // цикл завершается одинаково и при успехе, и при обрыве соединенияУтечка коварна: Next сам закрывает rows, когда итерация доходит до естественного конца, — поэтому код без defer rows.Close() проходит любой тест, читающий все строки. Добавьте один ранний return или break внутри цикла — и это соединение больше не вернётся. Ничего не падает; db.Stats().InUse просто прирастает на единицу, пока в один день пул не упрётся в MaxOpenConns и каждый запрос в процессе не повиснет навсегда — или до дедлайна контекста, если вы его дали. Пара defer rows.Close() плюс rows.Err() не обсуждается: цикл for rows.Next() завершается одинаково и когда результат кончился, и когда соединение умерло на полпути, и только rows.Err() скажет, получили вы всё или половину результата, которую собираетесь выдать за целое.
Три глагола делятся чисто. QueryRowContext — для чтения ровно одной строки: все ошибки он откладывает до Scan, где ноль строк приходит как sql.ErrNoRows (проверяйте через errors.Is; это результат, а не сбой). QueryContext — для наборов строк, с обязательствами Close/Err выше. ExecContext — для команд без результата, возвращает RowsAffected: UPDATE, не нашедший ни одной строки, ошибкой не является, и коду обычно нужно это заметить. Для nullable-колонок голый string уронит Scan: берите sql.NullString (явно, некрасиво, ищется грепом) или *string (тише, но в одном nil-чеке от паники) — работают оба, но выберите одну конвенцию на кодовую базу. И используйте Context-варианты везде: формы без контекста — доконтекстное наследие, а запрос без дедлайна держит соединение ровно столько, сколько вздумается медленному серверу.
▸Почему это работает
Почему Open вообще ленив? Потому что конструкторы не должны делать I/O: sql.Open зовут в коде сборки зависимостей, в init-путях и в тестах, никогда не трогающих базу, и дозвон там сделал бы каждый такой путь медленным и нестабильным. Цена дизайна — опечатку в хосте обнаружит первый пользователь, а не супервизор процесса, если не включить проверку обратно. Производственный паттерн механичен: Open, четыре ручки, затем PingContext с коротким таймаутом в main — и уронить процесс при ошибке. Под, умерший на старте, заменят; под, раздающий 500-е, разбудит дежурного.
- 01Проследи, что происходит, когда запросу нужно соединение, и назови ручку, формирующую каждую ветку, плюс аварию, которую она предотвращает.
- 02Объясни, как отсутствующий rows.Close() проходит все тесты, но исчерпывает пул в проде, и от чего страхует rows.Err().
sql.DB — ленивый потокобезопасный пул, создаваемый один раз на базу на всю жизнь процесса. Open не делает I/O — парсит DSN и возвращает хендл, — поэтому стартовый контракт таков: Open, выставить ручки, PingContext с таймаутом, упасть при ошибке. Каждый запрос одалживает соединение: сначала реюз свободного, затем новый дозвон в пределах MaxOpenConns, затем очередь ожидания. Четыре ручки отображаются на аварии один к одному: пул без потолка превращает всплеск трафика в сотни многомегабайтных backend-процессов Postgres и отказы для всех клиентов сервера; зажатые idle-настройки превращают стабильную нагрузку в шторм редиалов; бесконечный ConnMaxLifetime пришпиливает установленные сокеты к мёртвому или разжалованному узлу после failover, потому что TCP-соединения не перечитывают DNS; ConnMaxIdleTime отпускает ночной излишек. Арифметика размера общая на флот — поды на максимуме HPA, умноженные на MaxOpenConns, плюс джобы и люди, в пределах max_connections минус резерв, — и после горстки подов честные ответы это маленькие потолки на под или PgBouncer. На проводе: QueryRow откладывает всё до Scan, где ноль строк — это sql.ErrNoRows, Exec сообщает RowsAffected, а Query выдаёт Rows, владеющий своим соединением: defer rows.Close(), иначе один ранний return утекает соединение навсегда, и проверка rows.Err(), потому что обрыв соединения завершает цикл точно как успех. Nullable-колонкам нужны sql.NullString или указатели, а Context-варианты — единственные, что позволяют ограничить, как долго запрос держит соединение. Теперь, когда видишь сервис, где запросы необъяснимо зависают в пике, — первым делом проверь db.Stats().InUse: пул, пришпиленный утёкшими Rows или неверными ручками, расскажет всё до того, как ты откроешь Postgres.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.