Модуль 4. Распределённые транзакции: Saga

Цель модуля: Научить студента проектировать распределённые бизнес-процессы, сохраняющие целостность данных при сбоях. Освоить паттерн Saga (Orchestration), реализовать стейт-машину, идемпотентные компенсирующие транзакции и защитить оркестратор от гонок при масштабировании. Понять, что компенсация — это НЕ ROLLBACK, а новая бизнес-операция, и что для необратимых операций (например, git merge) компенсация = «зафиксировать провал + точечный retry».

Стадия роста проекта: Сложные процессы. Проект пережил MVP, интеграции и Outbox. Теперь бизнес-операции требуют нескольких шагов в разных сервисах: зарезервировать товар, списать деньги, отправить уведомление. И первое, что ломается — шаги зависают на полпути. Деньги списаны, товар не зарезервирован.

Перед началом: Модули 1–3 (Trade-offs, Идемпотентность, Outbox) Инструменты: PostgreSQL, Kafka, pgx v5, k6, Toxiproxy Трудоёмкость: ~8 ч (теория 2 ч · практика 5 ч · ADR 1 ч)

🔥 Инцидент: перевод, который завис на полпути

Пользователь запускает P2P-перевод: зарезервировать средства → списать → зачислить → уведомить. Шаги выполняются в разных сервисах. Деньги зарезервированы, и… процесс остановился. В базе saga_instances статус RESERVING, время идёт, списания нет. Деньги заморожены.

Сервис уведомлений упал с 503 и не поднимался несколько минут. Оркестратор отправил команду, получил таймаут и не знает, что делать: считать шаг выполненным? Повторить? Компенсировать? А ещё — в K8s поднялось два пода оркестратора, и оба одновременно взяли одну Saga. Один записал WITHDRAWING, второй перезаписал обратно в RESERVING. Стейт «поехал», и теперь непонятно, на каком мы шаге.

Ты решаешь «просто откатить» через ROLLBACK — но это не работает: шаг Reserve уже закоммичен в другом сервисе, в другой БД. Чтобы вернуть деньги, нужна отдельная бизнес-операция — RefundWithdraw. И если запустить её без идемпотентности (Модуль 2), при ретраях можно вернуть деньги дважды.

И ещё один сценарий — из Git-хостинга. Пользователь нажимает Merge PR. Шаги: validate CI checks → git merge → notify watchers → update search index. CI проверку прошли, git merge сделали, но индексация упала. PR остаётся в статусе merging бесконечно. Команда предлагает «просто перезапустить Saga с начала». Но git merge уже прошёл: повторный merge даст конфликт или Already up to date, а откатить коммит в master значит переписать историю всех, кто успел git pull. Git merge нельзя отменить — значит, компенсация должна быть другой.

В распределённой системе «всё или ничего» нельзя получить одной транзакцией. Нужна Saga: последовательность шагов с компенсирующими операциями, явная стейт-машина и защита от гонок (FOR UPDATE). Компенсация — это не ROLLBACK, а новая бизнес-операция. Для обратимых операций (платёж) — отмена эффекта. Для необратимых (git merge) — фиксация провала + точечный retry упавшего шага.

📖 Теория: Раскрытые термины

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

  • Saga (Orchestration) — последовательность шагов с компенсирующими действиями; оркестратор ведёт стейт-машину.
  • Компенсация — бизнес-операция, отменяющая эффект уже выполненного шага (например, RefundWithdraw); это НЕ ROLLBACK БД. Для необратимых операций (git merge) — фиксация провала + точечный retry.
  • Стейт-машина — модель с конечным набором состояний и переходов между ними по событиям.
  • FOR UPDATE — блокировка строки от конкурентных транзакций; основа защиты оркестратора от гонок.
  • Таймаут-джоб — отдельный воркер, ищущий «зависшие» Saga в статусах *ING и запускающий компенсацию.
  • 2PC / XA — две фазы: Prepare (блокировки) + Commit; зависание на Prepare — антипаттерн для микросервисов.

4.1. Почему 2PC/XA мертвы в микросервисах?

2PC (Two-Phase Commit) — протокол распределённой транзакции в две фазы. XA — стандарт, реализующий 2PC в базах данных и брокерах.

Как работает 2PC:

