0

[Microservices Series] Bài 9: Saga Pattern – Cứu tinh của dữ liệu phân tán (Distributed Transactions)

Chào anh em, mình đây! Ở bài 8, chúng ta đã giải quyết êm đẹp bài toán dò đường cho các request bằng Service Discovery. Nhưng request chạy trơn tru đến đúng đích mới chỉ là một nửa chặng đường. Bề chìm của tảng băng trôi — thứ làm anh em đau đầu nhất khi tách Monolith thành Microservices — chính là Dữ liệu.

Trong Monolith, khi hành khách nạp tiền vào thẻ Metro, bạn chỉ cần mở một DB::transaction(). Bên trong đó, bạn update bảng users (trừ tiền) và update bảng cards (cộng số dư). Nếu dòng code thứ 2 báo lỗi, bạn gọi DB::rollBack(). Mọi thứ trở lại như chưa từng có cuộc chia ly. Dữ liệu luôn đúng 100%.

Nhưng khi lên Microservices: Service Payment (xử lý thanh toán) giữ một Database riêng. Service Wallet (quản lý ví/thẻ) giữ một Database riêng. Lỡ Service Payment đã gọi API trừ tiền qua ví MoMo thành công, nhưng khi gọi sang Service Wallet để cộng tiền vào thẻ thì đứt cáp, timeout hoặc crash Database. Lúc này, tiền khách đã trừ mà thẻ thì không có đồng nào. Bạn không thể dùng lệnh ROLLBACK vì chúng nằm ở hai Database hoàn toàn khác nhau.

Để giải quyết thảm họa này, chúng ta dùng Saga Pattern.

1. Saga Pattern là gì? (Compensating Transactions)

Thay vì cố gắng "khóa" (lock) cả 2 Database lại cùng lúc (cách này gọi là Two-Phase Commit - 2PC, cực kỳ chậm và gây nghẽn cổ chai), Saga Pattern chọn cách tiếp cận "bước nào chắc bước đó".

Saga chia một giao dịch lớn thành một chuỗi các giao dịch cục bộ (Local Transactions). Mỗi service sẽ tự commit dữ liệu vào Database của nó. Nhưng bù lại, mỗi service bắt buộc phải cung cấp một Giao dịch bù trừ (Compensating Transaction) — tức là hành động "hoàn tác" (Undo) cho chính nó.

Nếu một bước ở giữa bị lỗi, hệ thống sẽ gọi các hành động Undo của tất cả các bước đã thành công trước đó để lùi hệ thống về trạng thái ban đầu.

2. Hai trường phái của Saga: Nhạc trưởng vs Vũ đạo

Có 2 cách để điều phối các bước đi và bước lùi này:

Cách 1: Choreography (Vũ đạo - Không có trung tâm điều phối) Các service giao tiếp qua Message Broker (Kafka/RabbitMQ) bằng các Event.

  • Luồng thuận: Payment trừ tiền xong -> Bắn event Payment_Success. Wallet nghe thấy -> Cộng tiền vào thẻ -> Bắn event Wallet_Updated.

  • Luồng lỗi: Nếu Wallet cộng tiền thất bại, nó sẽ bắn event Wallet_Failed. Payment nghe thấy event này -> Tự động kích hoạt hàm hoàn tiền (Refund).

  • Ưu/Nhược: Dễ làm với luồng ngắn (2-3 bước). Nhưng nếu nghiệp vụ lên tới 10 bước, luồng event sẽ bắn chéo ngoe như một đĩa mỳ Ý (Spaghetti code), cực kỳ khó debug.

Cách 2: Orchestration (Nhạc trưởng - Có trung tâm điều phối) Sẽ có một Service đứng ra làm "Nhạc trưởng" (Orchestrator). Nhạc trưởng cầm tờ kịch bản và ra lệnh cho từng người.

  • Nhạc trưởng gọi Payment: "Trừ tiền đi!". Payment báo "OK".

  • Nhạc trưởng gọi Wallet: "Cộng tiền đi!". Wallet báo "Lỗi DB rồi!".

  • Nhạc trưởng lập tức lật sang kịch bản dự phòng, gọi lại Payment: "Thằng Wallet tạch rồi, mày Refund lại tiền cho khách đi!".

  • Ưu/Nhược: Rõ ràng, dễ theo dõi toàn bộ trạng thái của một luồng giao dịch dài. Nhược điểm là sinh ra thêm một service "Nhạc trưởng" phức tạp.

