SAGA có lẽ là một trong những pattern phổ biến nhất trong mảng distributed transaction. SAGA dc giới thiệu lần đầuu vào năm 1987 (Hector Garcia-Molina and Kenneth Salem, “Sagas,” ACM Sigmod Record 16, no. 3 (1987): 249–59.)
Một saga sẽ có nhiều transaction nhỏ, mỗi transaction thực thi tại một local microservice. Khi transaction đầu tiên bắt đầu thực thi, nếu thành công sẽ trigger transaction kế tiếp, và cứ tiếp tục như thế cho tới cuối cùng. Nếu trong quá trình đó có 1 transaction thất bại, nó sẽ trigger một hành động "compensation" (đền bù) cho các transaction trước đó. Có 2 cách để cài đặt là "pattern-events-based choreography" và "orchestration thông qua một service điều phối."
The are two main ways to implement the pattern—events-based choreography and orchestration via a coordinator service.
ACID
Chúng ta đều đã nghe qua và biết tới ACID khi nhắc tới DB transaction, nên mình sẽ ko nói thêm về 4 yếu tố này. Không phải tất cả các DB đều support ACID.
Nếu chỉ có 1 một "single microservice" và một single db, thì mọi chuyện đơn giản hơn. Giả sử ta có 1 transaction update data trong 2 table của single db, nếu 1 lệnh update bị fail thì lệnh update thứ 2 sẽ không được thực hiện.
Tuy nhiên sẽ phức tạp hơn nếu ta có 2 microservice, 2 database, và ta có 2 transaction cần udpate data của 2 table nằm ở 2 db khác nhau. Nếu 1 update ở một table bị fail thì update ở table kia sẽ không được thực hiện.
Ví dụ như trong hình sau: (mình lấy hình từ sách cho đỡ tốn công).
Xem tệp đính kèm 960857
Ta có 2 microservice, và thực hiện thay đổi lên 2 table Customer và PendingEnrollment. 2 transaction trong hình sẽ thực hiện độc lập với nhau và có thể bị fail mà ko phụ thuộc vào transaction còn lại.
Giả sử thứ tự thực hiện như sau:
Thực thi transaction A
Nếu thành công -> thực thi transaction B
Nhưng nếu B fail thì sao?
Lưu ý là 2 transaction này thực hiện độc lập với nhau trên 2 DB khác nhau!
Việc thiếu tính atomicity này sẽ dẫn tới rất nhiều vấn đề, đặc biệt là khi migrate các hệ thống phụ thuộc vào yếu tố này.
Chúng ta sẽ thử bắt đầu với việc xem xét thuật toán 2-phase commit (2PC) để thực hiện các distributed transaction.
2PC
Thường được sử dụng để thực hiện các giao dịch thay đổi trong 1 hệ thống phân tán, nhiều quy trình riêng biệt sẽ được cập nhật như là một phần của hoạt động tổng thể. Tuy nhiên 2PC có thể ko giải quyết dc vấn đề và mang tới nhiều rắc rối hơn.
2PC gồm 2 phần :
Voting phase và
commit phase.
-
Voting phase: Một central coordination (trung tâm giao tiếp) sẽ liên lạc với các worker tham gia vào transaction, và yêu cầu xác nhận thông tin có thực hiện thay đổi hay không.
Ví dụ trong hình sau:
Xem tệp đính kèm 960859
Chúng ta thấy có 2 yêu cầu thực hiện thay đổi trên 2 table, nếu cả 2 request này đều dc cho phép thì sẽ thực hiện phase tiếp theo. Nếu không, có 1 worker không cho phép thực hiện request, thì toàn bộ quá trình sẽ bị chặn lại. Ta có thể thấy worker không thực hiện thay đổi ngay lập tức mà đảm bảo là có thể thực hiện thay đổi đó trong tương lai. Vì thế để tránh các can thiệp vào row đó (ví dụ có 1 thao tác bên ngoài xóa đi row đó hay update lại data chẳng hạn) thì worker phải thực hiện "lock" lại các row trên 2 table. Do đó, sau quá trình thì release lock là việc cần làm.
-
Commit phase:
Tại bước này, các thay đổi sẽ được thực hiện, sau đó release lock. Một điều cần lưu ý là các commit này sẽ không được thực hiện cùng 1 thời điểm, do message sẽ được gửi đi từ Coordinate vào thời gian khác nhau. Có nghĩa là có thể ta sẽ thấy thay đổi trên worker A, nhưng trên worker B thì chưa. Khoảng latency giữa Coordinate và các bên khác càng lớn, các worker thực hiện càng trễ, thì ta sẽ thấy data không nhất quán (inconsistency).
--> mất đi tính chất isolation.
Ngoài ra vấn đề về lock cũng là một vấn đề khó khăn khác.
Giả sử có 1 host bị tạch do bất kỳ 1 lý do nào đó (mất điện chẳng hạn). Worker đã không phản hồi ở bước commit. Vậy ta cần làm gì? Fix tay?
Việc có càng nhiều bên tham gia, thay vì 2 worker mà là nhiều worker hơn sẽ càng khiến cho latency lớn hơn, đặc biệt là khi cần lock nhiều dữ liệu. Do đó 2PC thường chỉ thích hợp với các thao tác cần ít thời gian, vì thao tác của bạn càng nhiều thì thời gian bạn lock lại resource càng lớn.
Vì các lý do đó ta nên tránh sử dụng distributed transacion như 2PC để giải quyết bài toán này.
Một giải pháp khác đó chính là không chia nhỏ data ra. Nếu bạn có những thông tin gắn kết với nhau, và bạn vẫn muốn đảm bảo tính atomic + consistency, ACID transaction, thì tốt nhất nên để chúng trong 1 DB và được quản lý bởi 1 single service.
Nhưng nếu bạn vẫn muốn phải tách nó ra để quản lý bởi các microservices, thì đây là lúc cần tới... SAGA.
(Đối với Distributed Transaction các bạn có thể tham khảo thêm Spanner, 1 NewSQL với khả năng scale nhưng vẫn đảm bảo ACID, tất nhiên cũng sẽ có những trade-off đi kèm, mình sẽ giới thiệu ở các bài sau).
SAGA
Không như 2PC, Saga dc thiết kế để tránh việc locking resource trong 1 thời gian dài, "how to handle operations known as long lived transactions (LLTs)".
Một single DB cũng có thể gặp tình huống LLT khi lock 1 phần lớn (hoặc toàn bộ) db trong 1 thời gian dài -> lock các hoạt động khác liên quan.
Ý tưởng chính của tác giả là chia nhỏ transaction ra thành 1 chuỗi các transaction có thể được handle một cách độc lập. Các sub-transaction này sẽ nhỏ hơn, thực thi trong thời gian ngắn hơn, và chỉ tác động lên 1 phần dữ liệu -> qua đó giảm đi tác động của việc lock lên data trong 1 thời gian dài.
Saga ban đầu được coi là cơ chế giúp các LLT hoạt động trên 1 DB duy nhất, thì mô hình này cũng có thể áp dụng để điều phối hoạt động trên microservices. Chúng ta sẽ chia một "single business operation" thành một tập hợp các lệnh sẽ thực hiện lên các service.
Lưu ý: Saga ko cung cấp tính atomicity như các DB thông thường. Saga cung cấp cho ta đủ thông tin để suy luận về các trạng thái của dữ liệu, qua đó handle bài toán này.
Mấu chốt của bài toán là làm sao handle failure của 1 transaction độc lập. Hình dung luồng hoạt động là Worker A -> thực hiện transacion A lên DB A, sau đó nếu thành công tới Worker B -> thực hiện transaction B lên DB B. Cứ như thế tới lúc kết thúc, vậy nếu có 1 transaction ở giữa bị fail thì sao? Làm sao để recovery?
bản thân paper gốc của Saga mô tả 2 cách: backward recovery và forward recovery.
- Backward recovery: reverting failure và rollback các transactiont trước đó. Để làm dc việc này, ta cần phải định nghĩa các thao tác để undo các transaction đã commit trước đó.
- Forward recovery: Ta bắt đầu từ điểm thất bại và thực hiện retry -> hệ thống cần đủ thông tin để cho phép thực hiện retry.
Tùy thuộc vào business mà bạn có thể chọn 1 trong 2 hay cả 2 phương pháp trên.
(Một lưu ý khác là saga cho phép ta recover từ business fail chứ ko phải technical fail, saga hoạt động dựa trên giả định là ta đang tương tác với các reliable component.)
Saga rollback
Với 1 ACID transaction, giả sử ta gặp 1 problem, ta có thể trigger rollback trước khi thực hiện commit. Sau khi rollback mọi thứ trở về như cũ. Với saga chúng ta có nhiều transaction tham gia, một trong số chúng đã được commit trước khi ta quyết định rollback lại toàn bộ thao tác. Vậy câu hỏi đặt ra là, làm sao ta có thể rollback transaction khi mà nó đã commit? Các transaction này lại có thể dc quản lý bởi các service khác nhau, các DB khác nhau.
Lúc này bạn cần implement một "compensating transaction" (giao dịch bù), một hoạt động hoàn tác transaction trước đó. Để khôi phục quy trình, ta cần kích hoạt (trigger) các giao dịch bù cho từng transaction đã commit.
Ví dụ với quy trình:
Worker A -> Transaction A -> DB A (Commited) ==> Compen transaction
Worker B -> Transaction B -> DB B (Commited) ==> Compen transaction
Worker C -> Transaction C -> DB C (Commited) ==> Compen transaction
Worker D -> Transaction D -> DB D (Failed)
Tuy nhiên ko thể thao tác nào cũng thực hiện được (ví dụ gửi email cho khách, ta ko thể unsend email!)
Nhưng ta có thể gửi 1 email thứ 2 để báo với khách hàng thông tin.
Vì ta ko thể revert 1 cách hoàn toàn nên ta gọi đây là semantic rollbacks.
Một cách khác để giảm rollback, ta có thể thực hiện reorder workflow.
Ta move lại thứ tự như sau
Worker A -> Transaction A -> DB A (Commited) ->
Worker C -> Transaction C -> DB C (Commited) -> if fail, roll back the whole saga
Worker D -> Transaction D -> DB D (Commited) ->
Worker B -> Transaction B -> DB B (Commited)
Việc này giúp ta ko phải lo về rollback, nếu gặp sự cố ở Worker B hay D. Bằng việc đẩy các transaction có khả năng fail về phía trước (Worker C), ta tránh dc việc phải trigger compensating transaction.
Mixing fail-backward and fail-forward situations
Ta có thể kết hợp nhiều phương án với nhau. Ví dụ với 1 hệ thống thương mại điện tử, sau khi người dùng thanh toán và đi tới khâu cuối cùng là vận chuyển. Vì một lý do nào đó việc vận chuyển bị thất bại (social distancing), ta có thể thử lại vào hôm sau. Nếu vẫn ko dc ta có thể yêu cầu sự can thiệp của con người.
Implementing Sagas
Rồi bây giờ sẽ tới phần quan trọng là implement saga. Như nói ngay từ đầu bài, chúng ta có 2 phương án cơ bản: orchestration và choreography.
Orchestrated sagas
Với phương án này, ta sử dụng 1 trung tâm điều phối, gọi là orchestrator. Trung tâm này sẽ định nghĩa thứ tự thực thi cũng như trigger các giao dịch bù. Nó sẽ điều phối, quản lý làm những việc gì, vào lúc nào một cách rõ ràng.
Ví dụ:
Xem tệp đính kèm 960860
Trong hình trên bạn sẽ thấy trung tâm điều phối Order Processor đóng vai trò orchestrator, giao tiếp với các component khác. Nó biết các service khác làm gì và khi nào gọi tới các service đó. Nếu một lệnh call thất bại, nó sẽ quyết định phải làm gì tiếp theo. Một cách tổng quát, mô hình này sẽ cần một lượng lớn giao tiếp theo mô hình request-response giữa các service. Order Processor gửi request tới một service và chờ đợi 1 response để biết là request đã được thực hiện thành công và nhận về kết quả.
Về mặt bản chất, đây là phương án tiếp cận couple. Order Processor cần phải biết về các service liên quan dẫn tới domain coupling. (Đã giải thích ở các post trước). Domain coupling ko phải là điều gì quá xấu, nhưng ta nên giữ nó ở mức nhỏ nhất có thể. Trong tình huống này Order Processor cần phải beiest và quản lý rất nhiều thứ -> coupling là ko thể tránh khỏi.
Một vấn đề khác là các logic xử lý sẽ bị đưa vào orchestrator nhiều hơn. Khi điều này xả ray, các service sẽ ít đi các behavior của chính nó, chỉ nhận các lệnh từ orchestrator.
(If logic has a place where it can be centralized, it will become centralized!)
Để tránh việc tập trung quá nhiều luồng điều phối này, cần đảm bảo bạn có các service khác nhau đóng vai trò orchestrator cho các luồng khác nhau.
Choreographed sagas
Ví dụ:
Xem tệp đính kèm 960861
Các microservice sẽ react với các event nhận được. Các event sẽ được phát broadcast trong toàn hệ thống và được nhận bởi các service quan tâm tới kiểu event đó (pub - sub). Bạn không gửi event tới 1 microservice, mà phát nó đi trong hệ thống, các microservice đã đăng ký nhận event sẽ nhận được nó và xử lý tiếp theo.
Ví dụ serice WareHouse khi nhận được event "Order Place", nó sẽ xử lý và kích hoạt 1 event khác tiếp theo. Nếu không thể xử lý (hết hàng chẳng hạn), nó sẽ raise lên 1 event khác để chặn lại việc đặt hàng.
Điều này cũng cho phép các service xử lý song song với nhau, ví dụ sau khi service Payment đã thực hiện phần thanh toán xong và bắn event Payment Taken, các service Warehouse và Loyalty có thể thực hiện cùng lúc.
Quá trình này cần một message broker để quản lý phần broadcast và delivery event 1 cách hiệu quả, nhiều service có thể cùng react với 1 event (subscriber cùng topic).
Với phương án này, các service sẽ không cần phải biết về các service khác hoạt động như thế nào. Chúng chỉ cần biết là chúng sẽ nhận event gì và xử lý tiếp theo như thế nào, qua đó giảm bớt domain coupling.
Một điểm quan trọng là cần 1 unique id cho saga (correlation id) và đặt nó vào trong các event. Qua đó giúp các service kiểm soát và xử lý logic đúng với từng event nhận được.
Mixing styles
Bạn có thể kết hợp cả 2 phương án lại với nhau.
Để làm được việc này, bạn cần hiểu rõ về các trạng thái, các hoạt động nào sẽ xảy ra như là 1 phần của saga để việc recovery từ failure dễ xử lý hơn.
Tham khảo:
- Sách: Building Microservice 2nd