Координатор              Участник A           Участник B
    │                        │                    │
    ├── Phase 1: PREPARE ───>│                    │
    ├── Phase 1: PREPARE ────────────────────────>│
    │<── OK (locked) ────────│                    │  ← удерживают блокировки
    │<── OK (locked) ─────────────────────────────│
    │                        │                    │
    ├── Phase 2: COMMIT ────>│                    │  ← снимают блокировки
    ├── Phase 2: COMMIT ────────────────────────>│
    │<── DONE ───────────────│                    │
    │<── DONE ────────────────────────────────────│

Фаза 1 (Prepare): координатор спрашивает каждого участника «готов?». Каждый блокирует данные и отвечает OK или ABORT. Пока все не ответят — данные заблокированы.

Фаза 2 (Commit/Abort): если все ответили OK — координатор шлёт COMMIT. Если хоть один ABORT — ROLLBACK всем.

Почему это убивает микросервисы:

ПроблемаЧто происходитПоследствие
Блокировки во время PrepareКаждый участник удерживает блокировки до получения CommitДругие транзакции ждут → лавина latency
Координатор упал после PrepareУчастники заблокировали данные и ждут решения координатора, который «молчит»Вечная блокировка — данные недоступны
Сеть упала между фазамиКоординатор отправил Commit участнику A, но не доставил участнику BA закоммичен, B заблокирован — расщепление
Участник упал на PrepareКоординатор ждёт ответа, таймаут → отмена всемТранзакция отменена, но пользователю уже сказано «перевод принят»
СинхронностьКаждый шаг ждёт ответа; N участников = N последовательных сетевых задержекp99 растёт линейно с числом сервисов

Почему XA не спасает: XA-транзакции работают только если все участники поддерживают XA. В реальности сторонние API (KYC, платёжный шлюз), Redis, MongoDB — не поддерживают XA. Даже PostgreSQL + Kafka через XA — хрупкая конструкция.

Где 2PC ещё жив: внутри одной БД (PostgreSQL сам использует 2PC-подобный механизм для распределённых транзакций внутри кластера), maintenance windows, Spanner/CockroachDB (с Paxos/Raft для координатора).

Вывод: 2PC/XA работает внутри одного хранилища, но не работает между микросервисами. Для распределённых бизнес-транзакций нужен Saga — без блокировок, с компенсациями.

4.2. Паттерн Saga (Orchestration vs Choreography)

Saga — это последовательность локальных транзакций. Для FinTech мы используем Orchestration: выделенный сервис (Saga Orchestrator) управляет процессом как стейт-машиной. Он отправляет команды сервисам через Outbox → Kafka и слушает их ответы. Это даёт единую точку истины (статус Saga в БД) и явную логику отката.

Choreography — альтернатива: сервисы реагируют на события друг друга без центрального оркестратора. Плюс: нет SPOF. Минус: сложно отследить общий статус, риск гонок между событиями. Для финтеха — Orchestration (явные гарантии, аудит). Для менее критичных потоков — Choreography допустима.

4.3. Компенсация — это НЕ ROLLBACK

Компенсирующая транзакция — это новая бизнес-операция (например, RefundWithdraw), которая отменяет эффект предыдущей.

Она обязана быть идемпотентной (через compensation_id).

Она сама должна использовать Outbox и иметь retry-логику. Иначе вы получите «компенсацию, которая дублирует возврат денег».

Для обратимых операций (финтех):

  • Компенсация = RefundWithdraw — отмена эффекта.
  • Деньги возвращаются, статус меняется.

Для необратимых операций (Git merge):

  • Компенсация ≠ ROLLBACK: git merge не отменяем.
  • Компенсация = пометить PR merge-failed + записать failed_step и last_error + ручной retry только упавшего шага.
  • Git не трогаем (merge уже прошёл и валиден).

Принцип тот же: компенсация — новая бизнес-операция, не SQL ROLLBACK. Но для Git это «зафиксировать провал + точечный retry», а не «отменить действие».

4.4. Связь Saga и Outbox (Модуль 3)

Оркестратор не делает HTTP-запросы к сервисам. Он обновляет свой стейт и в той же транзакции пишет команду в outbox_events.

