Модуль 5. Database Access: PostgreSQL (углублённо)

Цели модуля

После модуля студент сможет:

  • Настраивать connection pool под нагрузку
  • Проектировать индексы и анализировать запросы через EXPLAIN ANALYZE
  • Распознавать и устранять N+1 problem
  • Использовать batch operations и COPY для bulk inserts
  • Понимать уровни изоляции и их влияние на конкурентность
  • Настраивать read replicas для масштабирования чтения
  • Тестировать repository слой с testcontainers

5.1 Connection pool tuning (детально)

Параметры пула в pgxpool

go
config, err := pgxpool.ParseConfig(dsn)
if err != nil {
    return nil, err
}

config.MaxConns = 25
config.MinConns = 5
config.MaxConnLifetime = time.Hour
config.MaxConnIdleTime = 30 * time.Minute
config.HealthCheckPeriod = time.Minute
config.MaxConnLifetimeJitter = 5 * time.Minute

Параметры и их назначение

ПараметрНазначениеРекомендация
MaxConnsМаксимум соединений в пулеcores * 2 + 1
MinConnsМинимум соединений (поддерживаются всегда)5-10
MaxConnLifetimeМаксимальное время жизни соединения1 час
MaxConnIdleTimeМаксимальное время простоя30 минут
HealthCheckPeriodПериод проверки здоровья соединений1 минута
MaxConnLifetimeJitterСлучайное отклонение времени жизни5 минут

Формула для MaxConns

text
MaxConns = (cores * 2) + effective_spindle_count

Для SSD: effective_spindle_count = 1 Для HDD: effective_spindle_count = количество дисков

Примеры:

  • 2 cores, SSD: MaxConns = 2 * 2 + 1 = 5
  • 4 cores, SSD: MaxConns = 4 * 2 + 1 = 9
  • 8 cores, SSD: MaxConns = 8 * 2 + 1 = 17

Учёт количества инстансов

ВАЖНО: MaxConns учитывается на инстанс сервиса.

Если 3 инстанса сервиса с MaxConns = 25: Общее количество соединений = 3 * 25 = 75

Проверьте max_connections в PostgreSQL:

sql
SHOW max_connections;

Если max_connections = 100, а у вас 75 соединений от сервиса + соединения от других сервисов + админские — можно упереться в лимит.

Решение:

  • Уменьшить MaxConns на инстанс
  • Использовать PgBouncer (connection pooler)
  • Увеличить max_connections (осторожно)

PgBouncer

PgBouncer — внешний connection pooler для PostgreSQL. Позволяет сотням клиентов использовать десятки соединений.

Режимы PgBouncer:

  • session: соединение на сессию клиента
  • transaction: соединение на транзакцию (рекомендуется)
  • statement: соединение на один запрос

С PgBouncer в DSN указывается адрес PgBouncer, не PostgreSQL:

text
postgres://app:secret@pgbouncer:6432/mydb?sslmode=disable

Мониторинг пула

go
stats := pool.Stat()

log.Info("pool stats",
    "acquired", stats.AcquiredConns(),
    "idle", stats.IdleConns(),
    "total", stats.TotalConns(),
    "constructing", stats.ConstructingConns(),
    "empty_acquire_count", stats.EmptyAcquireCount(),
)

Метрики для мониторинга:

  • AcquiredConns: сколько соединений используется
  • IdleConns: сколько соединений простаивает
  • EmptyAcquireCount: сколько раз пул был пуст (высокое значение = мало соединений)

5.2 Индексы и оптимизация запросов

Типы индексов в PostgreSQL

ТипКогда использовать
B-treeПо умолчанию. Равенство, диапазоны, сортировка
HashТолько равенство (редко используется)
GiSTПолнотекстовый поиск, геометрия
GINМассивы, JSONB, полнотекстовый поиск
BRINБольшие таблицы с упорядоченными данными

Создание индексов

sql
-- B-tree (по умолчанию)
CREATE INDEX idx_repos_owner_id ON repos(owner_id);

-- Составной индекс
CREATE INDEX idx_repos_owner_created ON repos(owner_id, created_at DESC);

