Перейти к содержимому

Saga pattern и Temporal в production

Зачем знать на Middle 3: В микросервисах нет global transactions. Когда оформление заказа = reserve inventory + charge card + ship + notify, и шаг 3 падает — нужен механизм compensation. Saga — это паттерн, Temporal — production-grade платформа для его реализации. На уровне Senior: понимаешь orchestration vs choreography, идемпотентность, компенсирующие действия, ограничения детерминированных workflow, retry-политики, versioning long-running workflows в проде.

  1. Концепция Saga
  2. Глубже / production-практики (Temporal, Cadence, alternatives)
  3. Gotchas
  4. Real cases
  5. Вопросы (25)
  6. Practice
  7. Источники

В моноблоке: транзакция БД даёт ACID. Все шаги либо commit, либо rollback.

В микросервисах:

[Order Service] ──→ INSERT order
[Inventory Service] ──→ DECREMENT stock
[Payment Service] ──→ CHARGE card
[Shipping Service] ──→ CREATE shipment
[Notification Service] ──→ SEND email

Каждый сервис — свой DB. Нет global commit. Что если payment OK, но shipping упал?

Варианты:

  1. 2PC (Two-Phase Commit) — coordinator-based. Блокирующий, fragile.
  2. TCC (Try-Confirm-Cancel) — explicit reservation pattern.
  3. Saga — sequence of local transactions + compensation.

Saga — индустриальный standard для long-running business transactions.

Saga = последовательность T1, T2, …, Tn локальных транзакций. Если Tk упал, выполнить компенсации Ck-1, Ck-2, …, C1 в обратном порядке.

T1 → T2 → T3 → T4 → T5
↓ fail
C2 ← C1 (rollback по цепочке)

Compensation не = undo. Это семантический rollback — действие, нивелирующее эффект. Пример: T3 = “charge $100” → C3 = “refund $100”, не «отменить запись в БД» (БД может быть в read replica).

Orchestration: центральный координатор знает план, дёргает шаги.

┌────────────────────┐
│ Saga Orchestrator │
└────────┬───────────┘
│ 1. ReserveInventory
[Inventory Service]
│ 2. ChargeCard
[Payment Service]
│ 3. CreateShipment
[Shipping Service]

Pros: centralized logic, easier to debug, можно увидеть state. Cons: orchestrator — bottleneck/single point of failure, tight coupling.

Реализация: Temporal, Cadence, Camunda, AWS Step Functions.

Choreography: events trigger next step, decentralized.

[Order] ──OrderCreated──→
[Inventory] reserves → ──InventoryReserved──→
[Payment] charges → ──Payed──→
[Shipping] ships → ──Shipped──→

Pros: loose coupling, no SPOF. Cons: hard to trace, infinite event loop risk, state не в одном месте.

Реализация: Kafka + event-driven services.

В production обычно: orchestration для core flows (订单, payment), choreography для side effects (analytics, notifications).

  • Forward recovery (retry): если step transient fail — retry (с backoff).
  • Backward recovery (compensate): если retry не помог — компенсация предыдущих шагов.

Не все шаги поддерживают backward recovery: «email уже отправлен» компенсировать нельзя. Дизайн saga: положить irreversible actions в конец (after-PIT-of-no-return) или explicitly handle (отправить «cancel» email).

Saga retries → каждый step может вызываться несколько раз. Все шаги должны быть idempotent.

Стандартные паттерны:

  • Idempotency key в request.
  • Server stores result для дубликатов.
  • Conditional updates (UPDATE ... WHERE state='pending').

Saga не даёт ACID. Между T1 и T2 система в inconsistent state — inventory зарезервирован, но payment не списан. Это видно user через “Processing…” UI.

Это компромисс: availability важнее, чем strict consistency для большинства бизнес-cases.


