Backend

Kafka trong dự án thực tế: Saga đặt hàng — Order, Inventory, Payment không cần distributed transaction

SSite Admin
31 tháng 10, 2026 24 phút đọc 3 lượt xem
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 orderId cho mọi message của một đơn

  • Nộ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 service

  • Nộ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 Payment
  • Process 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?

Hai cách điều phối một saga

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_saga

  • Tiê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

Luồng thành công: command đi, reply về

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, ReleaseStock

  • Topic: inventory.replies. Ai ghi: Inventory. Ai đọc: Orchestrator. Nội dung: StockReserved, StockRejected, StockReleased

  • Topic: payment.commands. Ai ghi: Orchestrator. Ai đọc: Payment. Nội dung: ChargePayment, CancelPayment, RefundPayment

  • Topic: payment.replies. Ai ghi: Payment. Ai đọc: Orchestrator. Nội dung: PaymentCaptured, PaymentFailed, PaymentRefunded

  • Topic: 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ới messageId (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

Trạng thái saga và đường bù trừ

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ột reply, một transaction, nhiều chỗ crash

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ỏ qua

  • Crash ở đâ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 theo messageId

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 để DefaultErrorHandler retry theo BackOff; 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 sang RELEASED, reply StockReleased.

  • Đã RELEASED hoặc REJECTED → không cộng lần nữa, vẫn reply StockReleased để orchestrator đi tiếp.

  • Chưa có reservation → ghi một bản ghi RELEASED với qty = 0. Nếu ReserveStock của đơn này đến muộn (ví dụ bị retry từ một relay chậm), bước 1 gặp bản ghi RELEASED và 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ừ

Hết thời gian chờ không có nghĩa là thanh toán lỗi

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ửi ReleaseStock rồi hủy đơn. Lý do: Release idempotent và chặn được reserve đến muộn

  • Trạng thái hết hạn: STOCK_RESERVED (chưa có reply thu tiền). Hành động: Hỏi lại Payment theo idempotencyKey trước khi quyết định. Lý do: Có thể tiền đã bị trừ mà reply chưa tới

  • Trạng thái hết hạn: RELEASING_STOCK. Hành động: Gửi lại ReleaseStock, 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:

  1. Đã thu: xử lý như nhận được PaymentCaptured.

  2. 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ồi ReleaseStock.

  3. 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 RESERVED với SOLD; báo cáo chỉ đếm trạng thái cuối

  • Bấ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.commands tí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 ở PENDING lâu hơn, không mất đơn

  • Sự cố: Kafka ngừng. Hệ thống phản ứng: Outbox tích PENDING trong 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ậm

  • Sự 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_messages chặn. Người dùng thấy: Không ảnh hưởng

  • Sự 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ền

  • Sự cố: ReleaseStock đến trước ReserveStock. Hệ thống phản ứng: Bản ghi RELEASED chặ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 PENDING và 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 payment

  • Cả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ào

  • Cả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ước

  • Cảnh thử: Trùng reply. Thao tác: Gửi lại một StockReserved bằng tay. Bằng chứng cần ghi hình: Log “đã xử lý”; trạng thái không đổi

  • Cả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ả PaymentCaptured sau khi đơn đã hủy. Bằng chứng cần ghi hình: RefundPayment được phát; đơn vẫn CANCELLED

  • Cả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ột REJECTED; tồn không âm

  • Cảnh thử: Release trước reserve. Thao tác: Gửi ReleaseStock rồi ReserveStock cù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.

  1. Hector Garcia-Molina, Kenneth Salem — “Sagas”, ACM SIGMOD 1987.

  2. microservices.io — Pattern: Saga.

  3. microservices.io — Pattern: Transactional outbox.

  4. microservices.io — Pattern: Idempotent Consumer.

  5. Chris Richardson — “Microservices Patterns”, Manning 2018, chương 4 (quản lý transaction bằng saga).

  6. Apache Kafka 4.1 — Design.

  7. Spring for Apache Kafka — Reference.

  8. Debezium 3.3 — Outbox Event Router.

  9. PostgreSQL 17 — SELECT … FOR UPDATE SKIP LOCKED.

  10. W3C Trace Context.

S

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.

Bài viết liên quan

99 Ngày Spring — Ngày 96: Performance Tuning

Đo trước khi chỉnh, kích thước pool HikariCP, các nút thắt thường gặp ở tầng dữ liệu, tùy chọn JVM và load test sơ lược.

30 thg 10, 20263 phút6
99 Ngày Java — Ngày 96: Reflection và Annotation

Class API, đọc và gọi thành viên lúc chạy, tự tạo annotation và những đánh đổi về hiệu năng, an toàn kiểu.

30 thg 10, 20263 phút6
Kafka trong dự án thực tế: đồng bộ PostgreSQL sang Elasticsearch bằng Debezium CDC

Thiết kế pipeline CDC cho catalog sản phẩm: Kafka Connect, snapshot, update/delete, WAL, retry, retention và dựng lại Elasticsearch bằng index mới cùng alias — kèm cấu hình và sơ đồ thực tế.

30 thg 10, 202633 phút6