-- Уникальный индекс
CREATE UNIQUE INDEX idx_users_email ON users(email);

-- Частичный индекс (partial index)
CREATE INDEX idx_repos_active ON repos(owner_id) WHERE deleted_at IS NULL;

-- GIN для JSONB
CREATE INDEX idx_events_payload ON events USING GIN(payload);

Правила создания индексов

  1. Индексируйте колонки в WHERE, JOIN, ORDER BY
  2. Составные индексы: порядок колонок важен (селективность)
  3. Не создавайте слишком много индексов (замедляют запись)
  4. Используйте partial indexes для фильтрации
  5. Проверяйте использование индексов через pg_stat_user_indexes

Составные индексы: порядок колонок

sql
-- Запрос: WHERE owner_id = $1 AND created_at > $2 ORDER BY created_at DESC
-- ПРАВИЛЬНО: owner_id первая (равенство), created_at вторая (диапазон)
CREATE INDEX idx_repos_owner_created ON repos(owner_id, created_at DESC);

-- ПЛОХО: created_at первая (не использует owner_id эффективно)
CREATE INDEX idx_repos_created_owner ON repos(created_at DESC, owner_id);

Правило: колонки с равенством (=) перед колонками с диапазоном (>, <, BETWEEN).

Когда индекс не используется

Индекс не используется если:

  • Запрос возвращает большую часть таблицы (seq scan быстрее)
  • Функция на колонке: WHERE LOWER(name) = 'test'
  • Тип не совпадает: WHERE id = '123' (id — UUID, передаём string)
  • LIKE '%pattern%' (префиксный поиск)

Решение для LOWER:

sql
CREATE INDEX idx_repos_name_lower ON repos(LOWER(name));

5.3 EXPLAIN ANALYZE

Что показывает EXPLAIN ANALYZE

EXPLAIN ANALYZE выполняет запрос и показывает:

  • План выполнения
  • Реальное время каждой операции
  • Количество строк на каждом шаге
  • Используемые индексы

Синтаксис

sql
EXPLAIN ANALYZE
SELECT * FROM repos WHERE owner_id = 'user-123' ORDER BY created_at DESC LIMIT 20;

Пример вывода

text
Limit  (cost=0.28..8.30 rows=20 width=120) (actual time=0.021..0.045 rows=20 loops=1)
  ->  Index Scan Backward using idx_repos_owner_created on repos
        (cost=0.28..45.67 rows=112 width=120) (actual time=0.020..0.042 rows=20 loops=1)
        Index Cond: (owner_id = 'user-123'::uuid)
Planning Time: 0.085 ms
Execution Time: 0.062 ms

Чтение плана

Ключевые элементы:

  • Seq Scan: полное сканирование таблицы (плохо для больших таблиц)
  • Index Scan: использование индекса (хорошо)
  • Index Only Scan: чтение только из индекса (отлично)
  • Bitmap Index Scan: индекс для множества строк
  • Nested Loop: вложенный цикл для JOIN
  • Hash Join: хеш-соединение
  • Sort: сортировка (дорого если много строк)

Красные флаги в плане

  1. Seq Scan на большой таблице

    Seq Scan on repos (cost=0.00..12345.00 rows=500000 width=120)

Решение: добавить индекс на колонку в WHERE.

  1. Sort с большим количеством строк

    Sort (cost=5000.00..5100.00 rows=40000 width=120) Sort Key: created_at Sort Method: external merge Disk: 5000kB

Решение: индекс на колонке сортировки, увеличить work_mem.

  1. Nested Loop с большим количеством итераций

    Nested Loop (cost=0.00..50000.00 rows=10000 width=120) -> Seq Scan on users -> Index Scan using idx_repos_owner_id on repos

Решение: Hash Join (изменить запрос или увеличить work_mem).

EXPLAIN в коде Go

