“Gói mọi thứ trong một transaction” là mô hình mental đầu tiên mà hầu hết dev backend học khi làm việc với database. BEGIN, làm mọi thứ, COMMIT. Nếu fail thì ROLLBACK. ACID đảm bảo consistency. Dễ hiểu, dễ reason, dễ test.

Cho đến khi hệ thống phức tạp hơn. Đặt hàng cần ghi database, gọi payment gateway, gửi email confirmation, cập nhật CRM partner. Bốn thứ đó không nằm trong cùng một database, ACID boundary kết thúc ở biên database. Payment gateway timeout giữa chừng: database đã commit đơn hàng, payment chưa charge, email chưa gửi. Rollback database? Payment đã charge (hoặc chưa, bạn không biết vì timeout). Đây là thực tế mà mọi team phải đối mặt khi chuyển từ monolith đơn DB sang hệ thống có nhiều side-effect ngoài DB.

Bài này đi qua cách suy nghĩ về transaction boundary khi ACID không đủ, từ khi nào local transaction còn đủ dùng, đến khi nào cần saga/outbox/compensation, và quan trọng nhất là cách chọn pattern phù hợp với mức coupling và failure domain cụ thể.


Local transaction: khi nào “đủ”

Trước khi nhảy vào distributed pattern, câu hỏi đầu tiên luôn là: có thực sự cần distributed không?

Local transaction đủ khi mọi invariant nghiệp vụ có thể kiểm tra và commit trong cùng một database transaction. Chuyển tiền giữa hai tài khoản cùng database, trừ A, cộng B, commit. Đặt hàng + trừ inventory cùng schema, constraint check và commit atomic.

Hay gặp trường hợp team tách service sớm rồi phải implement saga cho thứ mà một transaction đơn giải quyết tốt hơn nhiều. Trước khi tách, hỏi: “invariant nghiệp vụ này có thể kiểm tra trong cùng DB transaction không?” Nếu có, đừng tạo distributed problem cho bài toán local.

Khi nào local transaction không đủ? Khi use case có side-effect ngoài database mà bạn muốn “cùng đơn vị nghiệp vụ”: gọi API bên ngoài, gửi message queue, ghi object storage, webhook partner. Lúc này ACID kết thúc, bạn bước vào thế giới retry, eventual consistency, và compensation.


Distributed transaction: tâm lý vs thực tế

Hai khái niệm hay bị trộn lẫn khi team nói “distributed transaction”.

Thứ nhất là phân tán nghiệp vụ, nhiều service tham gia vào một use case. Đây luôn có eventual consistency ở đâu đó, không có ACID xuyên suốt. Bạn phải thiết kế cho trạng thái trung gian và failure recovery.

Thứ hai là distributed atomic commit (2PC/XA), protocol cho phép nhiều participant commit hoặc rollback atomic. Nghe hấp dẫn nhưng thực tế: 2PC blocking, kém chịu network partition, và coupling availability giữa các participant. Nếu coordinator hoặc một participant chết giữa prepare phase, các participant khác block chờ. Trong microservice latency-sensitive, đây thường là non-starter.

2PC có thể phù hợp bên trong một technology cụ thể, distributed database nội bộ (CockroachDB, Spanner) nơi protocol đã được tối ưu. Ở lớp microservice tự chế, 2PC thường kém hấp dẫn. Middle+ cần biết nó tồn tại để không đề xuất nó như default.

Từ bỏ ảo tưởng “một nút bấm ACID cho cả hệ thống” là bước đầu tiên để thiết kế đúng.


Outbox pattern: atomic giữa DB và message

Outbox giải quyết vấn đề cơ bản nhất: bạn muốn ghi database VÀ publish message, nhưng database commit và message publish là hai operation riêng biệt, crash giữa chừng mất message.

Giải pháp: ghi business row + event row vào bảng outbox trong cùng một database transaction. Dispatcher process đọc bảng outbox, publish message ra queue, đánh dấu đã publish. Database transaction đảm bảo atomic giữa business data và event, nếu business commit thì event chắc chắn được ghi (chờ dispatcher publish).

Trade-off: outbox introduce lag giữa commit database và thời điểm message xuất hiện trên bus. Nếu downstream cần gần real-time, phải đo lag (timestamp outbox vs timestamp publish) và tune dispatcher (batch size, polling interval, parallelism).

