มารู้จักและเล่นกับ Saga : Microservices Design Patterns กันเถอะ

Thanatach Assavarattanakorn
SCB Engineer
Published in
6 min readFeb 21, 2024

สวัสดีผู้อ่านทุกท่านอีกครั้งครับ 😎 หลายๆท่านที่กำลังอ่านอยู่ คงคุ้นเคย หรือเคยพัฒนา Microservice ไม่ก็ API กันมาอย่างแน่นอนเลยถูกไหมครับ 😁 ซึ่งหลายๆคนก็คงจะคุ้นเคยกับการพัฒนาโปรแกรม หรือสร้าง API ตามหลัก Design Patterns กันแบบเหนียวแน่นเลย แต่เพื่อนๆรู้กันไหมครับ ว่าจริงๆแล้ว นอกจากสิ่งที่เรียกว่าเป็น Code Design Patterns ก็ยังมีสิ่งที่เรียกว่า Microservices Design Patterns เช่นกันครับ🤔

Photo by Kaleidico on Unsplash

ทำไมต้องรู้ Microservices Design Patterns รู้แล้วดียังไง 🫣 ?

เฉกเช่นเดียวกันกับ Code Design Patterns เลยครับ งานบางงาน หรือระบบบางจุด อาจมีความซับซ้อน หรือมีเงื่อนไขบางอย่าง ที่ไม่เอื้อกับการออกแบบการทำงานแบบตรงไปตรงมา จนอาจต้องมีการใช้งาน Patterns บางอย่าง เพื่อนำมาปรับปรุงกับระบบงานเพื่อให้การสร้าง, แก้ไข, พัฒนา เป็นไปอย่างเป็นระบบและบำรุงรักษาให้ได้ง่ายที่สุดครับ

Photo by Sandy Millar on Unsplash

โดยจริงๆแล้วเนี่ย เจ้า Microservices Design Patterns นั้นมีหลากหลายกระบวนท่ามากเลย 🧐 และแยกย่อยลงไปตามหมวดหมู่อีก ซึ่ง Patterns ในวันนี้ที่ผมจะมาชวนทุกคนคุย จะอยู่ในหมวดของ Database Pattern คือ Saga Pattern นั่นเองครับ 👀

แต่ก่อนอื่น อยากให้ลองฟังโจทย์นี้ก่อนครับ 😬

เคยไหมครับ ที่ในหลาย ๆ ครั้ง Service หรือ Microservice ของเรา จะมีลำดับของการทำงานเชิงพฤติกรรมที่อยู่ในลักษณะของ ลำดับ หรือ Sequence ในแต่ละ transactions เช่นในระบบคำสั่งซื้อของสินค้าซักอย่างนึง กว่าจะเสร็จสมบูรณ์เนี่ย เราจะต้องเช็คเยอะมากเลย 😑

เช่น

➡️🃏ลูกค้าคนนี้มีสิทธิ์สั่งซื้อสินค้าชิ้นนี้ไหม

➡️ 📦คลังสินค้าเราเหลือสำหรับการขายไหม

➡️ 💵ระบบ Payment ของเราทำงานได้ปกติไหม หรือการตัดเงินมีปัญหาไหม

➡️ 🔔ยิง Notification ซึ่งอาจจะเป็น Email

➡️ 💯คำสั่งซื้อเสร็จสมบูรณ์

และแน่นอนว่าหากกระบวนการใดซักอย่างนึงที่เราวางไว้ผิดพลาด เราจะต้อง Handle การทำงานที่ผิดพลาดนั้นได้ด้วย

เช่น หากระบบ Payment มีปัญหา เช่น

💸ลูกค้ามียอดเงินในธนาคารไม่เพียงพอ ➡️ 📦เราต้องปรับคลังสินค้าของเราให้กลับมาได้ด้วยเช่นกัน

ซึ่งจากลำดับที่เล่าให้ฟัง จะเห็นเลยว่า หากจะออกแบบกระบวนการทั้งหมดให้อยู่ใน Microservice เดียวที่โยนภาระทั้งหมดนี้มาจัดการแบบตรงไปตรงมา จะบำรุงรักษา และพัฒนายากพอสมควร 🥹