go
func (r *RepoRepository) ExplainQuery(ctx context.Context, query string, args ...interface{}) (string, error) {
    var plan strings.Builder
    rows, err := r.pool.Query(ctx, "EXPLAIN ANALYZE "+query, args...)
    if err != nil {
        return "", err
    }
    defer rows.Close()

    for rows.Next() {
        var line string
        if err := rows.Scan(&line); err != nil {
            return "", err
        }
        plan.WriteString(line + "\n")
    }

    return plan.String(), nil
}

Практический процесс оптимизации

  1. Найдите медленный запрос (логи, мониторинг)
  2. Выполните EXPLAIN ANALYZE
  3. Найдите узкое место (Seq Scan, Sort, Nested Loop)
  4. Добавьте индекс или измените запрос
  5. Повторите EXPLAIN ANALYZE
  6. Сравните Execution Time

5.4 N+1 problem и решения

Что такое N+1 problem

Запрос списка + N запросов для связанных данных.

Пример: получить список репозиториев с именами владельцев.

go
// N+1: 1 запрос на список + N запросов на владельцев
repos, _ := repoRepo.List(ctx, filter)  // 1 запрос

for _, repo := range repos {
    user, _ := userRepo.GetByID(ctx, repo.OwnerID)  // N запросов
    repo.OwnerName = user.Name
}

Для 100 репозиториев: 1 + 100 = 101 запрос к БД.

Решение 1: JOIN

go
func (r *RepoRepository) ListWithOwners(ctx context.Context, filter RepoFilter) ([]*RepoWithOwner, error) {
    query := `
        SELECT r.id, r.name, r.owner_id, r.created_at,
               u.name as owner_name, u.email as owner_email
        FROM repos r
        JOIN users u ON r.owner_id = u.id
        WHERE r.owner_id = $1
        LIMIT $2 OFFSET $3
    `

    rows, err := r.pool.Query(ctx, query, filter.OwnerID, filter.Limit, filter.Offset)
    if err != nil {
        return nil, err
    }
    defer rows.Close()

    var results []*RepoWithOwner
    for rows.Next() {
        var item RepoWithOwner
        if err := rows.Scan(
            &item.ID, &item.Name, &item.OwnerID, &item.CreatedAt,
            &item.OwnerName, &item.OwnerEmail,
        ); err != nil {
            return nil, err
        }
        results = append(results, &item)
    }

    return results, rows.Err()
}

Один запрос вместо N+1.

Решение 2: Batch query

Если JOIN не подходит (разные таблицы, сложная логика):

go
func (r *RepoRepository) ListWithOwners(ctx context.Context, filter RepoFilter) ([]*RepoWithOwner, error) {
    // 1. Получаем репозитории
    repos, err := r.List(ctx, filter)
    if err != nil {
        return nil, err
    }

    // 2. Собираем уникальные owner_id
    ownerIDs := make([]string, 0, len(repos))
    seen := make(map[string]bool)
    for _, repo := range repos {
        if !seen[repo.OwnerID] {
            ownerIDs = append(ownerIDs, repo.OwnerID)
            seen[repo.OwnerID] = true
        }
    }

    // 3. Один запрос на всех владельцев
    users, err := r.userRepo.GetByIDs(ctx, ownerIDs)
    if err != nil {
        return nil, err
    }

    // 4. Маппинг
    userMap := make(map[string]*models.User)
    for _, user := range users {
        userMap[user.ID] = user
    }

    results := make([]*RepoWithOwner, 0, len(repos))
    for _, repo := range repos {
        owner := userMap[repo.OwnerID]
        results = append(results, &RepoWithOwner{
            Repo:      repo,
            OwnerName: owner.Name,
        })
    }

    return results, nil
}

// GetByIDs: один запрос с IN
func (r *UserRepository) GetByIDs(ctx context.Context, ids []string) ([]*models.User, error) {
    query := "SELECT id, name, email FROM users WHERE id = ANY($1)"
    rows, err := r.pool.Query(ctx, query, ids)
    // ...
}

2 запроса вместо N+1.

Решение 3: DataLoader pattern

Для сложных случаев с множеством связанных сущностей:

go
type DataLoader struct {
    pool    *pgxpool.Pool
    cache   map[string]*models.User
    pending []string
}

