21. Работа с базой данных

В программировании баз данных существует опасный соблазн — спрятать реальность реляционной модели за толстым слоем абстракций. В экосистемах PHP (Eloquent), Ruby (ActiveRecord) или Java (Hibernate/JPA) разработчиков годами учили, что таблицы базы данных — это просто классы в памяти, а запросы можно генерировать неявно. За эту иллюзию приходится платить спонтанными проблемами N+1 запросов, чудовищным потреблением памяти под кэши первого и второго уровней и неконтролируемым поведением блокировок в транзакциях.

Язык Go возвращает нас к суровой и прекрасной инженерной классике. Реляционная база данных — это отдельный сетевой сервис со своими сокетами, системными буферами, планировщиком транзакций и дисковым вводом-выводом. Пакет database/sql намеренно не пытается быть ORM. Он не строит за вас SQL-запросы и не генерирует схемы таблиц. Его задача — служить высокопроизводительным, надежным и потокобезопасным менеджером пула соединений, обеспечивая предсказуемый контроль над ресурсами машины.

Философия работы с БД в Go

В отличие от PHP (где каждое HTTP-запрос традиционно порождает изолированный процесс с собственным одноразовым TCP-соединением) или Java/Spring (где тяжелые ORM вроде Hibernate абстрагируют SQL за слоями динамических прокси и магических аннотаций), Go предлагает минималистичный и кристально явный подход. Стандартный пакет database/sql не реализует сетевые протоколы конкретных СУБД. Он предоставляет унифицированный интерфейс для управления пулом соединений, транзакциями и запросами, а всю низкоуровневую работу делегирует специализированным драйверам.

Эта архитектура дает разработчику полный контроль над временем выполнения запросов, потреблением памяти и поведением пула соединений, что критично для высоконагруженных бэкенд-систем.

Архитектура database/sql и драйверов

Пакет database/sql выступает как фасад и диспетчер. Конкретные драйверы (например, github.com/jackc/pgx/v5 для PostgreSQL или github.com/go-sql-driver/mysql для MySQL) реализуют системный интерфейс driver.Connector и регистрируются в глобальном реестре пакета через sql.Register().

Go
import (
    "context"
    "database/sql"
    "log"
    "time"

    _ "github.com/jackc/pgx/v5/stdlib" // Регистрация драйвера через init()
)

func main() {
    // Open НЕ устанавливает соединение. Он только валидирует DSN и создает пул.
    db, err := sql.Open("pgx", "postgres://user:pass@localhost:5432/db")
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()

    // Настройка параметров пула
    db.SetMaxOpenConns(25)
    db.SetMaxIdleConns(25)
    db.SetConnMaxLifetime(15 * time.Minute)
    db.SetConnMaxIdleTime(5 * time.Minute)

    // Реальная проверка физической доступности БД
    ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
    defer cancel()

    if err := db.PingContext(ctx); err != nil {
        log.Fatalf("database ping failed: %v", err)
    }
}

Под капотом. Пул соединений и механика sql.DB

Структура sql.DB — это не одно соединение, а сложный многопоточный пул (connection pool). Внутри он содержит:

  • freeConn: слайс доступных соединений *driverConn, готовых к немедленному использованию;
  • connRequests: очередь ожидающих горутин chan connRequest при исчерпании лимита;
  • numOpen: атомарный счетчик открытых в данный момент соединений;
  • openerCh: канал для асинхронного открытия новых соединений фоновым воркером;
  • mu: защитный sync.Mutex, координирующий состояние пула.

При вызове QueryContext:

  1. Горутина берет sync.Mutex пула.
  2. Если freeConn не пуст — берется последнее свободное соединение.
  3. Если свободно ничего нет и numOpen < MaxOpenConns — инициируется создание нового соединения.
  4. Если лимит достигнут — горутина создает структуру запроса с буферизованным каналом (make(chan connRequest, 1)), регистрирует ее в очереди connRequests и ожидает свободного соединения через select между чтением из канала и отменой ctx.Done().
  5. После выполнения запроса соединение очищается и возвращается в пул либо закрывается, если превышен ConnMaxIdleTime или ConnMaxLifetime.