Temporal — open-source workflow engine, fork от Cadence (Uber). Гарантирует:

  • Workflows survive crashes, restarts, host migrations.
  • Activities (side effects) retry с настраиваемой policy.
  • Visibility: state любого workflow в UI.
  • Versioning для long-running workflows (некоторые crashуют месяцами).

Архитектура:

┌─────────────────────────────────────┐
│ Temporal Server │
│ - Frontend (gRPC) │
│ - History (event store, Cassandra) │
│ - Matching (task queue) │
│ - Worker (system workflows) │
└──────────┬──────────────────────────┘
│ gRPC
┌───────┴────────┬───────────────┐
▼ ▼ ▼
┌───────┐ ┌───────┐ ┌───────┐
│Worker │ │Worker │ │Worker │
│Process│ │Process│ │Process│
│ - WF │ │ - WF │ │ - WF │
│ - Act│ │ - Act│ │ - Act│
└───────┘ └───────┘ └───────┘

Workers — это ваши Go-процессы, которые:

  • Получают tasks из task queue.
  • Выполняют workflow code (deterministic) и activity code (side effects).

Workflow function в Go:

import "go.temporal.io/sdk/workflow"
func OrderWorkflow(ctx workflow.Context, order Order) (Result, error) {
// Activity options
ao := workflow.ActivityOptions{
StartToCloseTimeout: 30 * time.Second,
RetryPolicy: &temporal.RetryPolicy{
InitialInterval: 1 * time.Second,
BackoffCoefficient: 2.0,
MaximumInterval: 1 * time.Minute,
MaximumAttempts: 5,
},
}
ctx = workflow.WithActivityOptions(ctx, ao)
// Step 1: Reserve inventory
var resID string
err := workflow.ExecuteActivity(ctx, ReserveInventory, order).Get(ctx, &resID)
if err != nil {
return Result{}, err
}
// Step 2: Charge card
var chargeID string
err = workflow.ExecuteActivity(ctx, ChargeCard, order).Get(ctx, &chargeID)
if err != nil {
// Compensate step 1
workflow.ExecuteActivity(ctx, ReleaseInventory, resID).Get(ctx, nil)
return Result{}, err
}
// Step 3: Create shipment
var shipID string
err = workflow.ExecuteActivity(ctx, CreateShipment, order).Get(ctx, &shipID)
if err != nil {
workflow.ExecuteActivity(ctx, RefundCharge, chargeID).Get(ctx, nil)
workflow.ExecuteActivity(ctx, ReleaseInventory, resID).Get(ctx, nil)
return Result{}, err
}
return Result{OrderID: order.ID, ShipID: shipID}, nil
}

⚠️ Workflow MUST be deterministic: same inputs → same outputs. Нельзя:

  • time.Now() — используй workflow.Now(ctx).
  • rand.Int()workflow.NewRandom(ctx).
  • HTTP calls / DB — только через activities.
  • go func()workflow.Go(ctx, ...).
  • Maps with random iteration — sort keys first.
func ReserveInventory(ctx context.Context, order Order) (string, error) {
// Это обычный Go код, HTTP/DB можно
resp, err := inventoryClient.Reserve(ctx, ReserveRequest{
OrderID: order.ID,
Items: order.Items,
})
if err != nil {
return "", err
}
return resp.ReservationID, nil
}

Activities — non-deterministic. Они могут retry, fail, take long. Temporal:

  • Записывает в history: “ReserveInventory called → returned ‘res-123’”.
  • Если workflow перезапускается (worker crashed), история replay-ит: activity не запускается заново, возвращается записанный result.
  • Если activity failed после retries — workflow видит ошибку, решает что делать.

Главная магия Temporal:

[Worker A crashed mid-execution]
[Worker B picks up workflow]
[Temporal replays event history]
[Worker B reaches the point where A stopped]
[Worker B continues from there]

Это работает потому, что workflow deterministic. Activities — не replayed, их results read from history.

⚠️ Если workflow code меняется (новая версия), а старый workflow в process — replay может fail. Решение: versioning.

