Модуль 3: YDB Serverless — БД за 0₽ (метаданные без SQL и FOR UPDATE)

Модуль 3. YDB Serverless: БД за 0₽ (метаданные без SQL и FOR UPDATE)

Цель модуля: Хранить метаданные Git-хостинга в YDB Serverless за 0₽/мес. Понять, чем YQL отличается от SQL, почему нет FOR UPDATE, и как атомарный UPDATE с условием + retry с exponential backoff заменяет пессимистичную блокировку. Научиться читать EXPLAIN для YQL и укладываться в 10 RU/с free-tier. Осознать проблему Dual-Write между YDB и NFS.

Стадия роста проекта: MVP / первые юзеры. Git Smart HTTP работает (Модуль 2), push проходит. Но метаданные нигде не хранятся. Первый вопрос: «Как хранить метаданные, если нет SQL и FOR UPDATE?»

Перед началом: Модули 1–2 (Setup, Smart HTTP), понимание Git (refs, packfile)

Инструменты: YDB Serverless, YQL, Go YDB SDK (ydb-go-sdk v3.55.0+), local-ydb

Трудоёмкость: ~6 ч (теория 1.5 ч · практика 3.5 ч · EXPLAIN 1 ч)

🔥 Инцидент: push прошёл, но система не знает об этом

Вася сделал git push origin main. Packfile записан на NFS, refs обновлены. Но:

  • API не знает, что репозиторий test-repo существует.
  • Нет записи о том, что Вася — владелец.
  • Outbox-событие RepositoryPushed не создано — CI/CD не запустится.

Команда предлагает: «Поднимем PostgreSQL». Но Managed PostgreSQL стоит от 500₽/мес. YDB Serverless даёт 10 RU/с бесплатно — достаточно для MVP.

Но YDB ≠ PostgreSQL:

  • Нет FOR UPDATE — нельзя заблокировать строку.
  • Нет SERIALIZABLE изоляции — только snapshot isolation.
  • YQL ≠ SQL — другой синтаксис.
  • Оптимистичные транзакции — конфликт версий вместо блокировок.

Сквозной User Story: «Вася сделал git push origin main. Packfile записан на NFS (Модуль 2). В этом модуле мы сохраним метаданные push в YDB.»

📖 Теория: YDB Serverless и YQL

Глоссарий модуля:

YDB Serverless — управляемая БД Yandex Cloud: масштабируется от 0, оплата за RU. Free-tier: 10 RU/с.

YQL (Yandex Query Language) — язык запросов YDB. Похож на SQL, но отличается: нет FOR UPDATE, нет SERIALIZABLE, UPSERT вместо INSERT ON CONFLICT.

RU (Request Unit) — единица вычислительных ресурсов YDB. 1 RU ≈ 1 мс CPU + 1 МБ I/O.

Оптимистичная транзакция — транзакция без блокировок: при коммите проверяется, не изменились ли данные. Конфликт → retry.

Атомарный UPDATE с условием — UPDATE ... SET ... WHERE version = $expected. Если версия не совпала — 0 строк затронуто.

Retry с exponential backoff — при конфликте повторяем операцию с экспоненциально растущей задержкой + jitter.

Conflict rate — метрика частоты конфликтов; при > 5% — эскалация архитектуры.

Snapshot Isolation — каждая транзакция видит «снимок» данных на момент начала.

EXPLAIN — команда YQL для просмотра плана запроса. Показывает Index Scan vs Full Scan.

3.1. YQL vs SQL: ключевые отличия

ФичаPostgreSQL (SQL)YDB (YQL)
Блокировка строкиSELECT ... FOR UPDATEНет. Атомарный UPDATE с условием + retry
ИзоляцияREAD COMMITTED, REPEATABLE READ, SERIALIZABLEТолько Snapshot Isolation
UPSERTINSERT ... ON CONFLICT DO UPDATEUPSERT INTO ... VALUES ...
АвтоинкрементSERIAL, BIGSERIALНет. UUID или приложение
ТранзакцииBEGIN / COMMIT / ROLLBACKИнтерактивные транзакции в SDK
EXPLAINEXPLAIN ANALYZE SELECT ...EXPLAIN SELECT ...