func (dl *DataLoader) Load(ctx context.Context, id string) (*models.User, error) {
    // Проверяем кэш
    if user, ok := dl.cache[id]; ok {
        return user, nil
    }

    // Добавляем в pending
    dl.pending = append(dl.pending, id)

    // Batch загрузка при достижении порога
    if len(dl.pending) >= 100 {
        if err := dl.flush(ctx); err != nil {
            return nil, err
        }
    }

    return dl.cache[id], nil
}

Как обнаружить N+1

  1. Логи: включите логирование SQL-запросов
  2. Мониторинг: количество запросов на эндпоинт
  3. pg_stat_statements: статистика запросов
sql
-- Включение логирования запросов
   ALTER SYSTEM SET log_statement = 'all';
   SELECT pg_reload_conf();

5.5 Batch operations и COPY

Batch INSERT

Для вставки множества строк используйте batch INSERT:

go
// ПЛОХО: N отдельных INSERT
for _, repo := range repos {
    r.pool.Exec(ctx, "INSERT INTO repos ...", repo.ID, repo.Name)
}

// ПРАВИЛЬНО: один INSERT с множеством значений
func (r *RepoRepository) BatchCreate(ctx context.Context, repos []*models.Repo) error {
    if len(repos) == 0 {
        return nil
    }

    query := "INSERT INTO repos (id, name, owner_id, created_at) VALUES "
    args := make([]interface{}, 0, len(repos)*4)

    for i, repo := range repos {
        if i > 0 {
            query += ", "
        }
        query += fmt.Sprintf("($%d, $%d, $%d, $%d)", i*4+1, i*4+2, i*4+3, i*4+4)
        args = append(args, repo.ID, repo.Name, repo.OwnerID, repo.CreatedAt)
    }

    _, err := r.pool.Exec(ctx, query, args...)
    return err
}

pgx Batch API

pgx предоставляет удобный Batch API:

go
func (r *RepoRepository) BatchCreate(ctx context.Context, repos []*models.Repo) error {
    batch := &pgx.Batch{}

    for _, repo := range repos {
        batch.Queue(
            "INSERT INTO repos (id, name, owner_id, created_at) VALUES ($1, $2, $3, $4)",
            repo.ID, repo.Name, repo.OwnerID, repo.CreatedAt,
        )
    }

    results := r.pool.SendBatch(ctx, batch)
    defer results.Close()

    for range repos {
        if _, err := results.Exec(); err != nil {
            return fmt.Errorf("batch insert: %w", err)
        }
    }

    return nil
}

COPY protocol для bulk inserts

Для очень больших объёмов (тысячи+ строк) используйте COPY:

go
func (r *RepoRepository) BulkCreate(ctx context.Context, repos []*models.Repo) error {
    txn, err := r.pool.Begin(ctx)
    if err != nil {
        return err
    }
    defer txn.Rollback(ctx)

    _, err = txn.CopyFrom(
        ctx,
        pgx.Identifier{"repos"},
        []string{"id", "name", "owner_id", "created_at"},
        pgx.CopyFromSlice(len(repos), func(i int) ([]interface{}, error) {
            return []interface{}{
                repos[i].ID,
                repos[i].Name,
                repos[i].OwnerID,
                repos[i].CreatedAt,
            }, nil
        }),
    )
    if err != nil {
        return err
    }

    return txn.Commit(ctx)
}

COPY в 5-10 раз быстрее batch INSERT для больших объёмов.


5.6 Транзакции и уровни изоляции (углублённо)

Уровни изоляции в PostgreSQL

УровеньDirty ReadNon-repeatable ReadPhantom ReadПроизводительность
READ COMMITTEDНетВозможноВозможноВысокая
REPEATABLE READНетНетВозможноСредняя
SERIALIZABLEНетНетНетНизкая

PostgreSQL по умолчанию: READ COMMITTED.

Когда использовать каждый уровень

READ COMMITTED (default):

  • Большинство CRUD операций
  • Когда допустимо видеть зафиксированные изменения других транзакций