go
// internal/saga/orchestrator.go
func (o *Orchestrator) transition(ctx context.Context, sagaID int64, to State, cmd string) error {
    tx, err := o.pool.BeginTx(ctx, pgx.TxOptions{})
    if err != nil {
        return err
    }
    defer tx.Rollback(ctx) // no-op после Commit
    
    // (1) Защита от гонок: блокируем строку Saga
    var s Saga
    err = tx.QueryRow(ctx,
        `SELECT saga_id, state, aggregate_id FROM saga_instances
         WHERE saga_id = $1 FOR UPDATE`, sagaID).Scan(&s.ID, &s.State, &s.AggregateID)
    if err != nil {
        return err
    }
    // Идемпотентность: если стейт уже сменился — выходим
    if s.State != stateExpectedFor(to) {
        return ErrAlreadyAdvanced
    }
    
    // (2) Переход в новое состояние
    if _, err = tx.Exec(ctx,
        `UPDATE saga_instances SET state = $2, updated_at = NOW() WHERE saga_id = $1`,
        sagaID, to); err != nil {
        return err
    }
    
    // (3) Команда следующего шага — в Outbox (в той же транзакции)
    if cmd != "" {
        if _, err = tx.Exec(ctx,
            `INSERT INTO outbox_events(aggregate_id, event_type, payload, status)
             VALUES ($1, $2, $3, 'pending')`,
            s.AggregateID, cmd, []byte("{}")); err != nil {
            return err
        }
    }
    
    return tx.Commit(ctx) // одна транзакция — стейт + команда
}

4.5. Гонки при масштабировании Оркестратора

Если запущено несколько инстансов Оркестратора (Kubernetes), они могут одновременно взять одну и ту же Saga и начать менять её стейт. Решение: При чтении Saga использовать SELECT ... FOR UPDATE (блокировка строки) или pg_advisory_lock(saga_id).

go
// Защита от гонок: FOR UPDATE при чтении Saga
err = tx.QueryRow(ctx,
    `SELECT saga_id, state, aggregate_id FROM saga_instances
     WHERE saga_id = $1 FOR UPDATE`, sagaID).Scan(&s.ID, &s.State, &s.AggregateID)

Плюс идемпотентная проверка текущего state: если уже сменился — ErrAlreadyAdvanced, без повторного перехода.

4.6. Таймауты: джоб поиска «зависших» Saga

Если Saga висит в статусе WITHDRAWING более 2 минут, значит сервис-исполнитель упал или не ответил. Таймаут-джоб ищет такие Saga и запускает компенсацию.

go
// Таймаут-джоб: ищет зависшие Saga и запускает компенсацию
func (j *TimeoutJob) Run(ctx context.Context) {
    ticker := time.NewTicker(1 * time.Minute)
    defer ticker.Stop()
    for {
        select {
        case <-ctx.Done():
            return
        case <-ticker.C:
            j.findAndCompensate(ctx)
        }
    }
}

func (j *TimeoutJob) findAndCompensate(ctx context.Context) {
    rows, err := j.pool.Query(ctx,
        `SELECT saga_id, state FROM saga_instances
         WHERE state LIKE '%ING'
           AND updated_at < NOW() - INTERVAL '2 minutes'
         LIMIT 10`)
    if err != nil {
        return
    }
    defer rows.Close()
    
    for rows.Next() {
        var sagaID int64
        var state State
        rows.Scan(&sagaID, &state)
        // Запускаем компенсацию для зависшей Saga
        j.orchestrator.compensate(ctx, sagaID, state)
    }
}

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

Saga проявляется по-разному в четырёх архетипах проектов:

АрхетипШаги SagaКомпенсацияОбратимость
Финтех (P2P)Reserve → Withdraw → NotifyRefundWithdrawОбратимая
Git-хостингValidate → Merge → Notify → IndexMark merge-failed + retry шагаНеобратимая (git merge)
МессенджерSend → Deliver → ReadMark delivery-failed + retryЧастично обратимая
Лента / чтениеN/A (нет многошаговых процессов)N/AN/A

Общий принцип: каждый шаг Saga — локальная транзакция, компенсация — новая бизнес-операция. Различается только обратимость: финтех компенсирует отменой, Git компенсирует фиксацией провала.

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