func MyWorkflow(ctx workflow.Context) error {
v := workflow.GetVersion(ctx, "fix-inventory-bug", workflow.DefaultVersion, 1)
if v == workflow.DefaultVersion {
// Old logic
workflow.ExecuteActivity(ctx, OldReserve).Get(ctx, nil)
} else {
// New logic
workflow.ExecuteActivity(ctx, NewReserve).Get(ctx, nil)
}
// ... остальное workflow продолжается одинаково
}

GetVersion записывается в history. На replay видит, какая версия была активна.

⚠️ Versioning sprawl — после многих изменений код полон if v == ... else. Best practice: cleanup старые версии когда нет running workflows старой версии.

Signal: external event meaning “workflow получи это”.

// In workflow
ch := workflow.GetSignalChannel(ctx, "approval")
var approved bool
ch.Receive(ctx, &approved)
if !approved { /* compensate */ }
// Client side
temporalClient.SignalWorkflow(ctx, workflowID, "", "approval", true)

Use case: human approval, external callback.

Query: read-only state inspect.

// In workflow
workflow.SetQueryHandler(ctx, "status", func() (string, error) {
return currentStatus, nil
})
// Client
resp, _ := temporalClient.QueryWorkflow(ctx, workflowID, "", "status")
var status string
resp.Get(&status)
err := workflow.Sleep(ctx, 24 * time.Hour)

Workflow «спит» на сутки. Worker может умереть, перезапуститься, нет проблем — timer хранится в Temporal server. Через сутки workflow resume на любом worker.

Use case: SLA timeout, scheduled retry, batch jobs.

func ProcessOrder(ctx workflow.Context, order Order) error {
ctx = workflow.WithActivityOptions(ctx, workflow.ActivityOptions{
StartToCloseTimeout: 30 * time.Second,
RetryPolicy: &temporal.RetryPolicy{MaximumAttempts: 3},
})
saga := compensations{}
defer func() {
if err := recover(); err != nil {
saga.compensate(ctx)
panic(err)
}
}()
// 1. Validate
var validatedOrder Order
must(workflow.ExecuteActivity(ctx, ValidateOrder, order).Get(ctx, &validatedOrder))
// 2. Reserve inventory
var resID string
must(workflow.ExecuteActivity(ctx, ReserveInventory, validatedOrder).Get(ctx, &resID))
saga.add(func(ctx workflow.Context) {
workflow.ExecuteActivity(ctx, ReleaseInventory, resID).Get(ctx, nil)
})
// 3. Charge
var chargeID string
must(workflow.ExecuteActivity(ctx, ChargeCard, validatedOrder).Get(ctx, &chargeID))
saga.add(func(ctx workflow.Context) {
workflow.ExecuteActivity(ctx, RefundCharge, chargeID).Get(ctx, nil)
})
// 4. Ship — irreversible after this point
var shipID string
must(workflow.ExecuteActivity(ctx, CreateShipment, validatedOrder).Get(ctx, &shipID))
// 5. Notify
workflow.ExecuteActivity(ctx, SendOrderConfirmation, order, shipID).Get(ctx, nil)
return nil
}
type compensations struct {
fns []func(workflow.Context)
}
func (s *compensations) add(fn func(workflow.Context)) {
s.fns = append(s.fns, fn)
}
func (s *compensations) compensate(ctx workflow.Context) {
for i := len(s.fns) - 1; i >= 0; i-- {
s.fns[i](ctx)
}
}
  • Already-sent email: не отменить. Решение — отправить compensating email («ваш заказ отменён»).
  • External service билинг: refund возможен, но платный API call. Учитывать в cost.
  • Shipment in-flight: после grant-from-warehouse уже не остановить — нужен «return шипмент» pипelin отдельный.
  • Cascading compensation: C2 само может фейлиться → C-C2 = «alert ops, нужен manual fix».

В реальности saga не покрывает 100% случаев. Должен быть DLQ-флоу для «stuck» workflows и manual operations dashboard.

