ข้ามไปยังเนื้อหา

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
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
Choreography saga — แต่ละ service ตอบสนองต่อ event; การ compensate ไหลย้อนกลับเป็นทอด ๆ เมื่อล้มเหลว
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 — ตัวประสานงานส่วนกลางออกคำสั่งและ trigger การ compensate เมื่อล้มเหลว

ตัวอย่างนี้คือ 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);

สิ่งที่คุณได้รับ:

  • ความสอดคล้องโดยไม่ต้องใช้ 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 commiteventual consistency — state อาจไม่ consistent ชั่วคราว
service ยังคง autonomous — ไม่มี global lockcompensating transaction ซับซ้อน โดยเฉพาะถ้า fail กลางคัน
ทนต่อ partial failure — rollback ได้ผ่าน compensating eventdebug ยากเมื่อ 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
แก่นแท้ของ saga คืออะไร?
saga ยกเลิกงานที่ commit ไปแล้วใน step ก่อนหน้าได้อย่างไร?
อะไรที่แยก orchestration ออกจาก choreography?
ข้อใดเป็นข้อเสียที่แท้จริงของ saga เมื่อเทียบกับ ACID transaction เดียว?