ПодходПлюсМинусКогда выбирать
2PC / XAатомарность всех шагов (all-or-nothing)блокировки на этапе Prepare → зависание при сбое; НЕ подходит для микросервисовантипаттерн для распределённых систем
Saga Orchestrationединый стейт-машина; явные компенсации; простота аудитаоркестратор = SPOF; требуется его отказоустойчивость (Multi-AZ)когда важна согласованность шагов и понятный аудит
Saga Choreographyнет единой точки отказа; сервисы автономнысложно отследить общий статус; риск гонок между событиямикогда сервисы слабо связаны
Компенсация = отмена эффекта (финтех)возвращает систему в исходное состояниепользователь может временно увидеть «отменено»обратимые операции (платежи, резервирования)
Компенсация = фиксация провала + retry (Git)не трогает историю; точечный повторPR временно в merge-failed (ручной шаг)необратимые операции (git merge, публикация)

Вывод для курса: Orchestration — для финтеха (явные гарантии, компенсации); Choreography — для менее критичных потоков. Для Git merge — компенсация = mark merge-failed + retry упавшего шага, НЕ ROLLBACK. См. ADR-4.


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

Сценарий: Перевод денег (P2P). Шаги: Reserve (счет отправителя) → Withdraw (счет отправителя) → Notify (пользователь).

Примерный план работы:

  1. Спроектировать saga_instances → индексы по state + updated_at (для таймаут-джоба).
  2. Реализовать обработчик handleReserveSuccess: SELECT … FOR UPDATE + переход + команда в Outbox (одна транзакция).
  3. Компенсация: NotifyFailed/Timeout → COMPENSATING_WITHDRAW → RefundWithdraw через Outbox.
  4. Таймаут-джоб: cron ищет *ING с updated_at < NOW() - 2 min → запуск компенсации.
  5. Тест Toxiproxy: деньги возвращаются, RTO < 1 мин, Saga не висит.
  6. ADR-4: «Orchestration vs Choreography».

Задача 1: Проектирование БД и Индексов

Создать таблицу saga_instances:

sql
CREATE TABLE saga_instances (
    saga_id          UUID PRIMARY KEY,
    aggregate_id     UUID NOT NULL,
    state            VARCHAR(50) NOT NULL,
    last_event_type  VARCHAR(100),
    amount           NUMERIC(18, 2) NOT NULL,
    source_account   VARCHAR(255) NOT NULL,
    target_account   VARCHAR(255) NOT NULL,
    updated_at       TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
    created_at       TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);

-- Индексы для быстрого поиска таймаутов и переходов
CREATE INDEX idx_saga_state ON saga_instances(state);
CREATE INDEX idx_saga_updated ON saga_instances(updated_at);
CREATE INDEX idx_saga_aggregate ON saga_instances(aggregate_id);

✅ Готово, когда:

  • Таблица saga_instances: saga_id UUID PK, state, amount, aggregate_id, updated_at
  • Индексы: state (для таймаут-джоба), updated_at (для поиска просроченных), aggregate_id
  • ADR-4 «Orchestration vs Choreography» написан

Задача 2: Реализация Saga Orchestrator (Атомарность и Блокировки)

Студенту необходимо реализовать обработчик события (например, ReserveSuccessEvent).

Go (pgx): Использовать tx.QueryRow(ctx, "SELECT ... FOR UPDATE"). Запись команды в Outbox в той же tx.

go
// Блокировка Saga + переход + команда в Outbox — одной транзакцией
func (o *Orchestrator) HandleReserveSuccess(ctx context.Context, sagaID string) error {
    tx, err := o.pool.BeginTx(ctx, pgx.TxOptions{})
    if err != nil {
        return err
    }
    defer tx.Rollback(ctx) // no-op после Commit
    
    // 1. Защита от гонок: блокируем строку Saga
    var state string
    if err := tx.QueryRow(ctx,
        "SELECT state FROM saga_instances WHERE saga_id=$1 FOR UPDATE", sagaID).Scan(&state); err != nil {
        return err
    }
    // Идемпотентность: если стейт уже сменился — выходим
    if state != "RESERVING" {
        return nil
    }
    
    // 2. Переход в новое состояние
    if _, err := tx.Exec(ctx,
        "UPDATE saga_instances SET state='RESERVED', updated_at=NOW() WHERE saga_id=$1", sagaID); err != nil {
        return err
    }
    
    // 3. Команда следующего шага — в Outbox (в той же tx)
    _, err = tx.Exec(ctx,
        `INSERT INTO outbox_events (event_type, aggregate_id, payload, status)
         VALUES ('WithdrawCommand', $1, $2, 'pending')`, sagaID, withdrawPayload)
    if err != nil {
        return err
    }
    
    return tx.Commit(ctx) // COMMIT для стейта и Outbox одновременно
}