Cadence от Uber, открыт в 2017. Temporal — fork от 2019 (Maxim Fateev + Samar Abbas создали Temporal Technologies).

Differences (2026):

  • Temporal: лучше docs, более активный community, managed cloud (Temporal Cloud).
  • Cadence: всё ещё используется в Uber и других ранних adopters.
  • API очень похож — миграция возможна.

Для нового проекта в 2026 — Temporal.

Restate (2023+): durable execution, async/await syntax, ставит фокус на developer ergonomics. SDK для TS, Java; Go SDK в development.

Inngest: durable workflows + event-driven. Очень popular в TS-stack.

AWS Step Functions: JSON-based DSL (Amazon States Language), tight AWS integration.

Azure Durable Functions: C#/Python-фокус.

Google Workflows: serverless, YAML-based.

Trade-off vs Temporal: Temporal требует self-hosting (или Cloud), но более powerful (signals, queries, child workflows). Cloud-native альтернативы проще, но менее flexible.

Иногда саму простую saga можно сделать без Temporal:

type Saga struct {
steps []func(context.Context) error
compensations []func(context.Context) error
}
func (s *Saga) Run(ctx context.Context) error {
for i, step := range s.steps {
if err := step(ctx); err != nil {
for j := i - 1; j >= 0; j-- {
s.compensations[j](ctx) // best-effort
}
return err
}
}
return nil
}

Проблемы DIY:

  • Нет durability: если process crashed mid-saga — состояние потеряно.
  • Нет visibility.
  • Нет retry policies, timers, signals.
  • Reinventing the wheel.

Используйте DIY только для очень коротких saga (< 1 sec, no remote calls) и non-critical.

Self-hosted:

  • Free (open source), но нужна команда на ops.
  • Cassandra/MySQL/PostgreSQL backend.
  • Сложно scaling history shards.

Temporal Cloud: managed, $25–500+ /mo в зависимости от usage.

Большие компании self-host (Stripe, Box, Coinbase ходили через Cadence/Temporal self-host). Stripupы — Cloud.

Temporal throughput:

  • ~10K–100K workflow starts/sec на правильно scaled cluster.
  • Single workflow latency: ~10–50 мс per activity (overhead Temporal).
  • Limit factor: Cassandra history writes, matching service.

Optimizations:

  • Batch activities (один activity делает много вещей внутри).
  • Local activities (для коротких operations внутри workflow process).
  • Short-lived workflows предпочтительнее, чем infinite (workflow.NewContinueAsNewError()).

Temporal Web UI показывает:

  • Список workflows.
  • Event history (every activity, signal, timer).
  • Stack trace для running workflows.
  • Search по workflow ID, custom search attributes.

Это огромное преимущество vs DIY orchestration: incident debugging — открыл UI, увидел, на каком шаге залип.

⚠️ Search attributes должны быть зарегистрированы в Temporal server (operator command). Cannot dynamically add.


⚠️ Workflow MUST be deterministic. time.Now(), random, maps with non-determined order — ломают replay.

⚠️ Activities can be called multiple times. Always idempotent (даже если timeout = retry happens).

⚠️ Compensation can fail. Plan for “compensation failed” → manual ops alert.

⚠️ Workflow size limit: Temporal history typically up to 50K events. Для долгих workflows — ContinueAsNew.

⚠️ Versioning sprawl: после многих GetVersion-в код unreadable. Cleanup periodically.

⚠️ Activity timeouts: StartToCloseTimeout < ScheduleToCloseTimeout. Документировать в SOP.

⚠️ Worker poisoning: один bad workflow blocks worker. Set max concurrent activities.

⚠️ Local activities не имеют heartbeats / cancellation. Только для < 5 sec.

⚠️ Cron workflows в Temporal — separate API. Не confuse с workflow.Sleep.

