Go: SQL

Теория: Советы и лучшие практики

Закрытие соединений и ошибок

Работа с базой данных всегда упирается в аккуратное управление ресурсами. Каждое соединение, курсор, транзакция или подготовленное выражение что-то занимает на стороне СУБД, поэтому их нужно явно закрывать. Удобнее всего выработать рефлекс: как только код получил объект, который поддерживает Close(), сразу после проверки ошибки ставится defer с закрытием. Тогда даже при раннем выходе и панике ресурсы освободятся.

Для подключения этот приём выглядит просто. Программа открывает пул соединений через sql.Open и сразу планирует закрытие на момент завершения main.

db, err := sql.Open("pgx", dsn)
if err != nil {
	log.Fatal(err)
}
defer db.Close() // закрывается при завершении main()

Запросы, которые возвращают набор строк, требуют такого же подхода. Объект rows держит соединение занятым, пока его не закрыли, поэтому закрытие через defer должно появляться сразу после успешного QueryContext(). После этого код читает строки в цикле и в конце обязательно проверяет rows.Err, чтобы поймать ошибки, случившиеся уже во время итерации.

rows, err := db.QueryContext(ctx, `SELECT id, name FROM users`)
	if err != nil {
	log.Fatal(err)
}
defer rows.Close()

for rows.Next() {
	var id int
	var name string
	if err := rows.Scan(&id, &name); err != nil {
		log.Fatal(err)
	}
}
if err := rows.Err(); err != nil {
	log.Fatal(err)
}

В транзакциях шаблон похожий, но вместо Close() используется пара Commit() и Rollback(). Сразу после BeginTx() ставится defer tx.Rollback, который работает как защитная сетка. Если в середине выполнения произойдёт ошибка или функция выйдет раньше времени, Rollback() отменит все изменения и освободит соединение. В конце, когда все операции прошли успешно, код вызывает Commit(), и транзакция фиксируется. Повторный Rollback() после удачного Commit() ничего не делает и не мешает.

tx, err := db.BeginTx(ctx, nil)
if err != nil {
	log.Fatal(err)
}
defer tx.Rollback() // откат по умолчанию, если что-то пойдёт не так

_, err = tx.ExecContext(ctx, `
	UPDATE accounts
	   SET balance = balance - 100
	 WHERE id = 1
`)
if err != nil {
	return // Rollback выполнится автоматически
}

_, err = tx.ExecContext(ctx, `
	UPDATE accounts
	   SET balance = balance + 100
	 WHERE id = 2
`)
if err != nil {
	return // Rollback выполнится автоматически
}

if err := tx.Commit(); err != nil {
	log.Fatal("commit:", err)
}

Точно такие же шаблоны работают и вместе с sqlc. Сгенерированный код опирается на database/sql, поэтому и пул соединений, и транзакции управляются теми же правилами: соединения закрываются через Close(), строки — через rows.Close(), транзакции — через defer Rollback() и явный Commit() в конце успешного сценария.

Обработка ошибок и контекст

Ошибки, возвращаемые из database/sql, полезно оборачивать контекстом. Простое возвращение err без пояснения делает диагностику сложной, особенно когда приложение вызывает десятки разных запросов. Гораздо понятнее, когда каждый уровень добавляет описание того, что именно не получилось.

if err := saveUser(ctx, user); err != nil {
	return fmt.Errorf("update user: %w", err)
}

В логах такая ошибка разворачивается в цепочку и показывает как высокоуровневую операцию, так и низкоуровневую причину от СУБД.

При работе с PostgreSQL через драйвер pgx стоит различать типы ошибок по коду SQLSTATE. Тогда код может превращать конфликт уникальности в одну доменную ошибку, нарушение внешнего ключа — в другую и так далее. Для этого используются тип pgconn.PgError и набор констант из пакета pgerrcode.

import (
	"errors"
	"fmt"

	"github.com/jackc/pgerrcode"
	"github.com/jackc/pgx/v5/pgconn"
)

func handleDBError(err error) error {
	var pgErr *pgconn.PgError
	if errors.As(err, &pgErr) {
		switch pgErr.Code {
		case pgerrcode.UniqueViolation:
			return fmt.Errorf("user conflict: %w", err)
		case pgerrcode.ForeignKeyViolation:
			if pgErr.ConstraintName == "user_id_fkey" {
				return fmt.Errorf("unknown user: %w", err)
			}
			return fmt.Errorf("foreign key violation: %w", err)
		default:
			return fmt.Errorf("db error: %w", err)
		}
	}
	return err
}

