Цели модуля
После модуля студент сможет:
- Проектировать безопасную эволюцию схемы БД без downtime
- Настраивать read/write separation для масштабирования
- Реализовывать стратегию бэкапов и восстановления
- Выбирать между NFS, S3 и Object Storage под задачу
- Определять data retention policies для разных типов данных
- Проектировать Disaster Recovery стратегию с RTO/RPO
- Понимать основы sharding и горизонтального масштабирования БД
7.1 Storage strategy overview
Типы данных в системе
Разные данные требуют разных хранилищ:
| Тип данных | Примеры | Хранилище |
|---|---|---|
| Структурированные | Users, orders, repos | PostgreSQL |
| Неструктурированные | Файлы, изображения | S3, NFS |
| Кэш | Sessions, hot data | Redis |
| Логи | Application logs | Loki, Elasticsearch |
| Метрики | Time-series | Prometheus, VictoriaMetrics |
| Очереди | Events, tasks | Kafka, RabbitMQ |
Принципы выбора хранилища
-
Структура данных
- Таблицы с отношениями: реляционная БД
- Ключ-значение: KV store
- Документы: документная БД
- Графы: graph DB
-
Паттерн доступа
- Частое чтение, редкая запись: кэш + БД
- Частая запись, редкое чтение: append-only storage
- Потоковая обработка: message queue
-
Требования к консистентности
- Strong consistency: реляционная БД
- Eventual consistency: distributed stores
-
Требования к доступности
- High availability: реплики, кластеры
- Durability: репликация, бэкапы
-
Стоимость
- Hot data: быстрые диски (SSD)
- Cold data: дешёвое хранилище (S3 Glacier)
Data ownership
Каждый сервис владеет своими данными:
Catalog Service -> owns products, categories
Order Service -> owns orders, order_items
User Service -> owns users, profiles
Правила:
- Сервис не читает таблицы других сервисов напрямую
- Взаимодействие через API или события
- Денормализация допустима для производительности
7.2 Schema evolution
Проблема миграций в production
Изменение схемы в production может:
- Заблокировать таблицу на время миграции
- Сломать работающее приложение
- Потребовать downtime
Безопасные миграции: expand and contract
Паттерн для изменений без downtime:
Фаза 1: Expand (добавление)
- Добавляем новую колонку/таблицу
- Приложение пишет в обе (старую и новую)
- Старая логика продолжает работать
Фаза 2: Migrate (миграция данных)
- Переносим данные из старой структуры в новую
- Батчами, без блокировки
Фаза 3: Contract (удаление)
- Приложение переключается на новую структуру
- Удаляем старую колонку/таблицу
Пример: переименование колонки
НЕ ДЕЛАЙТЕ:
sql-- ПЛОХО: блокирует таблицу, ломает приложение ALTER TABLE users RENAME COLUMN name TO full_name;
ПРАВИЛЬНО (expand and contract):
sql-- Шаг 1: Добавляем новую колонку ALTER TABLE users ADD COLUMN full_name VARCHAR(255); -- Шаг 2: Копируем данные (батчами) UPDATE users SET full_name = name WHERE full_name IS NULL LIMIT 1000; -- Шаг 3: Приложение пишет в обе колонки -- INSERT INTO users (name, full_name) VALUES ($1, $1) -- Шаг 4: Приложение переключается на full_name -- Шаг 5: Удаляем старую колонку ALTER TABLE users DROP COLUMN name;
Пример: изменение типа колонки
sql-- Шаг 1: Добавляем новую колонку с новым типом ALTER TABLE orders ADD COLUMN amount_cents BIGINT; -- Шаг 2: Копируем данные UPDATE orders SET amount_cents = (amount * 100)::BIGINT; -- Шаг 3: Приложение пишет в обе -- Шаг 4: Переключаем чтение на amount_cents -- Шаг 5: DROP COLUMN amount
Безопасные операции с индексами
sql-- ПЛОХО: блокирует таблицу CREATE INDEX idx_users_email ON users(email); -- ПРАВИЛЬНО: не блокирует (PostgreSQL) CREATE INDEX CONCURRENTLY idx_users_email ON users(email);
Важно: CREATE INDEX CONCURRENTLY не работает внутри транзакции. Для goose:
sql-- +goose Up -- +goose NO TRANSACTION CREATE INDEX CONCURRENTLY idx_users_email ON users(email); -- +goose Down DROP INDEX IF EXISTS idx_users_email;
Миграции с данными
Для больших таблиц миграцию данных выполняем батчами:
gofunc MigrateData(ctx context.Context, db *sql.DB) error { batchSize := 1000 var lastID int64 = 0 for { rows, err := db.QueryContext(ctx, ` UPDATE users SET full_name = name WHERE id IN ( SELECT id FROM users WHERE full_name IS NULL AND id > $1 ORDER BY id LIMIT $2 ) RETURNING id `, lastID, batchSize) if err != nil { return err } count := 0 for rows.Next() { var id int64 rows.Scan(&id) lastID = id count++ } rows.Close() if count < batchSize { break // всё обработано } // Пауза между батчами чтобы не нагружать БД time.Sleep(100 * time.Millisecond) } return nil }
Backward compatible schema changes
Безопасные изменения (не ломают приложение):
- Добавление колонки с NULL или DEFAULT
- Добавление индекса (CONCURRENTLY)
- Добавление таблицы
- Добавление optional поля
Опасные изменения (требуют expand/contract):
- Переименование колонки
- Изменение типа колонки
- Добавление NOT NULL без DEFAULT
- Удаление колонки
Миграции и деплой
Порядок операций:
1. Применение миграций (schema)
2. Деплой нового кода
3. Миграция данных (если нужна)
4. Очистка (удаление старых колонок)
Миграции должны быть backward compatible с предыдущей версией кода.
7.3 Read/Write separation
Что такое read replicas
Read replica — копия primary БД для чтения. Запись только в primary.
Primary (read/write)
|
├── Replica 1 (read only)
├── Replica 2 (read only)
└── Replica 3 (read only)
Когда использовать
- Чтений значительно больше записей (90/10 или больше)
- Нужна горизонтальная масштабируемость чтения
- Отчёты и аналитика не должны нагружать primary
- Географическое распределение (replicas ближе к пользователям)
Настройка в коде
gotype Database struct { primary *pgxpool.Pool replicas []*pgxpool.Pool replicaIndex int64 // atomic для round-robin } func NewDatabase(cfg DatabaseConfig) (*Database, error) { primary, err := pgxpool.New(ctx, cfg.PrimaryDSN) if err != nil { return nil, fmt.Errorf("connect primary: %w", err) } replicas := make([]*pgxpool.Pool, 0, len(cfg.ReplicaDSNs)) for _, dsn := range cfg.ReplicaDSNs { replica, err := pgxpool.New(ctx, dsn) if err != nil { return nil, fmt.Errorf("connect replica: %w", err) } replicas = append(replicas, replica) } return &Database{ primary: primary, replicas: replicas, }, nil } 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 ..." // ... }
Replication lag
Проблема: запись в primary, чтение из replica — данные ещё не реплицировались.
Решения:
- Read-your-writes: после записи читать из primary
gofunc (s *Service) CreateAndGet(ctx context.Context, req CreateReq) (*Repo, error) { repo, err := s.repo.Create(ctx, req) // запись в primary if err != nil { return nil, err } // Читаем из primary сразу после записи return s.repo.GetByIDFromPrimary(ctx, repo.ID) }
- Session sticky: после записи читать из primary N секунд
gofunc (s *Service) CreateRepo(ctx context.Context, req CreateReq) error { err := s.repo.Create(ctx, req) if err != nil { return err } // Устанавливаем cookie/header: читать из primary 5 секунд ctx = context.WithValue(ctx, ReadFromPrimaryUntil, time.Now().Add(5*time.Second)) return nil }
- Принять eventual consistency для некритичных данных
Мониторинг replication lag
sql-- На replica SELECT now() - pg_last_xact_replay_timestamp() AS replication_lag;
Метрика в Prometheus:
pg_replication_lag_seconds{replica="replica-1"}
Алерт:
yaml- alert: HighReplicationLag expr: pg_replication_lag_seconds > 10 for: 5m labels: severity: warning
7.4 Backups and restore
Зачем нужны бэкапы
- Защита от потери данных (hardware failure, human error)
- Compliance требования
- Возможность отката к предыдущему состоянию
- Disaster recovery
Типы бэкапов
| Тип | Описание | Размер | Скорость восстановления |
|---|---|---|---|
| Full | Полная копия БД | Большой | Быстрая |
| Incremental | Изменения с последнего бэкапа | Малый | Медленная |
| Differential | Изменения с последнего full | Средний | Средняя |
| WAL archiving | Логи транзакций | Малый | Point-in-time recovery |
Стратегия бэкапов для PostgreSQL
-
Ежедневный full backup
pg_dump для небольших БД
pg_dump -Fc mydb > backup_$(date +%Y%m%d).dump
pg_basebackup для больших БД
pg_basebackup -D /backups/base_$(date +%Y%m%d) -Ft -z
-
Непрерывный WAL archiving
postgresql.conf
archive_mode = on archive_command = 'test ! -f /wal_archive/%f && cp %p /wal_archive/%f'
-
Point-in-time recovery (PITR)
Восстановление на любой момент времени:
- Берём последний full backup
- Применяем WAL логи до нужного времени
Автоматизация бэкапов
Cron job для бэкапов:
# /etc/cron.d/postgres-backup
0 2 * * * postgres /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1
Скрипт backup.sh:
bash#!/bin/bash set -e BACKUP_DIR="/backups" DATE=$(date +%Y%m%d_%H%M%S) DB_NAME="mydb" # Создаём бэкап pg_dump -Fc $DB_NAME > $BACKUP_DIR/${DB_NAME}_${DATE}.dump # Шифруем openssl enc -aes-256-cbc -salt \ -in $BACKUP_DIR/${DB_NAME}_${DATE}.dump \ -out $BACKUP_DIR/${DB_NAME}_${DATE}.dump.enc \ -pass file:/etc/backup-key # Удаляем незашифрованный rm $BACKUP_DIR/${DB_NAME}_${DATE}.dump # Загружаем в object storage aws s3 cp $BACKUP_DIR/${DB_NAME}_${DATE}.dump.enc s3://backups/postgres/ # Удаляем старые бэкапы (старше 30 дней) find $BACKUP_DIR -name "*.dump.enc" -mtime +30 -delete
Тестирование восстановления
Бэкап без теста восстановления — не бэкап.
Регулярно (минимум ежемесячно):
- Восстанавливаем бэкап в тестовое окружение
- Проверяем целостность данных
- Измеряем время восстановления
- Документируем результат
bash# Восстановление pg_restore -d mydb_test backup_20260115.dump # Проверка psql -d mydb_test -c "SELECT COUNT(*) FROM users;" psql -d mydb_test -c "SELECT COUNT(*) FROM repos;"
Бэкапы файлов (NFS, S3)
Для файловых хранилищ:
bash# Синхронизация с S3 aws s3 sync /mnt/nfs/git-repos s3://git-storage/ --delete # Или rsync для NFS rsync -avz /mnt/nfs/git-repos backup-server:/backups/
Retention policy для бэкапов
| Возраст | Хранение |
|---|---|
| 0-7 дней | Ежедневные бэкапы |
| 7-30 дней | Еженедельные бэкапы |
| 30-365 дней | Ежемесячные бэкапы |
| 1+ год | Годовые бэкапы (compliance) |
7.5 NFS vs S3 vs Object Storage
Сравнение хранилищ
| Критерий | NFS | S3/Object Storage | Локальный диск |
|---|---|---|---|
| Доступ | POSIX (read/write) | HTTP API | POSIX |
| Latency | Низкая | Средняя | Очень низкая |
| Стоимость | Средняя | Низкая | Зависит от сервера |
| Масштабируемость | Ограниченная | Неограниченная | Ограниченная |
| Durability | Зависит от RAID | 99.999999999% | Низкая |
| Shared access | Да (multi-client) | Да | Нет |
| Use case | Git repos, shared files | Static assets, backups | Temp files |
Когда использовать NFS
- Git repositories (нужен POSIX доступ)
- Shared файлы между инстансами
- Низкая latency критична
- Частые операции read/write
Пример из шаблона:
go// internal/adapters/nfs/storage.go type NFSStorage struct { basePath string } func (s *NFSStorage) Write(ctx context.Context, path string, data []byte) error { fullPath := filepath.Join(s.basePath, path) // Создаём директории если нужно if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil { return fmt.Errorf("mkdir: %w", err) } // Атомарная запись: пишем во временный файл, потом rename tmpPath := fullPath + ".tmp" if err := os.WriteFile(tmpPath, data, 0644); err != nil { return fmt.Errorf("write: %w", err) } if err := os.Rename(tmpPath, fullPath); err != nil { return fmt.Errorf("rename: %w", err) } return nil } func (s *NFSStorage) Read(ctx context.Context, path string) ([]byte, error) { fullPath := filepath.Join(s.basePath, path) return os.ReadFile(fullPath) }
Когда использовать S3/Object Storage
- Статические файлы (изображения, видео)
- Бэкапы
- Архивы
- Большие файлы с редким доступом
- CDN-дистрибуция
Пример из шаблона:
go// internal/adapters/s3/storage.go type S3Storage struct { client *s3.Client bucket string } func (s *S3Storage) Write(ctx context.Context, path string, data []byte) error { _, err := s.client.PutObject(ctx, &s3.PutObjectInput{ Bucket: aws.String(s.bucket), Key: aws.String(path), Body: bytes.NewReader(data), }) return err } func (s *S3Storage) Read(ctx context.Context, path string) ([]byte, error) { result, err := s.client.GetObject(ctx, &s3.GetObjectInput{ Bucket: aws.String(s.bucket), Key: aws.String(path), }) if err != nil { return nil, err } defer result.Body.Close() return io.ReadAll(result.Body) }
Абстракция StorageLayer
Для возможности переключения между NFS и S3:
go// internal/repository/storage_repository.go type StorageLayer interface { Write(ctx context.Context, path string, data []byte) error Read(ctx context.Context, path string) ([]byte, error) Delete(ctx context.Context, path string) error Exists(ctx context.Context, path string) (bool, error) }
DI-контейнер выбирает реализацию по конфигурации:
gofunc BuildStorage(cfg *config.Config) (repository.StorageLayer, error) { switch cfg.StorageType { case "nfs": return nfs.NewStorage(cfg.NFSPath), nil case "s3": return s3.NewStorage(cfg.S3Endpoint, cfg.S3Bucket), nil default: return nil, fmt.Errorf("unknown storage type: %s", cfg.StorageType) } }
Миграция NFS -> S3
При эволюции системы может потребоваться миграция:
- Dual write: пишем в оба хранилища
- Backfill: копируем старые данные из NFS в S3
- Switch read: переключаем чтение на S3
- Cleanup: удаляем NFS после проверки
gofunc (s *MigrationStorage) Write(ctx context.Context, path string, data []byte) error { // Пишем в оба if err := s.nfs.Write(ctx, path, data); err != nil { return err } if err := s.s3.Write(ctx, path, data); err != nil { // Логируем, но не фейлим (S3 пока secondary) log.Error("s3 write failed", "path", path, "error", err) } return nil }
7.6 Data retention policies
Зачем нужны retention policies
- Compliance (GDPR: право на удаление)
- Стоимость хранения
- Производительность (меньше данных = быстрее запросы)
- Безопасность (меньше данных = меньше риск утечки)
Категории данных по retention
| Категория | Примеры | Retention |
|---|---|---|
| Бизнес-данные | Orders, users | Бессрочно или 7+ лет |
| Логи | Application logs | 30-90 дней |
| Метрики | Time-series | 15 дней raw, 1 год aggregated |
| Трейсы | Distributed traces | 7-14 дней |
| Temp данные | Sessions, cache | 24 часа |
| Аудит | Audit events | 1-7 лет (compliance) |
Реализация cleanup job
gotype DataRetentionJob struct { db *pgxpool.Pool logger *slog.Logger } func (j *DataRetentionJob) Run(ctx context.Context) error { // Удаляем старые логи (старше 90 дней) if err := j.cleanupLogs(ctx, 90*24*time.Hour); err != nil { j.logger.Error("cleanup logs failed", "error", err) } // Удаляем старые метрики (старше 15 дней) if err := j.cleanupMetrics(ctx, 15*24*time.Hour); err != nil { j.logger.Error("cleanup metrics failed", "error", err) } // Удаляем истёкшие idempotency keys (старше 24 часов) if err := j.cleanupIdempotencyKeys(ctx, 24*time.Hour); err != nil { j.logger.Error("cleanup idempotency keys failed", "error", err) } // Удаляем старые outbox events (старше 7 дней) if err := j.cleanupOutbox(ctx, 7*24*time.Hour); err != nil { j.logger.Error("cleanup outbox failed", "error", err) } return nil } func (j *DataRetentionJob) cleanupLogs(ctx context.Context, retention time.Duration) error { cutoff := time.Now().Add(-retention) // Удаляем батчами чтобы не блокировать таблицу batchSize := 10000 for { result, err := j.db.Exec(ctx, ` DELETE FROM logs WHERE id IN ( SELECT id FROM logs WHERE created_at < $1 LIMIT $2 ) `, cutoff, batchSize) if err != nil { return err } if result.RowsAffected() < int64(batchSize) { break } // Пауза между батчами time.Sleep(100 * time.Millisecond) } return nil }
GDPR: право на удаление
Для персональных данных нужна возможность полного удаления:
gofunc (s *UserService) DeleteUser(ctx context.Context, userID string) error { // 1. Удаляем/анонимизируем персональные данные if err := s.anonymizeUserData(ctx, userID); err != nil { return fmt.Errorf("anonymize user: %w", err) } // 2. Удаляем связанные данные if err := s.deleteUserData(ctx, userID); err != nil { return fmt.Errorf("delete user data: %w", err) } // 3. Удаляем из внешних систем (S3, логи) if err := s.deleteFromExternalSystems(ctx, userID); err != nil { return fmt.Errorf("delete external: %w", err) } // 4. Audit log (обязательно для compliance) s.auditLogger.Log(ctx, AuditEvent{ Action: "user.deleted", Resource: userID, Result: "success", }) return nil }
Partitioning для больших таблиц
Для таблиц с time-series данными используем partitioning:
sql-- Создаём partitioned таблицу CREATE TABLE logs ( id UUID, created_at TIMESTAMPTZ NOT NULL, message TEXT ) PARTITION BY RANGE (created_at); -- Создаём partitions по месяцам CREATE TABLE logs_2026_01 PARTITION OF logs FOR VALUES FROM ('2026-01-01') TO ('2026-02-01'); CREATE TABLE logs_2026_02 PARTITION OF logs FOR VALUES FROM ('2026-02-01') TO ('2026-03-01'); -- Удаление старых данных = DROP PARTITION (мгновенно) DROP TABLE logs_2025_01;
7.7 Disaster Recovery strategy
Что такое Disaster Recovery
Disaster Recovery (DR) — план восстановления системы после катастрофического сбоя:
- Потеря дата-центра
- Потеря данных
- Кибератака
- Ошибка оператора
Ключевые метрики DR
RPO (Recovery Point Objective): Максимально допустимая потеря данных. Пример: RPO = 1 час — можем потерять максимум 1 час данных.
RTO (Recovery Time Objective): Максимально допустимое время восстановления. Пример: RTO = 4 часа — система должна работать через 4 часа.
| Уровень | RPO | RTO | Стоимость | Пример |
|---|---|---|---|---|
| Mission Critical | < 1 мин | < 15 мин | Очень высокая | Финансовые системы |
| Business Critical | < 1 час | < 4 часа | Высокая | E-commerce |
| Important | < 4 часа | < 24 часа | Средняя | Внутренние системы |
| Standard | < 24 часа | < 72 часа | Низкая | Dev/test окружения |
DR стратегии
-
Backup and Restore
- Регулярные бэкапы
- Восстановление из бэкапа при сбое
- RPO: часы, RTO: часы
- Стоимость: низкая
-
Pilot Light
- Минимальная инфраструктура всегда запущена
- При сбое масштабируем до полной
- RPO: минуты, RTO: десятки минут
- Стоимость: средняя
-
Warm Standby
- Уменьшенная копия системы всегда запущена
- При сбое масштабируем
- RPO: секунды, RTO: минуты
- Стоимость: высокая
-
Multi-site Active/Active
- Несколько дата-центров работают одновременно
- Трафик распределяется между ними
- RPO: ~0, RTO: ~0
- Стоимость: очень высокая
DR план для шаблона проекта
# Disaster Recovery Plan
## Сценарий 1: Потеря primary БД
RPO: 5 минут (WAL archiving)
RTO: 30 минут
Шаги восстановления:
1. Детектирование сбоя (мониторинг, алерт)
2. Promotion replica в primary
3. Обновление конфигурации приложения
4. Перезапуск приложения
5. Верификация данных
## Сценарий 2: Потеря файлового хранилища (NFS)
RPO: 24 часа (ежедневные бэкапы)
RTO: 2 часа
Шаги восстановления:
1. Создание нового NFS volume
2. Восстановление из бэкапа
3. Проверка целостности (git fsck)
4. Монтирование к приложению
## Сценарий 3: Полная потеря дата-центра
RPO: 1 час
RTO: 4 часа
Шаги восстановления:
1. Активация DR-региона
2. Восстановление инфраструктуры из Terraform
3. Восстановление БД из бэкапов
4. Восстановление файлов из S3
5. Переключение DNS
6. Верификация
DR тестирование
DR план без тестирования — не DR план.
Регулярные DR тесты (минимум ежеквартально):
bash# DR test script #!/bin/bash echo "=== DR Test: Database Restore ===" # 1. Восстанавливаем БД из бэкапа echo "1. Restoring database from backup..." pg_restore -d mydb_dr_test /backups/latest.dump # 2. Проверяем целостность echo "2. Verifying data integrity..." psql -d mydb_dr_test -c "SELECT COUNT(*) FROM users;" psql -d mydb_dr_test -c "SELECT COUNT(*) FROM repos;" # 3. Проверяем git repositories echo "3. Running git fsck..." for repo in /mnt/nfs/git-repos/*/; do git -C $repo fsck --full done # 4. Измеряем время восстановления echo "4. Measuring recovery time..." echo "=== DR Test Complete ==="
Внутренний DR модуль в шаблоне
internal/dr/
├── fsck.go # Проверка целостности git repos
├── snapshot.go # Создание снапшотов NFS
└── restore.go # Восстановление из бэкапов
7.8 Database at Scale
Вертикальное vs горизонтальное масштабирование
Вертикальное (scale up):
- Больше CPU/RAM на сервере
- Проще, но есть предел
- Single point of failure
Горизонтальное (scale out):
- Больше серверов
- Сложнее, но масштабируется
- Требует partitioning/sharding
Read replicas (уже рассмотрено)
Для масштабирования чтения.
Sharding
Разделение данных по шардам (горизонтальное partitioning):
Shard 1: users A-M
Shard 2: users N-Z
Или по tenant_id:
Shard 1: tenants 1-100
Shard 2: tenants 101-200
Ключевые решения:
- Sharding key: по какому полю делим
- Количество шардов: сколько изначально
- Rebalancing: как перераспределять при добавлении шардов
Проблемы sharding:
- Cross-shard queries сложные
- Cross-shard транзакции невозможны
- Rebalancing дорогой
- Операционная сложность
Рекомендация: избегайте sharding пока возможно. Используйте read replicas, partitioning, кэширование.
Partitioning в PostgreSQL
Разделение большой таблицы на части:
sql-- Partitioning по диапазону дат CREATE TABLE events ( id UUID, created_at TIMESTAMPTZ NOT NULL, data JSONB ) PARTITION BY RANGE (created_at); -- Partitions по месяцам CREATE TABLE events_2026_01 PARTITION OF events FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
Преимущества:
- Query pruning: запрос читает только нужные partitions
- Быстрое удаление: DROP PARTITION вместо DELETE
- Параллельная обработка
Connection pooling на масштабе
При большом количестве инстансов:
10 инстансов * 25 соединений = 250 соединений к БД
Решение: PgBouncer
Приложение -> PgBouncer (100 соединений) -> PostgreSQL (50 соединений)
PgBouncer режимы:
- session: соединение на сессию (максимальная совместимость)
- transaction: соединение на транзакцию (рекомендуется)
- statement: соединение на запрос (ограниченная совместимость)
Кэширование
Для снижения нагрузки на БД:
gotype CachedRepoRepository struct { repo repository.RepoRepository cache *redis.Client ttl time.Duration } func (r *CachedRepoRepository) GetByID(ctx context.Context, id string) (*models.Repo, error) { // Проверяем кэш cached, err := r.cache.Get(ctx, "repo:"+id).Bytes() if err == nil { var repo models.Repo if json.Unmarshal(cached, &repo) == nil { metrics.CacheHits.Inc() return &repo, nil } } metrics.CacheMisses.Inc() // Читаем из БД repo, err := r.repo.GetByID(ctx, id) if err != nil { return nil, err } // Сохраняем в кэш if data, err := json.Marshal(repo); err == nil { r.cache.Set(ctx, "repo:"+id, data, r.ttl) } return repo, nil }
Cache invalidation:
- TTL (простой, но возможна staleness)
- Event-driven invalidation (сложнее, но актуальнее)
- Write-through (запись и в БД, и в кэш)
Привязка к шаблону проекта
Где storage в шаблоне
internal/adapters/nfs/
└── storage.go # NFS storage implementation
internal/adapters/s3/
└── storage.go # S3 storage implementation
internal/repository/
└── storage_repository.go # StorageLayer interface
internal/dr/
├── fsck.go # Git fsck для проверки целостности
├── snapshot.go # NFS snapshots
└── restore.go # Восстановление из бэкапов
migrations/
├── 001_init.sql
├── 002_repos.sql
└── ...
Что изучаем на примере шаблона
- StorageLayer интерфейс для абстракции хранилища
- NFS и S3 реализации
- DR модуль для проверки целостности и восстановления
- Миграции для эволюции схемы
- Outbox events с retention
Задание: изучите internal/adapters/ и internal/dr/
- Как реализован StorageLayer?
- Есть ли переключение между NFS и S3?
- Какие DR процедуры реализованы?
- Как проверяется целостность данных?
💱 Компромиссы
- Expand and contract vs прямой ALTER: переименование через ADD COLUMN, батчи и двойную запись не блокирует таблицу, но тянет несколько релизов; один RENAME COLUMN закрывается одной миграцией, но блокирует таблицу и ломает работающее приложение.
- Read replicas vs чтение из primary: реплики масштабируют чтение и разгружают primary, но replication lag отдаёт устаревшие данные — после записи нужен read-your-writes из primary; жёсткое чтение только из primary консистентно, но не масштабируется вообще.
- NFS vs S3: POSIX-доступ и низкая latency идеальны для git-репо, но масштабируемость ограничена, а durability упирается в RAID; S3 масштабируется без предела и держит 99.999999999% durability, но HTTP API медленнее и не даёт POSIX-семантики.
- Ежедневный full backup vs WAL archiving: pg_dump раз в сутки прост и предсказуем, но RPO — до 24 часов потерянных данных; непрерывный WAL archiving даёт PITR на любую минуту, но требует настройки archive_command, хранения WAL и более сложного restore.
- Backup and Restore vs Warm Standby: восстановление из бэкапов почти ничего не стоит в простое, но RPO и RTO измеряются часами; Warm Standby даёт RPO в секунды и RTO в минуты, но уменьшенная копия системы крутится и оплачивается постоянно.
Практическое задание
Задание 1: Безопасная миграция
Выполните переименование колонки через expand and contract:
sql-- Шаг 1: Добавьте новую колонку ALTER TABLE repos ADD COLUMN display_name VARCHAR(255); -- Шаг 2: Скопируйте данные батчами UPDATE repos SET display_name = name WHERE display_name IS NULL; -- Шаг 3: Обновите приложение (запись в обе колонки) -- Шаг 4: Переключите чтение на display_name -- Шаг 5: Удалите старую колонку ALTER TABLE repos DROP COLUMN name;
Задание 2: Read/Write separation
Реализуйте Database с primary и replicas:
gotype Database struct { primary *pgxpool.Pool replicas []*pgxpool.Pool } func (db *Database) ReadPool() *pgxpool.Pool func (db *Database) WritePool() *pgxpool.Pool
Добавьте round-robin для replicas и read-your-writes для критичных операций.
Задание 3: Backup script
Напишите скрипт бэкапа PostgreSQL:
bash#!/bin/bash # backup.sh # Full backup pg_dump -Fc mydb > backup.dump # Шифрование openssl enc -aes-256-cbc ... # Загрузка в S3 aws s3 cp backup.dump.enc s3://backups/ # Cleanup старых бэкапов find /backups -mtime +30 -delete
Добавьте в cron и настройте алерты при сбоях.
Задание 4: Restore test
Проведите тест восстановления:
bash# Восстановление в тестовую БД pg_restore -d mydb_test backup.dump # Проверка целостности psql -d mydb_test -c "SELECT COUNT(*) FROM users;" # Измерение времени восстановления
Задокументируйте результат.
Задание 5: Storage abstraction
Реализуйте StorageLayer интерфейс с двумя реализациями:
gotype StorageLayer interface { Write(ctx context.Context, path string, data []byte) error Read(ctx context.Context, path string) ([]byte, error) Delete(ctx context.Context, path string) error }
Реализации:
- NFSStorage (локальная файловая система)
- S3Storage (object storage)
Напишите тесты для обеих реализаций.
Задание 6: Data retention job
Реализуйте cleanup job:
gofunc (j *DataRetentionJob) Run(ctx context.Context) error { // Удаляем старые логи (90 дней) // Удаляем старые outbox events (7 дней) // Удаляем истёкшие idempotency keys (24 часа) }
Удаляйте батчами чтобы не блокировать таблицы.
Задание 7: DR plan
Напишите DR plan для вашего сервиса:
# Disaster Recovery Plan
## Сценарий 1: Потеря БД
RPO: ...
RTO: ...
Шаги восстановления: ...
## Сценарий 2: Потеря файлового хранилища
RPO: ...
RTO: ...
Шаги восстановления: ...
Проведите DR тест и задокументируйте результат.
Задание 8: Мониторинг storage
Добавьте метрики:
db_replication_lag_seconds{replica="..."}
storage_used_bytes{storage="nfs|s3"}
backup_last_success_timestamp
backup_size_bytes
Настройте алерты:
- Replication lag > 10s
- Backup не выполнялся > 24 часов
- Storage usage > 80%
Quiz "Проверь себя"
CREATE INDEX CONCURRENTLY?CREATE INDEX CONCURRENTLY idx_repos_owner ON repos(owner_id);✅ Итоги модуля
Студент должен:
- Проектировать безопасные миграции без downtime
- Настраивать read/write separation
- Реализовывать бэкапы и тестировать восстановление
- Выбирать между NFS и S3 под задачу
- Определять data retention policies
- Проектировать DR стратегию с RTO/RPO
- Понимать partitioning и sharding
- Ответить на все вопросы Quiz
🔗 Мостик к Модулю 8
Следующий модуль: Модуль 8. Reliability and Operations - graceful degradation, timeouts and cancellation, circuit breaker / bulkhead, operational runbooks, incident response basics, capacity planning. Привязка к internal/dr/ и docs/operations/ шаблона.