Thiết kế Booking System cho Travel Marketplace: Saga Pattern và Event-Driven Workflow trong Medusa v2
Bài này có bản tiếng Anh. Read in English →
Nguyen Hai Trinh • Senior Software Engineer @ SmileViet
Ở SmileViet, backend mình đang maintain là một Medusa v2 service cho marketplace du lịch: khách đặt tour, cruise, vé transport, visa — chung một nền tảng thương mại điện tử, mở rộng thêm module vertical cho du lịch.
Chuyện bắt đầu từ lúc tụi mình nhận ra transaction kiểu “tất cả hoặc không gì cả” không còn đủ. Một đơn tour trông đơn giản trên giao diện. Phía sau nó chạm vào năm nguồn sự thật khác nhau, và một trong số đó mình không kiểm soát được.
1. Vì sao một booking không phải là một transaction DB
Năm nguồn đó là:
- Inventory service: giữ chỗ (hold seat) trên tour/cruise — số chỗ có hạn, phải lock đúng lúc.
- Pricing/FX service: tính giá theo hạng ghế, mùa cao điểm, tỷ giá tại thời điểm đặt.
- Payment service: charge thẻ qua Stripe/Adyen hoặc ví điện tử khu vực (LINE Pay, KakaoPay).
- Supplier confirmation: với nhiều tour, mình phải gọi API (hoặc gửi email) cho nhà cung cấp bên thứ ba để xác nhận chỗ — bước này có thể mất vài giây đến vài phút, và có thể bị từ chối sau khi khách đã trả tiền.
- Notification/CMS sync: gửi email xác nhận, đồng bộ dữ liệu về hệ thống CMS cho đội vận hành.
Không service nào trong số đó chung một database. Vài cái lại nằm ngoài tầm với — supplier confirmation là ví dụ rõ nhất.
Hai-phase-commit (2PC) đòi hỏi mọi participant hỗ trợ transaction phân tán. API của một nhà cung cấp tour thì không, và sẽ không bao giờ. Nên nếu charge tiền xong mà supplier từ chối, mình cần một cách để hoàn tác phần đã làm. Rollback transaction DB không giúp được gì ở đây.
2. Saga: chuỗi bước có đường lùi
Tụi mình chọn choreography-based saga. Không có transaction lớn nào cả. Booking bị chia thành chuỗi bước chạy tuần tự (hoặc song song khi an toàn), và mỗi bước có sẵn một compensating action để lùi lại nếu bước sau thất bại.
Bước 1: Hold inventory → Compensate: Release inventory
Bước 2: Calculate final price → Compensate: (không cần, read-only)
Bước 3: Charge payment → Compensate: Refund payment
Bước 4: Confirm with supplier → Compensate: Cancel + release inventory + refund
Bước 5: Send confirmation → Compensate: Send cancellation notice
Bước 4 thất bại — supplier báo hết chỗ ngay lúc đó — thì saga chạy ngược: refund tiền đã charge ở bước 3, release lại chỗ đã hold ở bước 1, rồi gửi thông báo huỷ cho khách thay vì email xác nhận. Sơ đồ dưới đây vẽ cả hai nhánh đó, cạnh nhau:
sequenceDiagram
autonumber
actor KH as Khách
participant WF as Booking Workflow<br/>(Medusa v2)
participant INV as Inventory
participant PAY as Payment<br/>(Stripe/Adyen)
participant SUP as Supplier API
participant BUS as Event Bus
KH->>WF: Đặt tour (kèm idempotencyKey)
WF->>INV: Hold seat
WF->>PAY: Charge
WF->>SUP: Xác nhận chỗ
alt Supplier xác nhận
SUP-->>WF: OK
WF-->>KH: Đặt tour thành công
WF->>BUS: booking.confirmed
Note over BUS: Email xác nhận + sync CMS<br/>chạy nền, retry độc lập
else Supplier từ chối
SUP-->>WF: Hết chỗ
Note over WF,PAY: Compensate theo thứ tự ngược
WF->>PAY: Refund
WF->>INV: Release seat
WF-->>KH: Thông báo huỷ
WF->>BUS: booking.cancelled
end
Mình implement saga này bằng workflow engine có sẵn của Medusa v2 (defineWorkflow + createStep), mỗi step khai báo rõ hàm invoke và compensate:
import { createWorkflow, createStep, StepResponse } from "@medusajs/framework/workflows-sdk";
const chargePaymentStep = createStep(
"charge-payment",
async (input: { bookingId: string; amount: number }, { container }) => {
const paymentService = container.resolve("paymentModuleService");
const payment = await paymentService.capturePayment(input);
// Lưu lại dữ liệu cần thiết để compensate (ví dụ payment id)
return new StepResponse(payment, payment.id);
},
async (paymentId, { container }) => {
// Compensation: hoàn tiền nếu bước sau thất bại
const paymentService = container.resolve("paymentModuleService");
await paymentService.refundPayment(paymentId);
},
);
const bookTourWorkflow = createWorkflow("book-tour", (input) => {
const inventory = holdInventoryStep(input);
const price = calculatePriceStep(input);
const payment = chargePaymentStep({ bookingId: input.bookingId, amount: price.total });
const confirmation = confirmWithSupplierStep({ bookingId: input.bookingId });
return new WorkflowResponse({ inventory, payment, confirmation });
});
Workflow engine tự gọi compensate theo thứ tự ngược của các step đã chạy thành công. Nghĩa là mình không phải viết logic rollback thủ công ở từng nơi gọi workflow.
3. Việc gì không cần đồng bộ thì đẩy ra subscriber
Gửi email xác nhận. Đồng bộ booking về CMS. Ghi log audit. Mấy việc này không cần nằm trong saga, nên tụi mình đẩy hết ra thành subscriber nghe event booking.confirmed hoặc booking.cancelled:
export default async function bookingConfirmedHandler({
event: { data },
container,
}: SubscriberArgs<{ bookingId: string }>) {
const notificationService = container.resolve("notificationModuleService");
await notificationService.createNotifications({
to: data.bookingId,
template: "booking-confirmation",
});
}
Cái lợi: service gửi email có chậm hay down tạm cũng không chặn luồng booking. Supplier confirm xong là saga kết thúc. Notification retry độc lập qua job queue (Redis-backed).
4. Idempotency: chỗ đau nhất
Viết saga không khó. Khó là làm sao để mỗi step chạy lại nhiều lần vẫn an toàn. Network timeout, client retry, worker restart giữa chừng — kiểu gì cũng có lúc một step bị gọi lại.
Cách tụi mình xử lý:
- Mọi request tạo booking đều mang một
idempotencyKeydo client sinh (hoặc server sinh ngay khi user bấm “Đặt ngay”, lưu lại trong local storage phòng khi mất mạng). - Trước khi charge payment, step kiểm tra xem đã có payment nào gắn với
idempotencyKeynày chưa — nếu có và đã thành công, trả lại kết quả cũ thay vì charge lần hai. - Step
holdInventorydùngINSERT ... ON CONFLICT DO NOTHINGtrên một bảnginventory_holdscó unique constraint theo(booking_id, inventory_item_id), nên gọi lại không tạo ra hai hold trùng nhau. - Supplier confirmation API thường không idempotent theo thiết kế của bên thứ ba, nên tụi mình tự lưu trạng thái “đã gửi request” trước khi gọi, và khi retry sẽ check trạng thái đó trước — nếu đã gửi mà chưa có phản hồi, poll trạng thái thay vì gửi lại request mới.
5. Kết quả, và cái giá
Trước đây tụi mình xử lý bằng try-catch và rollback thủ công. Sau khi chuyển sang saga, số ca booking “kẹt” ở trạng thái mơ hồ — tiền đã charge mà không rõ tour có xác nhận hay không — giảm gần như về 0. Mọi trạng thái trung gian đều được model thành step, và step nào cũng biết cách tự dọn.
Cái giá là code phức tạp hơn hẳn. Mỗi step phải nghĩ cả đường thành công lẫn đường compensate. Debug một saga nhiều bước, nhất là lúc nó compensate ngược, đòi hỏi logging và tracing tốt hơn nhiều so với một transaction DB. Đổi lại, domain này chạm vào tiền thật, và có một bên thứ ba mình không kiểm soát được. Mình thấy đáng.
Đọc tiếp
Cần người cho dự án contract hoặc freelance?
Mình nhận dự án fullstack — backend Go, frontend React/Next.js, hoặc cả hai đầu. Gửi vài dòng về phạm vi công việc và thời gian, mình phản hồi trong 48 giờ.