Критично для Git-хостинга:

  • Нет FOR UPDATE → конкурентные push защищаются через атомарный UPDATE с условием + retry (Модуль 5).
  • Нет SERIAL → ID генерирует приложение (UUID v7).
  • UPSERT вместо INSERT ON CONFLICT → проще для идемпотентности.

3.2. Схема метаданных Git-хостинга

100%
🖱 Перетащите • Колесо для зума

3.3. Миграции: YQL DDL

sql
-- migrations/001_create_tables.yql

CREATE TABLE repositories (
    repo_id     UUID NOT NULL,
    name        UTF8 NOT NULL,
    owner_id    UUID NOT NULL,
    created_at  TIMESTAMP NOT NULL,
    updated_at  TIMESTAMP NOT NULL,
    version     INT64 NOT NULL DEFAULT 0,
    PRIMARY KEY (repo_id)
);

CREATE TABLE refs (
    repo_id     UUID NOT NULL,
    ref_name    UTF8 NOT NULL,
    sha         UTF8 NOT NULL,
    updated_at  TIMESTAMP NOT NULL,
    version     INT64 NOT NULL DEFAULT 0,
    PRIMARY KEY (repo_id, ref_name)
);

CREATE TABLE outbox_events (
    event_id    UUID NOT NULL,
    repo_id     UUID NOT NULL,
    event_type  UTF8 NOT NULL,
    payload     JSON NOT NULL,
    status      UTF8 NOT NULL DEFAULT 'pending',
    trace_id    UTF8,
    attempts    INT64 NOT NULL DEFAULT 0,
    created_at  TIMESTAMP NOT NULL,
    PRIMARY KEY (event_id)
);

CREATE INDEX idx_outbox_status ON outbox_events (status, created_at);

CREATE TABLE users (
    user_id     UUID NOT NULL,
    username    UTF8 NOT NULL,
    email       UTF8 NOT NULL,
    created_at  TIMESTAMP NOT NULL,
    PRIMARY KEY (user_id)
);

go.mod:

require (
    github.com/ydb-platform/ydb-go-sdk/v3 v3.55.0+
)

Версия 3.55.0+ поддерживает интерактивные транзакции через DoTx, необходимые для Outbox.

3.4. Проблема Dual-Write: YDB + NFS

Когда git-http-backend пишет packfile на NFS, а мы обновляем refs в YDB — это Dual-Write. Если YDB обновится, а NFS упадёт — в YDB будет запись о push, но packfile отсутствует.

Решение для MVP: сначала NFS (git-http-backend пишет), потом YDB (метаданные). Если YDB упадёт — push прошёл, но метаданные отсутствуют. Ручное восстановление возможно через git fsck.

Решение для v2 (Highload): синхронизация через WAL NFS + Outbox. См. курс «Git Hosting: от 10k до 100k RPS».

3.5. Атомарный UPDATE с условием + non-fast-forward

go
func (h *PushHandler) HandlePush(ctx context.Context, req PushRequest) error {
    // (1) Читаем текущий SHA
    currentSHA, err := h.refRepo.GetSHA(ctx, req.RepoID, req.RefName)
    if err != nil {
        return err
    }

    // (2) Non-fast-forward проверка
    if req.ExpectedSHA != currentSHA {
        return ErrNonFastForward // 409 Conflict
    }

    // (3) Идемпотентность: push с тем же SHA = no-op
    if currentSHA == req.NewSHA {
        return nil
    }

    // (4) Атомарный UPDATE с условием
    res, err := h.ydb.Execute(ctx, `
        UPDATE refs
        SET sha = $new_sha,
            updated_at = CurrentUtcTimestamp(),
            version = version + 1
        WHERE repo_id = $repo_id
          AND ref_name = $ref_name
          AND sha = $expected_sha
    `, map[string]any{
        "$repo_id":      req.RepoID,
        "$ref_name":     req.RefName,
        "$expected_sha": req.ExpectedSHA,
        "$new_sha":      req.NewSHA,
    })
    if err != nil {
        return fmt.Errorf("update ref: %w", err)
    }

    if res.RowsAffected() == 0 {
        return ErrConflict // SHA изменился конкурентным push
    }
    return nil
}

