Kafka trong dự án thực tế: Saga đặt hàng — Order, Inventory, Payment không cần distributed transaction
Dành cho Backend Developer đã nắm producer, consumer, partition, offset và Transaction Outbox. Đây là bài chuyên sâu bổ sung cho khóa Kafka 14 buổi, dùng làm tài liệu gốc để chuẩn bị video. Case study, con số và tên topic do tác giả xây dựng để phân tích thiết kế; đây không phải báo cáo triển khai hay benchmark của doanh nghiệp nào. Đối chiếu tài liệu ngày 31/10/2026.
1. Chủ đề này bổ sung gì cho khóa học?
Đối chiếu README khóa Kafka, kịch bản 14 buổi, các demo và bài Kafka CDC:
Nội dung: Key, partition, thứ tự trong partition. Đã có ở đâu: Buổi 3, 5. Phạm vi bài này: Chọn key
orderIdcho mọi message của một đơnNội dung: Commit offset, AckMode. Đã có ở đâu: Buổi 7. Phạm vi bài này: Thứ tự “DB commit rồi mới commit offset”
Nội dung: Retry, DLT. Đã có ở đâu: Buổi 9. Phạm vi bài này: Phân biệt lỗi kỹ thuật (retry) với lỗi nghiệp vụ (reply thất bại)
Nội dung: Idempotent producer, Transaction Outbox, idempotent consumer. Đã có ở đâu: Buổi 11,
demo-11-outbox. Phạm vi bài này: Ghép thành từng bước của một quy trình nhiều serviceNội dung: CDC, Debezium Outbox Event Router. Đã có ở đâu: Bài Kafka CDC. Phạm vi bài này: Một cách thay relay polling
Nội dung: Saga: chuỗi local transaction và bước bù trừ. Đã có ở đâu: Chưa có trong khóa Kafka; series Spring ngày 80 mới giới thiệu khái niệm. Phạm vi bài này: Nội dung chính
Nội dung: Orchestration và choreography trên Kafka, thiết kế command/reply topic. Đã có ở đâu: Chưa có. Phạm vi bài này: Nội dung chính
Nội dung: Timeout khi chưa biết kết quả, reply đến muộn, bất thường do thiếu isolation. Đã có ở đâu: Chưa có. Phạm vi bài này: Nội dung chính
Buổi 11 trả lời câu hỏi: làm sao để một service ghi DB và phát message mà không mất, không trùng. Bài này trả lời câu hỏi tiếp theo: khi một nghiệp vụ cần ba service, mỗi service một database, thì làm sao để chúng không lệch nhau?
2. Bài toán: thu tiền lỗi, hàng đã giữ đi đâu?
Một sàn thương mại điện tử tách thành ba service:
Order lưu đơn hàng (PostgreSQL riêng).
Inventory quản lý tồn kho (database riêng).
Payment gọi cổng thanh toán và lưu giao dịch (database riêng).
Khách đặt đơn 5001: 2 tai nghe, tổng 790.000 đồng. Quy trình cần ba việc: tạo đơn, giữ hàng, thu tiền. Nếu thu tiền thất bại, hàng đã giữ phải được trả về kho; nếu hết hàng, không được thu tiền.
Trong một ứng dụng một database, ta bọc ba việc trong một transaction ACID. Ở đây không làm vậy được:
Mỗi service sở hữu database của nó; service khác không được ghi thẳng vào đó.
Two-phase commit (2PC/XA) đòi mọi bên cùng tham gia một giao thức khóa và điều phối. Cổng thanh toán bên ngoài không tham gia, và Kafka không đóng vai một XA resource.
Kafka transaction cũng không giải được bài này. Nó đảm bảo ghi nguyên tử nhiều partition Kafka cùng việc commit offset, nhưng không bao trùm database của Order, Inventory hay hệ thống của cổng thanh toán. Xem Kafka Design — message delivery semantics.
2.1. Yêu cầu thiết kế giả định
Thuộc tính: Tải. Giả định cho case study: Trung bình 20 đơn/giây, giờ cao điểm 200 đơn/giây
Thuộc tính: Phản hồi API. Giả định cho case study: Trả về ngay khi đơn được ghi nhận; kết quả cuối cùng hiển thị sau vài giây
Thuộc tính: Nhất quán. Giả định cho case study: Eventual consistency giữa ba service; không bao giờ “thu tiền mà không giữ hàng” ở trạng thái cuối
Thuộc tính: Gián đoạn. Giả định cho case study: Một service ngừng vài phút thì các đơn đang dở phải chạy tiếp được khi nó hoạt động lại
Thuộc tính: Quan sát. Giả định cho case study: Trả lời được “đơn 5001 đang ở bước nào, vì sao kẹt?”
Các con số này để định cỡ và viết test plan. Chưa đo thì chưa được tuyên bố đạt.
2.2. Vì sao gọi REST tuần tự là chưa đủ?
// Minh họa cửa sổ lỗi, không phải giải pháp.
orderRepository.save(order); // 1. commit DB Order
inventoryClient.reserve(order.id(), items); // 2. gọi Inventory
paymentClient.charge(order.id(), total); // 3. gọi PaymentProcess chết sau bước 2: hàng bị giữ mãi, không ai trả lại.
Bước 3 timeout: không biết tiền đã bị trừ hay chưa. Gọi lại có thể trừ hai lần; bỏ qua có thể mất tiền của khách.
Payment ngừng 5 phút: mọi request đặt hàng trong 5 phút đó đều lỗi.
Saga không làm những lỗi này biến mất. Nó biến chúng thành trạng thái được lưu lại, có người chịu trách nhiệm xử lý tiếp.
3. Saga là gì?
Saga là một chuỗi local transaction: mỗi bước commit trong database của một service rồi phát message để kích hoạt bước sau. Nếu một bước thất bại, saga chạy các compensating transaction (bước bù trừ) để đảo tác động nghiệp vụ của các bước đã xong. Khái niệm này có từ bài báo “Sagas” của Hector Garcia-Molina và Kenneth Salem (SIGMOD 1987), được phổ biến cho microservices qua microservices.io — Saga.
Ba loại bước, theo cách phân loại của Chris Richardson:
Loại bước: Compensatable. Ý nghĩa: Có bước bù trừ tương ứng. Trong case study: Giữ hàng ↔ trả hàng; tạo đơn ↔ hủy đơn
Loại bước: Pivot. Ý nghĩa: Điểm quyết định: qua được thì saga sẽ đi đến cùng. Trong case study: Thu tiền thành công
Loại bước: Retriable. Ý nghĩa: Sau pivot, không bù trừ, chỉ retry đến khi xong. Trong case study: Xác nhận đơn, gửi email
Đặt bước khó đảo ngược nhất (thu tiền) làm pivot và đặt nó sau các bước rẻ, dễ bù trừ (giữ hàng). Nếu thu tiền trước rồi mới phát hiện hết hàng, bước bù trừ là hoàn tiền: chậm, tốn phí và khách thấy rõ.
Bù trừ không phải rollback. Rollback xóa dấu vết như chưa từng xảy ra; bù trừ là một nghiệp vụ mới (“trả 2 tai nghe về kho”) để lại lịch sử, có thể chạy chậm và cũng có thể thất bại.
4. Choreography hay orchestration?