flowchart TD
    A["Горутина вызывает Query"] --> B{"Блокировка sync.Mutex"}
    B --> C{"Есть свободные в freeConn?"}
    C -->|"Да"| D["Извлечение соединения"]
    C -->|"Нет"| E{"numOpen меньше лимита?"}
    E -->|"Да"| F["Создание нового соединения"]
    E -->|"Нет"| G["Ожидание в connRequests"]
    D --> H["Выполнение SQL запроса"]
    F --> H
    G -->|"Получение conn через канал"| D
    H --> I["Возврат соединения в пул"]
    I --> J{"Соединение живо и не просрочено?"}
    J -->|"Да"| K["Добавление в freeConn"]
    J -->|"Нет"| L["Закрытие сокета"]
    K --> M["Отпускание sync.Mutex"]
    L --> M

    classDef normal fill:#1e293b,stroke:#38bdf8,color:#f8fafc;
    classDef decision fill:#334155,stroke:#f59e0b,color:#f8fafc;
    classDef action fill:#0f172a,stroke:#10b981,color:#f8fafc;
    classDef term fill:#4c0519,stroke:#f43f5e,color:#ffffff;
    class A,D,F,H,I,K normal;
    class B,C,E,J decision;
    class G action;
    class L term;
  

Идиоматичный паттерн запросов

При работе с одиночными строками используется метод QueryRowContext. Он гарантирует атомарную выборку ровно одной строки и автоматически закрывает внутренний курсор:

Go
type User struct {
    ID    int64
    Name  string
    Email string
}

func GetUser(ctx context.Context, db *sql.DB, id int64) (*User, error) {
    // QueryRow гарантирует, что если строка найдена, она будет прочитана
    // или вернется sql.ErrNoRows. Закрытие rows происходит автоматически.
    row := db.QueryRowContext(ctx, "SELECT id, name, email FROM users WHERE id = $1", id)

    var u User
    err := row.Scan(&u.ID, &u.Name, &u.Email)
    if err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            return nil, nil // Или кастомная доменная ошибка ErrUserNotFound
        }
        return nil, fmt.Errorf("scan user %d: %w", id, err)
    }
    return &u, nil
}

Для итерации по наборам данных используется QueryContext в связке с rows.Next() и строгим defer rows.Close():

Go
func ListUsers(ctx context.Context, db *sql.DB) ([]User, error) {
    rows, err := db.QueryContext(ctx, "SELECT id, name, email FROM users")
    if err != nil {
        return nil, err
    }
    defer rows.Close() // Гарантирует возврат соединения в пул даже при панике

    var users []User
    for rows.Next() {
        var u User
        if err := rows.Scan(&u.ID, &u.Name, &u.Email); err != nil {
            return nil, err
        }
        users = append(users, u)
    }
    // Проверка ошибок после завершения цикла
    if err := rows.Err(); err != nil {
        return nil, err
    }
    return users, nil
}

Контекст, таймауты и отмена на уровне сети

Контекст в QueryContext/ExecContext управляет двумя критическими аспектами:

  1. Таймаутом ожидания в очереди пула: если все соединения заняты, горутина ждет освобождения не дольше дедлайна контекста.
  2. Отменой выполнения на стороне СУБД: если драйвер реализует интерфейс driver.QueryerContext, он пересылает отмену по протоколу СУБД.

При отмене контекста pgx или mysql драйвер отправляет команду отмены (CANCEL в PostgreSQL) или закрывает сокет. Важно: отмена контекста не разрывает соединение в пуле сразу. Соединение остается заблокированным до завершения системного вызова чтения/записи, после чего драйвер помечает его как driver.ErrBadConn и пул закрывает его.