โอ่.. นี่ยังไม่รวมเรื่องการ Scalability ที่ Microservice นั้น จะเป็น Single point of failure ในทันที 😵‍💫 อีกทั้งยังเลือก Scale เป็นเฉพาะส่วนที่ Load หนักๆไม่ได้ด้วย เช่นหากเราต้องการ Scale แค่ส่วนที่เป็นการตรวจคลังสินค้า แต่ไม่ได้ต้องการ Scale ส่วนที่เป็นการส่ง Notification เราก็จะทำไม่ได้ครับ😤

มารู้จักพระเอกของเรา Saga Pattern กันดีกว่า🎊

เอาหละ หลายๆคนคงจะสงสัยกันสุดๆแล้วในตอนนี้ ว่าเจ้า Saga มันจะเข้ามาช่วยในโจทย์ของเรายังไง

คือเจ้า Saga เนี่ย จะเป็นแนวคิดการออกแบบ Microservice เพื่อแยก Behavior ที่ซับซ้อนออกจากกัน ให้กลายเป็น Microservice ย่อยที่มีการทำงานแบบแยกส่วน เป็น Sequence ย่อยที่ผูกติดกันไว้ครับ โดยมีแนวคิดที่น่าสนใจคือ 1 Microservice ต่อ 1 Database ทำให้แต่ละ Microservice ที่จะเป็น Sequence ต่อกันเนี่ย มีลักษณะที่ละม้ายคล้ายคลึง State เลยครับผม😉

ซึ่งถ้าการทำงานในเชิงที่เป็น Happy Case จะเห็นได้ว่าแต่ละ Sequence จะทำงานคล้ายกับ Waterfall ได้อย่างราบรื่นแบบนี้ 👏🏻

เอาหละ จากที่ผมได้อธิบายไปเมื่อครู่นี้ สมมุติให้ระบบ Payment เรามีปัญหา หรือลูกค้ามีเงินในธนาคารไม่เพียงพอ ลำดับในแต่ละ Microservice จะออกมาประมาณนี้ครับผม

จะเห็นได้ว่า หลังตัดเงินไม่ได้ ตัว Microservice ที่เป็น checkStock จะทำการ Revert Stock ที่เคยตัดไปก่อนหน้านั้นกลับมา และ Callback กลับไปหา Order ที่เป็น Microservice ต้นทางที่เรียกมันมาอีกทีครับ 🪄

ซึ่งการทำงานแบบนี้ที่เล่าไป จะเห็นได้ว่าแต่ละ Microservice ทำงานเป็นเชิง State ที่สามารถ RollBack สิ่งที่เกิดขึ้นไปแล้วหากเกิดปัญหา และโยนปัญหาก้อนนั้นสวนทาง Waterfall ขึ้นไปเพื่อส่งต่อให้ Microservice ลำดับก่อนหน้า Rollback สิ่งที่ตัวเองทำกลับเหมือนกัน ซึ่งตรงนี้เนี่ย หลายๆคนก็คงจะเห็นแล้วใช่ไหมครับ ว่ายิ่งพอแยกออกมาเป็น Microservice แบบนี้เนี่ย การพัฒนาก็จะสามารถแยกทำได้อย่างอิสระ และดูจะเป็นเรื่องที่รับมือง่ายขึ้นมากโขเลย ถ้าเทียบกับการกองทั้ง Behavior ไว้ใน Microservice ตัวเดียว หรือ Controller ของ Endpoint นั้นๆแค่เส้นเดียวครับ

ตอนนี้หลายๆคนอาจจะกำลังคิดอยู่ใช่ไหมหละครับ 😏 ว่าจริงๆแล้ว ก็แค่เป็นการวาง Step การทำงานให้ Handle ปัญหาแบบต่อๆกันไปแค่นั้นเอง

ถูกต้องครับ !

📌 เพราะจริงๆแล้ว Design Pattern ไม่ใช่รูปแบบ Code แต่เป็นแนวคิด ซึ่งในตอนนี้เราอาจกำลังใช้ Design Pattern ไหนซักอย่างอยู่โดยไม่รู้ตัวก็ได้