Mở sơ đồ kích thước đầy đủ: Hai cách điều phối một saga
Choreography: không ai điều phối. Order phát OrderCreated; Inventory nghe, giữ hàng, phát StockReserved; Payment nghe, thu tiền, phát PaymentCaptured hoặc PaymentFailed; Inventory và Order nghe PaymentFailed để tự bù trừ.
Orchestration: một orchestrator (ở đây đặt trong Order service) giữ trạng thái saga, gửi command cho từng participant và chờ reply.
Tiêu chí: Số thành phần. Choreography: Ít hơn. Orchestration: Thêm orchestrator
Tiêu chí: Luồng nghiệp vụ nằm ở. Choreography: Rải trong nhiều service. Orchestration: Một state machine
Tiêu chí: Thêm bước mới. Choreography: Sửa nhiều service đang nghe nhau. Orchestration: Sửa orchestrator, thêm participant
Tiêu chí: “Đơn đang ở đâu?”. Choreography: Phải ghép log nhiều nơi. Orchestration: Đọc một bảng
order_sagaTiêu chí: Rủi ro. Choreography: Phụ thuộc vòng giữa các event, khó thấy toàn cục. Orchestration: Orchestrator ôm quá nhiều logic nghiệp vụ của service khác
Case study chọn orchestration vì có ba bước, có timeout, có bù trừ và cần trả lời được câu hỏi vận hành. Choreography vẫn hợp lý cho luồng hai bước, ít nhánh lỗi, ví dụ “đơn xác nhận → gửi email + cộng điểm thưởng”. Hai cách có thể tồn tại cùng nhau: saga đặt hàng dùng orchestration, còn OrderConfirmed được phát rộng cho các service khác tự phản ứng.
Orchestrator chỉ nên biết thứ tự và trạng thái, không chứa luật nghiệp vụ của participant. “Còn hàng hay không” do Inventory quyết định; orchestrator chỉ nhận StockReserved hoặc StockRejected.
5. Thiết kế topic và message

