Модуль 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 |
| UPSERT | INSERT ... ON CONFLICT DO UPDATE | UPSERT INTO ... VALUES ... |
| Автоинкремент | SERIAL, BIGSERIAL | Нет. UUID или приложение |
| Транзакции | BEGIN / COMMIT / ROLLBACK | Интерактивные транзакции в SDK |
| EXPLAIN | EXPLAIN ANALYZE SELECT ... | EXPLAIN SELECT ... |
Критично для Git-хостинга:
- Нет FOR UPDATE → конкурентные push защищаются через атомарный UPDATE с условием + retry (Модуль 5).
- Нет SERIAL → ID генерирует приложение (UUID v7).
- UPSERT вместо INSERT ON CONFLICT → проще для идемпотентности.
3.2. Схема метаданных Git-хостинга
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
gofunc (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:
gofunc (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:
gofunc (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:
hclresource "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_limitconflict_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 RU | 50 | 100 |
| git clone (чтение refs) | ~0.5 RU | 200 | 100 |
| API (CRUD репозиториев) | ~1 RU | 100 | 100 |
| Outbox relay (поллинг каждые 5 с) | ~0.5 RU | 17 280 | 8 640 |
| Outbox relay при пустой очереди | ~0.3 RU | 17 280 | 5 184 |
| Saga orchestrator | ~0.5 RU | 17 280 | 8 640 |
| Итого с пустыми поллингами | ~22 764 RU/день |
10 RU/с = 864 000 RU/день. Мы тратим 22 764 = 2.6%. Запас 38×.
3.8. Этот паттерн в архетипах
| Архетип | БД для MVP | Почему | Когда мигрировать |
|---|---|---|---|
| Финтех | Managed PostgreSQL | FOR UPDATE, SERIALIZABLE | Никогда |
| Git-хостинг | YDB Serverless | Спорадическая нагрузка | > 100 юзеров |
| Мессенджер | YDB Serverless (MVP) → PostgreSQL | Eventual допустима | > 1k msg/s |
| Лента | YDB Serverless + Object Storage | Read-heavy | > 10k RPS |
3.9. Компромиссы (Trade-offs)
| Решение | Плюс | Минус | Когда выбирать |
|---|---|---|---|
| YDB Serverless | 0₽/мес при 10 RU/с; автомасштаб | Нет FOR UPDATE; YQL ≠ SQL | MVP |
| Managed PostgreSQL | Полный SQL; FOR UPDATE SKIP LOCKED | От 500₽/мес | Рост > 100 юзеров |
| SQLite на NFS | 0₽; простой | Нет конкурентного доступа | Антипаттерн |
| Атомарный 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
⚠️ Антипаттерн для разбора
gofunc (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 }
🧩 Проверь себя
🤖 Инструкция для LLM — Режим Модуля 3
Привет! Я начинаю Модуль 3 курса «Git Hosting на Go: Serverless MVP на Yandex Cloud».
Дай короткий ответ (2–3 предложения): почему в YDB нет FOR UPDATE, и как атомарный UPDATE с условием + retry с exponential backoff решает проблему конкурентных push.
Выдай задание:
- Спроектировать схему YDB.
- Написать миграции YQL.
- Реализовать UpdateRefWithRetry с non-fast-forward проверкой и метрикой conflict_rate.
- Добавить 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.»