Цели модуля

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

  • Проектировать безопасную эволюцию схемы БД без downtime
  • Настраивать read/write separation для масштабирования
  • Реализовывать стратегию бэкапов и восстановления
  • Выбирать между NFS, S3 и Object Storage под задачу
  • Определять data retention policies для разных типов данных
  • Проектировать Disaster Recovery стратегию с RTO/RPO
  • Понимать основы sharding и горизонтального масштабирования БД

7.1 Storage strategy overview

Типы данных в системе

Разные данные требуют разных хранилищ:

Тип данныхПримерыХранилище
СтруктурированныеUsers, orders, reposPostgreSQL
НеструктурированныеФайлы, изображенияS3, NFS
КэшSessions, hot dataRedis
ЛогиApplication logsLoki, Elasticsearch
МетрикиTime-seriesPrometheus, VictoriaMetrics
ОчередиEvents, tasksKafka, RabbitMQ

Принципы выбора хранилища

  1. Структура данных

    • Таблицы с отношениями: реляционная БД
    • Ключ-значение: KV store
    • Документы: документная БД
    • Графы: graph DB
  2. Паттерн доступа

    • Частое чтение, редкая запись: кэш + БД
    • Частая запись, редкое чтение: append-only storage
    • Потоковая обработка: message queue
  3. Требования к консистентности

    • Strong consistency: реляционная БД
    • Eventual consistency: distributed stores
  4. Требования к доступности

    • High availability: реплики, кластеры
    • Durability: репликация, бэкапы
  5. Стоимость

    • 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;

Миграции с данными

Для больших таблиц миграцию данных выполняем батчами:

go
func 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 ближе к пользователям)

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

go
type 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

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 ..."
    // ...
}

Replication lag

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

Решения:

  1. Read-your-writes: после записи читать из primary
go
func (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)
}
  1. Session sticky: после записи читать из primary N секунд
go
func (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
}
  1. Принять 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

  1. Ежедневный 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

  2. Непрерывный WAL archiving

    postgresql.conf

    archive_mode = on archive_command = 'test ! -f /wal_archive/%f && cp %p /wal_archive/%f'

  3. 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

Тестирование восстановления

Бэкап без теста восстановления — не бэкап.

Регулярно (минимум ежемесячно):

  1. Восстанавливаем бэкап в тестовое окружение
  2. Проверяем целостность данных
  3. Измеряем время восстановления
  4. Документируем результат
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

Сравнение хранилищ

КритерийNFSS3/Object StorageЛокальный диск
ДоступPOSIX (read/write)HTTP APIPOSIX
LatencyНизкаяСредняяОчень низкая
СтоимостьСредняяНизкаяЗависит от сервера
МасштабируемостьОграниченнаяНеограниченнаяОграниченная
DurabilityЗависит от RAID99.999999999%Низкая
Shared accessДа (multi-client)ДаНет
Use caseGit repos, shared filesStatic assets, backupsTemp 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-контейнер выбирает реализацию по конфигурации:

go
func 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

При эволюции системы может потребоваться миграция:

  1. Dual write: пишем в оба хранилища
  2. Backfill: копируем старые данные из NFS в S3
  3. Switch read: переключаем чтение на S3
  4. Cleanup: удаляем NFS после проверки
go
func (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 logs30-90 дней
МетрикиTime-series15 дней raw, 1 год aggregated
ТрейсыDistributed traces7-14 дней
Temp данныеSessions, cache24 часа
АудитAudit events1-7 лет (compliance)

Реализация cleanup job

go
type 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: право на удаление

Для персональных данных нужна возможность полного удаления:

go
func (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 часа.

УровеньRPORTOСтоимостьПример
Mission Critical< 1 мин< 15 минОчень высокаяФинансовые системы
Business Critical< 1 час< 4 часаВысокаяE-commerce
Important< 4 часа< 24 часаСредняяВнутренние системы
Standard< 24 часа< 72 часаНизкаяDev/test окружения

DR стратегии

  1. Backup and Restore

    • Регулярные бэкапы
    • Восстановление из бэкапа при сбое
    • RPO: часы, RTO: часы
    • Стоимость: низкая
  2. Pilot Light

    • Минимальная инфраструктура всегда запущена
    • При сбое масштабируем до полной
    • RPO: минуты, RTO: десятки минут
    • Стоимость: средняя
  3. Warm Standby

    • Уменьшенная копия системы всегда запущена
    • При сбое масштабируем
    • RPO: секунды, RTO: минуты
    • Стоимость: высокая
  4. 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

Ключевые решения:

  1. Sharding key: по какому полю делим
  2. Количество шардов: сколько изначально
  3. 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: соединение на запрос (ограниченная совместимость)

Кэширование

Для снижения нагрузки на БД:

go
type 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
└── ...

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

  1. StorageLayer интерфейс для абстракции хранилища
  2. NFS и S3 реализации
  3. DR модуль для проверки целостности и восстановления
  4. Миграции для эволюции схемы
  5. 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:

go
type 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 интерфейс с двумя реализациями:

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
}

Реализации:

  • NFSStorage (локальная файловая система)
  • S3Storage (object storage)

Напишите тесты для обеих реализаций.

Задание 6: Data retention job

Реализуйте cleanup job:

go
func (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 "Проверь себя"

1. Что такое RPO и RTO в disaster recovery?
2. Когда оправдан sharding базы данных?
3. Что такое replication lag и как с ним работать?
4. Что такое expand and contract паттерн миграций?
5. Какие DR-стратегии существуют — от дешёвой к самой дорогой?
6. Что даёт partitioning большой таблицы? — выберите все подходящие
7. Зачем нужен CREATE INDEX CONCURRENTLY?
sql
CREATE INDEX CONCURRENTLY idx_repos_owner ON repos(owner_id);
8. Зачем тестировать восстановление из бэкапов?
9. Чем NFS отличается от S3?
10. Что такое data retention policy?

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

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

  • Проектировать безопасные миграции без 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/ шаблона.