🧩🧠 Thanh toán thất bại, Microservices xử lý thế nào? - Microservice Architecture P7
Thanh toán thất bại, Microservices xử lý thế nào?
Đã bao giờ bạn nhận được email thông báo thanh toán thành công, nhưng khi vào ứng dụng thì đơn hàng lại không tồn tại? Hoặc tệ hơn — tiền bị trừ khỏi tài khoản, không có đơn hàng, và bộ phận chăm sóc khách hàng phải xử lý thủ công từng ca hoàn tiền?
Đây không phải là lỗi code đơn giản. Đây là hậu quả của việc thiết kế giao dịch phân tán sai căn bản trong Microservices. Và ở phần lớn các hệ thống thương mại điện tử mà tôi từng đụng vào, vấn đề này đến từ một niềm tin rất phổ biến: "Cứ dùng transaction database như trong Monolith là xong."
Câu chuyện production: Khi khách hàng mất tiền mà không có đơn hàng
Một startup fintech ở thị trường Đông Nam Á ra mắt tính năng thanh toán tích hợp, gồm ba bước nghiệp vụ liên tiếp:
- Order Service — Tạo đơn hàng, chuyển trạng thái sang
PENDING. - Inventory Service — Trừ số lượng hàng tồn kho.
- Payment Service — Thực hiện giao dịch thanh toán với cổng thanh toán bên ngoài (VNPay, Stripe...).
Trong môi trường staging, toàn bộ luồng chạy hoàn hảo. Lên production được ba tuần, bộ phận CSKH bắt đầu nhận được khiếu nại: khách hàng bị trừ tiền nhưng đơn hàng ở trạng thái FAILED. Kiểm tra lại database thì thấy:
- Bảng
paymentstrongPayment DB: trạng tháiCOMPLETED, tiền đã chuyển đi. - Bảng
orderstrongOrder DB: trạng tháiFAILEDvìInventory Servicetimeout. - Bảng
inventorytrongInventory DB: số lượng đã bị trừ nhưng không được hoàn lại.
Hệ thống đã thực hiện một nửa giao dịch rồi dừng lại giữa chừng. Không có cơ chế nào tự động hoàn trả lại.
Nhóm kỹ sư lúc đó đã cố gắng fix bằng cách bọc toàn bộ logic vào một @Transactional annotation của Spring. Điều đó hoàn toàn không có tác dụng — vì @Transactional chỉ bảo vệ một database connection duy nhất. Ba service, ba database vật lý, ba transaction độc lập. Không có transaction nào biết đến sự tồn tại của transaction kia.
Niềm tin phổ biến: "Cứ dùng transaction database là xong"
Khi còn làm với Monolith, chúng ta được che chở bởi một trong những tính chất đẹp nhất của cơ sở dữ liệu quan hệ: ACID transaction.
BEGIN TRANSACTION;
INSERT INTO orders (...) VALUES (...);
UPDATE inventory SET stock = stock - 1 WHERE ...;
INSERT INTO payments (...) VALUES (...);
COMMIT;
-- Nếu bất kỳ bước nào fail: ROLLBACK tự động
Mọi thứ trong một block. Thất bại ở bước nào, database tự cuộn ngược toàn bộ. Sạch sẽ, an toàn, không cần lo.
Khi chuyển sang Microservices, trực giác đầu tiên của rất nhiều kỹ sư là: "Vẫn có thể làm như vậy, chỉ là phải dùng Distributed Transaction — hai pha commit (2PC) là xong."
Nghe có lý. Nhưng đây chính là cái bẫy.
Tại sao 2PC không phải là giải pháp?
Two-Phase Commit (2PC) là giao thức cho phép nhiều database node cùng commit hoặc cùng rollback một giao dịch phân tán. Về lý thuyết, nó giải quyết đúng vấn đề.
Coordinator (Transaction Manager)
|
┌─────────┴──────────┐
│ │
Phase 1: PREPARE Phase 1: PREPARE
│ │
Order DB Payment DB
"Sẵn sàng?" "Sẵn sàng?"
│ │
└─────────┬──────────┘
│
┌─────────┴──────────┐
│ │
Phase 2: COMMIT Phase 2: COMMIT
Vấn đề là trong thực tế production:
Hiệu năng rất tệ. Mỗi giao dịch yêu cầu ít nhất hai lượt network round-trip đồng bộ giữa Coordinator và tất cả các Participant. Với hàng nghìn giao dịch/giây, đây là điểm nghẽn cổ chai không thể chấp nhận được.
Khả năng chịu lỗi kém. Nếu Coordinator crash sau Phase 1 nhưng trước Phase 2, tất cả các Participant bị block vô thời hạn — họ đã PREPARE nhưng không biết phải COMMIT hay ROLLBACK. Đây là hiện tượng in-doubt transaction — trạng thái lơ lửng không thể tự phục hồi.
Không phù hợp với kiến trúc phân tán hiện đại. Các database cloud-native (DynamoDB, MongoDB Atlas, PlanetScale) không hỗ trợ 2PC cross-service. Kafka không hỗ trợ. gRPC không hỗ trợ.
Cộng đồng microservices đã đúc kết một bài học đắt giá sau hơn một thập kỷ thực chiến: Đừng cố chống lại Eventual Consistency. Hãy thiết kế hệ thống để sống cùng với nó.
Và đó chính là lý do Saga Pattern ra đời.
Góc nhìn mới: Saga Pattern — Chuỗi giao dịch cục bộ có khả năng tự phục hồi
Saga không phải là một transaction duy nhất. Nó là một chuỗi các giao dịch cục bộ (local transactions), mỗi giao dịch chỉ chạm vào database của một service duy nhất. Điều quan trọng là: mỗi bước trong chuỗi đều phải có một giao dịch bù (Compensating Transaction) tương ứng — logic để hoàn trả lại trạng thái nếu bước phía sau thất bại.
Hãy nhìn lại ví dụ đặt hàng:
Bước 1: Order Service → Tạo đơn hàng (PENDING)
Compensate → Hủy đơn hàng (CANCELLED)
Bước 2: Inventory Service → Trừ kho
Compensate → Hoàn trả kho
Bước 3: Payment Service → Thực hiện thanh toán
Compensate → Hoàn tiền (Refund)
Khi Payment Service thất bại ở Bước 3, Saga sẽ kích hoạt backward recovery: chạy ngược lại Compensating Transaction của Bước 2 và Bước 1 theo thứ tự.
Đây là điểm mấu chốt cần hiểu đúng: Compensating Transaction không phải là SQL ROLLBACK. Nó là một nghiệp vụ hoàn toàn mới, chạy tiến về phía trước (forward) để đưa dữ liệu về trạng thái logic ban đầu. Nó tạo ra dấu vết audit trail, có thể fail và cần được retry, không phải phép màu tự động.
Hai kiểu Saga: Choreography và Orchestration
Có hai cách để điều phối chuỗi Saga, và việc chọn đúng cái phù hợp là quyết định kiến trúc quan trọng.
Choreography Saga — Điều phối bằng sự kiện
Không có ai đứng ra chỉ huy. Mỗi service lắng nghe sự kiện từ service trước, xử lý, rồi phát ra sự kiện tiếp theo.
Order Service Inventory Service Payment Service
│ │ │
│──[OrderCreated]────────►│ │
│ │──[StockReserved]────────►│
│ │ │
│ │ [PaymentFailed]───►│
│ │◄──[ReleaseStock]─────────│
│◄──[CancelOrder]────────│ │
Ưu điểm: Loose coupling cao. Không có single point of failure. Dễ thêm service mới vào chuỗi mà không cần sửa service hiện tại.
Nhược điểm: Khi logic phức tạp lên, chuỗi sự kiện trở nên rất khó theo dõi và debug. Nguy hiểm nhất là circular dependency: Service A phát event cho Service B, Service B phát event cho Service C, Service C trong một điều kiện lỗi nào đó lại phát event ngược lại Service A. Vòng lặp vô tận hình thành, hệ thống không bao giờ thoát ra được.
Orchestration Saga — Điều phối bằng Orchestrator trung tâm
Có một thành phần đứng ra chỉ huy toàn bộ luồng. Orchestrator biết thứ tự các bước, quyết định bước tiếp theo cần làm, và quyết định khi nào cần kích hoạt compensation.
┌─────────────────────┐
│ Saga Orchestrator │
└──────────┬──────────┘
│
┌──────────────────┼──────────────────┐
│ │ │
▼ ▼ ▼
Order Service Inventory Service Payment Service
(createOrder) (reserveStock) (processPayment)
(cancelOrder) (releaseStock) (refundPayment)
Orchestrator — thường được implement bằng một State Machine — gọi từng service theo thứ tự xác định. Nếu bước nào thất bại, nó biết chính xác những bước nào đã thành công và cần gọi Compensating Transaction theo thứ tự ngược lại.
Ưu điểm: Luồng nghiệp vụ tập trung, dễ quan sát, dễ debug. Không có risk circular dependency. Orchestrator có thể được persist trạng thái để tự phục hồi sau crash.
Nhược điểm: Orchestrator có thể trở thành điểm phụ thuộc tập trung (centralized dependency). Nếu Orchestrator sập, toàn bộ luồng dừng lại — dù bản thân các service vẫn hoạt động bình thường. Đây là trade-off quan trọng cần cân nhắc.
Thiết kế Compensating Transaction: Idempotency là yêu cầu bắt buộc
Một lỗi thiết kế rất phổ biến là viết Compensating Transaction mà không đảm bảo idempotency — khả năng chạy nhiều lần nhưng cho kết quả như nhau.
Hãy tưởng tượng Orchestrator gọi releaseStock của Inventory Service để hoàn trả kho. Request thành công nhưng mạng timeout trước khi Orchestrator nhận được response. Orchestrator không chắc request đã được xử lý hay chưa, nên nó retry. Nếu releaseStock không idempotent, bạn vừa hoàn trả kho hai lần — tạo ra dữ liệu sai nghiêm trọng hơn cả sự cố ban đầu.
Giải pháp chuẩn là dùng Idempotency Key: mỗi request của Saga được gán một saga_id và step_id duy nhất. Service nhận request sẽ kiểm tra xem cặp (saga_id, step_id) này đã được xử lý trước đó chưa trước khi thực hiện bất kỳ thao tác ghi nào.
POST /inventory/release-stock
Headers:
Idempotency-Key: saga-1234-step-2-compensate
Body:
{ "orderId": "ORD-9999", "productId": "SKU-001", "quantity": 2 }
Inventory Service lưu lại Idempotency-Key đã xử lý. Nếu nhận được request trùng key, trả về kết quả cũ mà không thực hiện ghi thêm lần nào.
Phân tích trade-offs: Saga không phải miễn phí
Saga giải quyết được bài toán giao dịch phân tán, nhưng nó đến với một chi phí thực sự mà nhiều team đánh giá thấp khi mới áp dụng.
Thiếu tính cô lập (Lack of Isolation): Trong SQL transaction cổ điển, dữ liệu đang trong transaction được khóa (lock) — user khác không thể nhìn thấy trạng thái trung gian. Trong Saga, không có lock như vậy. Trong khoảng thời gian từ Bước 1 đến Bước 3, người dùng khác hoàn toàn có thể thấy đơn hàng ở trạng thái PENDING và tồn kho đã bị trừ — dù giao dịch chưa hoàn tất.
Đây là hiện tượng gọi là dirty read ở cấp độ nghiệp vụ. Để giảm thiểu, các hệ thống thường áp dụng hai chiến lược:
- Semantic Lock: Đánh dấu dữ liệu đang trong trạng thái trung gian (ví dụ:
order.status = PROCESSING) để các luồng nghiệp vụ khác biết cần xử lý cẩn thận. - Pessimistic Update: Chủ động trừ trước tồn kho khi bắt đầu Saga, thay vì chờ đến Bước 2. Nếu Saga fail, Compensating Transaction hoàn trả lại.
Độ phức tạp tăng cao: Với mỗi bước trong Saga, bạn phải thiết kế thêm một Compensating Transaction. Một luồng gồm 5 bước nghĩa là bạn cần thiết kế và test 10 đơn vị logic nghiệp vụ (5 forward + 5 compensating). Khối lượng công việc tăng gấp đôi so với ước tính ban đầu.
Không phải mọi hành động đều có thể bù: Một số hành động là không thể hoàn tác hoàn toàn. Ví dụ: bạn đã gửi email thông báo đặt hàng thành công cho khách hàng. Bạn không thể "unsend" email đó. Trong những trường hợp này, Compensating Transaction là gửi một email xin lỗi và thông báo hủy đơn — một nghiệp vụ mới, không phải rollback kỹ thuật.
Những kịch bản Saga thất bại trong production
Choreography tạo vòng lặp sự kiện: Kịch bản phổ biến nhất là thiết kế Choreography Saga thiếu cẩn thận khiến các service gọi vòng quanh không thoát ra được. Order Service phát OrderFailed → Notification Service gửi thông báo → lỗi gửi email khiến Notification Service phát NotificationFailed → Order Service catch event này và lại phát OrderFailed. Vòng lặp vô tận hình thành, sinh ra hàng chục nghìn record rác trong database. Debug loại lỗi này trong môi trường distributed tracing thiếu sót là cực kỳ tốn thời gian.
Compensating Transaction cũng fail: Bạn đang chạy backward recovery — Payment Service đã thất bại, giờ đang cố hoàn trả kho ở Inventory Service. Nhưng Inventory Service lúc này cũng đang quá tải và trả về lỗi 503. Saga bị kẹt ở trạng thái compensation failed. Đây là lý do Orchestrator cần được thiết kế để persist trạng thái Saga và tự động retry sau một khoảng thời gian, kết hợp với Dead Letter Queue để xử lý thủ công các case không thể tự phục hồi.
Idempotency Key hết hạn hoặc bị xóa: Một số team implement Idempotency Key với TTL ngắn để tiết kiệm bộ nhớ. Khi một Saga chạy lâu (vài giờ do retry nhiều lần), key đã hết hạn. Retry sau đó sẽ vô tình xử lý lại như một request hoàn toàn mới, gây ra double-execution. TTL cho Idempotency Key phải dài hơn đáng kể so với thời gian tối đa một Saga có thể tồn tại.
Key takeaways
Sau tất cả những gì chúng ta đã phân tích, có ba nguyên tắc cốt lõi bạn cần mang theo khi thiết kế giao dịch phân tán trong Microservices:
Thứ nhất: Chấp nhận Eventual Consistency, đừng chống lại nó. Thay vì cố gắng tái tạo ACID trên môi trường phân tán — một bài toán gần như vô nghiệm — hãy thiết kế hệ thống để các service tự phục hồi thông qua Compensating Transactions.
Thứ hai: Mỗi forward step phải có compensating step tương ứng, và compensating step đó phải idempotent. Đây không phải tùy chọn. Thiếu idempotency là nguyên nhân gốc rễ của hàng loạt lỗi data corruption trong production.
Thứ ba: Chọn Choreography hay Orchestration dựa trên độ phức tạp của luồng nghiệp vụ, không phải trào lưu. Choreography phù hợp với các chuỗi đơn giản, ít bước, ít điều kiện phân nhánh. Orchestration phù hợp với các luồng phức tạp, nhiều điều kiện, cần visibility cao. Phần lớn các hệ thống tài chính và thương mại điện tử sẽ thấy Orchestration dễ maintain hơn về dài hạn.
Nhìn lại câu chuyện đầu bài — hệ thống trừ tiền nhưng không tạo được đơn hàng — bây giờ bạn đã thấy vấn đề nằm ở đâu: không phải lỗi code, mà là thiếu hoàn toàn cơ chế Saga. Không có Compensating Transaction nào được thiết kế. Không có Orchestrator nào biết đến trạng thái FAILED để kích hoạt refund. Không có Idempotency Key nào để đảm bảo compensation chỉ chạy đúng một lần.
Saga Pattern là cách chúng ta thiết kế hệ thống để không bao giờ để khách hàng rơi vào trạng thái tiền mất nhưng không có hàng — bằng cách chấp nhận rằng lỗi là không thể tránh khỏi trong hệ thống phân tán, và thiết kế để lỗi được xử lý một cách có kiểm soát, thay vì để nó gây ra hậu quả không thể phục hồi.
Khi đã có Saga bảo vệ tính toàn vẹn nghiệp vụ, thách thức tiếp theo là: làm thế nào để một service bị quá tải hoặc crash không kéo sập toàn bộ hệ thống? Đó là câu hỏi mà các mẫu thiết kế tự vệ (Resiliency Patterns) sẽ giải quyết trong tập tiếp theo.
Góc nhìn thêm: Trong thanh toán, lỗi nguy hiểm nhất không phải fail, mà là "không biết đã thành công hay chưa"
Với payment flow, thất bại rõ ràng còn dễ xử lý hơn trạng thái lửng. Khi request timeout giữa chừng, hệ thống thường rơi vào vùng xám:
- đã trừ tiền ở cổng chưa?
- đã tạo order chưa?
- đã giữ tồn kho chưa?
- email xác nhận có nên gửi không?
Nếu team không thiết kế flow với idempotency key, compensation logic và audit trail đủ tốt, mỗi timeout sẽ biến thành một bài toán nghiệp vụ cần con người can thiệp.
Nhìn thêm: Payment workflow là nơi microservices buộc team phải trưởng thành
Ở đây, những khái niệm như saga, outbox, retry policy, dead letter queue hay reconciliation không còn là "pattern học cho biết". Chúng trở thành cơ chế sinh tồn.
Một workflow thanh toán tốt thường cần:
- bước nào là source of truth
- bước nào được retry an toàn
- bước nào phải compensation nếu fail sau commit
- dashboard nào để ops nhìn ra lệch trạng thái trước khi khách hàng phát hiện
Chính các flow như thanh toán mới phơi bày rằng microservices không phải chuyện tách code. Nó là chuyện đưa correctness đi qua nhiều failure boundary mà vẫn giữ được niềm tin của business.
🧭 Học theo lộ trình
Nếu bạn muốn tiếp tục hành trình nâng cấp tư duy thiết kế hệ thống và làm chủ các kỹ thuật Microservices nâng cao, hãy tiếp tục với:
🚀 Dev Insider https://www.patreon.com/techcraft_official/posts/vi-sao-dev-ra-161163881?collection=2220113
📘 Facebook https://www.facebook.com/techcraft.official
🎥 YouTube https://www.youtube.com/@techcraft.official
🎵 TikTok https://www.tiktok.com/@techcraft.official
Build Systems. Not Just Features.
All rights reserved