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 в проде.
Содержание
Заголовок раздела «Содержание»- Концепция Saga
- Глубже / production-практики (Temporal, Cadence, alternatives)
- Gotchas
- Real cases
- Вопросы (25)
- Practice
- Источники
1. Концепция
Заголовок раздела «1. Концепция»1.1 Проблема distributed transactions
Заголовок раздела «1.1 Проблема distributed transactions»В моноблоке: транзакция БД даёт 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 упал?
Варианты:
- 2PC (Two-Phase Commit) — coordinator-based. Блокирующий, fragile.
- TCC (Try-Confirm-Cancel) — explicit reservation pattern.
- Saga — sequence of local transactions + compensation.
Saga — индустриальный standard для long-running business transactions.
1.2 Saga: основные идеи
Заголовок раздела «1.2 Saga: основные идеи»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).
1.3 Orchestration vs Choreography
Заголовок раздела «1.3 Orchestration vs Choreography»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).
1.4 Failure handling
Заголовок раздела «1.4 Failure handling»- 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).
1.5 Идемпотентность
Заголовок раздела «1.5 Идемпотентность»Saga retries → каждый step может вызываться несколько раз. Все шаги должны быть idempotent.
Стандартные паттерны:
- Idempotency key в request.
- Server stores result для дубликатов.
- Conditional updates (
UPDATE ... WHERE state='pending').
1.6 Eventually consistent
Заголовок раздела «1.6 Eventually consistent»Saga не даёт ACID. Между T1 и T2 система в inconsistent state — inventory зарезервирован, но payment не списан. Это видно user через “Processing…” UI.
Это компромисс: availability важнее, чем strict consistency для большинства бизнес-cases.
2. Глубже / production-практики
Заголовок раздела «2. Глубже / production-практики»2.1 Temporal: что это
Заголовок раздела «2.1 Temporal: что это»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).
2.2 Workflow (deterministic)
Заголовок раздела «2.2 Workflow (deterministic)»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.
2.3 Activity (side effects)
Заголовок раздела «2.3 Activity (side effects)»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 видит ошибку, решает что делать.
2.4 Workflow replay
Заголовок раздела «2.4 Workflow replay»Главная магия 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.
2.5 Versioning
Заголовок раздела «2.5 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 старой версии.
2.6 Signals и queries
Заголовок раздела «2.6 Signals и queries»Signal: external event meaning “workflow получи это”.
// In workflowch := workflow.GetSignalChannel(ctx, "approval")var approved boolch.Receive(ctx, &approved)if !approved { /* compensate */ }// Client sidetemporalClient.SignalWorkflow(ctx, workflowID, "", "approval", true)Use case: human approval, external callback.
Query: read-only state inspect.
// In workflowworkflow.SetQueryHandler(ctx, "status", func() (string, error) { return currentStatus, nil})// Clientresp, _ := temporalClient.QueryWorkflow(ctx, workflowID, "", "status")var status stringresp.Get(&status)2.7 Timers
Заголовок раздела «2.7 Timers»err := workflow.Sleep(ctx, 24 * time.Hour)Workflow «спит» на сутки. Worker может умереть, перезапуститься, нет проблем — timer хранится в Temporal server. Через сутки workflow resume на любом worker.
Use case: SLA timeout, scheduled retry, batch jobs.
2.8 Реальный e-commerce saga
Заголовок раздела «2.8 Реальный e-commerce saga»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) }}2.9 Compensation challenges
Заголовок раздела «2.9 Compensation challenges»- 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.
2.10 Cadence (Uber, предшественник Temporal)
Заголовок раздела «2.10 Cadence (Uber, предшественник Temporal)»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.
2.11 Restate, Inngest и альтернативы
Заголовок раздела «2.11 Restate, Inngest и альтернативы»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.
2.12 DIY orchestration: когда оправдано
Заголовок раздела «2.12 DIY orchestration: когда оправдано»Иногда саму простую 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.
2.13 Temporal Cloud vs Self-hosted
Заголовок раздела «2.13 Temporal Cloud vs Self-hosted»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.
2.14 Performance
Заголовок раздела «2.14 Performance»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()).
2.15 Visibility и debugging
Заголовок раздела «2.15 Visibility и debugging»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.
3. Gotchas
Заголовок раздела «3. Gotchas»⚠️ 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.
4. Real cases
Заголовок раздела «4. Real cases»Case 1: E-commerce checkout flow
Заголовок раздела «Case 1: E-commerce checkout flow»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.
Case 2: Insurance claim processing
Заголовок раздела «Case 2: Insurance claim processing»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.
Case 3: Booking system
Заголовок раздела «Case 3: Booking system»Setup: travel booking — flight + hotel + car. Все три должны быть booked, иначе rollback.
Сложность: hotel API имеет 30-minute hold (если не confirmed в 30 минут — auto-release).
Solution:
// Reserve hotel — 30-min holdExecuteActivity(ctx, ReserveHotel, ...).Get(ctx, &hotelHoldID)saga.compensate(ReleaseHotelHold, hotelHoldID)
// Reserve flight — separate flowExecuteActivity(ctx, ReserveFlight, ...).Get(ctx, &flightHoldID)saga.compensate(ReleaseFlight, flightHoldID)
// Reserve carExecuteActivity(ctx, ReserveCar, ...).Get(ctx, &carHoldID)saga.compensate(ReleaseCar, carHoldID)
// ChargeExecuteActivity(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.
Case 4: Migration от DIY к Temporal
Заголовок раздела «Case 4: Migration от DIY к Temporal»Контекст: Company had DIY saga через Postgres state machine. 10% workflows stuck weekly, требовали manual intervention.
Migration:
- Implemented same logic in Temporal.
- Dual-write для 1 месяц (old + new).
- Compare results.
- Switch traffic.
Result: stuck workflows < 0.1%. Operations team размером 2 → 0.5 engineer.
Case 5: Compensation cascade failure
Заголовок раздела «Case 5: Compensation cascade failure»Симптом: 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.
5. Вопросы (25)
Заголовок раздела «5. Вопросы (25)»- В чём принципиальное отличие Saga от 2PC?
- Forward recovery vs backward recovery: когда какой?
- Orchestration vs choreography: pros/cons.
- Что значит compensation не равно undo? Приведите пример.
- Идемпотентность в saga: почему обязательна?
- Что такое eventually consistent состояние и как оно проявляется в UI?
- Архитектура Temporal: Frontend, History, Matching, Worker.
- Workflow MUST be deterministic. Что НЕЛЬЗЯ использовать?
- Activity vs Workflow: что выполняется как side effect?
- Workflow replay: как работает и зачем нужен?
GetVersionдля versioning workflows — пример.- Signal vs Query: когда что?
workflow.Sleep(24h)— что происходит на worker уровне?- Compensation для отправленного email — как реализовать?
- ContinueAsNew: зачем и когда?
- Temporal Cloud vs self-hosted: trade-offs.
- DIY orchestration: когда оправдано, когда — нет?
- Local activity vs regular activity: разница.
- Cadence vs Temporal: коротко.
- Restate / Inngest / Step Functions — отличия от Temporal.
- Worker poisoning: что это и как защититься?
- Compensation само может fail. Что делать?
- Workflow history size limit и как обойти.
- Child workflows: когда использовать вместо activities?
- Опишите production case с saga, где compensation cascade.
6. Practice
Заголовок раздела «6. Practice»Задача 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.
7. Источники
Заголовок раздела «7. Источники»- Hector Garcia-Molina, Kenneth Salem, “Sagas”, ACM SIGMOD 1987 — original paper.
- Chris Richardson, “Microservices Patterns”, Manning, 2018 — глава про sagas.
- Temporal Documentation, https://docs.temporal.io/
- Maxim Fateev, Samar Abbas, talks о Temporal architecture 2020–2024.
- Cadence Documentation (archive), https://cadenceworkflow.io/
- Caitie McCaffrey, “Distributed Sagas: A Protocol for Coordinating Microservices”, Yow! 2017.
- The Temporal Go SDK source: github.com/temporalio/sdk-go
- Box Engineering Blog, “Why Box switched to Temporal”, 2022.
- AWS Step Functions Developer Guide.
- Restate documentation, https://docs.restate.dev/
- Inngest documentation, https://www.inngest.com/docs
- Cherny Bohdan, “Production Sagas with Temporal”, QCon 2023.
- Bernd Rücker, “Practical Process Automation”, O’Reilly, 2021.
- Stripe Engineering, “Designing robust idempotency keys”, 2017.
- Christian Posta, “Microservices for Java Developers”, chapters on sagas.