✅ Готово, когда:

  • SELECT ... FOR UPDATE при поиске Saga (защита от гонок)
  • Идемпотентность: если state уже сменился — код выходит без повторного перехода
  • Переход стейта + запись команды в Outbox — одним tx.Commit()

Задача 3: Компенсирующие транзакции и Таймауты

Сервис уведомлений упал. Оркестратор получает NotifyFailed (или срабатывает таймаут).

Оркестратор переводит Saga в COMPENSATING_WITHDRAW и отправляет команду RefundWithdraw (через Outbox).

Таймауты: Cron-задача ищет Saga в статусе *ING с updated_at < NOW() - INTERVAL '2 minutes' (используя индекс idx_saga_state). Если найдена — принудительно запускает компенсацию.

✅ Готово, когда:

  • При NotifyFailed Saga → COMPENSATING_WITHDRAW → команда RefundWithdraw через Outbox
  • Cron-задача ищет *ING старше 2 минут (по индексу) и принудительно запускает компенсацию
  • Toxiproxy: при отказе сервиса уведомлений деньги возвращаются, RTO < 1 мин, Saga не висит

Задача 4: Тест Toxiproxy + k6

Toxiproxy отбрасывает 50% запросов к сервису уведомлений. k6 шлёт 100 переводов. SLO: деньги возвращаются при отказе, RTO < 1 мин, Saga не зависает.

✅ Готово, когда:

  • Toxiproxy: 50% timeout между оркестратором и сервисом уведомлений
  • k6: 100 переводов параллельно
  • Деньги возвращаются при отказе; RTO < 1 мин; Saga не висит

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

Студенту дается этот код для анализа.

go
// Go Orchestrator (Антипаттерн)
func (h *Handler) Transfer(w http.ResponseWriter, r *http.Request) {
    // (1) Резервируем средства
    h.reserveService.Reserve(r.Context(), amount, sourceAccount)
    
    // (2) Списываем средства
    h.withdrawService.Withdraw(r.Context(), amount, sourceAccount)
    
    // (3) Уведомляем пользователя
    h.notifyService.Notify(r.Context(), userID) // ❌ упало → деньги списаны, уведомление не ушло
    
    // «просто перезапустить» → повторное списание
    writeJSON(w, map[string]string{"status": "ok"})
}

Вопросы студенту:

  1. Что произойдет, если notifyService будет недоступен (HTTP 503)?
  2. Что произойдет, если сервис списал деньги, но сеть оборвалась до получения HTTP-ответа? В каком статусе останется Saga?
  3. Почему «просто перезапустить» недопустимо, если Withdraw уже прошёл?

✅ Правильное решение

Saga Orchestration с компенсациями. Каждый шаг — локальная транзакция. Компенсация — новая бизнес-операция, идемпотентная через compensation_id.

go
func (o *Orchestrator) compensate(ctx context.Context, sagaID string, step string) error {
    tx, err := o.pool.BeginTx(ctx, pgx.TxOptions{})
    if err != nil {
        return err
    }
    defer tx.Rollback(ctx) // no-op после Commit
    
    // Защита от гонок + идемпотентность
    var state string
    if err := tx.QueryRow(ctx,
        "SELECT state FROM saga_instances WHERE saga_id=$1 FOR UPDATE", sagaID).Scan(&state); err != nil {
        return err
    }
    // Если уже компенсируем или компенсировали — выходим
    if state == "COMPENSATING" || state == "COMPENSATED" {
        return nil
    }
    
    // Переход в COMPENSATING + команда компенсации в Outbox
    if _, err := tx.Exec(ctx,
        "UPDATE saga_instances SET state='COMPENSATING', updated_at=NOW() WHERE saga_id=$1", sagaID); err != nil {
        return err
    }
    _, err = tx.Exec(ctx,
        `INSERT INTO outbox_events (event_type, aggregate_id, payload, status)
         VALUES ('RefundWithdraw', $1, $2, 'pending')`, sagaID, compensationPayload)
    if err != nil {
        return err
    }
    return tx.Commit(ctx)
}

