Saga
ตอนนี้แต่ละ service เป็นเจ้าของฐานข้อมูลของตัวเองแล้ว แต่การสั่งซื้อหนึ่งครั้งเป็นการดำเนินงานทางธุรกิจหนึ่งเดียวที่พาดข้ามถึงสามตัว: Order service บันทึก order, Customer service จองเครดิตของผู้ซื้อ และ Inventory service กันสต๊อกไว้ ใน monolith นี่เป็น transaction เดียว — การเปลี่ยนแปลงทั้งสามอย่าง commit พร้อมกัน หรือไม่มีอะไร commit เลย เมื่อมีฐานข้อมูลส่วนตัวสามตัว transaction เดียวนั้นเป็นไปไม่ได้
คุณอาจนึกถึง distributed transaction กลไกคลาสสิกอย่าง two-phase commit (2PC) จะสั่งให้ผู้เข้าร่วมทุกรายเตรียมตัวก่อน แล้วค่อยสั่งให้ทุกรายพร้อมกัน commit วิธีนี้ให้ atomicity ได้จริง แต่ราคาแพงมาก ผู้เข้าร่วมทุกรายต้องถือ lock ไว้ตลอดช่วงเวลานั้น ถ้ามีรายเดียวช้าหรือ crash ที่เหลือก็ค้างตามหมด แถม data store และ message broker สมัยใหม่จำนวนมากยังไม่รองรับด้วยซ้ำ สรุปคือ 2PC เอา availability และ scalability ไปแลก strong consistency ซึ่งตรงข้ามกับเหตุผลที่คุณแยกระบบออกมาตั้งแต่แรก
ดังนั้นแรงที่ขัดแย้งกันคือ: คุณต้องการให้การดำเนินงานที่พาดหลาย service ทำสำเร็จทั้งหมด หรือย้อนกลับทั้งหมด แต่คุณถือ global lock ไม่ได้ คุณสมมติไม่ได้ว่าทุก service ใช้ transaction manager ร่วมกัน และคุณต้องคง available ไว้แม้ตอนที่ service หนึ่งช้า
วิธีแก้
หัวข้อที่มีชื่อว่า “วิธีแก้”saga คือลำดับของ local transaction แต่ละ step ทำงานทั้งหมดภายใน service เดียว commit ลงฐานข้อมูลของ service นั้นเอง แล้วจึง trigger step ถัดไป เพราะแต่ละ step commit อย่างอิสระ จึงไม่มี global lock และไม่มี transaction manager ร่วม
ปมอยู่ที่การ rollback พอ local transaction commit ไปแล้ว คุณจะย้อนกลับทีหลังไม่ได้ง่าย ๆ ทางออกคือให้แต่ละ step มี compensating transaction ประกบไว้ ที่เป็น local transaction ที่ทำหน้าที่กลับด้านเชิงความหมายเพื่อยกเลิกผลของ step นั้น ถ้า step ไหนล้มเหลวกลางทาง saga จะไล่รัน compensation ของทุก step ที่สำเร็จไปแล้วในลำดับย้อนกลับ เพื่อดึงระบบกลับสู่สถานะที่สอดคล้องกัน
มีสองวิธีในการประสานงาน step เหล่านี้:
- Choreography — ไม่มีตัวประสานงานส่วนกลาง แต่ละ service ฟัง event จาก step ก่อนหน้าและตอบสนองด้วยการทำ local transaction ของตัวเองและ publish event ของตัวเองออกมา workflow คือผลรวมของ subscription ทั้งหมด
- Orchestration — มีตัวประสานงานหนึ่งตัว (orchestrator) คอยบอกแต่ละ service ว่าต้องทำอะไรและรอผลตอบกลับ ขับเคลื่อน saga ทีละ step และตัดสินใจว่าเมื่อใดควร compensate
Choreography: service ต่าง ๆ ตอบสนองต่อ event ของกันและกัน
หัวข้อที่มีชื่อว่า “Choreography: service ต่าง ๆ ตอบสนองต่อ event ของกันและกัน”sequenceDiagram
participant O as Order Service
participant C as Customer Service
participant I as Inventory Service
O->>O: create order (PENDING)
O-->>C: OrderCreated
C->>C: reserve credit
C-->>I: CreditReserved
I->>I: reserve stock
alt stock available
I-->>O: StockReserved
O->>O: approve order (CONFIRMED)
else out of stock
I-->>C: StockReservationFailed
C->>C: release credit (compensate)
C-->>O: CreditReleased
O->>O: reject order (CANCELLED)
end Orchestration: ตัวประสานงานขับเคลื่อนแต่ละ step
หัวข้อที่มีชื่อว่า “Orchestration: ตัวประสานงานขับเคลื่อนแต่ละ step”sequenceDiagram
participant SO as Order Saga Orchestrator
participant O as Order Service
participant C as Customer Service
participant I as Inventory Service
SO->>O: createOrder()
O-->>SO: ok (order PENDING)
SO->>C: reserveCredit()
C-->>SO: ok
SO->>I: reserveStock()
alt stock available
I-->>SO: ok
SO->>O: approveOrder()
O-->>SO: order CONFIRMED
else out of stock
I-->>SO: failed
SO->>C: releaseCredit() (compensate)
C-->>SO: ok
SO->>O: rejectOrder() (compensate)
O-->>SO: order CANCELLED
end ตัวอย่าง
หัวข้อที่มีชื่อว่า “ตัวอย่าง”ตัวอย่างนี้คือ orchestration saga สำหรับ flow การสั่งซื้อ ตัว orchestrator จะรัน step ไปข้างหน้าตามลำดับ ถ้า step ไหน throw ก็จะไล่รัน compensation ของ step ที่สำเร็จไปแล้วในลำดับย้อนกลับ แต่ละตัวอย่างรันได้ด้วยตัวเองและเขียนตามสไตล์ของภาษานั้น ๆ
type Ctx = { orderId: string; customerId: string; amount: number; sku: string };
interface Step { name: string; action: (ctx: Ctx) => Promise<void>; compensate: (ctx: Ctx) => Promise<void>;}
async function runSaga(ctx: Ctx, steps: Step[]): Promise<void> { const done: Step[] = []; try { for (const step of steps) { await step.action(ctx); done.push(step); } } catch (err) { // Undo completed steps in reverse order. for (const step of done.reverse()) { await step.compensate(ctx).catch((e) => console.error(`compensation failed for ${step.name}`, e), ); } throw err; }}
const orderSaga: Step[] = [ { name: 'createOrder', action: (c) => orderSvc.create(c.orderId, c.customerId), compensate: (c) => orderSvc.reject(c.orderId), }, { name: 'reserveCredit', action: (c) => customerSvc.reserveCredit(c.customerId, c.amount), compensate: (c) => customerSvc.releaseCredit(c.customerId, c.amount), }, { name: 'reserveStock', action: (c) => inventorySvc.reserve(c.sku), compensate: (c) => inventorySvc.release(c.sku), },];
await runSaga(ctx, orderSaga);from dataclasses import dataclassfrom typing import Awaitable, Callable
@dataclassclass Ctx: order_id: str customer_id: str amount: int sku: str
@dataclassclass Step: name: str action: Callable[[Ctx], Awaitable[None]] compensate: Callable[[Ctx], Awaitable[None]]
async def run_saga(ctx: Ctx, steps: list[Step]) -> None: done: list[Step] = [] try: for step in steps: await step.action(ctx) done.append(step) except Exception: # Undo completed steps in reverse order. for step in reversed(done): try: await step.compensate(ctx) except Exception as e: print(f"compensation failed for {step.name}: {e}") raise
order_saga = [ Step("createOrder", lambda c: order_svc.create(c.order_id, c.customer_id), lambda c: order_svc.reject(c.order_id)), Step("reserveCredit", lambda c: customer_svc.reserve_credit(c.customer_id, c.amount), lambda c: customer_svc.release_credit(c.customer_id, c.amount)), Step("reserveStock", lambda c: inventory_svc.reserve(c.sku), lambda c: inventory_svc.release(c.sku)),]
await run_saga(ctx, order_saga)type Ctx struct { OrderID string CustomerID string Amount int SKU string}
type Step struct { Name string Action func(Ctx) error Compensate func(Ctx) error}
func RunSaga(ctx Ctx, steps []Step) error { var done []Step for _, step := range steps { if err := step.Action(ctx); err != nil { // Undo completed steps in reverse order. for i := len(done) - 1; i >= 0; i-- { if cerr := done[i].Compensate(ctx); cerr != nil { log.Printf("compensation failed for %s: %v", done[i].Name, cerr) } } return err } done = append(done, step) } return nil}
orderSaga := []Step{ {"createOrder", func(c Ctx) error { return orderSvc.Create(c.OrderID, c.CustomerID) }, func(c Ctx) error { return orderSvc.Reject(c.OrderID) }}, {"reserveCredit", func(c Ctx) error { return customerSvc.ReserveCredit(c.CustomerID, c.Amount) }, func(c Ctx) error { return customerSvc.ReleaseCredit(c.CustomerID, c.Amount) }}, {"reserveStock", func(c Ctx) error { return inventorySvc.Reserve(c.SKU) }, func(c Ctx) error { return inventorySvc.Release(c.SKU) }},}
if err := RunSaga(ctx, orderSaga); err != nil { log.Printf("order saga rolled back: %v", err)}use std::future::Future;use std::pin::Pin;
type BoxFut = Pin<Box<dyn Future<Output = Result<(), String>>>>;
struct Step { name: &'static str, action: Box<dyn Fn(&Ctx) -> BoxFut>, compensate: Box<dyn Fn(&Ctx) -> BoxFut>,}
#[derive(Clone)]struct Ctx { order_id: String, customer_id: String, amount: i64, sku: String,}
async fn run_saga(ctx: &Ctx, steps: &[Step]) -> Result<(), String> { let mut done: Vec<&Step> = Vec::new(); for step in steps { if let Err(e) = (step.action)(ctx).await { // Undo completed steps in reverse order. for s in done.iter().rev() { if let Err(ce) = (s.compensate)(ctx).await { eprintln!("compensation failed for {}: {ce}", s.name); } } return Err(e); } done.push(step); } Ok(())}ผลลัพธ์ที่ตามมา
หัวข้อที่มีชื่อว่า “ผลลัพธ์ที่ตามมา”สิ่งที่คุณได้รับ:
- ความสอดคล้องโดยไม่ต้องใช้ 2PC. การดำเนินงานที่พาดหลาย service ทำสำเร็จทั้งหมด หรือย้อนกลับผ่านการ compensation โดยไม่มี global lock และไม่มี transaction manager ร่วม — service ยังคง available และ loosely coupled
- ใช้ได้กับทุกอย่าง. เพราะแต่ละ step เป็นเพียง local transaction ธรรมดาบวกข้อความ saga จึงทำงานได้ข้ามฐานข้อมูลและ broker ทุกชนิดที่ผสมกัน
สิ่งที่คุณต้องจ่าย:
- eventual consistency ไม่ใช่ isolation. ระหว่าง step ระบบอยู่ในสถานะบางส่วน transaction อื่นอาจสังเกตเห็น order ที่เครดิตถูกจองแล้วแต่สต๊อกยังไม่ได้ยืนยัน คุณต้องออกแบบรองรับสถานะกลางเหล่านี้ (เช่น ด้วย semantic lock อย่างสถานะ
PENDING) แทนที่จะพึ่งพา isolation ของฐานข้อมูล - คุณต้องออกแบบ compensation. ทุก step ที่มีผลกระทบที่สังเกตเห็นได้ต้องการด้านกลับที่สมเหตุสมผล และ compensation ต้อง idempotent และรับมือกับการถูก retry ได้
- การประสานงานซับซ้อนขึ้น choreography กระจาย workflow ไปตาม subscription จำนวนมาก พอ step เยอะขึ้นก็ไล่ตามยาก ส่วน orchestration รวม logic ไว้ที่เดียวก็จริง แต่เพิ่ม component ใหม่ที่ต้องน่าเชื่อถือและกู้คืนตัวเองได้
กฎเชิงปฏิบัติ: ใช้ choreography สำหรับ flow ที่สั้นและเรียบง่ายโดยมีผู้เข้าร่วมไม่กี่ตัว และใช้ orchestration เมื่อ flow มี branch มีหลาย step หรือต้องการให้คิดวิเคราะห์ในภาพรวมได้
เนื้อหาที่เกี่ยวข้อง
หัวข้อที่มีชื่อว่า “เนื้อหาที่เกี่ยวข้อง”- Database per Service — ขอบเขตที่ทำให้ saga จำเป็น
- Event Sourcing — วิธีที่น่าเชื่อถือในการ publish event ที่ saga พึ่งพา
- CQRS — มักใช้เพื่อ query สถานะกลางที่ saga สร้างขึ้น
| ข้อดี | ข้อแลกเปลี่ยน |
|---|---|
| จัดการ distributed transaction โดยไม่ต้องใช้ 2-phase commit | eventual consistency — state อาจไม่ consistent ชั่วคราว |
| service ยังคง autonomous — ไม่มี global lock | compensating transaction ซับซ้อน โดยเฉพาะถ้า fail กลางคัน |
| ทนต่อ partial failure — rollback ได้ผ่าน compensating event | debug ยากเมื่อ saga ล้มเหลว — ต้อง trace ข้าม service |
| scale ได้ดี — ไม่มี global coordinator lock ทั้งระบบ | idempotency ของทุก step เป็นสิ่งจำเป็น |
ข้อผิดพลาดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ข้อผิดพลาดที่พบบ่อย”Saga ที่ไม่ Idempotent — step ใน saga ทำซ้ำไม่ได้โดยปลอดภัย อาการ:
- retry ทำให้เกิด double charge ใน payment
- compensating transaction ทำงานผิดเพราะ state ไม่คาดเดาได้
Saga ที่ยาวเกินไป — saga มีมากกว่า 7-10 step อาการ:
- compensating logic ซับซ้อนมาก ทีมไม่มั่นใจว่า rollback ถูก
- failure ตรงกลาง saga ทำให้ระบบอยู่ใน inconsistent state นาน
💡 ตัวอย่างจากของจริง
Uber Eats:
- order placement เป็น saga: validate order → charge payment → notify restaurant → assign driver
- ถ้า driver หาไม่ได้ → refund payment → notify customer
Airbnb:
- booking saga: reserve listing → charge card → send confirmation
- compensating: refund → release listing → send cancellation notification