Mở sơ đồ kích thước đầy đủ: Luồng thành công: command đi, reply về
5.1. Topic
Topic:
inventory.commands. Ai ghi: Orchestrator. Ai đọc: Inventory. Nội dung:ReserveStock,ReleaseStockTopic:
inventory.replies. Ai ghi: Inventory. Ai đọc: Orchestrator. Nội dung:StockReserved,StockRejected,StockReleasedTopic:
payment.commands. Ai ghi: Orchestrator. Ai đọc: Payment. Nội dung:ChargePayment,CancelPayment,RefundPaymentTopic:
payment.replies. Ai ghi: Payment. Ai đọc: Orchestrator. Nội dung:PaymentCaptured,PaymentFailed,PaymentRefundedTopic:
order.events. Ai ghi: Order. Ai đọc: Mọi service quan tâm. Nội dung:OrderConfirmed,OrderCancelled(domain event công khai)
Command là yêu cầu gửi tới đúng một service (“hãy giữ hàng”), có thể bị từ chối. Event là sự thật đã xảy ra (“đơn đã xác nhận”), ai cần thì nghe. Tách hai loại vào topic khác nhau để quyền ghi/đọc và hợp đồng schema rõ ràng.
Cấu hình nền tảng giống buổi 11: replication.factor=3, min.insync.replicas=2, producer acks=all, enable.idempotence=true. Số partition chọn theo throughput consumer cần đạt (buổi 5); case study dùng 12 partition cho mỗi topic.
5.2. Key là `orderId`
Mọi message của một đơn dùng key orderId, nên cùng một topic thì các message của đơn 5001 vào cùng partition và giữ thứ tự. Ví dụ ReserveStock luôn đến Inventory trước ReleaseStock của cùng đơn, miễn là relay gửi theo đúng thứ tự ghi outbox.
Ngoài phạm vi một partition thì không có thứ tự chung. StockReserved ở inventory.replies và PaymentCaptured ở payment.replies có thể đến orchestrator theo bất kỳ thứ tự nào, kể cả khi hai topic có cùng số partition. Thứ tự giữa các bước phải do state machine đảm bảo, không phải do Kafka.
Đổi số partition của topic đang chạy làm thay đổi ánh xạ key → partition; message mới của đơn đang dở có thể sang partition khác trong khi message cũ còn chưa xử lý. Tăng partition trong cửa sổ ít đơn dở dang, và để state machine từ chối message không hợp lệ thay vì tin vào thứ tự.
5.3. Phong bì message
{
"messageId": "0b9f3c2e-6a1d-4c8e-9f57-2d3b1a7e4c10",
"type": "ChargePayment",
"sagaId": "order-5001",
"orderId": 5001,
"causationId": "7d2e8a41-1c55-4f0b-b6a3-9e0c2f6d8b72",
"occurredAt": "2026-10-31T10:15:02Z",
"schemaVersion": 1,
"payload": { "amount": 790000, "currency": "VND", "idempotencyKey": "order-5001" }
}messageId: duy nhất cho mỗi message, dùng để khử trùng ở consumer.sagaId,orderId: định danh quy trình nghiệp vụ.causationId: message nào gây ra message này. Ghép chuỗi causation là dựng lại được toàn bộ hành trình của một đơn.idempotencyKey: khóa mà Payment dùng để không thu tiền hai lần cho cùng một yêu cầu. Đây là hợp đồng nghiệp vụ, khác vớimessageId(một yêu cầu có thể được gửi lại bằng message mới).
Có thể đặt messageId, sagaId, type vào Kafka header để route và trace mà không cần parse payload. Khi nhiều team cùng đổi schema, cân nhắc Schema Registry với Avro/Protobuf; bài này giữ JSON có schemaVersion cho dễ đọc.
6. Orchestrator: state machine trong database