Отдельно полезно обрабатывать ситуацию, когда данных нет совсем. database/sql в таких случаях возвращает sql.ErrNoRows, и это не авария, а нормальный сценарий. Его удобно сводить к своей доменной ошибке вроде ErrNotFound и дальше уже работать с ней на уровне сервиса или HTTP-слоя.

var ErrNotFound = errors.New("not found")

func getUser(ctx context.Context, db *sql.DB, id int) (User, error) {
	var u User
	err := db.QueryRowContext(ctx,
		`SELECT id, name FROM users WHERE id = $1`,
		id,
	).Scan(&u.ID, &u.Name)
	if errors.Is(err, sql.ErrNoRows) {
		return User{}, ErrNotFound
	}
	if err != nil {
		return User{}, fmt.Errorf("get user: %w", err)
	}
	return u, nil
}

Таймауты и отмена запросов контролируются через context.Context. Каждый вызов ExecContext(), QueryContext() или метод sqlc принимает ctx первым параметром. Если контекст истёк или был отменён, драйвер вернёт context.DeadlineExceeded или context.Canceled. Это не ошибка базы, а управляемое завершение операции, которое можно обрабатывать отдельно.

ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()

_, err := db.ExecContext(ctx, `SELECT pg_sleep(5)`)
if errors.Is(err, context.DeadlineExceeded) {
	fmt.Println("запрос превысил время ожидания")
}

В коде с sqlc этот же подход работает без изменений: сгенерированные методы принимают контекст, и вызов с истёкшим дедлайном завершится с той же ошибкой, что и прямой ExecContext().

Настройка пула соединений

Пул соединений в database/sql настраивается через три основных параметра. Максимальное количество открытых соединений ограничивается SetMaxOpenConns, количество соединений в простое — SetMaxIdleConns, максимальное время жизни одного соединения — SetConnMaxLifetime. Эти настройки помогают балансировать между нагрузкой на базу и скоростью ответа.

db.SetMaxOpenConns(20)
db.SetMaxIdleConns(10)
db.SetConnMaxLifetime(30 * time.Minute)

Для небольшого веб-сервиса обычно достаточно нескольких десятков соединений. Если пул часто «забивается», а приложение долго ждёт свободный коннект, можно увеличить SetMaxOpenConns. Если наоборот, база перегружена большим числом одновременных запросов, лимит соединений уменьшает давление на СУБД и заставляет приложение естественным образом ждать. Все эти настройки одинаково важны и для ручного SQL, и для кода, сгенерированного sqlc, потому что под капотом используется один и тот же пул database/sql.

Профилирование и отладка SQL-запросов

Отладка производительности начинается с измерений. На стороне Go проще всего замерять время выполнения каждого важного запроса и логировать текст, параметры и длительность. Такой лог даёт быстрый ответ, какие запросы тормозят и при каких аргументах.

start := time.Now()
defer func() {
	log.Printf(
		"sql=UPDATE users... args=%v took=%s err=%v",
		[]any{"Алексей", 42},
		time.Since(start),
		err, // замыкание увидит финальное значение err
	)
}()

_, err = db.ExecContext(ctx,
	`UPDATE users SET name = $1 WHERE id = $2`,
	"Алексей", 42,
)

При использовании pgx можно подключить встроенный логгер драйвера или обернуть sql.DB адаптером, который записывает метрики и текст запросов. Дополнительно в DSN стоит указать application_name, чтобы в системных видах PostgreSQL было видно, какие запросы пришли именно от этого сервиса.

dsn := "postgres://user:pass@localhost:5432/app?sslmode=disable&application_name=api"

На стороне PostgreSQL основным инструментом анализа остаётся EXPLAIN ANALYZE. Команда EXPLAIN (ANALYZE, BUFFERS, VERBOSE) показывает план выполнения, количество просмотренных строк, использование индексов и время работы. Если запрос вместо индексного поиска выполняет последовательное сканирование по всей таблице, план сразу это показывает и подсказывает, где нужен индекс.

EXPLAIN (ANALYZE, BUFFERS, VERBOSE)
SELECT * FROM users WHERE email LIKE '%@gmail.com';

Для системного профилирования полезно включить расширение pg_stat_statements. Оно накапливает статистику по всем запросам: текст, среднее время выполнения, количество вызовов. По этой таблице легко найти самые дорогие или самые частые запросы и уже для них запускать детальный EXPLAIN.

