Quick start: Dựng Saga Orchestrator chạy ngay trong 5 phút
Tách monolith thành microservices luôn kéo theo một bài toán hóc búa: phân tán dữ liệu. Giả sử khách đặt hàng, Payment Service trừ 500.000đ nhưng Inventory Service lại báo hết sạch hàng trong kho. Lúc này, cơ chế ACID của một database đơn lẻ không còn tác dụng. Bạn phải tự tay hoàn tiền cho khách.
Để hiểu rõ cơ chế giải quyết, hãy dựng ngay một luồng Orchestration Saga bằng Node.js thuần. Bạn tạo file saga-demo.js:
// saga-demo.js
class OrderSagaOrchestrator {
constructor() {
this.steps = [];
}
addStep({ name, execute, compensate }) {
this.steps.push({ name, execute, compensate });
return this;
}
async run(context) {
const executedSteps = [];
console.log(`[START] Xử lý Saga cho Order #${context.orderId}`);
for (const step of this.steps) {
try {
console.log(`-> Thực thi: ${step.name}`);
await step.execute(context);
executedSteps.push(step);
} catch (error) {
console.error(`<!> Lỗi tại [${step.name}]: ${error.message}`);
await this.rollback(executedSteps, context);
throw new Error(`Saga gián đoạn: ${error.message}`);
}
}
console.log(`[DONE] Giao dịch phân tán thành công!`);
}
async rollback(executedSteps, context) {
console.log(`[ROLLBACK] Kích hoạt chuỗi bù trừ (Compensating Transactions)...`);
// Đảo ngược danh sách các bước đã chạy thành công trước đó
const stepsToRevert = [...executedSteps].reverse();
for (const step of stepsToRevert) {
try {
console.log(`<- Đang hoàn tác: ${step.name}`);
await step.compensate(context);
} catch (compensateError) {
// Ghi log mức độ nghiêm trọng nếu bù trừ thất bại
console.error(`[CRITICAL] Rollback [${step.name}] thất bại: ${compensateError.message}`);
}
}
}
}
// Giả lập các Microservices độc lập qua API call
const PaymentService = {
charge: async (ctx) => { console.log(` [Payment] Trừ ${ctx.amount}$ thành công.`); },
refund: async (ctx) => { console.log(` [Payment] Hoàn lại ${ctx.amount}$ vào ví người dùng.`); }
};
const InventoryService = {
reserve: async (ctx) => {
if (ctx.item === 'out-of-stock-item') {
throw new Error('Mã hàng SKU-9901 đã hết tồn kho!');
}
console.log(` [Inventory] Giữ chỗ thành công cho item: ${ctx.item}.`);
},
release: async (ctx) => { console.log(` [Inventory] Trả lại số lượng tồn kho cho item: ${ctx.item}.`); }
};
// Kịch bản: Khách mua sản phẩm đã cháy hàng
async function main() {
const saga = new OrderSagaOrchestrator();
saga
.addStep({
name: 'Thanh toán tiền',
execute: (ctx) => PaymentService.charge(ctx),
compensate: (ctx) => PaymentService.refund(ctx)
})
.addStep({
name: 'Giữ hàng trong kho',
execute: (ctx) => InventoryService.reserve(ctx),
compensate: (ctx) => InventoryService.release(ctx)
});
try {
await saga.run({
orderId: 'ORD-9921',
amount: 150,
item: 'out-of-stock-item'
});
} catch (err) {
console.log(`Kết quả: Đã hủy đơn hàng và hoàn tiền an toàn.`);
}
}
main();
Chạy thử file trên terminal:
node saga-demo.js
Đầu ra hiển thị rõ trình tự: Trừ tiền → Kho báo lỗi → Kích hoạt hàm hoàn tiền (Compensate). Trạng thái hệ thống trở về điểm cân bằng ban đầu. Toàn bộ quá trình không hề cần tới cơ chế khóa 2-Phase Commit (2PC) cồng kềnh.
Bản chất kỹ thuật của Saga Pattern
Rào cản Database-per-service
Mỗi service quản lý một database riêng. Khi người dùng bấm mua chiếc MacBook Pro, luồng dữ liệu lập tức chạm vào ba cơ sở dữ liệu riêng rẽ: OrderDB (PostgreSQL), PaymentDB (MySQL) và InventoryDB (MongoDB).
Trước đây, chuẩn 2-Phase Commit (2PC) thường được dùng để khóa tài nguyên xuyên suốt nhiều database. Nhưng trên hạ tầng cloud phân tán, 2PC bộc lộ nhược điểm chí mạng. Thời gian phản hồi có thể vọt từ 20ms lên 2000ms chỉ vì mạng trễ. Bảng dữ liệu bị lock cứng, kéo theo nghẽn connection pool toàn hệ thống.
Cơ chế vận hành của Saga
Saga giải bài toán này bằng cách chia nhỏ quy trình thành chuỗi các Local Transactions độc lập. Mỗi service chỉ thao tác trên database nội bộ, commit ngay lập tức rồi gửi tín hiệu cho bước tiếp theo.
Trọng tâm của Saga nằm ở Compensating Transaction (Giao dịch bù trừ). Nếu bước thứ N gặp sự cố, bạn không thể dùng lệnh ROLLBACK của SQL để quay ngược thời gian. Lý do là các bước từ 1 đến N-1 đã commit xong từ lâu. Thay vào đó, Orchestrator sẽ kích hoạt chuỗi hoàn tác từ N-1 ngược về 1 (ví dụ: hủy đơn hàng, gửi lệnh refund qua cổng thanh toán).
Choreography hay Orchestration?
- Choreography (Event-driven): Các service tự trao đổi qua message broker (Kafka, RabbitMQ). Service A xử lý xong sẽ emit event
OrderCreated. Service B nghe thấy thì trừ tiền rồi emitPaymentCompleted. Mô hình này không cần bộ điều khiển trung tâm, chạy rất mượt với quy trình ngắn (2-3 bước). Tuy nhiên, khi hệ thống phình to lên 8-10 microservices, việc theo dõi luồng event trở nên cực kỳ rối rắm. - Orchestration (Bộ điều phối tập trung): Một service chuyên trách đứng ra làm “nhạc trưởng”. Service này gửi command trực tiếp tới từng worker, chờ kết quả phản hồi và quyết định bước kế tiếp hoặc phát lệnh bù trừ. Lợi thế lớn nhất là dễ debug, dễ ghi log tập trung và nắm rõ trạng thái đơn hàng theo thời gian thực.
Xử lý sự cố mạng, Idempotency và Lưu trạng thái
Đoạn script mẫu hoạt động tốt trên máy cá nhân. Song khi triển khai lên cụm Kubernetes thực tế, hiện tượng rớt mạng giữa chừng chắc chắn sẽ xảy ra.
Thiết kế Idempotent Receiver (Chống trừ tiền trùng lặp)
Hãy hình dung tình huống: Orchestrator gọi sang Payment Service để trừ tiền. Database của Payment Service đã trừ tiền thành công, nhưng gói tin HTTP 200 trả về bị timeout trên đường truyền mạng sau 5000ms. Orchestrator coi như request thất bại và gửi lại lần hai. Nếu không có cơ chế Idempotency, tài khoản khách hàng sẽ bị trừ tiền hai lần.
// Middleware kiểm tra Idempotency Key tại Payment Service
async function processPayment(req, res) {
const { idempotencyKey, orderId, amount } = req.body;
// 1. Kiểm tra transaction đã từng được xử lý hay chưa
const existingTx = await db.paymentTransactions.findOne({ idempotencyKey });
if (existingTx) {
return res.status(200).json(existingTx.responsePayload);
}
try {
// 2. Trừ tiền và ghi nhận idempotency key trong cùng một transaction DB nội bộ
const result = await db.transaction(async (trx) => {
const payment = await trx.payments.insert({ orderId, amount, status: 'SUCCESS' });
await trx.idempotencyRecords.insert({
idempotencyKey,
responsePayload: payment
});
return payment;
});
return res.status(201).json(result);
} catch (err) {
// Bắt lỗi trùng khóa Unique Constraint nếu có 2 request bay vào cùng mili-giây
if (err.code === 'ER_DUP_ENTRY' || err.code === '23505') {
const duplicateTx = await db.paymentTransactions.findOne({ idempotencyKey });
return res.status(200).json(duplicateTx.responsePayload);
}
return res.status(500).json({ error: 'Lỗi xử lý giao dịch' });
}
}
Lưu vết trạng thái Saga vào Database
Server Orchestrator hoàn toàn có thể bị crash hoặc bị Kubernetes kill pod giữa chừng. Để pod mới khởi động lại biết chính xác cần đi tiếp hay rollback, bạn phải lưu trạng thái từng bước vào database:
CREATE TABLE saga_instances (
id VARCHAR(64) PRIMARY KEY,
saga_type VARCHAR(50) NOT NULL,
current_step VARCHAR(50) NOT NULL,
status VARCHAR(20) NOT NULL, -- PENDING, COMPENSATING, COMPLETED, FAILED
payload JSONB NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
Trước khi bắn lệnh sang service tiếp theo, Orchestrator cập nhật bản ghi sang bước mới. Một background worker sẽ quét các bản ghi treo ở trạng thái PENDING quá 60 giây để quyết định kích hoạt retry hay chạy bù trừ.
Kinh nghiệm thực chiến khi vận hành Saga
Vận hành Saga trên production đòi hỏi kỷ luật cao về logging và xử lý lỗi biên. Một số lưu ý quan trọng gồm:
- Chuẩn hóa định dạng Payload: Dữ liệu trao đổi qua HTTP request hoặc Kafka topic thường phình to và khó đọc khi debug. Bạn có thể tận dụng các tiện ích định dạng online tại toolcraft.app (chẳng hạn toolcraft.app/vi/tools/developer/json-formatter) để kiểm tra nhanh cấu trúc JSON, validate schema hoặc test biểu thức regex mà không cần cài extension rườm rà.
- Truy vết bằng Trace Context: Hãy đính kèm mã định danh duy nhất (UUID) qua header
x-correlation-idhoặc chuẩn W3Ctraceparentngay từ API Gateway. Khi một giao dịch bù trừ gặp trục trặc, bạn chỉ cần gõ mã ID này vào Grafana Loki hoặc Datadog là có thể lần ra toàn bộ dấu vết các bước đã chạy. - Hàm bù trừ (Compensate) không được phép thất bại: Khi viết logic hoàn tiền hoặc mở lại kho hàng, tuyệt đối không chèn các validation nghiệp vụ khiến hàm ném lỗi (exception). Nếu hệ thống thứ ba (ví dụ: cổng thanh toán ngân hàng) bảo trì, hãy đẩy message vào Dead Letter Queue (DLQ) kèm cơ chế retry theo chu kỳ, đồng thời bắn alert ngay lập tức về Slack để trực ca xử lý.
- Tận dụng framework chuyên dụng: Tự code state machine từ đầu chỉ phù hợp cho demo hoặc bài toán nhỏ. Khi hệ thống mở rộng quy mô, việc tự bảo trì cơ chế timeout và retry tốn rất nhiều nguồn lực. Bạn nên cân nhắc các giải pháp trưởng thành như
Temporal.io,CadencehoặcBullMQtrên nền Redis.