Связь с Модулем 5: concurrency_limit = 5 на git-gateway (Bulkhead) снижает вероятность конфликта, но не устраняет её полностью. Атомарный UPDATE — последняя линия защиты.

3.5.1. Retry с exponential backoff для конкурентных обновлений

При высоких конфликтах (например, 10 конкурентных push одновременно) атомарный UPDATE может вернуть rowsAffected=0. Добавляем retry с экспоненциальной задержкой и метрику conflict_rate:

go
func (r *RefRepo) UpdateRefWithRetry(ctx context.Context, req UpdateRefRequest) error {
    maxRetries := 5
    baseDelay := 100 * time.Millisecond
    conflicts := 0

    for i := 0; i < maxRetries; i++ {
        // Читаем текущий SHA
        currentSHA, err := r.GetSHA(ctx, req.RepoID, req.RefName)
        if err != nil {
            return err
        }

        // Non-fast-forward проверка
        if currentSHA != req.ExpectedSHA {
            return ErrNonFastForward
        }

        // Идемпотентность
        if currentSHA == req.NewSHA {
            return nil
        }

        // Атомарный UPDATE с условием
        res, err := r.ydb.Execute(ctx, `
            UPDATE refs
            SET sha = $new_sha, updated_at = CurrentUtcTimestamp(), version = version + 1
            WHERE repo_id = $repo_id AND ref_name = $ref_name AND sha = $expected_sha
        `, map[string]any{
            "$repo_id":      req.RepoID,
            "$ref_name":     req.RefName,
            "$expected_sha": req.ExpectedSHA,
            "$new_sha":      req.NewSHA,
        })
        if err != nil {
            return err
        }

        if res.RowsAffected() == 1 {
            if conflicts > 0 {
                metricsExporter.ExportConflictRetries("refs", conflicts)
            }
            return nil
        }

        // Конфликт — retry с exponential backoff + jitter
        conflicts++
        if i < maxRetries-1 {
            delay := baseDelay * time.Duration(1<<uint(i)) + time.Duration(rand.Int63n(int64(baseDelay)))
            time.Sleep(delay)
        }
    }

    metricsExporter.ExportConflictRetries("refs", conflicts)
    return ErrConflictRetriesExhausted
}

Метрика conflict_rate:

go
func (m *MetricsExporter) ExportConflictRetries(table string, retries int) error {
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()

    _, err := m.sdk.Monitoring().Metrics().Publish(ctx, &monitoring.PublishMetricsRequest{
        FolderId: os.Getenv("YC_FOLDER_ID"),
        Metrics: []*monitoring.Metric{
            {
                Name: "ydb_conflict_retries_total",
                Labels: map[string]string{
                    "table":   table,
                    "retries": strconv.Itoa(retries),
                },
                Type:  monitoring.Metric_COUNTER,
                Value: 1,
            },
        },
    })
    return err
}

Алерт на высокий conflict rate:

hcl
resource "yandex_monitoring_alert" "ydb_conflict_rate" {
  name = "ydb-high-conflict-rate"
  conditions {
    metric {
      name      = "ydb_conflict_retries_total"
      threshold = 10  # > 10 конфликтов за 5 минут
      duration  = 300
    }
  }
  notification_channels = [yandex_monitoring_notification_channel.telegram.id]
}

