PostgreSQL и базы данных в Go
База данных - это не просто место, куда "сохраняют данные". В backend-приложении база отвечает за долговременное состояние: пользователей, книги, заказы, платежи, настройки, историю действий. Ошибка в работе с базой часто приводит не к красивому panic, а к потерянным данным, медленным запросам или странному поведению продукта.
Работа с PostgreSQL в Go обычно строится вокруг трех вещей: понятная схема, короткие SQL-запросы и слой, который отделяет хранилище от бизнес-логики.
database/sql и драйвер
В стандартной библиотеке есть пакет database/sql. Он задает общий интерфейс для работы с SQL-базами. Для конкретной базы нужен драйвер. Для PostgreSQL часто используют pgx.
db, err := sql.Open("pgx", os.Getenv("DATABASE_URL")) if err != nil { return err } defer db.Close() db.SetMaxOpenConns(10) db.SetMaxIdleConns(10) db.SetConnMaxLifetime(time.Hour) if err := db.Ping(); err != nil { return err }
sql.Open не открывает соединение сразу. Он создает pool. Поэтому Ping нужен, чтобы проверить, что база действительно доступна.
Connection pool
Pool - это набор переиспользуемых соединений с базой. Открывать новое TCP-соединение на каждый запрос дорого. Pool держит несколько соединений и раздает их операциям.
Основные настройки:
SetMaxOpenConns- максимум открытых соединений;SetMaxIdleConns- сколько соединений держать свободными;SetConnMaxLifetime- как долго соединение может жить.
Слишком маленький pool ограничит throughput. Слишком большой может перегрузить базу. Для учебного проекта достаточно разумных небольших значений, но важно понимать, что *sql.DB - это не одно соединение.
Схема таблицы
Пример таблицы:
CREATE TABLE books ( id UUID PRIMARY KEY, title TEXT NOT NULL, author TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() );
Правила:
- обязательные поля помечайте
NOT NULL; - используйте
created_atиupdated_atдля сущностей, которые меняются; - выбирайте типы осознанно:
TEXT,UUID,TIMESTAMPTZ,NUMERIC; - добавляйте индексы под реальные запросы;
- не храните несколько значений в одной строке через запятую, если по ним нужно искать.
Схема - это контракт данных. Если она не выражает ограничения, ошибки придется ловить вручную в коде.
sqlx
sqlx - популярная библиотека, которая делает работу с SQL удобнее, но не прячет SQL полностью. Она умеет сканировать строки в структуры по db tags.
type Book struct { ID string `db:"id"` Title string `db:"title"` Author string `db:"author"` CreatedAt time.Time `db:"created_at"` }
Запрос одной записи:
func FindBook(ctx context.Context, db *sqlx.DB, id string) (*Book, error) { var book Book err := db.GetContext(ctx, &book, ` SELECT id, title, author, created_at FROM books WHERE id = $1 `, id) if errors.Is(err, sql.ErrNoRows) { return nil, nil } if err != nil { return nil, fmt.Errorf("find book %s: %w", id, err) } return &book, nil }
$1 - placeholder. Не собирайте SQL через склеивание строк с пользовательским вводом. Это путь к SQL injection.
Запрос списка
var books []Book err := db.SelectContext(ctx, &books, ` SELECT id, title, author, created_at FROM books ORDER BY created_at DESC LIMIT $1 `, limit) if err != nil { return nil, fmt.Errorf("list books: %w", err) }
У списков почти всегда должен быть LIMIT. Endpoint, который случайно возвращает миллион строк, может положить и приложение, и базу.
sql.ErrNoRows
Отсутствие записи - не всегда авария. Если пользователь запросил книгу, которой нет, это нормальный сценарий для 404.
if errors.Is(err, sql.ErrNoRows) { return nil, ErrBookNotFound }
Лучше преобразовать техническую ошибку базы в ошибку домена, которую поймет сервисный слой.
Транзакции
Транзакция нужна, когда несколько запросов должны выполниться вместе:
tx, err := db.BeginTxx(ctx, nil) if err != nil { return err } defer tx.Rollback() if _, err := tx.ExecContext(ctx, `INSERT INTO books ...`); err != nil { return err } if _, err := tx.ExecContext(ctx, `INSERT INTO audit_log ...`); err != nil { return err } return tx.Commit()
Rollback после успешного Commit не навредит, но защитит от забытых откатов. Если второй запрос упал, первый тоже будет отменен.
Индексы
Индекс ускоряет чтение, но замедляет запись и занимает место. Добавляйте его под конкретный запрос:
CREATE INDEX books_author_idx ON books (author); CREATE INDEX books_created_at_idx ON books (created_at DESC);
Если фильтр по нескольким полям, может понадобиться составной индекс:
CREATE INDEX reviews_book_id_created_at_idx ON reviews (book_id, created_at DESC);
Индекс не магия. Он помогает только тем запросам, которые могут его использовать.
Migrations
Миграции - это история изменений схемы базы. Вместо ручного "создал таблицу локально" вы храните SQL-файлы в репозитории:
001_create_books.up.sql 001_create_books.down.sql 002_add_reviews.up.sql 002_add_reviews.down.sql
Так команда, CI и production получают одинаковую схему.
Repository
Repository прячет SQL за интерфейсом:
type BookRepository interface { FindByID(ctx context.Context, id string) (*Book, error) Create(ctx context.Context, book Book) error }
Сервис работает с интерфейсом, а не с SQL напрямую:
type BookService struct { books BookRepository }
Так бизнес-логику проще тестировать и менять хранилище.
Чеклист
- Все запросы принимают
context.Context. - Пользовательский ввод передается через placeholders.
- Ошибки SQL оборачиваются с контекстом.
sql.ErrNoRowsобрабатывается отдельно.- У списков есть
LIMITи пагинация. - Транзакции закрываются через
CommitилиRollback. - Индексы соответствуют реальным запросам.
- Схема меняется через migrations.
- Сервисный слой не знает деталей SQL.