Khi một hệ thống đơn hàng tách thành các microservice, một thao tác nghe rất đơn giản như "đặt hàng" bỗng trải qua ba cơ sở dữ liệu: kho giữ hàng, cổng thanh toán, đơn vị vận chuyển. Không còn một transaction ACID bao trùm tất cả, và câu hỏi "nếu bước thứ ba thất bại thì hai bước trước xử lý thế nào" trở thành bài toán thiết kế thật sự. Saga pattern là câu trả lời phổ biến nhất, và Transactional Outbox là mảnh ghép giúp saga gửi thông điệp đáng tin cậy. Bài này dựng cả hai bằng Java 21 và JDBC thuần với H2 trong bộ nhớ, chạy thật các kịch bản lỗi và dán đúng output, để bạn thấy từng cơ chế hoạt động thay vì chỉ đọc sơ đồ.
Lưu ý phạm vi: hai phần của Saga pattern và Outbox được demo tách rời. Outbox chạy ở Demo2Outbox, còn orchestrator trong Demo3Saga gọi các service bằng lời gọi hàm đồng bộ để tập trung vào bù và khôi phục; khi chạy thật, lệnh orchestrator gửi cho service nên đi qua outbox.
Bài nối tiếp Circuit Breaker, Retry và Bulkhead với Resilience4j: Resilience4j giúp một lời gọi qua mạng chịu lỗi tốt hơn, còn bài này giải quyết việc giữ dữ liệu nhất quán khi nhiều lời gọi như vậy phải cùng thành công hoặc cùng bị hoàn tác. Nó cũng là bước tiếp theo tự nhiên của CQRS và Event Sourcing trong Java, vì cả hai đều dựa trên sự kiện làm trung tâm.
Vì sao không còn transaction chung
Trong một service với một database, @Transactional cho ta đủ bốn tính chất ACID. Microservices thường áp dụng quy tắc "mỗi service một database", nên một nghiệp vụ trải rộng nhiều service không thể dùng một transaction cục bộ. Giao thức two-phase commit (2PC) về lý thuyết giải được vấn đề, nhưng trong thực tế nhiều broker và database NoSQL không hỗ trợ, nó giữ khoá lâu và biến người điều phối thành điểm nghẽn. Vì vậy microservices.io của Chris Richardson định nghĩa Saga pattern là "một chuỗi transaction cục bộ được điều phối đáng tin cậy", và tài liệu Azure Architecture Center mô tả cùng ý: mỗi bước commit trong database của chính nó, và nếu một bước lỗi thì chuỗi compensating transaction (giao dịch bù) hoàn tác ảnh hưởng của các bước đã xong.
Có hai điểm khác biệt lớn so với rollback thông thường. Thứ nhất, các bước trước đã commit thật sự, nên "hoàn tác" là một nghiệp vụ mới (hoàn tiền, trả hàng về kho) chứ không phải xoá dấu vết. Thứ hai, Saga pattern không có tính cô lập (chữ "I" trong ACID): giữa lúc đang chạy, dữ liệu trung gian đã nhìn thấy được bởi các thao tác khác. Ta sẽ quay lại hai điểm này ở phần đánh đổi.
Hai cách điều phối Saga pattern: choreography và orchestration
| Tiêu chí | Choreography | Orchestration |
|---|---|---|
| Ai quyết định bước kế tiếp | Mỗi service phản ứng với sự kiện của service khác | Một orchestrator ra lệnh cho từng service |
| Phù hợp | Luồng ngắn, ít service | Luồng dài, nhiều nhánh, hay thêm bước |
| Điểm yếu | Khó theo dõi toàn cảnh, dễ vòng phụ thuộc, kiểm thử tích hợp khó | Orchestrator là thành phần phải thiết kế thêm và là một điểm lỗi |
| Trạng thái saga nằm ở | Phân tán trong các service | Một nơi, dễ truy vấn và giám sát |
Bảng trên dựa trên tài liệu Azure về Saga pattern; riêng hàng "Trạng thái saga nằm ở" là nhận xét của chúng tôi, không phải của tài liệu đó. Với luồng đặt hàng có ba bước và các nhánh bù, bài này cài Saga pattern kiểu orchestration vì trạng thái nằm ở một chỗ, đúng với mục tiêu giải thích. Sau đây là cấu trúc chung: orchestrator chạy từng bước theo thứ tự, ghi nhật ký vào bảng saga_log, và khi gặp lỗi nghiệp vụ thì chạy phần bù của các bước đã hoàn thành theo thứ tự ngược lại.
Vấn đề trước khi viết saga: dual write
Dù áp dụng Saga pattern kiểu nào, mỗi service đều phải làm hai việc: cập nhật database của mình và gửi thông điệp cho service khác. Hai việc đó thuộc hai hệ thống khác nhau và không thể nằm trong cùng một transaction. Đây là lỗi dual write. Đoạn code Demo1DualWrite thử cả hai thứ tự:
// Cách 1: commit rồi publish; broker lỗi đúng lúc đó
Db.exec(db, "insert into orders values ('DH001','PENDING')");
db.commit();
broker.failNextPublish = true;
broker.publish("order.events", Msg.of("OrderCreated", "DH001", "")); // ném lỗi
// Cách 2: publish rồi commit; transaction bị rollback sau khi tin đã đi
broker.publish("order.events", Msg.of("OrderCreated", "DH002", ""));
Db.exec(db, "insert into orders values ('DH002','PENDING')");
db.rollback();Kết quả chạy thật của dual write:
Cách 1 (commit rồi publish): publish lỗi -> broker không phản hồi
DB có đơn: 1, broker có tin: 0 => đơn tồn tại nhưng không ai biết
Cách 2 (publish rồi commit): rollback sau khi publish
DB có đơn DH002: 0, broker có tin: 1 => tin về một đơn không tồn tạiCách 1 để lại đơn "mồ côi" mà kho và thanh toán không bao giờ biết; cách 2 khiến các service khác xử lý một đơn không tồn tại. Cả hai đều phá vỡ Saga pattern ngay bước đầu, nên tính đáng tin của việc gửi thông điệp phải được giải quyết trước.
Transactional Outbox: ghi thông điệp cùng transaction với dữ liệu
Theo mô tả của microservices.io, bên gửi lưu mỗi thông điệp ra ngoài vào một bảng outbox trong cùng transaction cập nhật dữ liệu nghiệp vụ; một tiến trình riêng (message relay) đọc outbox rồi đẩy lên broker. Thông điệp chỉ tồn tại khi transaction commit, và được gửi theo đúng thứ tự tạo ra. Bảng outbox của bài này:
create table outbox(
seq bigint auto_increment primary key,
topic varchar(40), msg_id varchar(40), type varchar(40),
order_id varchar(40), data varchar(200),
published boolean default false)Hàm ghi và hàm relay (kiểu polling publisher) trong Outbox.java:
public static void add(Connection c, String topic, Msg m) throws SQLException {
Db.exec(c, "insert into outbox(topic,msg_id,type,order_id,data) values (?,?,?,?,?)",
topic, m.id(), m.type(), m.orderId(), m.data()); // cùng connection, cùng transaction
}
// Relay: đọc theo seq, publish rồi MỚI đánh dấu đã gửi
for (Object[] r : rows) {
broker.publish((String) r[1], (Msg) r[2]);
Db.exec(c, "update outbox set published=true where seq=?", r[0]);
c.commit();
}Điều then chốt của Saga pattern và outbox nằm ở thứ tự trong relay: publish trước, đánh dấu sau. Nếu relay chết ở giữa hai việc đó, tin sẽ được gửi lại ở lần chạy sau. Tức là Outbox đổi "có thể mất tin" lấy "có thể trùng tin" (at-least-once). Đây là đánh đổi có chủ đích, và Demo2Outbox chứng minh từng bước:
--- 1. Rollback thì outbox cũng rollback ---
orders=0, outbox=0
--- 2. Commit thì cả hai cùng có ---
orders=1, outbox chưa gửi=1
--- 3. Broker lỗi: tin không mất, relay thử lại sau ---
relay lần 1 lỗi: broker không phản hồi
outbox chưa gửi=1, broker=0
relay lần 2 gửi được: 1 tin
--- 4. Relay chết sau publish, trước khi đánh dấu: giao trùng ---
sự cố: relay chết sau khi publish, trước khi đánh dấu
Khởi động lại relay: gửi 1 tin (DH103 bị gửi lần 2)
tin trong broker=3Bước 1 và 2 giải quyết cả hai ca của dual write: rollback thì không có tin, commit thì chắc chắn có tin. Bước 3 cho thấy broker lỗi không làm mất thông điệp. Bước 4 là cái giá: DH103 bị gửi hai lần.
Idempotent consumer: nửa còn lại của lời hứa
Với Saga pattern, tin trùng chỉ vô hại nếu bên nhận xử lý idempotent, nghĩa là xử lý lại không đổi kết quả. Cách đơn giản nhất là bảng processed_messages với khoá chính là id thông điệp, ghi trong cùng transaction với tác dụng nghiệp vụ:
public static boolean firstTime(Connection c, String msgId) throws SQLException {
try {
Db.exec(c, "insert into processed_messages(msg_id) values (?)", msgId);
return true;
} catch (SQLException e) {
if (e.getErrorCode() == 23505) return false; // vi phạm khoá chính
throw e;
}
}--- 5. Consumer idempotent: mỗi msg_id chỉ xử lý một lần ---
xử lý OrderCreated[cd30763b, order=DH102, amount=500000]
xử lý OrderCreated[589af2a4, order=DH103, amount=900000]
bỏ qua tin trùng OrderCreated[589af2a4, order=DH103, amount=900000]
hoá đơn đã tạo=2 (đơn DH103 chỉ có 1 hoá đơn: 1)Mã 23505 là mã vi phạm ràng buộc duy nhất của H2 (MySQL dùng mã 1062), nên cần chỉnh theo database thật. Việc đánh dấu "đã xử lý" phải cùng transaction với tác dụng phụ, nếu không ta lại tái tạo đúng bài toán dual write ở phía nhận.
Cài đặt Saga pattern với orchestrator
Ba service trong ví dụ Saga pattern (Services.java) mỗi service dùng một database H2 riêng: Inventory giữ hàng, Payment tính tiền (cổng từ chối nếu quá 20 triệu VND), Shipping tạo vận đơn (từ chối vài khu vực). Mỗi service có hành động chính và, nếu cần, hành động bù. Orchestrator khai báo Saga pattern như một danh sách bước:
List.of(
new Step("giu-hang", () -> inv.reserve(id, "AO-THUN", qty), () -> inv.release(id)),
new Step("thanh-toan", () -> pay.charge(id, amount), () -> pay.refund(id)),
new Step("van-chuyen", () -> ship.create(id, city), null)); // bước cuối, không cần bùVòng chạy chính của Saga pattern ghi trạng thái trước và sau mỗi bước, khi gặp lỗi nghiệp vụ thì bù theo thứ tự ngược (đoạn này đã lược bớt tham số crashBefore và phần ghi vết so với Orchestrator.java):
for (Step s : steps) {
if ("DONE".equals(statusInLog(orderId, s.name()))) { done.add(s); continue; } // chạy lại thì bỏ qua
try {
log(orderId, s.name(), "STARTED");
s.action().run();
log(orderId, s.name(), "DONE");
done.add(s);
} catch (Services.StepFailed e) {
log(orderId, s.name(), "FAILED");
Collections.reverse(done);
for (Step d : done) {
if (d.compensation() == null) continue; // bước không có bù (bước cuối)
d.compensation().run(); log(orderId, d.name(), "COMPENSATED");
}
return "CANCELLED";
}
}
return "COMPLETED";Kết quả chạy Demo3Saga cho ba kịch bản đầu của Saga pattern (kho ban đầu có 10 áo thun):
--- Kịch bản 1: thành công ---
giu-hang OK
thanh-toan OK
van-chuyen OK
=> DH201: COMPLETED | tồn kho AO-THUN=8, thanh toán=CHARGED, vận đơn=1
--- Kịch bản 2: thanh toán bị từ chối (bù bước giữ hàng) ---
giu-hang OK
thanh-toan THẤT BẠI: cổng thanh toán từ chối 25000000 VND (quá hạn mức)
bù giu-hang
=> DH202: CANCELLED | tồn kho AO-THUN=8, thanh toán=NONE, vận đơn=1
--- Kịch bản 3: vận chuyển lỗi (hoàn tiền rồi trả hàng, theo thứ tự ngược) ---
giu-hang OK
thanh-toan OK
van-chuyen THẤT BẠI: đơn vị vận chuyển không giao tới Côn Đảo
bù thanh-toan
bù giu-hang
=> DH203: CANCELLED | tồn kho AO-THUN=8, thanh toán=REFUNDED, vận đơn=1Ở kịch bản 2, tồn kho trở lại 8 (đã trừ 2 của DH201 và hoàn 3 của DH202), thanh toán không có bản ghi. Ở kịch bản 3, bước thanh toán đã commit thật nên phải hoàn tiền, trạng thái cuối là REFUNDED chứ không phải "không có". Đó chính là khác biệt giữa compensation và rollback: dấu vết của giao dịch vẫn còn trong sổ sách, đúng với thực tế kế toán.
Khôi phục Saga pattern khi orchestrator chết giữa chừng
Orchestrator của Saga pattern là tiến trình có thể bị kill bất cứ lúc nào, nên trạng thái phải nằm trong database chứ không nằm trong bộ nhớ. Kịch bản 4 mô phỏng tiến trình chết ngay trước bước vận chuyển, rồi khởi động lại và gọi run lần nữa với cùng mã đơn:
--- Kịch bản 4: orchestrator chết giữa chừng, khởi động lại chạy tiếp ---
sự cố: tiến trình chết trước bước van-chuyen
saga_log: thanh-toan=DONE giu-hang=DONE
giu-hang OK
thanh-toan OK
bỏ qua giu-hang (đã DONE trong saga_log)
bỏ qua thanh-toan (đã DONE trong saga_log)
van-chuyen OK
=> DH204: COMPLETED | tồn kho AO-THUN=7, thanh toán=CHARGED, vận đơn=2
saga_log DH204 sau khi chạy lại: van-chuyen=DONE thanh-toan=DONE giu-hang=DONE
saga_log DH203: van-chuyen=FAILED thanh-toan=COMPENSATED giu-hang=COMPENSATEDQuan trọng là tồn kho chỉ giảm một lần (từ 8 xuống 7) và chỉ có một khoản thanh toán cho DH204, nhờ hai lớp bảo vệ: orchestrator bỏ qua bước đã DONE, và chính mỗi service cũng tự kiểm tra trùng theo mã đơn (reserve và charge trả về ngay nếu đã có bản ghi). Nếu orchestrator chết sau khi service đã commit nhưng trước khi ghi DONE, bước sẽ chạy lại và chỉ idempotent ở service mới cứu được ta. Kịch bản 5 kiểm chứng đúng điều này bằng cách gọi lặp reserve với cùng mã đơn DH204 (đã commit ở kịch bản 4):
--- Kịch bản 5: bước đã commit ở service nhưng orchestrator chưa ghi DONE, nên gọi lại reserve ---
tồn kho trước=7, sau khi gọi lại reserve(DH204)=7Tồn kho vẫn là 7, tức lần gọi lặp của Saga pattern không trừ hàng thêm.
Compensating transaction: những ràng buộc dễ bỏ sót
- Phải idempotent và có thể thử lại: bước bù của Saga pattern có thể bị gọi nhiều lần. Trong code,
releasechỉ cộng lại hàng nếu đặt chỗ chưa được trả (released=false), nên gọi lần hai không làm tồn kho tăng sai. - Không phải việc gì cũng bù được: email đã gửi, hàng đã giao không thu hồi bằng một lệnh. Tài liệu Azure gọi điểm không quay lại trong Saga pattern là pivot transaction; các bước sau nó phải retryable (thử lại tới khi thành công) chứ không bù.
- Sắp xếp bước hợp lý: đặt bước dễ thất bại lên sớm, bước không thể bù để sau cùng.
- Bù cũng có thể lỗi: cần thử lại có giám sát và cảnh báo khi vượt ngưỡng.
Thiếu cô lập: Saga pattern nhìn thấy trạng thái trung gian
Trong kịch bản 3, giữa lúc giữ hàng thành công và lúc bù, tồn kho đã giảm tạm thời. Một đơn khác đọc tồn kho đúng khoảnh khắc đó có thể bị từ chối oan, và đó là một dạng dirty read. Tài liệu Azure liệt kê các dị thường của Saga pattern (lost update, dirty read, fuzzy read) và các biện pháp đối phó. Đáng nhớ nhất:
- Semantic lock: đánh dấu bản ghi bằng trạng thái nghiệp vụ như
PENDINGđể các saga khác biết dữ liệu đang được xử lý. - Commutative updates: thiết kế phép cập nhật không phụ thuộc thứ tự (cộng trừ số lượng thay vì ghi đè).
- Pessimistic view: sắp xếp lại saga để các cập nhật dữ liệu nằm trong các bước retryable (sau pivot, không phải bù), nhờ đó không có dirty read do bước bù gây ra.
- Reread value: đọc lại giá trị ngay trước khi ghi, nếu đã đổi thì dừng bước và chạy lại.
Trong code mẫu, các phép cập nhật tồn kho (qty=qty-?) đã là commutative nên hai saga đồng thời không ghi đè nhau; việc tồn kho hiển thị tạm thấp vẫn có thể xảy ra.
Outbox trong sản xuất: polling hay đọc transaction log
Bản demo dùng polling publisher: relay quét bảng outbox theo chu kỳ. Cách này dễ cài, nhưng thêm tải truy vấn đều đặn và độ trễ bằng chu kỳ quét. Lựa chọn thứ hai là đọc transaction log của database (log tailing, hay CDC). Debezium cung cấp Outbox Event Router: connector bắt thay đổi của bảng outbox rồi định tuyến sang topic Kafka. Theo tài liệu của Debezium, bảng outbox hoạt động như hàng đợi chỉ có INSERT, với các cột như id (dùng để khử trùng), aggregatetype, aggregateid (khoá thông điệp, giữ thứ tự trong partition), type và payload.
| Tiêu chí | Polling publisher | CDC (Debezium) |
|---|---|---|
| Độ phức tạp vận hành | Thấp: một vòng lặp trong ứng dụng | Cao hơn: Kafka Connect, cấu hình binlog hoặc WAL |
| Độ trễ | Bằng chu kỳ quét | Thường rất thấp |
| Tải lên database | Truy vấn quét định kỳ | Đọc log, ít ảnh hưởng truy vấn |
Với cả hai cách, thông điệp vẫn có thể tới hơn một lần, nên consumer idempotent không phải tuỳ chọn. Outbox cũng cần chính sách dọn bản ghi đã gửi để bảng không phình mãi.
Khi nào không nên dùng Saga pattern
- Mọi dữ liệu cần nhất quán ngay lập tức nằm trong một database: gộp thành một service với transaction cục bộ đơn giản hơn nhiều. Saga pattern là giá phải trả của việc tách dữ liệu, không phải mục tiêu.
- Nghiệp vụ không thể bù: nếu mọi bước đều không hoàn tác được, Saga pattern chỉ còn là chuỗi thử lại; cần xem lại ranh giới service.
- Đội chưa có khả năng giám sát: Saga pattern cần truy vết theo mã đơn, cảnh báo bước kẹt và quy trình xử lý thủ công khi bù thất bại.
Lỗi thường gặp
- Publish ngay trong transaction nghiệp vụ: đây vẫn là dual write; dùng outbox hoặc CDC.
- Quên consumer idempotent: outbox đảm bảo at-least-once, không phải exactly-once; tin trùng sẽ xuất hiện đúng lúc có sự cố.
- Không lưu trạng thái Saga pattern bền vững: orchestrator chỉ giữ trạng thái trong RAM thì khởi động lại là mất.
- Không có timeout cho bước: một service treo làm saga treo mãi; kết hợp Retry và Circuit Breaker ở bài trước.
Checklist
- Mỗi bước của Saga pattern là một transaction cục bộ commit trong database của chính service đó.
- Chọn choreography cho luồng ngắn, orchestration cho luồng dài; trạng thái saga lưu bền vững.
- Mỗi bước có hành động bù (trừ bước cuối và bước sau pivot), hành động bù idempotent và thử lại được.
- Không dual write: mọi thông điệp đi qua outbox cùng transaction với dữ liệu.
- Consumer idempotent bằng bảng
processed_messageshoặc khoá tự nhiên, cùng transaction với tác dụng phụ. - Có biện pháp chống thiếu cô lập: semantic lock, cập nhật commutative, đọc lại giá trị.
- Có timeout, cảnh báo, và quy trình xử lý thủ công khi Saga pattern bù thất bại.
Tóm lại, Saga pattern thay transaction phân tán bằng một chuỗi bước có thể bù, còn Transactional Outbox bảo đảm mỗi bước phát thông điệp đúng khi và chỉ khi dữ liệu thật sự được commit. Saga pattern và Outbox đi cùng nhau, và cả hai đều đòi hỏi idempotent ở mọi đầu nhận. Toàn bộ mã nằm trong thư mục code/s6-28, chạy bằng JDK 21 và H2 2.2.224 trong bộ nhớ.
Bài tiếp theo
Saga pattern và Outbox mới giải quyết tính nhất quán giữa các service; bên trong một service, ta còn phải điều phối nhiều tác vụ chạy đồng thời. Mời bạn đọc tiếp Concurrency patterns: Producer-Consumer, thread pool và CompletableFuture.