มาลุยภาคปฏิบัติกัน !!

ในภาคปฏิบัตินี้ เราจะเล่นกับ Saga Pattern โดยใช้รูปแบบของ Choreography Saga Pattern ซึ่งจะเป็นท่าที่ในแต่ละ Microservices สื่อสารกันโดยมี Message Broker เป็นตัวกลางอีกทีนึงครับ

โดยเพื่อนร่วมทางหลักๆในวันนี้ของผมจะมี

  • NodeJS 👉 พาหนะสำหรับ Microservice ของพวกเรา
  • Apache Kafka 👉 ใช้สำหรับทำ Message Broker System
  • Express 👉 สำหรับขึ้น Endpoint ใช้ทดสอบ

โดยในเบื้องต้น ผมได้ทำการ Setup Environment ของ Message Broker System ไว้เรียบร้อยครับ

โดยจะมีไฟล์ และ Microservice ใน Folder service ที่ขึ้นไว้ตามนี้ครับ

หรือใครขี้เกียจตามอ่านก็ Clone มาเล่นได้เลยนะครับ ⬇️ https://github.com/thanatath/HelloKafkaBySaga

ในส่วนของ Service order นั้น เราวางหน้าที่เป็น BFF (Backend For Frontends) ด้วย โดยหากมีการเรียก Endpoint เข้ามาปุ๊บ จะถือว่าลูกค้าได้ส่งคำสั่งซื้อเข้ามา โดยส่ง Event Topic CHECK_STOCK ไปหา Message Broker เพื่อเช็ค Stock สินค้า

โดยใน Service นี้ เราจะ Consume Topic CHECK_STOCK_FAIL และ NOTIFICATION_RESULT ไว้ด้วยครับ

// order.js
const { clientProducer,clientConsumer } = require('../config/kafka.js')
const {topic} = require("../config/kafka.js");
const express = require("express");
const app = express();
const port = 3000;
const { uid } = require("uid");
const thisService = "order";

// kafka
const _clientConsumer = clientConsumer(thisService);

async function initialize() {
await _clientConsumer.subscribe({topics: [topic.CHECK_STOCK_FAIL,topic.NOTIFICATION_RESULT]});
await clientProducer.connect();
await _clientConsumer.connect();
}

async function handleConsumer(){
await _clientConsumer.run({
eachMessage: async ({ topic, partition, message }) => {
console.log({ topic: topic, value: message.value.toString() });
},
});
}

async function main()
{
initialize();
handleConsumer();
}

main();


// route
app.get("/", async (req, res) => {
checkStock();
res.send("ส่งคำสั่งซื้อเรียบร้อยแล้ว !");
});

async function checkStock() {

const orderDetail = {
id:uid(),
itemName:"watch",
success:true,
}

const orderTopic = {
topic: topic.CHECK_STOCK,
messages: [{ value: JSON.stringify(orderDetail) }],
};
await clientProducer.send(orderTopic);
}

app.listen(port, () => {
console.log(`listening on port ${port}`);
});

ในส่วนของ Service checkStock เราจะมีการ Mock database ไว้ว่าเรามีคลังสินค้าที่เป็น Watch อยู่ 5 ชิ้นครับผม และหากสินค้าหมดจะถือว่าการซื้อไม่สำเร็จและ Produce Topic CHECK_STOCK_FAIL ออกไปครับ

แต่ถ้าสินค้ายังไม่หมดจะ Produce Topic PURCHASE ออกไปเพื่อดำเนินการสั่งซื้อต่อไปครับ

และมีการ Consume Topic PURCHASE_FAIL ไว้ด้วยครับผม

// checkStock.js
const { clientConsumer,clientProducer } = require('../config/kafka.js')
const {topic} = require("../config/kafka.js");
const thisService = "checkStock";

// KafKa
const _clientConsumer = clientConsumer(thisService);

// Mock DataBase
var database = [
{
name:'watch',
stock: 5,
}
];

async function initialize() {
await _clientConsumer.subscribe({topics: [topic.CHECK_STOCK,topic.PURCHASE_FAIL]});
await clientProducer.connect();
}