REPEATABLE READ:

  • Отчёты и аналитика (консистентный снимок данных)
  • Операции где важно видеть одни и те же данные в рамках транзакции

SERIALIZABLE:

  • Финансовые операции
  • Критичная консистентность
  • Когда параллельные транзакции не должны влиять друг на друга

Установка уровня изоляции в pgx

go
tx, err := pool.BeginTx(ctx, pgx.TxOptions{
    IsoLevel: pgx.RepeatableRead,
})

Serializable и retry

При SERIALIZABLE уровне возможны ошибки сериализации. Нужно повторять транзакцию:

go
func WithSerializableRetry(ctx context.Context, pool *pgxpool.Pool, fn func(pgx.Tx) error) error {
    maxRetries := 3

    for attempt := 0; attempt < maxRetries; attempt++ {
        tx, err := pool.BeginTx(ctx, pgx.TxOptions{
            IsoLevel: pgx.Serializable,
        })
        if err != nil {
            return fmt.Errorf("begin tx: %w", err)
        }

        if err := fn(tx); err != nil {
            tx.Rollback(ctx)

            // Проверяем ошибку сериализации
            var pgErr *pgconn.PgError
            if errors.As(err, &pgErr) && pgErr.Code == "40001" {
                // Serialization failure, retry
                time.Sleep(time.Duration(attempt*100) * time.Millisecond)
                continue
            }

            return err
        }

        if err := tx.Commit(ctx); err != nil {
            var pgErr *pgconn.PgError
            if errors.As(err, &pgErr) && pgErr.Code == "40001" {
                continue
            }
            return err
        }

        return nil
    }

    return fmt.Errorf("max retries exceeded for serializable transaction")
}

Advisory locks

Для распределённой блокировки без таблиц:

go
// Захват блокировки
var acquired bool
err := pool.QueryRow(ctx, "SELECT pg_try_advisory_lock($1)", lockID).Scan(&acquired)
if err != nil {
    return err
}
if !acquired {
    return ErrLockNotAcquired
}

// Освобождение блокировки
_, err = pool.Exec(ctx, "SELECT pg_advisory_unlock($1)", lockID)

Использование: предотвращение двойного запуска job.


5.7 Read replicas

Что такое read replicas

Read replica — копия primary БД для чтения. Запись только в primary.

text
Primary (read/write)
    |
    ├── Replica 1 (read only)
    ├── Replica 2 (read only)
    └── Replica 3 (read only)

Когда использовать

  • Чтений значительно больше записей
  • Нужна горизонтальная масштабируемость чтения
  • Отчёты и аналитика не должны нагружать primary

Репликация

PostgreSQL streaming replication:

  • Асинхронная: задержка миллисекунды-секунды
  • Синхронная: запись ждёт подтверждения replica

Асинхронная репликация: возможна задержка (eventual consistency). Чтение из replica может вернуть устаревшие данные.

Настройка в коде

go
type DBConfig struct {
    PrimaryDSN  string
    ReplicaDSNs []string
}

type Database struct {
    primary  *pgxpool.Pool
    replicas []*pgxpool.Pool
}

func (db *Database) WritePool() *pgxpool.Pool {
    return db.primary
}

func (db *Database) ReadPool() *pgxpool.Pool {
    if len(db.replicas) == 0 {
        return db.primary
    }
    // Round-robin выбор replica
    idx := atomic.AddInt64(&db.replicaIndex, 1) % int64(len(db.replicas))
    return db.replicas[idx]
}

Использование в repository

go
func (r *RepoRepository) GetByID(ctx context.Context, id string) (*models.Repo, error) {
    // Чтение из replica
    pool := r.db.ReadPool()
    query := "SELECT ... FROM repos WHERE id = $1"
    // ...
}

func (r *RepoRepository) Create(ctx context.Context, repo *models.Repo) error {
    // Запись в primary
    pool := r.db.WritePool()
    query := "INSERT INTO repos ..."
    // ...
}

Read-after-write consistency

Проблема: запись в primary, чтение из replica — данные ещё не реплицировались.

Решения:

  1. Читать из primary сразу после записи
  2. Использовать сессию с "sticky primary" на время запроса
  3. Принять eventual consistency для некритичных данных

