Go: SQL
Теория: Советы и лучшие практики
Закрытие соединений и ошибок
Работа с базой данных всегда упирается в аккуратное управление ресурсами. Каждое соединение, курсор, транзакция или подготовленное выражение что-то занимает на стороне СУБД, поэтому их нужно явно закрывать. Удобнее всего выработать рефлекс: как только код получил объект, который поддерживает Close(), сразу после проверки ошибки ставится defer с закрытием. Тогда даже при раннем выходе и панике ресурсы освободятся.
Для подключения этот приём выглядит просто. Программа открывает пул соединений через sql.Open и сразу планирует закрытие на момент завершения main.
Запросы, которые возвращают набор строк, требуют такого же подхода. Объект rows держит соединение занятым, пока его не закрыли, поэтому закрытие через defer должно появляться сразу после успешного QueryContext(). После этого код читает строки в цикле и в конце обязательно проверяет rows.Err, чтобы поймать ошибки, случившиеся уже во время итерации.
В транзакциях шаблон похожий, но вместо Close() используется пара Commit() и Rollback(). Сразу после BeginTx() ставится defer tx.Rollback, который работает как защитная сетка. Если в середине выполнения произойдёт ошибка или функция выйдет раньше времени, Rollback() отменит все изменения и освободит соединение. В конце, когда все операции прошли успешно, код вызывает Commit(), и транзакция фиксируется. Повторный Rollback() после удачного Commit() ничего не делает и не мешает.
Точно такие же шаблоны работают и вместе с sqlc. Сгенерированный код опирается на database/sql, поэтому и пул соединений, и транзакции управляются теми же правилами: соединения закрываются через Close(), строки — через rows.Close(), транзакции — через defer Rollback() и явный Commit() в конце успешного сценария.
Обработка ошибок и контекст
Ошибки, возвращаемые из database/sql, полезно оборачивать контекстом. Простое возвращение err без пояснения делает диагностику сложной, особенно когда приложение вызывает десятки разных запросов. Гораздо понятнее, когда каждый уровень добавляет описание того, что именно не получилось.
В логах такая ошибка разворачивается в цепочку и показывает как высокоуровневую операцию, так и низкоуровневую причину от СУБД.
При работе с PostgreSQL через драйвер pgx стоит различать типы ошибок по коду SQLSTATE. Тогда код может превращать конфликт уникальности в одну доменную ошибку, нарушение внешнего ключа — в другую и так далее. Для этого используются тип pgconn.PgError и набор констант из пакета pgerrcode.
Отдельно полезно обрабатывать ситуацию, когда данных нет совсем. database/sql в таких случаях возвращает sql.ErrNoRows, и это не авария, а нормальный сценарий. Его удобно сводить к своей доменной ошибке вроде ErrNotFound и дальше уже работать с ней на уровне сервиса или HTTP-слоя.
Таймауты и отмена запросов контролируются через context.Context. Каждый вызов ExecContext(), QueryContext() или метод sqlc принимает ctx первым параметром. Если контекст истёк или был отменён, драйвер вернёт context.DeadlineExceeded или context.Canceled. Это не ошибка базы, а управляемое завершение операции, которое можно обрабатывать отдельно.
В коде с sqlc этот же подход работает без изменений: сгенерированные методы принимают контекст, и вызов с истёкшим дедлайном завершится с той же ошибкой, что и прямой ExecContext().
Настройка пула соединений
Пул соединений в database/sql настраивается через три основных параметра. Максимальное количество открытых соединений ограничивается SetMaxOpenConns, количество соединений в простое — SetMaxIdleConns, максимальное время жизни одного соединения — SetConnMaxLifetime. Эти настройки помогают балансировать между нагрузкой на базу и скоростью ответа.
Для небольшого веб-сервиса обычно достаточно нескольких десятков соединений. Если пул часто «забивается», а приложение долго ждёт свободный коннект, можно увеличить SetMaxOpenConns. Если наоборот, база перегружена большим числом одновременных запросов, лимит соединений уменьшает давление на СУБД и заставляет приложение естественным образом ждать. Все эти настройки одинаково важны и для ручного SQL, и для кода, сгенерированного sqlc, потому что под капотом используется один и тот же пул database/sql.
Профилирование и отладка SQL-запросов
Отладка производительности начинается с измерений. На стороне Go проще всего замерять время выполнения каждого важного запроса и логировать текст, параметры и длительность. Такой лог даёт быстрый ответ, какие запросы тормозят и при каких аргументах.
При использовании pgx можно подключить встроенный логгер драйвера или обернуть sql.DB адаптером, который записывает метрики и текст запросов. Дополнительно в DSN стоит указать application_name, чтобы в системных видах PostgreSQL было видно, какие запросы пришли именно от этого сервиса.
На стороне PostgreSQL основным инструментом анализа остаётся EXPLAIN ANALYZE. Команда EXPLAIN (ANALYZE, BUFFERS, VERBOSE) показывает план выполнения, количество просмотренных строк, использование индексов и время работы. Если запрос вместо индексного поиска выполняет последовательное сканирование по всей таблице, план сразу это показывает и подсказывает, где нужен индекс.
Для системного профилирования полезно включить расширение pg_stat_statements. Оно накапливает статистику по всем запросам: текст, среднее время выполнения, количество вызовов. По этой таблице легко найти самые дорогие или самые частые запросы и уже для них запускать детальный EXPLAIN.
Ещё один уровень контроля даёт журнал медленных запросов. Параметр log_min_duration_statement настраивается в конфигурации PostgreSQL и говорит серверу логировать все запросы, которые выполнялись дольше заданного порога, например 200 миллисекунд. В связке с auto_explain можно логировать сразу и план для таких запросов.
На стороне приложения поверх ExecContext() или методов sqlc часто вешают обёртку, которая пишет метрики. Функция фиксирует момент начала, потом через defer записывает длительность и наличие ошибки в систему мониторинга. Так появляется единая точка, в которой можно посчитать количество запросов, долю ошибок и медиану времени выполнения.
Код с sqlc оборачивается точно так же, только вместо ExecContext() вызывается сгенерированный метод. Метрики продолжают считать длительность и ошибки, а конкретный SQL берётся из .sql-файла.
Когда использовать sqlc, а когда — ручной SQL
sqlc подходит там, где запросы имеют фиксированную форму, а схема базы более-менее стабильна. В этих случаях инструмент берёт на себя всю рутину вокруг database/sql: создаёт структуры под результаты, описывает параметры, генерирует методы и защищает от ошибок типов и порядка колонок. Разработчик пишет SQL в .sql-файлах, задаёт имена через директиву -- name:, выбирает форму результата (:one, :many, :exec, :execrows), а Go-код получает типобезопасные функции.
Такой стиль удобен для CRUD-операций, отчётов с фиксированными фильтрами и любых запросов, которые можно описать заранее. При изменении схемы sqlc пересобирает модели, а компилятор сразу показывает, где код больше не соответствует базе.
Ручной SQL остаётся полезным там, где запросы нужно собирать динамически. Если набор фильтров зависит от пользовательского ввода, часть условий включается или выключается, порядок параметров меняется, sqlc уже не подходит. В таких сценариях проще собрать строку запроса и массив аргументов в коде.
При работе напрямую с pgx удобнее использовать именованные параметры, когда драйвер это поддерживает. Такой подход избавляет от ручного подсчёта $1, $2, $3 и делает запросы читабельнее.
Часть проектов комбинирует оба подхода. Базовый слой доступа к данным строится на sqlc: все стабильные запросы с чёткими контрактами описываются в .sql-файлах и генерируются в виде пакета db. Рядом существует пакет, где хранятся редкие или очень гибкие запросы, которые проще собрать вручную. Такой код использует тот же *sql.DB или *sql.Tx и те же настройки пулов и транзакций, что и слой sqlc.
В итоге выбор простой. Там, где важны типобезопасность, предсказуемость и минимум рутины вокруг Scan(), лучше использовать sqlc и описывать запросы явно. Там, где запросы сильно зависят от пользовательских фильтров, сложных конструкторов или нестандартной логики сборки, уместнее остаться на ручном SQL и аккуратно управлять строкой запроса и аргументами. В обоих случаях действуют одни и те же правила: соединения и курсоры закрываются вовремя, транзакции всегда имеют Rollback() по умолчанию и явный Commit() при успехе, ошибки оборачиваются контекстом, а производительность видна через метрики и планы выполнения.