async function handleConsumer(){
var configTopic = topic;
_clientConsumer.run({
eachMessage: async ({ topic, partition, message }) => {
console.log({ topic: topic, value: message.value.toString() });
var orderDetail = JSON.parse(message.value.toString());
if(topic == configTopic.CHECK_STOCK) handleCheckStock(orderDetail);
if(topic == configTopic.PURCHASE_FAIL) handlePurchaseFail(orderDetail);
console.log(database);
},
});
}

async function main()
{
initialize();
handleConsumer();
}


main();


function handleCheckStock(orderDetail){
var itemInDB = database.find(x=>x.itemName == orderDetail.name);
var isItemInStock = itemInDB.stock > 0;

if(isItemInStock)
{
itemInDB.stock-=1;
console.log(`Check stock ${orderDetail.itemName} successful`);
handlePurchase(orderDetail);
}
else
{
handleCheckStockFail(orderDetail);
console.error(`Check stock ${orderDetail.itemName} failed`);
}
}

async function handlePurchase(orderDetail) {
const orderTopic = {
topic: topic.PURCHASE,
messages: [{ value: JSON.stringify(orderDetail) }],
};
await clientProducer.send(orderTopic);
}

async function handleCheckStockFail(orderDetail) {

const orderDetailFail = {
id:orderDetail.id,
itemName: orderDetail.name,
success:false,
reason:"This item out of stock !"
}

const orderTopic = {
topic: topic.CHECK_STOCK_FAIL,
messages: [{ value: JSON.stringify(orderDetailFail) }],
};

await clientProducer.send(orderTopic);
}

async function handlePurchaseFail(orderDetail) {

var itemInDB = database.find(x=>x.itemName == orderDetail.name);

const orderTopic = {
topic: topic.CHECK_STOCK_FAIL,
messages: [{ value: JSON.stringify(orderDetail) }],
};

console.error(`Purchase failed reserve stock for ${itemInDB.name}`);
itemInDB.stock+=1;

await clientProducer.send(orderTopic);
}

สำหรับ Service purchase นั้นเราจะมีการ Mock ThridParty Service ไว้ด้วย ไว้เพื่อลำลองว่า Payment Service เรามีปัญหาครับ

โดยจะมีการ Produce Topic PURCHASE_FAIL หากเกิดปัญหาเกี่ยวกับการตัดเงิน และจะ Produce Topic NOTIFICATION หากการตัดเงินเสร็จสมบูรณ์เพื่อส่ง Notification ต่อไปครับ

// purchase.js
const { clientConsumer,clientProducer } = require('../config/kafka.js')
const {topic} = require("../config/kafka.js");
const { uid } = require("uid");
const thisService = "purchase";

// KafKa
const _clientConsumer = clientConsumer(thisService);

// Mock ThridParty Service
const ThridPartyPaymentService = {
success: true,
};

// Mock DataBase
var database = [];

async function initialize() {
await _clientConsumer.subscribe({
topic: topic.PURCHASE,
});
await clientProducer.connect();
await _clientConsumer.connect();
}

async function handleConsumer(){
await _clientConsumer.run({
eachMessage: async ({ topic, partition, message }) => {
console.log({ topic: topic, value: message.value.toString() });
var orderDetail = JSON.parse(message.value.toString());
handlePurchase(orderDetail);
console.log(database);
},
});
}

async function main()
{
initialize();
handleConsumer();
}



main();


function handlePurchase(orderDetail){

if(ThridPartyPaymentService.success)
{
database.push(orderDetail)
handleNotification(orderDetail);
}
else
{
purchaseFail(orderDetail);
}

}

async function handleNotification(orderDetail) {
const orderTopic = {
topic: topic.NOTIFICATION,
messages: [{ value: JSON.stringify(orderDetail) }],
};

await clientProducer.send(orderTopic);
}

async function purchaseFail(orderDetail) {

const orderDetailFail = {
id:orderDetail.id,
itemName:orderDetail.name,
success:false,
reason:"can't connect Payment Service"
}

const orderTopic = {
topic: topic.PURCHASE_FAIL,
messages: [{ value: JSON.stringify(orderDetailFail) }],
};
console.error(`can't connect Payment Service`);
await clientProducer.send(orderTopic);
}