5.8 Тестирование с БД

Стратегия тестирования

СлойТип тестовБД
ServiceЮнит-тестыMock репозитория
RepositoryИнтеграционныеРеальная БД (testcontainers)
МиграцииИнтеграционныеРеальная БД (testcontainers)

testcontainers-go

go
import (
    "github.com/testcontainers/testcontainers-go"
    "github.com/testcontainers/testcontainers-go/modules/postgres"
)

func setupTestDB(t *testing.T) *pgxpool.Pool {
    ctx := context.Background()

    container, err := postgres.Run(ctx,
        "postgres:16-alpine",
        postgres.WithDatabase("testdb"),
        postgres.WithUsername("test"),
        postgres.WithPassword("test"),
    )
    require.NoError(t, err)

    t.Cleanup(func() {
        container.Terminate(ctx)
    })

    dsn, err := container.ConnectionString(ctx)
    require.NoError(t, err)

    pool, err := pgxpool.New(ctx, dsn)
    require.NoError(t, err)

    t.Cleanup(func() {
        pool.Close()
    })

    // Применение миграций
    db := stdlib.OpenDBFromPool(pool)
    err = migrations.Run(db)
    require.NoError(t, err)

    return pool
}

Паттерн: переиспользование контейнера

Запуск контейнера для каждого теста медленный. Оптимизация: один контейнер на пакет тестов.

go
var testPool *pgxpool.Pool

func TestMain(m *testing.M) {
    ctx := context.Background()

    container, err := postgres.Run(ctx, "postgres:16-alpine", ...)
    if err != nil {
        log.Fatal(err)
    }

    dsn, _ := container.ConnectionString(ctx)
    testPool, _ = pgxpool.New(ctx, dsn)

    // Применение миграций
    // ...

    code := m.Run()

    testPool.Close()
    container.Terminate(ctx)

    os.Exit(code)
}

Очистка данных между тестами

Вариант 1: TRUNCATE после каждого теста

go
func cleanupDB(t *testing.T, pool *pgxpool.Pool) {
    t.Cleanup(func() {
        pool.Exec(context.Background(), "TRUNCATE repos, users, outbox_events CASCADE")
    })
}

Вариант 2: транзакция с rollback

go
func TestWithRollback(t *testing.T, pool *pgxpool.Pool) {
    tx, err := pool.Begin(context.Background())
    require.NoError(t, err)

    defer tx.Rollback(context.Background())

    // Тест использует tx
    // После теста все изменения откатываются
}

Привязка к шаблону проекта

Где PostgreSQL в шаблоне

text
internal/adapters/postgres/
├── connection.go            # Подключение и настройка пула
├── repo_repository.go       # Реализация RepoRepository
├── user_repository.go       # Реализация UserRepository
└── outbox_repository.go     # Реализация OutboxRepository

migrations/
├── 001_init.sql
├── 002_repos.sql
├── 003_users.sql
├── 004_outbox.sql
└── 005_sagas.sql

Что изучаем на примере шаблона

  1. Настройка пула в connection.go
  2. Параметризованные запросы в репозиториях
  3. Транзакции для атомарных операций
  4. Обработка pgx.ErrNoRows
  5. Миграции для версионирования схемы

Задание: изучите internal/adapters/postgres/

  • Какие параметры пула настроены?
  • Есть ли batch operations?
  • Как обрабатываются ошибки?
  • Есть ли транзакции?