Lỗi phổ biến khi implement outbox: dispatcher single-thread tạo lag tăng tuyến tính theo QPS, cần partition hoặc batch. Transaction quá dài (ghi business + outbox + nhiều thứ khác) gây lock contention, nhỏ hóa transaction. Duplicate publish do dispatcher crash sau publish nhưng trước khi đánh dấu, cần consumer idempotent.


Inbox pattern: dedupe phía consumer

Bổ sung cho outbox: bảng inbox ở consumer lưu message_id đã xử lý. Consumer nhận message → check inbox → đã có thì skip, chưa có thì xử lý + INSERT inbox trong cùng transaction với business logic.

Outbox + inbox kết hợp cho at-least-once delivery an toàn: outbox đảm bảo message không mất, inbox đảm bảo message không xử lý trùng.


Saga: khi cần phối hợp nhiều service

Saga là pattern cho use case phân tán, chuỗi bước qua nhiều service, mỗi bước có thể thành công hoặc thất bại độc lập.

Choreography vs orchestration

Choreography: mỗi service tự publish event khi hoàn thành bước mình, service tiếp theo subscribe và xử lý. Coupling lỏng, service không biết nhau, chỉ biết event. Nhưng khó theo dõi flow tổng thể khi có nhiều bước, debug khi fail ở giữa rất mệt.

Orchestration: một “tổng đạo diễn” (orchestrator) gọi từng service tuần tự, quản lý state machine. Nhìn flow một chỗ, dễ debug cho team. Nhưng orchestrator là single point cần HA, retry, backoff, không được trở thành SPOF.

Cả hai đều cần idempotency ở mỗi bước (retry an toàn) và timeout rõ ràng (không chờ mãi). Orchestration thường phù hợp hơn khi flow có hơn 3 bước hoặc team mới, nhìn flow một chỗ giúp onboarding và debug dễ hơn nhiều.

Compensation không phải undo

Điều quan trọng cần nhấn mạnh: compensation không phải “undo”, nó là nghiệp vụ bù trừ. “Undo” charge tiền không phải DELETE row, mà là tạo refund transaction mới, có thể tốn phí, có SLA, có thể cần human approval.

Compensation phải được thiết kế cho từng bước của saga, “nếu bước 3 fail sau khi bước 1, 2 đã commit, compensation cho bước 1 và 2 là gì?” Đây là câu hỏi business, không phải kỹ thuật thuần.


Cây quyết định: chọn pattern nào

Một decision tree đơn giản khi thiết kế:

Có side-effect ngoài DB trong cùng use case không? Nếu KHÔNG → ưu tiên local transaction + idempotency key cho HTTP. Đơn giản nhất, ít lỗi nhất.

Nếu CÓ → có thể gom vào một DB + outbox không? Nếu CÓ → outbox + consumer idempotent. Vẫn đơn giản, chỉ thêm dispatcher.

Nếu KHÔNG (nhiều service độc lập) → chấp nhận trạng thái trung gian hiển thị cho user? Nếu CÓ → saga + compensation rõ ràng. Phức tạp hơn nhưng realistic.

Nếu KHÔNG chấp nhận trạng thái trung gian → hỏi lại product: có thật sự cần “atomic ảo” không? Thường câu trả lời là “OK, show ‘đang xử lý’ cho user cũng được”, và bạn quay lại saga.

Chọn theo coupling

Cùng database schema → transaction + outbox. Đơn giản nhất.

Khác database nhưng cùng team → orchestrated saga + compensation. Team control cả hai đầu, dễ coordinate.

Khác organization hoặc SLA partner → async + reconciliation + support playbook. Partner có thể chậm, fail, không hỗ trợ idempotency, cần reconciliation process chạy định kỳ so sánh data hai bên.


Poison message và dead-letter queue

Consumer fail vì bug logic, message hợp lệ nhưng code không handle đúng. Nếu retry vô hạn, message quay lại đầu queue liên tục, block consumer, ảnh hưởng downstream.

Cần dead-letter queue (DLQ): sau N lần retry fail, chuyển message sang DLQ. Alert team. Fix bug. Replay message từ DLQ có kiểm soát.