สุดท้ายกับ Service notification ของเรากันครับ

ตรงนี้ไม่มีอะไรซับซ้อนเลยครับ Consume NOTIFICATION และ Produce NOTIFICATION_RESULT ไปหาฝั่ง BFF ของพวกเราครับ

// notification.js
const { clientConsumer,clientProducer } = require('../config/kafka.js')
const {topic} = require("../config/kafka.js");
const thisService = "notification";

// KafKa
const _clientConsumer = clientConsumer(thisService);

async function initialize() {
await _clientConsumer.subscribe({
topic: topic.NOTIFICATION,
});
await clientProducer.connect();
await _clientConsumer.connect();
}

async function handleConsumer(){
await _clientConsumer.run({
eachMessage: async ({ topic, partition, message }) => {
console.log({ topic: topic, value: message.value.toString() });
var orderDetail = JSON.parse(message.value.toString());
handleNotification(orderDetail);
},
});
}

async function main()
{
initialize();
handleConsumer()
}

main();


async function handleNotification(orderDetail){
console.log(`Notification to ${orderDetail.id} successful`);

orderDetail.reason ="Notification success";

const orderTopic = {
topic: topic.NOTIFICATION_RESULT,
messages: [{ value: JSON.stringify(orderDetail) }],
};

await clientProducer.send(orderTopic);

}

เอาหละ สมาชิกของเราครบแล้ว มาลองสร้างคำสั่งซื้อผ่าน BFF ของเรากันเลย

Flow ที่เกิดขึ้นจะเป็นตามนี้เลยครับ

หลังจากส่งคำสั่งซื้อผ่าน BFF ไปแล้ว

จะเห็นว่า order จะส่ง topic ไปเพื่อ checkStock หลังจากนั้น checkStock เราจะลด Stock ลง 1 หน่วย และส่ง Topic ไปเพื่อทำการตัดเงิน และส่ง Notification ต่อไปครับ

เอาหละ ถ้ากรณีมีคำสั่งซื้อครั้งที่ 6 มา ซึ่งทำให้ของหมดคลังเราหละ

ตรงนี้ก็จะเห็นว่า Service checkStock เรา Produce Topic checkStockFail ออกไปโดยที่ไม่วิ่งผ่าน Service purchase และ notification ครับ

ถัดมา ถ้ากรณี Service Payment หรือ purchase เรามีปัญหาหละ

ทีนี้อยากให้เพื่อนๆสังเกตุไปที่ Service checkStock นะครับผม หลัง Consume Topic purchaseFail ปุ๊บ ตัว Service จะ Rollback การตัด Stock ของสินค้าชิ้นนั้นๆ จาก 4 กลับมาเป็น 5 ทันทีครับ

ทีนี้เพื่อนๆจะเห็นว่าแต่ละ Microservice ที่เรามี แยกส่วนการทำงานตามหน้าที่ และสามารถ Handle การทำงานที่ผิดปกติได้อย่างเป็นระบบครับ 🥇

ทิ้งท้ายกับ Saga 👋

  • แน่นอนว่า Saga ไม่ใช่รูปแบบ Code หรือต้องผูกติดกับการใช้ Message Broker จริงๆแล้วแม้แต่ REST เราก็ยังเอาไปประยุกต์กับ Saga ได้
  • หาก Architecture เราวางไว้เป็นแบบ Hexagonal architecture หรือ Clean Architecture ที่มีตัว Orchestrate การเลือกใช้ Saga จะยิ่งน่าสนใจมากทีเดียว
  • ในบางสถานการณ์ หากประยุกต์ Saga อย่างไม่ระวัง จะเป็นการ over-engineering แบบได้ไม่คุ้มเสีย
  • การกระจาย Behavior ออกเป็น Microservice ย่อมมีเพิ่ม Overhead ตามมา ตรงนี้ต้องระวังด้วย
  • ในแต่ละ Microservice เราสามารถต่อยอดออกไปเป็น Service point หรือ Scale ได้

Reference

--

--

Thanatach Assavarattanakorn
SCB Engineer

Software Engineer, QA spin my head right round right round