💱 Компромиссы

  • Большой MaxConns vs PgBouncer: щедрый пул на инстанс снижает ожидание Acquire, но 3 инстанса по 25 соединений съедают max_connections; PgBouncer в режиме transaction обслуживает сотни клиентов десятками соединений, но session-level фичи вроде advisory locks при этом недоступны.
  • JOIN vs batch query против N+1: один JOIN закрывает 1+N запросов, но склеивает таблицы и раздувает сканирование; два запроса с WHERE id = ANY($1) и ручным маппингом в userMap держат репозитории раздельными — ценой сборки карты на стороне Go.
  • batch INSERT vs COPY: pgx.Batch проще и даёт построчные ошибки; CopyFrom в 5–10 раз быстрее на тысячах строк за счёт бинарного протокола, но весь объём идёт одной транзакцией без построчной обработки ошибок.
  • READ COMMITTED vs SERIALIZABLE: дефолтный READ COMMITTED быстр и достаточен для CRUD, но допускает non-repeatable и phantom read; SERIALIZABLE убирает аномалии ценой ретраев по коду 40001 и меньшей пропускной способности.
  • Read replica vs чтение из primary: реплика через ReadPool() снимает нагрузку с primary, но асинхронная репликация — eventual consistency: read-after-write приходится вести в primary или мириться с устаревшими данными.

Практическое задание

Задание 1: Connection pool tuning

Настройте пул для нагрузки 100 RPS:

text
config.MaxConns = ?
config.MinConns = ?
config.MaxConnLifetime = ?

Обоснуйте выбор. Проверьте через pg_stat_activity.

Задание 2: Индексы

Для запросов:

sql
SELECT * FROM repos WHERE owner_id = $1 ORDER BY created_at DESC LIMIT 20;
SELECT * FROM repos WHERE name ILIKE '%search%' AND owner_id = $1;
SELECT COUNT(*) FROM repos WHERE is_private = true;

Создайте оптимальные индексы. Обоснуйте выбор.

Задание 3: EXPLAIN ANALYZE

Выполните EXPLAIN ANALYZE для запроса из Задания 2. Проанализируйте план. Убедитесь что индексы используются.

Задание 4: N+1 problem

Дан код с N+1:

go
repos := getRepos()
for _, repo := range repos {
    owner := getOwner(repo.OwnerID)
}

Исправьте через JOIN и через batch query. Сравните количество запросов.

Задание 5: Batch INSERT

Реализуйте BatchCreate для 1000 записей тремя способами:

  1. Цикл с отдельными INSERT
  2. Один INSERT с множеством VALUES
  3. pgx Batch API

Сравните время выполнения.

Задание 6: Read replicas

Реализуйте Database с primary и replica:

go
type Database struct {
    primary  *pgxpool.Pool
    replicas []*pgxpool.Pool
}

func (db *Database) ReadPool() *pgxpool.Pool
func (db *Database) WritePool() *pgxpool.Pool

Quiz "Проверь себя"

1. Чем COPY отличается от batch INSERT?
2. Какой размер пула обычно выбирают для web-сервиса по формуле cores * 2 + effective_spindle_count?
3. Что входит в организацию тестов repository слоя с testcontainers-go? — выберите все подходящие
4. Почему после записи в primary нельзя сразу гарантированно прочитать объект из read replica?
5. Как устранить N+1 problem при загрузке 100 PR с авторами?
6. Сервис имеет 3 инстанса по 25 соединений в pgxpool. Что важно проверить?
7. Что покажет EXPLAIN ANALYZE для этого запроса?
sql
SELECT * FROM repos WHERE owner_id = 'u1'
  AND created_at > now() - interval '7 days';
8. Сколько запросов выполнит этот код и как называется проблема?
go
for _, pr := range pullRequests {
    repo.GetAuthor(ctx, pr.AuthorID) // запрос в цикле
}
9. Какой индекс нужен для этого запроса?
sql
WHERE owner_id = $1 AND created_at > $2
10. Когда выбрать REPEATABLE READ вместо READ COMMITTED?

✅ Итоги модуля

Студент должен:

  • Настраивать connection pool под нагрузку
  • Проектировать индексы и анализировать запросы
  • Распознавать и устранять N+1 problem
  • Использовать batch operations и COPY
  • Понимать уровни изоляции
  • Настраивать read replicas
  • Тестировать repository с testcontainers
  • Ответить на все вопросы Quiz

🔗 Мостик к Модулю 6

Следующий модуль: Модуль 6. Business Logic Layer - services vs use cases, domain models, validation rules, domain logic isolation, error propagation across layers. Привязка к internal/service/ и internal/usecase/ шаблона.