Ordering cũng là trade-off: “cùng key vào cùng partition” giúp tuần tự xử lý cho cùng entity, nhưng entity hot (nhiều event) tạo hotspot trên một partition. Quyết định ordering vs throughput phải chủ động, không để mặc định.


Timeout cascade: saga không được quên

Khi orchestrator gọi service B với timeout 2 giây, nhưng B gọi downstream C với timeout 5 giây, orchestrator timeout trước, B vẫn chạy, C vẫn chạy. Orchestrator nghĩ fail → trigger compensation. B và C thành công → dữ liệu inconsistent.

Thiết kế timeout theo chuỗi phụ thuộc: parent timeout phải lớn hơn tổng child timeout + buffer. Hoặc dùng fire-and-forget + callback thay vì synchronous chain.


Trạng thái trung gian: UX của consistency

Khi saga tách “charge payment” và “activate license”, user có thể thấy: “Thanh toán thành công, đang kích hoạt license”. Đó là thiết kế UX cho eventual consistency, không phải bug.

Nên thiết kế state machine rõ ràng cho user-facing state: pending_paymentpayment_confirmedactivatingactive. Mỗi trạng thái có message display cho user, có timeout (nếu stuck ở activating quá 5 phút → alert support), có retry logic.


Read-your-writes: UX sau transaction

User submit form, F5 ngay, kỳ vọng thấy data mới. Nếu read model ở behind replica lag, user thấy “mất data”. Đây không phải saga bug, là consistency UX problem.

Giải pháp: đọc từ primary sau write (ngắn hạn), session sticky read, hoặc UI optimistic update (show data mới ngay từ client, sync background). “Đang đồng bộ…” message cũng chấp nhận được nếu lag ngắn.


Isolation level: biết mình đang dùng gì

Ngay trong một database, transaction behavior khác nhau tùy isolation level. READ COMMITTED cho phép non-repeatable read. REPEATABLE READ chặn phantom read (Postgres) hoặc không (MySQL). SERIALIZABLE đảm bảo mạnh nhất nhưng đắt nhất.

Khi nói “transaction đảm bảo mọi thứ”, hãy nói rõ isolation level. Một bug hay gặp: team assume SERIALIZABLE nhưng Postgres default là READ COMMITTED, hai concurrent transaction đọc cùng row, cả hai thấy cùng giá trị, cả hai update, lost update.


Câu hỏi nhắc nhở khi thiết kế

“Nếu service B chết 30 phút, trạng thái hiển thị cho user và dữ liệu trong DB A là gì?”

Nếu team không trả lời được trong 2 phút, bạn chưa sẵn sàng chọn saga hay outbox, cần làm rõ failure story trước. Câu hỏi này nên xuất hiện trong mọi design review cho distributed flow, nó bắt team nghĩ về failure mode cụ thể thay vì “assume everything works”.


Khi không nên saga

Luồng đơn giản có thể đồng bộ: một call + transaction DB + webhook idempotent. Đừng over-engineer saga cho thứ mà sequential call xử lý tốt.

Khi team chưa có observability + runbook: saga nhân đôi incident mà team không hiểu trạng thái. Saga cần distributed tracing, state machine visibility, alerting cho stuck state. Nếu chưa có infrastructure đó, local transaction + outbox đủ cho hầu hết case.


Tóm tắt

ACID kết thúc ở boundary database, ra ngoài là thế giới retry, eventual consistency, và compensation. Trước khi chọn distributed pattern, hỏi: “có thể gom vào một DB transaction không?” Nếu có, đừng tạo distributed problem.

Outbox giải “mất event” giữa DB commit và message publish. Inbox/dedupe giải “trùng event” khi consumer retry. Hai pattern bổ sung nhau cho at-least-once delivery an toàn.

Saga là chi phí nhận thức đáng kể, chỉ dùng khi product chấp nhận trạng thái trung gian và team có observability đủ. Compensation là nghiệp vụ bù trừ, không phải undo kỹ thuật. Timeout phải thiết kế theo chuỗi phụ thuộc, không đặt độc lập.

Câu hỏi quan trọng nhất khi thiết kế: “service B chết 30 phút, user thấy gì, data ở đâu?” Nếu không trả lời được, failure story chưa rõ, thiết kế pattern khi chưa hiểu failure mode là nguồn incident.


Tham khảo