📐 Задание по визуализации (Mermaid)

Студенту необходимо нарисовать диаграмму состояний (State Diagram) для Saga, включая пути компенсации и ветку игнорирования (Notify).

Шаблон для ориентира:

Saga Orchestration: стейт-машина и компенсации

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

1. Компенсирующая транзакция в Saga — это…
2. Orchestration vs Choreography — в чём разница?
3. Почему компенсация Git merge — это НЕ ROLLBACK?
4. Таймаут-джоб в Saga ищет...
5. Как защитить оркестратор Saga от гонок при масштабировании?
6. Почему 2PC/XA не подходят для микросервисов?

🤖 Инструкция для LLM (Coding Plan Assistant) — Режим Модуля 4

Привет! Я начинаю Модуль 4 курса «Архитектура распределённых систем».

Дай короткий ответ (2–3 предложения): почему компенсирующая транзакция в Saga — это не то же самое, что ROLLBACK в SQL, и почему она обязана быть идемпотентной?

Объясни, зачем при чтении Saga в Оркестраторе нужно использовать SELECT ... FOR UPDATE (или advisory lock), если мы запускаем несколько подов Оркестратора?

Предоставь мне SQL-миграцию для таблицы saga_instances с правильными индексами для таймаут-джобов.

Покажи мне код Антипаттерна (синхронный HTTP из Оркестратора) и попроси меня объяснить, при каких сценариях Saga зависнет или потеряет данные.

Дай описание сценария P2P-перевода (Reserve -> Withdraw -> Notify).

НЕ ПИШИ за меня реализацию State Machine и компенсаций. Дождись моего ответа с архитектурным планом. После этого дай фидбек по чек-листу ментора и укажи на слепые зоны (например, «ты не учел таймауты», «нет блокировки Saga при чтении»).


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

  • Студент аргументирует, почему Orchestration, а не Choreography, подходит для финансов.

  • Оркестратор не делает синхронных HTTP-вызовов: команды идут через outbox_events → Kafka.

  • Обновление стейта Saga и запись команды в Outbox происходят в одной транзакции БД.

  • Оркестратор использует FOR UPDATE или advisory lock для защиты от гонок при масштабировании.

  • Стейт-машина покрывает все переходы, включая компенсации и Ignore-ветку для Notify.

  • Критично: Компенсирующие операции реализованы как новые идемпотентные бизнес-операции (через Outbox и compensation_id).

  • Для Git merge: компенсация = mark merge-failed + retry упавшего шага, НЕ ROLLBACK.

  • Реализован механизм таймаутов (Cron / delayed message), который находит «зависшие» Saga (используя индекс idx_saga_state) и запускает компенсацию.

  • Оркестратор корректно обрабатывает дубли событий (проверяет текущий стейт перед применением).

  • Тест с Toxiproxy доказывает, что при обрыве сети деньги возвращаются, Saga не зависает, RTO < 1 минута.

  • Mermaid-диаграмма корректно отображает все пути (happy path, компенсации, Ignore).

  • Студент понимает, как Saga проявляется в разных архетипах (финтех, Git, мессенджер, лента).

  • ADR-4: Student сформулировал ADR «Orchestration vs Choreography для Saga» (контекст, альтернативы, решение, последствия).

🔗 Мостик к Модулю 5 (Resilience)

В конце ревью обязательно скажи студенту:

  • «Отлично, мы научились откатывать транзакции, если сервис упал. Но что если внешний сервис (например, KYC) не падает, а просто начинает тормозить на 30 секунд? Наша Saga будет висеть, ожидая таймаута, забивая пулы соединений. В Модуле 5 мы научим систему не ждать, а быстро «размыкать цепь» (Circuit Breaker) и переходить в режим деградации (Fallback). При этом мы свяжем Circuit Breaker с Saga: если сервис в состоянии "открыт", мы сразу переводим Saga в компенсацию.»

Если ты прошёл курс «Git Hosting: Serverless MVP», ты уже видел этот паттерн на упрощённом стеке: YDB Serverless с атомарным UPDATE вместо PostgreSQL с FOR UPDATE, Cloud Function Timer Trigger вместо goroutine-оркестратора. Паттерн тот же — инфраструктура разная. Здесь мы разбираем его на полном стеке, с глубиной и trade-offs.