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().
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:
- Горутина берет
sync.Mutexпула. - Если
freeConnне пуст — берется последнее свободное соединение. - Если свободно ничего нет и
numOpen < MaxOpenConns— инициируется создание нового соединения. - Если лимит достигнут — горутина создает структуру запроса с буферизованным каналом (
make(chan connRequest, 1)), регистрирует ее в очередиconnRequestsи ожидает свободного соединения черезselectмежду чтением из канала и отменойctx.Done(). - После выполнения запроса соединение очищается и возвращается в пул либо закрывается, если превышен
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. Он гарантирует атомарную выборку ровно одной строки и автоматически закрывает внутренний курсор:
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():
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 управляет двумя критическими аспектами:
- Таймаутом ожидания в очереди пула: если все соединения заняты, горутина ждет освобождения не дольше дедлайна контекста.
- Отменой выполнения на стороне СУБД: если драйвер реализует интерфейс
driver.QueryerContext, он пересылает отмену по протоколу СУБД.
При отмене контекста pgx или mysql драйвер отправляет команду отмены (CANCEL в PostgreSQL) или закрывает сокет. Важно: отмена контекста не разрывает соединение в пуле сразу. Соединение остается заблокированным до завершения системного вызова чтения/записи, после чего драйвер помечает его как driver.ErrBadConn и пул закрывает его.
Производительность и Mechanical Sympathy
Работа с БД генерирует измеримое давление на аппаратные ресурсы:
- Аллокации при
Scan: Передача&u.IDвScanбезопасна, но создание[]any{}для динамических колонок заставляет данные уходить в кучу. Компилятор не может доказать, что указатель не выйдет за пределы функции.pgx/v5решает это через прямую запись в структуры без промежуточных интерфейсов, снижая аллокации на 60-80%. - Системные вызовы и TLS: Каждое новое соединение требует
socket(),connect(), TCP 3-way handshake и, при наличии, TLS handshake. Это 5-15 syscall и сотни миллисекунд задержки. Повторное использование соединений через пул — единственный способ снизить latency. - Кэш-линии и пул:
sync.Mutexзащиты пула концентрирует все горутины на одном адресе памяти. При 5000+ RPS происходит cache line bouncing. Решение: увеличениеMaxIdleConns, использование пулов уровня ОС (pgBouncer) или шардирование запросов на уровне приложения. - 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, пробрасывается в драйвер |
Итог
database/sql— это интерфейс управления пулом, а не драйвер. Драйвер подключается отдельно.sql.Open()не проверяет связность. Всегда используйтеPingContext().- Пул соединений управляется через
MaxOpenConns,MaxIdleConns,ConnMaxLifetime. Правильная настройка предотвращает дедлоки и превышение лимитов БД. - Всегда используйте
defer rows.Close()и проверяйтеrows.Err()после цикла. - Контекст отменяет запрос на уровне драйвера, но не мгновенно освобождает соединение в пуле.
- Для production Postgres предпочтительнее
pgxвместоlib/pqиз-за производительности, поддержкиpipeline modeи меньшего давления на GC. - Транзакции должны быть короткими и не содержать внешних блокирующих вызовов.
Следующая статья: 22. Repository pattern