3. Thực chiến Saga tại hệ thống AFC (Tuyến Metro số 1)

Khi thiết kế luồng nạp tiền từ E-Wallet (MoMo/VNPay) vào thẻ Metro (Top-up), mình ưu tiên dùng Orchestration kết hợp với Laravel (làm Nhạc trưởng điều phối logic) và Go (làm các service xử lý I/O tốc độ cao).

Quy trình diễn ra như sau:

  1. Topup Orchestrator (Laravel) tạo một giao dịch trạng thái PENDING.

  2. Orchestrator gọi Payment Service: Gọi API sang VNPay để cắt tiền khách. Thành công, trạng thái chuyển sang PAYMENT_DONE.

  3. Orchestrator gọi Card Service (Go): Ghi mã lệnh (MAC) xuống Database để chờ khách ra máy TVM (Ticket Vending Machine) quẹt thẻ là nạp tiền vật lý.

  4. Sự cố: Card Service báo lỗi vì không thể generate được mã MAC do hệ thống bảo mật HSM bị timeout.

  5. Orchestrator nhận được lỗi, lập tức chuyển trạng thái giao dịch sang ROLLBACKING.

  6. Orchestrator gọi lại Payment Service truyền lệnh: Refund(TransactionID).

  7. Payment Service gọi API hoàn tiền của VNPay. Khách nhận lại tiền sau vài giây. Giao dịch đóng lại ở trạng thái FAILED_BUT_REFUNDED.

Lời khuyên "xương máu"

  • Idempotency (Tính luỹ đẳng) là bắt buộc: Khi dùng Message Broker cho Saga, đôi khi mạng chập chờn khiến một event Refund bị gửi đi 2 lần. Code của bạn ở service Payment phải tự check xem: "Giao dịch này mình đã refund chưa? Nếu rồi thì bỏ qua". Đừng để hệ thống ngớ ngẩn trả lại tiền cho khách gấp đôi chỉ vì kẹt mạng.

  • Chấp nhận "Eventual Consistency" (Nhất quán cuối): Trong Saga, sẽ có một khoảng thời gian trễ (vài giây) mà dữ liệu ở Payment đã trừ nhưng Wallet chưa kịp cộng. Hoặc Payment đã trừ, báo lỗi, nhưng phải mất 5 giây sau tiền mới back lại tài khoản khách. Khách hàng có thể phàn nàn trong 5 giây đó. Đó là sự đánh đổi của Microservices: Đổi tính "Nhất quán ngay lập tức" lấy "Khả năng mở rộng không giới hạn". Hãy dùng UI/UX (hiển thị "Đang xử lý...") để xoa dịu người dùng trong khoảng thời gian này.

Kiểm soát được Saga Pattern là bạn đã thoát khỏi 80% bùn lầy của Microservices. Nhưng khi dữ liệu phình to lên hàng trăm triệu dòng (như log lịch sử quẹt thẻ), việc một service vừa phải Ghi dữ liệu cực nhanh, lại vừa phải Đọc dữ liệu ra báo cáo thống kê phức tạp sẽ khiến Database sập nguồn.

Ở bài tiếp theo (Bài 10), chúng ta sẽ nói về CQRS & Event Sourcing – Nghệ thuật tách biệt đường Đọc và đường Ghi, thứ vũ khí tối thượng để tối ưu performance cho Database trong Microservices.

Anh em đã từng đau đầu vì khách bị trừ tiền mà không nhận được hàng chưa? Team anh em xử lý bằng cách viết tool chạy batch ban đêm hay dùng Saga gỡ lỗi ngay realtime? Cùng thảo luận nhé!


All Rights Reserved

Viblo
Let's register a Viblo Account to get more interesting posts.