⚠️ Workflow.Sleep(28 days) работает, но во время этого workflow занимает slot в database. Heavy load = много sleep-ing workflows.

⚠️ Saga для money operations: refund APIs внешних сервисов часто eventually-consistent. Refund возвращает 200, но реальный refund может занять часы. Compensating logic должна учитывать это.

⚠️ Idempotency key TTL: если внешний API хранит idempotency key 24h, а ваш retry через 25h — будет duplicate.

⚠️ Child workflows vs activities: child = новый workflow, своя history. Useful для дробления больших saga.

⚠️ Temporal worker host affinity — если все workers stick на одну partition, нет load distribution. Set worker options accordingly.


Setup: Уральская e-commerce, Temporal для checkout (reserve → charge → ship → notify).

Incident: Stripe API outage 15 минут. 500 orders в pending state.

Что произошло:

  • Charge activity failed → retries continue (exponential backoff).
  • Inventory остался reserved (не released).
  • Через 15 минут Stripe recovered → activities finish.
  • 99% orders успешно завершили, 1% — manual intervention (clients cancelled между retries).

Без Temporal: либо пришлось бы делать manual replay 500 orders, либо потерять inventory reservations.

Setup: Insurance company, claim approval workflow. Включает human approval (signal от reviewer).

func ClaimWorkflow(ctx, claim) error {
ExecuteActivity(ctx, ValidateClaim, claim).Get(ctx, nil)
ch := workflow.GetSignalChannel(ctx, "approval")
var decision Decision
selector := workflow.NewSelector(ctx)
selector.AddReceive(ch, func(c workflow.ReceiveChannel, _ bool) {
c.Receive(ctx, &decision)
})
selector.AddFuture(workflow.NewTimer(ctx, 7*24*time.Hour), func(_ workflow.Future) {
decision = Decision{Auto: true, Approved: false} // timeout = decline
})
selector.Select(ctx)
if decision.Approved {
ExecuteActivity(ctx, PayoutClaim, claim).Get(ctx, nil)
} else {
ExecuteActivity(ctx, NotifyDecline, claim).Get(ctx, nil)
}
}

Workflow живёт 7 дней (если reviewer не отвечает). Workers могут перезапускаться много раз. Temporal сохраняет state.

Setup: travel booking — flight + hotel + car. Все три должны быть booked, иначе rollback.

Сложность: hotel API имеет 30-minute hold (если не confirmed в 30 минут — auto-release).

Solution:

// Reserve hotel — 30-min hold
ExecuteActivity(ctx, ReserveHotel, ...).Get(ctx, &hotelHoldID)
saga.compensate(ReleaseHotelHold, hotelHoldID)
// Reserve flight — separate flow
ExecuteActivity(ctx, ReserveFlight, ...).Get(ctx, &flightHoldID)
saga.compensate(ReleaseFlight, flightHoldID)
// Reserve car
ExecuteActivity(ctx, ReserveCar, ...).Get(ctx, &carHoldID)
saga.compensate(ReleaseCar, carHoldID)
// Charge
ExecuteActivity(ctx, ChargePayment, ...).Get(ctx, &chargeID)
saga.compensate(RefundPayment, chargeID)
// Confirm all (within 30 min hotel window)
ExecuteActivity(ctx, ConfirmAll, hotelHoldID, flightHoldID, carHoldID).Get(ctx, nil)

⚠️ Workflow timeout < 30 минут, иначе hotel hold expires mid-flow.

Контекст: Company had DIY saga через Postgres state machine. 10% workflows stuck weekly, требовали manual intervention.

Migration:

  1. Implemented same logic in Temporal.
  2. Dual-write для 1 месяц (old + new).
  3. Compare results.
  4. Switch traffic.

Result: stuck workflows < 0.1%. Operations team размером 2 → 0.5 engineer.

Симптом: Saga rolls back, но compensation для шага 2 (RefundCard) fails 100% времени.

Анализ: внешний payment provider требует refund < 24h после charge. Workflow застрял в state «charged but should refund», retries exhausted.