Производительность и Mechanical Sympathy

Работа с БД генерирует измеримое давление на аппаратные ресурсы:

  1. Аллокации при Scan: Передача &u.ID в Scan безопасна, но создание []any{} для динамических колонок заставляет данные уходить в кучу. Компилятор не может доказать, что указатель не выйдет за пределы функции. pgx/v5 решает это через прямую запись в структуры без промежуточных интерфейсов, снижая аллокации на 60-80%.
  2. Системные вызовы и TLS: Каждое новое соединение требует socket(), connect(), TCP 3-way handshake и, при наличии, TLS handshake. Это 5-15 syscall и сотни миллисекунд задержки. Повторное использование соединений через пул — единственный способ снизить latency.
  3. Кэш-линии и пул: sync.Mutex защиты пула концентрирует все горутины на одном адресе памяти. При 5000+ RPS происходит cache line bouncing. Решение: увеличение MaxIdleConns, использование пулов уровня ОС (pgBouncer) или шардирование запросов на уровне приложения.
  4. Escape Analysis: Контекст ctx передается по ссылке, но сам ctx и его значения часто аллоцируются. Короткоживущие запросы создают давление на молодое поколение GC. Минимизация цепочек context.WithValue внутри цикла запросов критична.

Ловушки и антипаттерны

  • Транзакции с долгими операциями: db.Begin() блокирует одно соединение. Если внутри транзакции выполняется тяжелый SELECT или внешний HTTP-запрос, соединение выпадает из пула. При высокой нагрузке это быстро истощает MaxOpenConns. Транзакции должны быть максимально короткими и выполняться внутри одной функции.
  • driver.ErrBadConn: Драйвер возвращает эту ошибку, если соединение повреждено (обрыв сети, таймаут, несоответствие протокола). database/sql автоматически повторяет запрос с новым соединением (до 2 раз). Не логируйте это как критическую ошибку бизнеса.
  • Сканирование в неинициализированные указатели: Если в БД NULL, а в структуре *string, Scan успешно запишет nil. Но если структура содержит sql.NullString, требуется ручная проверка .Valid. Смешивание подходов ведет к паникам или скрытым багам.

Сравнение с другими экосистемами

Аспект PHP (PDO) Java (JDBC / HikariCP) C# (ADO.NET) Go (database/sql)
Пул соединений Отсутствует (одно соединение на процесс/запрос) Внешний (обычно подключается к драйверу) Встроен в драйвер на уровне ОС Встроен в стандартную библиотеку
Управление временем жизни Закрывается в конце скрипта Настраивается через конфиг пула Настраивается через connection string ConnMaxLifetime, ConnMaxIdleTime
ORM vs Raw Eloquent, Doctrine (высокая абстракция) Hibernate (тяжелый, сложный тюнинг) Entity Framework (codegen + mapping) Минимум абстракций, sqlc/pgx для генерации
Контекст отмены Нет Таймауты на уровне Statement CancellationToken Нативный context.Context, пробрасывается в драйвер

Итог

  1. database/sql — это интерфейс управления пулом, а не драйвер. Драйвер подключается отдельно.
  2. sql.Open() не проверяет связность. Всегда используйте PingContext().
  3. Пул соединений управляется через MaxOpenConns, MaxIdleConns, ConnMaxLifetime. Правильная настройка предотвращает дедлоки и превышение лимитов БД.
  4. Всегда используйте defer rows.Close() и проверяйте rows.Err() после цикла.
  5. Контекст отменяет запрос на уровне драйвера, но не мгновенно освобождает соединение в пуле.
  6. Для production Postgres предпочтительнее pgx вместо lib/pq из-за производительности, поддержки pipeline mode и меньшего давления на GC.
  7. Транзакции должны быть короткими и не содержать внешних блокирующих вызовов.

Следующая статья: 22. Repository pattern