Правило эскалации:

  • conflict_rate < 1% — норма, retry справляется
  • conflict_rate 1–5% — наблюдение, возможно нужно увеличить concurrency_limit
  • conflict_rate > 5% — эскалация: пересмотр архитектуры (внешний lock service — Redis/etcd)

ADR-SV3-ADD: «Оптимистичные блокировки vs внешние lock services. Для MVP — retry с exponential backoff + jitter. При conflict_rate > 5% — миграция на внешний lock».

3.6. EXPLAIN: читаем план запроса

sql
-- ❌ Плохо: Full Scan
EXPLAIN SELECT * FROM outbox_events WHERE status = 'pending';

-- ✅ Хорошо: Index Scan
EXPLAIN SELECT * FROM outbox_events
VIEW idx_outbox_status
WHERE status = 'pending'
ORDER BY created_at
LIMIT 10;

Если видите "FullScan": true — запрос сканирует всю таблицу. Добавьте индекс или измените запрос.

RU-стоимость:

  • Index Scan по partition key: ~0.1 RU
  • Full Scan на 1000 строк: ~5–10 RU
  • При 10 RU/с free-tier: один Full Scan = весь бюджет на секунду

3.7. RU-бюджет: как уложиться в 10 RU/с

ОперацияRU/запросЗапросов/деньRU/день
git push (запись refs + outbox)~2 RU50100
git clone (чтение refs)~0.5 RU200100
API (CRUD репозиториев)~1 RU100100
Outbox relay (поллинг каждые 5 с)~0.5 RU17 2808 640
Outbox relay при пустой очереди~0.3 RU17 2805 184
Saga orchestrator~0.5 RU17 2808 640
Итого с пустыми поллингами~22 764 RU/день

10 RU/с = 864 000 RU/день. Мы тратим 22 764 = 2.6%. Запас 38×.

3.8. Этот паттерн в архетипах

АрхетипБД для MVPПочемуКогда мигрировать
ФинтехManaged PostgreSQLFOR UPDATE, SERIALIZABLEНикогда
Git-хостингYDB ServerlessСпорадическая нагрузка> 100 юзеров
МессенджерYDB Serverless (MVP) → PostgreSQLEventual допустима> 1k msg/s
ЛентаYDB Serverless + Object StorageRead-heavy> 10k RPS

3.9. Компромиссы (Trade-offs)

РешениеПлюсМинусКогда выбирать
YDB Serverless0₽/мес при 10 RU/с; автомасштабНет FOR UPDATE; YQL ≠ SQLMVP
Managed PostgreSQLПолный SQL; FOR UPDATE SKIP LOCKEDОт 500₽/месРост > 100 юзеров
SQLite на NFS0₽; простойНет конкурентного доступаАнтипаттерн
Атомарный UPDATE с условиемЗамена FOR UPDATEКонфликт → retryВсегда в YDB
Retry + exponential backoffОптимистичные блокировки без внешних сервисовLatency при конфликтахВсегда при конкурентных обновлениях
EXPLAIN перед деплоемВидим Full ScanДоп. шаг в CIВсегда

Вывод: для MVP — YDB Serverless + атомарный UPDATE + retry + EXPLAIN в CI. См. ADR-SV3.

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

Задача 1: Миграции YDB ✅ Готово, когда: [ ] 4 таблицы с правильными PRIMARY KEY [ ] refs: partition key = repo_id, sort key = ref_name [ ] outbox_events: индекс idx_outbox_status [ ] Миграции применяются через make migrate

Задача 2: Атомарный UPDATE с условием + retry ✅ Готово, когда: [ ] UPDATE refs SET ... WHERE sha = $expected_sha [ ] Non-fast-forward проверка перед UPDATE [ ] rowsAffected == 0 → retry с exponential backoff + jitter [ ] conflict_rate метрика экспортируется в Cloud Monitoring [ ] Алерт ydb_conflict_rate в Terraform [ ] Тест: два конкурентных UpdateRef → один успех, один retry → успех

