System Design: Saga, Distributed Transactions và Outbox
8. Saga Pattern
8.1 Vấn đề Saga giải quyết
Microservices: mỗi service có DB riêng
→ Không thể dùng ACID transaction qua nhiều services
→ Cần cách maintain data consistency across services
Ví dụ: E-commerce order flow
OrderService → tạo order
InventoryService → trừ stock
PaymentService → charge khách hàng
ShippingService → tạo shipment
Nếu Payment thất bại sau khi Inventory đã trừ stock
→ Phải rollback Inventory!
→ Không có distributed transaction → dùng Saga
8.2 Saga = Sequence of Local Transactions + Compensations
Mỗi step:
- Có một local transaction (trong 1 service)
- Có một compensating transaction (để undo nếu cần)
Forward (happy path):
T1: Create Order (OrderService)
T2: Reserve Inventory (InventoryService)
T3: Process Payment (PaymentService)
T4: Create Shipment (ShippingService)
→ ALL succeed → Done ✅
Backward (compensation path, nếu T3 fail):
C2: Release Inventory (InventoryService) ← compensate T2
C1: Cancel Order (OrderService) ← compensate T1
T3: không cần compensate (đã fail)
T4: không cần compensate (chưa chạy)
Compensating transaction:
- Phải idempotent (có thể chạy nhiều lần safely)
- Là business operation, không phải DB rollback
- "Release inventory" không phải undo INSERT,
mà là thêm stock lại
- Có thể không đảo ngược hoàn toàn
(ví dụ: email đã gửi → không "unsend" được,
nhưng gửi apology email)
8.3 Choreography-based Saga
Không có central coordinator
Mỗi service lắng nghe events và react
OrderService ──[OrderCreated]──────────────────────────────►
│
InventoryService ──────────────────────────► │
◄── [OrderCreated] │
→ Reserve stock │
──[InventoryReserved]────────────────────────►
│
PaymentService ────────── │
◄── [InventoryReserved] │
→ Charge customer │
──[PaymentProcessed]───────►
│
ShippingService ── │
◄── [PaymentProcessed]
→ Create shipment │
──[ShipmentCreated]─►
Failure flow:
PaymentService: Payment FAIL
──[PaymentFailed]──────────────────────────────────────────►
InventoryService: ◄── [PaymentFailed]
→ Release stock
──[InventoryReleased]────────────────────────────────────────►
OrderService: ◄── [InventoryReleased]
→ Cancel order
Pros:
Loose coupling
Simple (no central coordinator)
Services chỉ biết về events, không biết về nhau
Cons:
Khó debug và trace (flow phân tán)
Khó enforce ordering
Risk of cyclic dependencies
Không có global view của saga state
8.4 Orchestration-based Saga
Central Saga Orchestrator điều phối tất cả steps
Orchestrator biết toàn bộ flow và state của saga.
┌──────────────────────────────────────────────┐
│ Saga Orchestrator │
│ State: { sagaId, step, status, ... } │
└──────┬───────────────────────────────────────┘
│ command ▲ reply
│ │
┌──────▼──────┐ ┌──────┴──────┐ ┌───────────────┐
│OrderService │ │ Inventory │ │PaymentService │
└─────────────┘ │ Service │ └───────────────┘
└─────────────┘
Step 1: Orchestrator → OrderService: "CreateOrder"
OrderService → Orchestrator: "OrderCreated" (success)
Step 2: Orchestrator → InventoryService: "ReserveInventory"
InventoryService → Orchestrator: "InventoryReserved" (success)
Step 3: Orchestrator → PaymentService: "ProcessPayment"
PaymentService → Orchestrator: "PaymentFailed" (failure!)
Step 4 (compensation):
Orchestrator → InventoryService: "ReleaseInventory"
InventoryService → Orchestrator: "InventoryReleased"
Orchestrator → OrderService: "CancelOrder"
OrderService → Orchestrator: "OrderCancelled"
Orchestrator: Saga FAILED, compensated ✅
Saga State Machine:
STARTED → INVENTORY_RESERVING → INVENTORY_RESERVED
→ PAYMENT_PROCESSING → PAYMENT_FAILED
→ INVENTORY_RELEASING → ORDER_CANCELLING → COMPENSATED
Pros:
Clear flow, dễ debug
Global state của saga
Dễ thêm steps mới
Dễ implement timeout và retry
Cons:
Orchestrator có thể là bottleneck
Risk: orchestrator = single point of failure
(giải pháp: persist saga state, idempotent steps)
Tight coupling đến orchestrator
8.5 Isolation trong Saga
Vấn đề: Saga KHÔNG có isolation như ACID transaction
Các services khác có thể thấy intermediate state
Ví dụ:
Saga step 2: inventory reserved (stock giảm)
Concurrent query: thấy stock thấp hơn thực tế
Nếu saga fail và compensate: stock về lại
→ Dirty read ở application level!
Countermeasures:
1. Semantic Lock:
Đánh dấu record đang "pending" trong saga
Clients khác thấy pending → chờ hoặc fail-fast
ORDER_STATUS = 'SAGA_IN_PROGRESS'
2. Commutative Updates:
Thiết kế updates có thể apply theo bất kỳ thứ tự
Ví dụ: "add $10" và "subtract $5" commutative
→ Không cần lo về ordering
3. Pessimistic View:
Assume saga có thể fail → hiển thị pessimistic data
"Inventory: 5 available" (thực ra 8, 3 đang in saga)
4. Reread Values Before Use:
Trước khi dùng value → reread để confirm vẫn valid
Tránh dựa vào stale data từ trước
9. Distributed Transactions — 2PC & Alternatives
9.1 Two-Phase Commit (2PC) — Chi tiết
Participants: Coordinator + N participants (databases/services)
Phase 1 — Prepare (Voting):
Coordinator → Participant A: "PREPARE txn-123"
Coordinator → Participant B: "PREPARE txn-123"
Participant A:
- Thực thi transaction locally (nhưng CHƯA commit)
- Ghi PREPARE record vào WAL (không thể rollback một chiều nữa)
- Lock resources
- → "YES" (prepared, sẵn sàng commit)
hoặc → "NO" (không thể commit)
Participant B: tương tự
Phase 2 — Commit hoặc Abort:
Nếu ALL vote YES:
Coordinator ghi COMMIT vào WAL
Coordinator → A và B: "COMMIT"
A, B: commit locally, release locks, acknowledge
Nếu ANY vote NO hoặc timeout:
Coordinator ghi ABORT vào WAL
Coordinator → A và B: "ROLLBACK"
A, B: rollback, release locks
Failure scenarios:
Coordinator crash TRƯỚC Phase 2:
A và B đang ở "prepared" state (uncertain)
Resources bị LOCK → blocking indefinitely!
→ Phải đợi coordinator recover và check WAL
Participant crash TRONG Phase 2:
Khi recover: đọc WAL → thấy PREPARE → hỏi coordinator → commit hoặc rollback
→ "Termination protocol"
Network partition:
Coordinator không nhận được votes
→ Timeout → Abort
Vấn đề cốt lõi của 2PC: BLOCKING PROTOCOL
Nếu coordinator fail sau PREPARE → participants bị block
Không có way để participants tự quyết định (không biết vote của nhau)
9.2 Three-Phase Commit (3PC) — Non-blocking
Thêm "Pre-Commit" phase để giải quyết blocking:
Phase 1 — CanCommit: giống Phase 1 của 2PC (vote)
Phase 2 — PreCommit: coordinator thông báo quyết định TRƯỚC khi commit
Phase 3 — DoCommit: actual commit
Nếu participant không nhận PreCommit → tự rollback (timeout-based)
Nếu participant nhận PreCommit nhưng không DoCommit
→ Tự commit (vì biết coordinator đã quyết định commit)
Vẫn không hoàn hảo:
Cần timeout mechanism
Network partition → split-brain vẫn có thể xảy ra
Phức tạp hơn 2PC đáng kể
→ Ít được dùng trong thực tế
9.3 Consensus Protocols — Paxos & Raft
Consensus = tất cả nodes đồng ý về một giá trị,
kể cả khi một số nodes fail
Paxos (Leslie Lamport, 1989):
Roles: Proposer, Acceptor, Learner
Phases: Prepare → Promise → Accept → Accepted
Nổi tiếng là khó hiểu và implement đúng
Raft (2013) — "Paxos cho người bình thường":
Roles: Leader, Follower, Candidate
Đảm bảo: chỉ 1 Leader tại một thời điểm
Leader Election:
Mọi node bắt đầu là Follower
Nếu không nhận heartbeat từ Leader trong timeout
→ Chuyển thành Candidate → gửi RequestVote
→ Nếu nhận majority votes → trở thành Leader
→ Terms: mỗi election là 1 term
Log Replication:
Client gửi command đến Leader
Leader append vào log → gửi AppendEntries đến Followers
Khi majority confirm → Leader commit → apply to state machine
→ Inform followers to commit
Safety:
Node không thể vote cho candidate có log outdated hơn mình
→ Đảm bảo committed entries không bị mất
Used in: etcd (Kubernetes), CockroachDB, TiKV, Consul
9.4 Google Spanner — TrueTime
Spanner = globally distributed DB với external consistency
(stronger than linearizability across datacenters!)
Vấn đề: Clock skew giữa các datacenters
Node A (US): timestamp = 1000ms
Node B (EU): timestamp = 999ms (clock slightly behind)
→ Làm sao biết thứ tự events?
TrueTime API:
Mỗi server có GPS + atomic clock
TrueTime.now() trả về [earliest, latest] interval
Đảm bảo: real time nằm trong interval
Commit Wait:
Trước khi commit transaction:
→ Đợi cho đến khi TrueTime.now().earliest > commit_timestamp
→ Đảm bảo tất cả concurrent transactions có timestamp cũ hơn
Result:
Commits globally ordered by real time
No distributed locking needed!
External consistency: nếu T1 commit trước T2 start
→ T2 luôn thấy T1's changes
Cost: commit latency = 2× clock uncertainty (~7ms)
→ Acceptable cho global DB
10. Outbox Pattern
10.1 Vấn đề Dual Write
Cần: Update DB và publish event ATOMICALLY
(không thể để DB updated nhưng event không publish, và vice versa)
Naive approach (WRONG):
tx.begin()
db.update(order)
tx.commit() ← nếu crash ở đây...
eventBus.publish() ← event bị mất!
hoặc:
eventBus.publish() ← event published...
tx.begin()
db.update(order)
tx.commit() ← nếu fail → DB không update nhưng event đã gửi!
10.2 Outbox Pattern Solution
Trong cùng 1 DB transaction:
- Update business tables
- INSERT event vào "outbox" table
Separate process (Message Relay) reads outbox và publish đến message broker.
┌─────────────────────────────────────────────────────────────┐
│ Database Transaction (ATOMIC) │
│ UPDATE orders SET status='shipped' WHERE id=123 │
│ INSERT INTO outbox (event_type, payload, status) │
│ VALUES ('OrderShipped', '{...}', 'PENDING') │
└──────────────────────────┬──────────────────────────────────┘
│ committed atomically
│
┌────────────────────────▼──────────────────────────────┐
│ Outbox Table │
│ id | event_type | payload | status | created_at │
│ 1 | OrderShipped | {...} | PENDING | 2024-01-01 │
└────────────────────────┬──────────────────────────────┘
│ poll / CDC
┌────────────────────────▼──────────────────────────────┐
│ Message Relay (background process) │
│ - Đọc PENDING events từ outbox │
│ - Publish đến Kafka/RabbitMQ │
│ - Mark as PROCESSED (or delete) │
└────────────────────────┬──────────────────────────────┘
│
┌────────────────────────▼──────────────────────────────┐
│ Message Broker (Kafka, RabbitMQ) │
└───────────────────────────────────────────────────────┘
Guarantee: AT-LEAST-ONCE delivery
(event có thể published nhiều lần nếu relay crash sau publish
nhưng trước khi mark PROCESSED)
→ Consumers phải idempotent!
10.3 Change Data Capture (CDC)
Thay vì poll outbox table → dùng CDC để read DB transaction log
PostgreSQL: logical replication slot
MySQL: binlog
→ Debezium (phổ biến nhất) đọc transaction log → publish đến Kafka
CDC Pipeline:
PostgreSQL WAL/binlog
│ Debezium connector
▼
Kafka (event stream)
│ Consumer
▼
Downstream services
Pros:
Real-time (không phải polling)
Không cần modify application code (read log directly)
Capture ALL changes (not just what app explicitly publishes)
Low overhead trên DB
Cons:
Phụ thuộc vào internal DB format
Schema changes phức tạp
Cần manage Debezium cluster