Модуль 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
goconfig, 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
textMaxConns = (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:
sqlSHOW 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:
textpostgres://app:secret@pgbouncer:6432/mydb?sslmode=disable
Мониторинг пула
gostats := 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);
Правила создания индексов
- Индексируйте колонки в WHERE, JOIN, ORDER BY
- Составные индексы: порядок колонок важен (селективность)
- Не создавайте слишком много индексов (замедляют запись)
- Используйте partial indexes для фильтрации
- Проверяйте использование индексов через 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:
sqlCREATE INDEX idx_repos_name_lower ON repos(LOWER(name));
5.3 EXPLAIN ANALYZE
Что показывает EXPLAIN ANALYZE
EXPLAIN ANALYZE выполняет запрос и показывает:
- План выполнения
- Реальное время каждой операции
- Количество строк на каждом шаге
- Используемые индексы
Синтаксис
sqlEXPLAIN ANALYZE SELECT * FROM repos WHERE owner_id = 'user-123' ORDER BY created_at DESC LIMIT 20;
Пример вывода
textLimit (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: сортировка (дорого если много строк)
Красные флаги в плане
-
Seq Scan на большой таблице
Seq Scan on repos (cost=0.00..12345.00 rows=500000 width=120)
Решение: добавить индекс на колонку в WHERE.
-
Sort с большим количеством строк
Sort (cost=5000.00..5100.00 rows=40000 width=120) Sort Key: created_at Sort Method: external merge Disk: 5000kB
Решение: индекс на колонке сортировки, увеличить work_mem.
-
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
gofunc (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 }
Практический процесс оптимизации
- Найдите медленный запрос (логи, мониторинг)
- Выполните EXPLAIN ANALYZE
- Найдите узкое место (Seq Scan, Sort, Nested Loop)
- Добавьте индекс или измените запрос
- Повторите EXPLAIN ANALYZE
- Сравните 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
gofunc (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 не подходит (разные таблицы, сложная логика):
gofunc (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
Для сложных случаев с множеством связанных сущностей:
gotype 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
- Логи: включите логирование SQL-запросов
- Мониторинг: количество запросов на эндпоинт
- 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:
gofunc (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:
gofunc (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 Read | Non-repeatable Read | Phantom Read | Производительность |
|---|---|---|---|---|
| READ COMMITTED | Нет | Возможно | Возможно | Высокая |
| REPEATABLE READ | Нет | Нет | Возможно | Средняя |
| SERIALIZABLE | Нет | Нет | Нет | Низкая |
PostgreSQL по умолчанию: READ COMMITTED.
Когда использовать каждый уровень
READ COMMITTED (default):
- Большинство CRUD операций
- Когда допустимо видеть зафиксированные изменения других транзакций
REPEATABLE READ:
- Отчёты и аналитика (консистентный снимок данных)
- Операции где важно видеть одни и те же данные в рамках транзакции
SERIALIZABLE:
- Финансовые операции
- Критичная консистентность
- Когда параллельные транзакции не должны влиять друг на друга
Установка уровня изоляции в pgx
gotx, err := pool.BeginTx(ctx, pgx.TxOptions{ IsoLevel: pgx.RepeatableRead, })
Serializable и retry
При SERIALIZABLE уровне возможны ошибки сериализации. Нужно повторять транзакцию:
gofunc 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.
textPrimary (read/write) | ├── Replica 1 (read only) ├── Replica 2 (read only) └── Replica 3 (read only)
Когда использовать
- Чтений значительно больше записей
- Нужна горизонтальная масштабируемость чтения
- Отчёты и аналитика не должны нагружать primary
Репликация
PostgreSQL streaming replication:
- Асинхронная: задержка миллисекунды-секунды
- Синхронная: запись ждёт подтверждения replica
Асинхронная репликация: возможна задержка (eventual consistency). Чтение из replica может вернуть устаревшие данные.
Настройка в коде
gotype 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
gofunc (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 — данные ещё не реплицировались.
Решения:
- Читать из primary сразу после записи
- Использовать сессию с "sticky primary" на время запроса
- Принять eventual consistency для некритичных данных
5.8 Тестирование с БД
Стратегия тестирования
| Слой | Тип тестов | БД |
|---|---|---|
| Service | Юнит-тесты | Mock репозитория |
| Repository | Интеграционные | Реальная БД (testcontainers) |
| Миграции | Интеграционные | Реальная БД (testcontainers) |
testcontainers-go
goimport ( "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 }
Паттерн: переиспользование контейнера
Запуск контейнера для каждого теста медленный. Оптимизация: один контейнер на пакет тестов.
govar 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 после каждого теста
gofunc cleanupDB(t *testing.T, pool *pgxpool.Pool) { t.Cleanup(func() { pool.Exec(context.Background(), "TRUNCATE repos, users, outbox_events CASCADE") }) }
Вариант 2: транзакция с rollback
gofunc TestWithRollback(t *testing.T, pool *pgxpool.Pool) { tx, err := pool.Begin(context.Background()) require.NoError(t, err) defer tx.Rollback(context.Background()) // Тест использует tx // После теста все изменения откатываются }
Привязка к шаблону проекта
Где PostgreSQL в шаблоне
textinternal/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
Что изучаем на примере шаблона
- Настройка пула в connection.go
- Параметризованные запросы в репозиториях
- Транзакции для атомарных операций
- Обработка pgx.ErrNoRows
- Миграции для версионирования схемы
Задание: изучите internal/adapters/postgres/
- Какие параметры пула настроены?
- Есть ли batch operations?
- Как обрабатываются ошибки?
- Есть ли транзакции?
💱 Компромиссы
- Большой
MaxConnsvs 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:
textconfig.MaxConns = ? config.MinConns = ? config.MaxConnLifetime = ?
Обоснуйте выбор. Проверьте через pg_stat_activity.
Задание 2: Индексы
Для запросов:
sqlSELECT * 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:
gorepos := getRepos() for _, repo := range repos { owner := getOwner(repo.OwnerID) }
Исправьте через JOIN и через batch query. Сравните количество запросов.
Задание 5: Batch INSERT
Реализуйте BatchCreate для 1000 записей тремя способами:
- Цикл с отдельными INSERT
- Один INSERT с множеством VALUES
- pgx Batch API
Сравните время выполнения.
Задание 6: Read replicas
Реализуйте Database с primary и replica:
gotype Database struct { primary *pgxpool.Pool replicas []*pgxpool.Pool } func (db *Database) ReadPool() *pgxpool.Pool func (db *Database) WritePool() *pgxpool.Pool
Quiz "Проверь себя"
cores * 2 + effective_spindle_count?pgxpool. Что важно проверить?SELECT * FROM repos WHERE owner_id = 'u1'
AND created_at > now() - interval '7 days';for _, pr := range pullRequests {
repo.GetAuthor(ctx, pr.AuthorID) // запрос в цикле
}WHERE owner_id = $1 AND created_at > $2✅ Итоги модуля
Студент должен:
- Настраивать 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/ шаблона.