Содержание

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 }

Так бизнес-логику проще тестировать и менять хранилище.

Чеклист

  1. Все запросы принимают context.Context.
  2. Пользовательский ввод передается через placeholders.
  3. Ошибки SQL оборачиваются с контекстом.
  4. sql.ErrNoRows обрабатывается отдельно.
  5. У списков есть LIMIT и пагинация.
  6. Транзакции закрываются через Commit или Rollback.
  7. Индексы соответствуют реальным запросам.
  8. Схема меняется через migrations.
  9. Сервисный слой не знает деталей SQL.

Следующий шаг после статьи

Закрепите тему в реальном проекте и продолжайте обучение на курсах Praxis.

Продолжить изучение

Выбери следующую статью по маршруту или углубись в смежную тему.

Похожие статьи