CREATE EXTENSION IF NOT EXISTS pg_stat_statements;

SELECT query, mean_exec_time, calls
  FROM pg_stat_statements
 ORDER BY mean_exec_time DESC
 LIMIT 5;

Ещё один уровень контроля даёт журнал медленных запросов. Параметр log_min_duration_statement настраивается в конфигурации PostgreSQL и говорит серверу логировать все запросы, которые выполнялись дольше заданного порога, например 200 миллисекунд. В связке с auto_explain можно логировать сразу и план для таких запросов.

SET log_min_duration_statement = 200;

LOAD 'auto_explain';
SET auto_explain.log_min_duration = 100;

На стороне приложения поверх ExecContext() или методов sqlc часто вешают обёртку, которая пишет метрики. Функция фиксирует момент начала, потом через defer записывает длительность и наличие ошибки в систему мониторинга. Так появляется единая точка, в которой можно посчитать количество запросов, долю ошибок и медиану времени выполнения.

func execWithMetrics(ctx context.Context, db *sql.DB, query string, args ...any) error {
	start := time.Now()
	var err error
	defer func() {
		metrics.ObserveQuery(time.Since(start), err, query)
	}()

	_, err = db.ExecContext(ctx, query, args...)
	return err
}

Код с sqlc оборачивается точно так же, только вместо ExecContext() вызывается сгенерированный метод. Метрики продолжают считать длительность и ошибки, а конкретный SQL берётся из .sql-файла.

Когда использовать sqlc, а когда — ручной SQL

sqlc подходит там, где запросы имеют фиксированную форму, а схема базы более-менее стабильна. В этих случаях инструмент берёт на себя всю рутину вокруг database/sql: создаёт структуры под результаты, описывает параметры, генерирует методы и защищает от ошибок типов и порядка колонок. Разработчик пишет SQL в .sql-файлах, задаёт имена через директиву -- name:, выбирает форму результата (:one, :many, :exec, :execrows), а Go-код получает типобезопасные функции.

-- name: CreateUser :one
INSERT INTO users (email, name)
VALUES ($1, $2)
RETURNING id, email, name, created_at;

Такой стиль удобен для CRUD-операций, отчётов с фиксированными фильтрами и любых запросов, которые можно описать заранее. При изменении схемы sqlc пересобирает модели, а компилятор сразу показывает, где код больше не соответствует базе.

Ручной SQL остаётся полезным там, где запросы нужно собирать динамически. Если набор фильтров зависит от пользовательского ввода, часть условий включается или выключается, порядок параметров меняется, sqlc уже не подходит. В таких сценариях проще собрать строку запроса и массив аргументов в коде.

При работе напрямую с pgx удобнее использовать именованные параметры, когда драйвер это поддерживает. Такой подход избавляет от ручного подсчёта $1, $2, $3 и делает запросы читабельнее.

cmdTag, err := conn.Exec(ctx,
	`UPDATE users SET name = @name WHERE id = @id`,
	pgx.NamedArgs{
		"name": "Алексей",
		"id":   42,
	},
)

Часть проектов комбинирует оба подхода. Базовый слой доступа к данным строится на sqlc: все стабильные запросы с чёткими контрактами описываются в .sql-файлах и генерируются в виде пакета db. Рядом существует пакет, где хранятся редкие или очень гибкие запросы, которые проще собрать вручную. Такой код использует тот же *sql.DB или *sql.Tx и те же настройки пулов и транзакций, что и слой sqlc.

В итоге выбор простой. Там, где важны типобезопасность, предсказуемость и минимум рутины вокруг Scan(), лучше использовать sqlc и описывать запросы явно. Там, где запросы сильно зависят от пользовательских фильтров, сложных конструкторов или нестандартной логики сборки, уместнее остаться на ручном SQL и аккуратно управлять строкой запроса и аргументами. В обоих случаях действуют одни и те же правила: соединения и курсоры закрываются вовремя, транзакции всегда имеют Rollback() по умолчанию и явный Commit() при успехе, ошибки оборачиваются контекстом, а производительность видна через метрики и планы выполнения.

Рекомендуемые программы

+7 800 100 22 47

бесплатно по РФ

+7 495 085 21 62

бесплатно по Москве

108813 г. Москва, вн.тер.г. поселение Московский,
г. Московский, ул. Солнечная, д. 3А, стр. 1, помещ. 20Б/3
ОГРН 1217300010476
ИНН 7325174845