Solution:

  • Alert ops при exhausted compensation retries.
  • Manual ops console для force-mark as compensated.
  • Audit log: ops sees что произошло, делает manual refund через provider’s web UI, обновляет workflow.

  1. В чём принципиальное отличие Saga от 2PC?
  2. Forward recovery vs backward recovery: когда какой?
  3. Orchestration vs choreography: pros/cons.
  4. Что значит compensation не равно undo? Приведите пример.
  5. Идемпотентность в saga: почему обязательна?
  6. Что такое eventually consistent состояние и как оно проявляется в UI?
  7. Архитектура Temporal: Frontend, History, Matching, Worker.
  8. Workflow MUST be deterministic. Что НЕЛЬЗЯ использовать?
  9. Activity vs Workflow: что выполняется как side effect?
  10. Workflow replay: как работает и зачем нужен?
  11. GetVersion для versioning workflows — пример.
  12. Signal vs Query: когда что?
  13. workflow.Sleep(24h) — что происходит на worker уровне?
  14. Compensation для отправленного email — как реализовать?
  15. ContinueAsNew: зачем и когда?
  16. Temporal Cloud vs self-hosted: trade-offs.
  17. DIY orchestration: когда оправдано, когда — нет?
  18. Local activity vs regular activity: разница.
  19. Cadence vs Temporal: коротко.
  20. Restate / Inngest / Step Functions — отличия от Temporal.
  21. Worker poisoning: что это и как защититься?
  22. Compensation само может fail. Что делать?
  23. Workflow history size limit и как обойти.
  24. Child workflows: когда использовать вместо activities?
  25. Опишите production case с saga, где compensation cascade.

Задача 1: Поднять Temporal локально (docker-compose temporalio/temporal). Пройти tutorial Hello World.

Задача 2: Реализовать OrderWorkflow с 5 шагами и compensations для каждого.

Задача 3: Добавить signal “cancelOrder” — если пришёл до shipping, отменить и compensation.

Задача 4: Реализовать workflow с human approval через signal и 7-day timeout.

Задача 5: Versioning: изменить workflow logic, добавить GetVersion, протестировать что старые in-flight workflows работают.

Задача 6: Implement идемпотентные activities: каждый activity создаёт уникальный idempotency key, при retry возвращает same result.

Задача 7: Симулировать crash worker mid-saga, посмотреть как другой worker подхватывает workflow.

Задача 8: DIY saga без Temporal — реализовать самостоятельно. Сравнить с Temporal-версией по сложности и features.

Задача 9 (advanced): Внедрить Temporal observability — экспортировать metrics в Prometheus, traces в Jaeger.


  1. Hector Garcia-Molina, Kenneth Salem, “Sagas”, ACM SIGMOD 1987 — original paper.
  2. Chris Richardson, “Microservices Patterns”, Manning, 2018 — глава про sagas.
  3. Temporal Documentation, https://docs.temporal.io/
  4. Maxim Fateev, Samar Abbas, talks о Temporal architecture 2020–2024.
  5. Cadence Documentation (archive), https://cadenceworkflow.io/
  6. Caitie McCaffrey, “Distributed Sagas: A Protocol for Coordinating Microservices”, Yow! 2017.
  7. The Temporal Go SDK source: github.com/temporalio/sdk-go
  8. Box Engineering Blog, “Why Box switched to Temporal”, 2022.
  9. AWS Step Functions Developer Guide.
  10. Restate documentation, https://docs.restate.dev/
  11. Inngest documentation, https://www.inngest.com/docs
  12. Cherny Bohdan, “Production Sagas with Temporal”, QCon 2023.
  13. Bernd Rücker, “Practical Process Automation”, O’Reilly, 2021.
  14. Stripe Engineering, “Designing robust idempotency keys”, 2017.
  15. Christian Posta, “Microservices for Java Developers”, chapters on sagas.