Mở sơ đồ kích thước đầy đủ: Trạng thái saga và đường bù trừ
6.1. Bảng trạng thái
CREATE TABLE order_saga (
order_id BIGINT PRIMARY KEY,
state VARCHAR(32) NOT NULL, -- PENDING, STOCK_RESERVED, CONFIRMED, ...
version BIGINT NOT NULL DEFAULT 0,
deadline_at TIMESTAMPTZ, -- hạn chờ reply của bước hiện tại
last_error TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE processed_messages (
consumer VARCHAR(64) NOT NULL,
message_id UUID NOT NULL,
processed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (consumer, message_id)
);Bảng outbox giống demo-11-outbox: id tăng dần, topic, key, payload, trạng thái gửi. processed_messages cần job dọn bản ghi cũ hơn thời gian retention của topic nguồn, vì sau đó message không thể được giao lại.
6.2. Bắt đầu saga
POST /orders làm một DB transaction: ghi orders, ghi order_saga(state=PENDING, deadline_at=now()+10s) và ghi outbox ReserveStock. API trả 202 Accepted kèm orderId và trạng thái PENDING; client hỏi lại hoặc nhận thông báo qua SSE/WebSocket (xem bài SSE/WebSocket/Polling).
Không trả “đặt hàng thành công” khi saga mới bắt đầu. Trạng thái PENDING hiện trên UI chính là semantic lock: mọi người, kể cả các service khác, biết đơn này chưa có kết quả cuối.
6.3. Xử lý reply

Mở sơ đồ kích thước đầy đủ: Một reply, một transaction, nhiều chỗ crash
// Order service — Spring Kafka, Spring Boot 3.x, Java 21. Rút gọn để đọc.
@KafkaListener(topics = {"inventory.replies", "payment.replies"}, groupId = "order-saga")
@Transactional // transaction của DataSource; container commit offset sau khi method trả về
public void onReply(SagaMessage reply) {
// 1. Khử trùng: INSERT ... ON CONFLICT DO NOTHING, trả về false nếu đã xử lý.
if (!processedMessages.markIfFirst("order-saga", reply.messageId())) {
return;
}
OrderSaga saga = sagas.findById(reply.orderId()).orElseThrow();
// 2. State machine quyết định; reply không hợp lệ ở trạng thái hiện tại thì ghi lại, không ném lỗi.
Transition t = saga.on(reply);
if (t instanceof Transition.Ignored) {
log.warn("Bỏ qua {} của đơn {} ở trạng thái {}", reply.type(), saga.orderId(), saga.state());
return;
}
// 3. Lưu trạng thái mới (optimistic lock theo version) và command kế tiếp trong cùng transaction.
sagas.save(saga);
t.nextCommands().forEach(outbox::append);
}// State machine thuần Java: dễ test bằng unit test, không cần Kafka.
public Transition on(SagaMessage m) {
return switch (state) {
case PENDING -> switch (m.type()) {
case "StockReserved" -> moveTo(STOCK_RESERVED, Duration.ofSeconds(30), chargePayment());
case "StockRejected" -> finish(REJECTED, orderRejected());
default -> Transition.ignored();
};
case STOCK_RESERVED -> switch (m.type()) {
case "PaymentCaptured" -> finish(CONFIRMED, orderConfirmed());
case "PaymentFailed" -> moveTo(RELEASING_STOCK, Duration.ofSeconds(30), releaseStock());
default -> Transition.ignored();
};
case RELEASING_STOCK -> "StockReleased".equals(m.type())
? finish(CANCELLED, orderCancelled())
: Transition.ignored();
default -> Transition.ignored(); // trạng thái cuối không nhận thêm bước nào
};
}Thứ tự “DB commit trước, commit offset sau” làm mọi điểm crash đều phục hồi được:
Crash ở đâu: Trước khi DB commit. Điều xảy ra khi chạy lại: Transaction rollback, message được đọc lại từ offset cũ. Vì sao an toàn: Chưa có gì được ghi
Crash ở đâu: Sau DB commit, trước commit offset. Điều xảy ra khi chạy lại: Message
m-77được giao lại. Vì sao an toàn:processed_messagesđã cóm-77→ bỏ quaCrash ở đâu: Sau khi ghi outbox, relay chưa gửi. Điều xảy ra khi chạy lại: Relay đọc outbox và gửi khi chạy lại. Vì sao an toàn: Command nằm bền vững trong DB
Crash ở đâu: Relay gửi xong, chưa đánh dấu
SENT. Điều xảy ra khi chạy lại: Command bị gửi lại. Vì sao an toàn: Participant cũng khử trùng theomessageId
Optimistic lock (version) bảo vệ trường hợp hai instance xử lý hai message của cùng một đơn cùng lúc, ví dụ sau rebalance hoặc khi reply và scheduler timeout chạm nhau. Bên thua nhận lỗi xung đột, transaction rollback, message được giao lại và lúc đó state machine đánh giá trên trạng thái mới.
Phân biệt hai loại lỗi:
Lỗi kỹ thuật (DB tạm mất kết nối, xung đột version): ném exception để
DefaultErrorHandlerretry theoBackOff; hết lượt thìDeadLetterPublishingRecovererđẩy vào DLT như buổi 9.Kết quả nghiệp vụ (
PaymentFailed,StockRejected): không phải exception. Đó là một reply hợp lệ, đi vào nhánh bù trừ.
Nếu một reply rơi vào DLT, saga sẽ kẹt ở trạng thái hiện tại; scheduler timeout ở phần 8 là lưới an toàn phát hiện điều đó.
7. Participant: Inventory giữ hàng idempotent
Inventory nhận ReserveStock và phải chịu được: message trùng, ReleaseStock đến khi chưa từng giữ, và hai đơn tranh nhau món hàng cuối.
CREATE TABLE reservations (
order_id BIGINT NOT NULL,
sku VARCHAR(64) NOT NULL,
qty INT NOT NULL,
status VARCHAR(16) NOT NULL, -- RESERVED, REJECTED, RELEASED
PRIMARY KEY (order_id, sku)
);Xử lý ReserveStock, tất cả trong một transaction:
-- 1. Đã có bản ghi cho (đơn, sku)? Nếu có: không làm lại, chỉ gửi lại reply theo status hiện có.
INSERT INTO reservations(order_id, sku, qty, status)
VALUES (5001, 'TAI-NGHE-01', 2, 'RESERVED')
ON CONFLICT (order_id, sku) DO NOTHING;
-- 2. Trừ tồn có điều kiện: hai đơn tranh món cuối thì chỉ một UPDATE thành công.
UPDATE stock SET available = available - 2
WHERE sku = 'TAI-NGHE-01' AND available >= 2;
-- 0 dòng bị ảnh hưởng → đổi reservation sang REJECTED, reply StockRejected.
-- 3. Ghi outbox reply StockReserved / StockRejected.Xử lý ReleaseStock:
Reservation
RESERVED→ cộng lại tồn, đổi sangRELEASED, replyStockReleased.Đã
RELEASEDhoặcREJECTED→ không cộng lần nữa, vẫn replyStockReleasedđể orchestrator đi tiếp.Chưa có reservation → ghi một bản ghi
RELEASEDvớiqty = 0. NếuReserveStockcủa đơn này đến muộn (ví dụ bị retry từ một relay chậm), bước 1 gặp bản ghiRELEASEDvà từ chối giữ hàng. Không có bản ghi “chặn” này, hàng sẽ bị giữ mãi cho một đơn đã hủy.
Payment áp dụng cùng nguyên tắc với idempotencyKey: cùng khóa thì trả lại kết quả cũ, không gọi cổng thanh toán lần hai. Nhiều cổng thanh toán hỗ trợ idempotency key hoặc mã tham chiếu đơn; cần đọc tài liệu nhà cung cấp cụ thể trước khi dựa vào nó.
8. Timeout: chưa biết kết quả thì chưa bù trừ

Mở sơ đồ kích thước đầy đủ: Hết thời gian chờ không có nghĩa là thanh toán lỗi
Kafka không có tính năng hẹn giờ giao message trong core, và consumer không thể “chờ” một message không đến. Timeout phải nằm ở orchestrator:
-- Scheduler chạy mỗi vài giây; SKIP LOCKED cho phép nhiều instance cùng quét mà không giẫm nhau.
SELECT order_id FROM order_saga
WHERE state IN ('PENDING', 'STOCK_RESERVED', 'RELEASING_STOCK')
AND deadline_at < now()
ORDER BY deadline_at
LIMIT 100
FOR UPDATE SKIP LOCKED;Phản ứng khi hết hạn tùy bước:
Trạng thái hết hạn:
PENDING(chưa có reply giữ hàng). Hành động: GửiReleaseStockrồi hủy đơn. Lý do: Release idempotent và chặn được reserve đến muộnTrạng thái hết hạn:
STOCK_RESERVED(chưa có reply thu tiền). Hành động: Hỏi lại Payment theoidempotencyKeytrước khi quyết định. Lý do: Có thể tiền đã bị trừ mà reply chưa tớiTrạng thái hết hạn:
RELEASING_STOCK. Hành động: Gửi lạiReleaseStock, quá N lần thì cảnh báo. Lý do: Bù trừ phải đi đến cùng
Không bao giờ tự động hiểu “hết thời gian” là “thanh toán thất bại”. Nếu orchestrator vội hủy đơn và trả hàng, trong khi cổng thanh toán đã trừ tiền, khách mất tiền mà không có hàng.
Với bước thu tiền, kết quả tra cứu có ba nhánh:
Đã thu: xử lý như nhận được
PaymentCaptured.Thất bại hoặc không có giao dịch: gửi
CancelPaymentđể chặn một lần thu đến muộn, rồiReleaseStock.Chưa rõ: gia hạn
deadline_at, hỏi lại có giới hạn; quá ngưỡng thì chuyển trạng thái cần người xử lý và cảnh báo.
Reply đến muộn sau khi saga đã kết thúc: PaymentCaptured tới khi đơn đã CANCELLED. State machine không “mở lại” đơn từ trạng thái cuối. Nó phát RefundPayment, ghi sự cố và theo dõi đến khi có PaymentRefunded. Trường hợp này hiếm nhưng sẽ xảy ra ở quy mô đủ lớn; phải có đường xử lý trước khi lên production.
Chọn deadline từ số liệu đo được: p99 thời gian xử lý của participant cộng độ trễ tiêu thụ chấp nhận được. Deadline quá ngắn tạo nhiều lần tra cứu vô ích; quá dài khiến hàng bị giữ lâu khi có sự cố.
9. Saga thiếu chữ I trong ACID
Saga cho atomicity ở mức nghiệp vụ (cuối cùng hoặc xong hết, hoặc được bù trừ) nhưng không có isolation: trạng thái trung gian của bước này hiện ra cho saga khác. Các bất thường thường gặp:
Bất thường: Đọc dữ liệu chưa chốt. Ví dụ: Báo cáo đếm hàng “đã giữ” của đơn sắp bị hủy là đã bán. Biện pháp: Semantic lock: phân biệt
RESERVEDvớiSOLD; báo cáo chỉ đếm trạng thái cuốiBất thường: Ghi đè lẫn nhau. Ví dụ: Khách hủy đơn trong lúc saga đang thu tiền. Biện pháp: Lệnh hủy cũng đi qua state machine; chỉ hợp lệ ở một số trạng thái
Bất thường: Đọc không lặp lại. Ví dụ: Giá đổi giữa lúc tạo đơn và lúc thu tiền. Biện pháp: Chụp giá vào đơn khi tạo (by value); thu đúng số tiền đã chụp
Các biện pháp này, gồm semantic lock, commutative update, pessimistic view, reread value và by value, được Chris Richardson tổng hợp trong “Microservices Patterns”. Phép “cộng/trừ tồn” là commutative update: release và reserve của các đơn khác nhau áp dụng theo thứ tự nào cũng cho cùng tổng.
10. Ma trận sự cố
Sự cố: Payment ngừng 10 phút. Hệ thống phản ứng:
payment.commandstích backlog; deadline hết hạn, orchestrator tra cứu thất bại rồi gia hạn. Người dùng thấy: Đơn ởPENDINGlâu hơn, không mất đơnSự cố: Kafka ngừng. Hệ thống phản ứng: Outbox tích
PENDINGtrong DB của từng service; relay gửi khi Kafka hoạt động lại. Người dùng thấy: Đặt hàng vẫn được ghi nhận; kết quả chậmSự cố: Orchestrator rebalance giữa chừng. Hệ thống phản ứng: Message chưa commit offset được giao lại cho instance khác. Người dùng thấy: Không ảnh hưởng nhờ khử trùng và version
Sự cố: Reply trùng. Hệ thống phản ứng: Bị
processed_messageschặn. Người dùng thấy: Không ảnh hưởngSự cố: Reply sai trạng thái. Hệ thống phản ứng: Bị state machine bỏ qua, có log cảnh báo. Người dùng thấy: Không ảnh hưởng; cần xem log nếu tăng đột biến
Sự cố: Message lỗi schema. Hệ thống phản ứng: Retry rồi vào DLT; saga kẹt đến khi timeout phát hiện. Người dùng thấy: Đơn chậm; đội vận hành nhận cảnh báo
Sự cố: Hai đơn tranh món cuối. Hệ thống phản ứng: UPDATE có điều kiện: một đơn giữ được, đơn kia
StockRejected. Người dùng thấy: Một khách nhận “hết hàng” trước khi bị thu tiềnSự cố:
ReleaseStockđến trướcReserveStock. Hệ thống phản ứng: Bản ghiRELEASEDchặn reserve muộn. Người dùng thấy: Không bị giữ hàng “ma”
11. Quan sát saga
Tín hiệu: Số saga theo trạng thái và tuổi của saga lâu nhất ở mỗi trạng thái. Câu hỏi: Có đơn nào kẹt quá hạn không?
Tín hiệu: Tỷ lệ
REJECTED,CANCELLED, số lần tra cứu do timeout. Câu hỏi: Bù trừ có tăng bất thường không?Tín hiệu: Consumer lag của
order-saga,inventory,payment. Câu hỏi: Chặng nào đang chậm?Tín hiệu: Số outbox
PENDINGvà tuổi bản ghi cũ nhất. Câu hỏi: Relay hoặc Kafka có vấn đề không?Tín hiệu: Số message vào DLT, số reply bị bỏ qua. Câu hỏi: Có lỗi schema hoặc lỗi logic state machine không?
Tín hiệu: Thời gian từ
POST /ordersđến trạng thái cuối (p50/p95/p99). Câu hỏi: Người dùng chờ bao lâu?
Truyền traceparent (W3C Trace Context) trong Kafka header để tracing nối được API → orchestrator → participant. Một màn hình admin tra cứu theo orderId, hiển thị lịch sử chuyển trạng thái và chuỗi causationId, thường hữu ích hơn mọi dashboard khi khách gọi tổng đài.
12. Khi nào không nên dùng saga?
Ba “service” thực ra dùng chung một database và deploy cùng nhau: dùng local transaction.
Quy trình chỉ có một bước ghi, các bước sau chỉ là thông báo: Outbox + event là đủ.
Đội chưa có khả năng vận hành Kafka, DLT, dashboard và trực sự cố: một saga tự viết sẽ đẩy độ phức tạp sang vận hành.
Quy trình kéo dài nhiều ngày với nhiều nhánh, cần người duyệt: cân nhắc workflow engine (Temporal, Camunda…) thay vì tự viết state machine; Kafka vẫn có thể là kênh tích hợp.
13. Kịch bản kiểm chứng trước khi quay demo
Các dòng dưới là test plan và kết quả kỳ vọng, chưa phải log đã chạy. Lab nên dựa trên demo-11-outbox (Spring Boot 3.3.5, Java 21, Kafka KRaft một node), tách thành ba ứng dụng hoặc ba module với ba schema DB riêng.
Cảnh thử: Luồng thành công. Thao tác: Đặt đơn còn hàng. Bằng chứng cần ghi hình:
PENDING → STOCK_RESERVED → CONFIRMED; tồn giảm; 1 giao dịch paymentCảnh thử: Hết hàng. Thao tác: Đặt số lượng vượt tồn. Bằng chứng cần ghi hình:
REJECTED; Payment không nhận command nàoCảnh thử: Thu tiền lỗi. Thao tác: Payment giả lập từ chối. Bằng chứng cần ghi hình:
RELEASING_STOCK → CANCELLED; tồn trở lại như trướcCảnh thử: Trùng reply. Thao tác: Gửi lại một
StockReservedbằng tay. Bằng chứng cần ghi hình: Log “đã xử lý”; trạng thái không đổiCảnh thử: Crash orchestrator. Thao tác: Kill sau DB commit, trước commit offset. Bằng chứng cần ghi hình: Khởi động lại: message giao lại và bị bỏ qua
Cảnh thử: Payment ngừng. Thao tác: Dừng Payment, đặt đơn. Bằng chứng cần ghi hình: Deadline hết, tra cứu thất bại, gia hạn; bật lại thì đơn hoàn tất
Cảnh thử: Reply muộn. Thao tác: Payment trả
PaymentCapturedsau khi đơn đã hủy. Bằng chứng cần ghi hình:RefundPaymentđược phát; đơn vẫnCANCELLEDCảnh thử: Tranh món cuối. Thao tác: Hai đơn đồng thời cho 1 sản phẩm còn 1. Bằng chứng cần ghi hình: Đúng một
CONFIRMED, mộtREJECTED; tồn không âmCảnh thử: Release trước reserve. Thao tác: Gửi
ReleaseStockrồiReserveStockcùng đơn. Bằng chứng cần ghi hình: Reserve bị từ chối; tồn không đổi
Khi quay, chia màn hình bốn ô: log orchestrator, bảng order_saga, bảng reservations/stock, và Kafka UI (hoặc kafka-console-consumer) cho các topic reply. Theo một orderId xuyên suốt để người xem không bị lạc.
14. Những câu hỏi cần trả lời được sau bài này
Kafka transaction có thay được saga không? Không. Nó chỉ nguyên tử trong phạm vi Kafka (ghi nhiều partition + commit offset). Database của từng service và cổng thanh toán nằm ngoài phạm vi đó.
Vì sao cần cả outbox lẫn idempotent consumer? Outbox đảm bảo command không bị mất khi service crash giữa “ghi DB” và “gửi Kafka”. Nhưng outbox là at-least once: relay có thể gửi lại. Idempotent consumer chặn hậu quả của việc gửi lại đó.
Cùng key orderId thì reply có đến đúng thứ tự không? Chỉ trong cùng một topic. Reply từ Inventory và Payment nằm ở hai topic khác nhau; thứ tự giữa các bước do state machine đảm bảo.
Timeout bước thu tiền thì làm gì? Tra cứu Payment theo idempotency key. Chỉ bù trừ khi biết chắc chưa thu tiền; chưa rõ thì gia hạn và cảnh báo.
Bù trừ thất bại thì sao? Bù trừ phải idempotent và retry được đến khi thành công. Nếu vẫn không được, saga chuyển sang trạng thái cần người xử lý, có cảnh báo và runbook. Không có cơ chế nào tự động đảm bảo bù trừ luôn thành công.
Choreography có tệ không? Không. Nó hợp với luồng ngắn, ít nhánh lỗi. Khi cần trả lời “đơn đang ở đâu” và có timeout, orchestration dễ vận hành hơn.
15. Kết luận
Saga không xóa lỗi trong hệ phân tán; nó biến mỗi lỗi thành một trạng thái có tên, được lưu lại và có đường đi tiếp. Kafka mang command và reply một cách bền vững, nhưng phần quyết định đúng sai nằm ở bốn thứ: outbox cho mỗi lần ghi kèm gửi, khử trùng ở mọi consumer, state machine với optimistic lock, và timeout biết hỏi lại trước khi bù trừ.
Trước khi đưa saga lên production, hãy chạy hết ma trận kiểm chứng ở phần 13 trên một lab riêng, đặc biệt các cảnh timeout, reply muộn và tranh món hàng cuối.
Nguồn tham khảo
Những quyết định về topic, deadline, trạng thái và test plan trong bài là phương án đề xuất cho case study, không phải mặc định của sản phẩm nào.
Hector Garcia-Molina, Kenneth Salem — “Sagas”, ACM SIGMOD 1987.
Chris Richardson — “Microservices Patterns”, Manning 2018, chương 4 (quản lý transaction bằng saga).
Site Admin
Engineer and writer. Building things with TypeScript and distributed systems.
Bình luận (0)
Bạn cần đăng nhập bằng Google để bình luận.
Hãy là người bình luận đầu tiên.