Задача 3: EXPLAIN в CI ✅ Готово, когда: [ ] CI запускает EXPLAIN для всех запросов [ ] "FullScan": true → CI падает [ ] Все запросы используют Index Scan

Задача 4: RU-калькулятор ✅ Готово, когда: [ ] Таблица с пустыми поллингами [ ] Пиковый RU/с при 10 одновременных push [ ] Точка миграции на Managed PostgreSQL

⚠️ Антипаттерн для разбора

go
func (h *Handler) Push(w http.ResponseWriter, r *http.Request) {
    // ❌ 1. Full Scan: нет фильтра по partition key
    res, _ := h.db.Execute(ctx, "SELECT * FROM refs WHERE ref_name = 'main'", nil)

    // ❌ 2. Нет проверки SHA → конкурентный push перезапишет
    h.db.Execute(ctx, "UPDATE refs SET sha = $new_sha WHERE ref_name = 'main'", params)

    // ❌ 3. Outbox-событие ПОСЛЕ коммита → Dual-Write
    h.db.Execute(ctx, "UPSERT INTO outbox_events ...", eventParams)

    // ❌ 4. Нет retry при конфликте → потерянный push
}

🧩 Проверь себя

1. Что такое Dual-Write между YDB и NFS?
2. Что происходит при конфликте (rowsAffected == 0)?
3. Что показывает EXPLAIN в YDB?
4. Чем атомарный UPDATE заменяет FOR UPDATE?
5. Free-tier YDB Serverless — сколько RU/с?
6. Почему outbox_events в той же транзакции, что и UPDATE refs?
7. Почему в YDB нет FOR UPDATE?
8. При каком conflict_rate нужна эскалация архитектуры?

🤖 Инструкция для LLM — Режим Модуля 3

Привет! Я начинаю Модуль 3 курса «Git Hosting на Go: Serverless MVP на Yandex Cloud».

Дай короткий ответ (2–3 предложения): почему в YDB нет FOR UPDATE, и как атомарный UPDATE с условием + retry с exponential backoff решает проблему конкурентных push.

Выдай задание:

  1. Спроектировать схему YDB.
  2. Написать миграции YQL.
  3. Реализовать UpdateRefWithRetry с non-fast-forward проверкой и метрикой conflict_rate.
  4. Добавить EXPLAIN в CI.

Покажи код Антипаттерна (Full Scan, нет проверки SHA, Dual-Write, нет retry) и попроси объяснить.

НЕ ПИШИ за меня. Дождись моего ответа.

📝 Чек-лист ментора

  • Схема YDB: 4 таблицы с правильными PRIMARY KEY.
  • Миграции: применяются через make migrate.
  • Атомарный UPDATE: WHERE sha = $expected_sha, конфликт → retry.
  • Retry: exponential backoff + jitter, максимум 5 попыток.
  • Метрика conflict_rate: экспортируется в Cloud Monitoring.
  • Алерт ydb_conflict_rate: threshold > 10 за 5 минут.
  • Non-fast-forward проверка перед UPDATE.
  • EXPLAIN в CI: Full Scan → CI падает.
  • Индекс: idx_outbox_status.
  • RU-калькулятор: с пустыми поллингами.
  • Dual-Write YDB + NFS: осознан, решение описано.
  • ydb-go-sdk v3.55.0+.
  • ADR-SV3: «YDB Serverless vs Managed PostgreSQL» + «Оптимистичные блокировки vs внешние lock services».

🔗 Мостик к Модулю 4 (Outbox)

«Отлично, метаданные push сохранены в YDB. Но событие RepositoryPushed всё ещё в таблице — оно не доставлено в CI/CD. В Модуле 4 мы реализуем Outbox Relay: Cloud Function с Timer Trigger каждые 5 секунд поллит outbox_events через Index Scan, публикует в Message Queue, и помечает published.»

Сквозной User Story: «Вася сделал git push origin main. Метаданные в YDB. В Модуле 4 outbox-relay забирает событие RepositoryPushed и публикует в Message Queue.»