patterns-for-distributed-transactions-within-a-microservices-architecture

Kiến trúc Microservices (Microservices architecture - MSA) ngày càng trờ lên phổ biến. Tuy nhiên có một vấn đề phổ biến là làm thế nào để quản lý các giao dịch phân tán (distributed transaction) trong hệ thống microservices. Mình có đọc được mấy bài hay hay, nay muốn viết lại đôi chút kiến thức về topic này. Mời mọi người cùng đọc bài viết.

Trước hết cần hiểu giao dịch phân tán (distributed transaction) là như thế nào?

Khi xây dựng một kiến trúc microservices nó phân dã kiến trúc của một hệ thống nguyên khối thành các dịch vụ tự đóng gói, ở tầng database nó có thể phá vỡ các giao dịch. Điều này có nghĩa là một giao dịch cục bộ trong hệ thống nguyên khối hiện được phân phối thành nhiều dịch vụ sẽ được gọi theo trình tự.
Ở đây hiểu hệ thống nguyên khối như kiểu cài cắm môi trường, dựng ứng dụng, dựng chung 1 schema DB trên tất cả một server ý.
Cùng xem hình dưới đây, đây là ví dụ đặt hàng của khách hàng với hệ thống nguyên khối (monolithic system) sử dụng giao dịch cục bộ:
Trong ví dụ đặt hàng của khách hàng ở trên, nếu người dùng gửi hành động Đặt hàng (Put Order) tới hệ thống nguyên khối, hệ thống sẽ tạo giao dịch cơ sở dữ liệu cục bộ hoạt động trên nhiều bảng cơ sở dữ liệu. Nếu bất kỳ bước nào thất bại, giao dịch sẽ được rollback. Điều này tuân đảm bảo tính ACID (Nguyên tử, Tính nhất quán, Cách ly, Độ bền), được đảm bảo bởi hệ thống cơ sở dữ liệu.

Khi ta phân tách hệ thống này thành các services là CustomerMicroservice và OrderMicroservice, mỗi service này có cơ sở dữ liệu riêng biệt. Dưới đây là ví dụ đặt hàng của khách hàng với microservices:
Khi yêu cầu Đặt hàng (Put Order) đến từ người dùng, cả hai dịch vụ sẽ được gọi để áp dụng các thay đổi vào cơ sở dữ liệu của từng service. Vì giao dịch trên nhiều cơ sở dữ liệu riêng biệt, đây chính là giao dịch phân tán (distributed transaction) mình đang nhắc tới.

Có vấn đề gì ở đây nhỉ???

Trong một hệ thống nguyên khối, chúng ta có một hệ thống cơ sở dữ liệu để đảm bảo tính chất ACID. Bây giờ chúng ta cần làm rõ các vấn đề chính sau đây.

Làm sao để giữ tính chất giao dịch đơn nguyên (nguyên tử)?

Trong một hệ thống cơ sở dữ liệu, tính nguyên tử có nghĩa là trong một giao dịch, tất cả các bước xử lý đều hoàn thành hoặc không có bước nào hoàn thành. Trong hệ thống microservice không có một điều phối viên toàn cầu (global coordinator) quản lý cơ chế giao dịch giữa các services. Ở ví dụ trên, nếu phương thức CreatOrder thất bại, làm thế nào để chúng ta khôi phục các thay đổi trong xử lý UpdateCustomerFund mà chúng ta đã áp dụng ở CustomerMicroservice?

Chúng ta có đang cô lập hành động xử lý của người dùng cho các yêu cầu không?

Nếu một đối tượng được đang được xử lý ghi bởi một giao dịch và đồng thời (trước khi giao dịch này kết thúc), nó được đọc bởi một yêu cầu khác, đối tượng nên trả lại dữ liệu cũ hay dữ liệu cập nhật?
Có nên yêu cầu quỹ khách hàng hiện tại trả lại số tiền cập nhật hay không?

Giải pháp ở đây là gì?

Có thể thấy các vấn đề trên rất quan trọng đối với các hệ thống microservices. Mặt khác, không có cách nào để biết nếu một giao dịch đã hoàn thành thành công hay chưa. Như mình đọc thì thấy có nhiều bài viết đã đưa ra hai mẫu sau có thể giải quyết vấn đề này:

  • 2pc (two-phase commit)
  • Saga

Tiếp tục nào, ta cùng tìm hiểu 2 mẫu này:

Two-phase commit (2pc) pattern

2pc được sử dụng rộng rãi trong các hệ thống cơ sở dữ liệu. Đối với một số tình huống, ta có thể sử dụng 2pc cho microservices. Nhưng cần thận trọng rằng không phải tất cả các tình huống phù hợp với 2pc và trên thực tế 2pc được coi là không thực tế trong kiến trúc microservices (mình sẽ giải thích bên dưới).

Trước hết cần hiểu two-phase commit là gì?
Như đúng cái tên nói lên tất cả, 2pc có hai giai đoạn: Giai đoạn chuẩn bị (prepare) và giai đoạn cam kết (commit). Trong giai đoạn chuẩn bị, tất cả các dịch vụ nhỏ (services) sẽ được yêu cầu chuẩn bị cho một số xử lý liên quan đến thay đổi dữ liệu có thể được thực hiện nguyên tử. Khi tất cả các dịch vụ được chuẩn bị, giai đoạn cam kết sẽ yêu cầu tất cả các dịch vụ thực hiện các thay đổi thực tế.

Thông thường, cần phải có một điều phối viên toàn cầu để duy trì vòng đời của giao dịch và điều phối viên sẽ cần gọi các dịch vụ nhỏ trong các giai đoạn chuẩn bị và cam kết. Dưới đây là cách triển khai 2pc cho ví dụ đặt hàng của khách hàng:
Trong ví dụ trên, khi người dùng gửi yêu cầu tạo orders, điều phối viên (Coordinator) trước tiên sẽ tạo ra một giao dịch toàn cầu với tất cả các thông tin trong bối cảnh cần xử lý. Sau đó, nó sẽ báo cho CustomerMicroservice để chuẩn bị cập nhật quỹ khách hàng với giao dịch đã tạo. CustomerMicroservice sau đó sẽ kiểm tra, ví dụ, nếu khách hàng có đủ tiền để tiến hành giao dịch. Khi đó CustomerMicroservice thấy OK để thực hiện thay đổi, nó sẽ khóa (lock) đối tượng khỏi các thay đổi tiếp theo và thông báo cho điều phối viên rằng nó đã được chuẩn bị.

Điều tương tự cũng xảy ra trong khi tạo đơn hàng trong OrderMicroservice. Khi điều phối viên đã xác nhận tất cả các dịch vụ đã sẵn sàng áp dụng các thay đổi của chúng, sau đó điều phối viên sẽ yêu cầu các services áp dụng các thay đổi của mình bằng cách yêu cầu cam kết với giao dịch. Tại thời điểm này, tất cả các đối tượng sẽ được mở khóa (unlock).

Nếu tại bất kỳ thời điểm nào, một dịch vụ ở bước chuẩn bị bị fail, điều phối viên sẽ hủy bỏ giao dịch và bắt đầu quá trình khôi phục (rollback). Dưới đây là sơ đồ khôi phục 2pc cho ví dụ đặt hàng của khách hàng:
Trong ví dụ trên, CustomerMicroservice không thể chuẩn bị vì một số lý do nào đó, nhưng OrderMicroservice đã trả lời rằng nó được chuẩn bị để tạo đơn hàng. Điều phối viên sẽ yêu cầu hủy bỏ xử lý ở OrderMicroservice và OrderMicroservice sau đó sẽ khôi phục mọi thay đổi được thực hiện đồng thời mở khóa các đối tượng cơ sở dữ liệu.

Ưu điểm khi sử dụng theo pattern 2pc

Theo đánh giá của nhiều cao thủ, các bậc tiền bối ở những bài viết mình đọc thì 2pc là một giao thức nhất quán rất mạnh. Đầu tiên, các giai đoạn chuẩn bị và cam kết đảm bảo rằng giao dịch là nguyên tử. Giao dịch sẽ kết thúc với tất cả các dịch vụ microservices xử lý đều thành công hoặc tất cả xử lý ở các dịch vụ đều được rollback để đảm bảo không có gì thay đổi. Thứ hai, 2pc cho phép cách ly đọc-ghi. Điều này có nghĩa là những thay đổi trên một trường không thể nhìn thấy bên ngoài transaction cho đến khi điều phối viên thực hiện commit các thay đổi.

Còn nhược điểm khi sử dụng 2pc thì sao?

Pattern 2pc không thực sự được khuyến nghị cho nhiều hệ thống microservice vì 2pc là đồng bộ (lock object). Giao thức sẽ cần khóa đối tượng được thay đổi trước khi giao dịch hoàn tất. Trong ví dụ trên, nếu một khách hàng đặt hàng, thì thuộc tính "fund" (số tiền trong thẻ của khách hàng) sẽ bị khóa . Điều này ngăn khách hàng áp dụng các đơn đặt hàng mới. Điều này thật sự rất không tốt. Trong một hệ thống cơ sở dữ liệu, các giao dịch có xu hướng xử lý nhanh chóng (thông thường trong vòng 50 ms). Tuy nhiên, trong hệ thống microservice khi tích hợp với các dịch vụ bên ngoài như dịch vụ thanh toán. Việc lock đối tượng có thể trở thành một nút cổ chai dẫn đến hiệu năng hệ thống bị giảm đáng kể.

Thế còn Saga pattern?

Saga pattern là một pattern được sử dụng rộng rãi cho các giao dịch phân tán. Nó khác với 2pc xử lý theo cơ chế đồng bộ, mô hình Saga xử lý không đồng bộ . Trong mô hình Saga, giao dịch phân tán được thực hiện bằng các giao dịch cục bộ không đồng bộ trên tất cả các dịch vụ nhỏ liên quan. Các dịch vụ nhỏ liên lạc với nhau thông qua một chuỗi sự kiện. Dưới đây là sơ đồ của mẫu Saga cho ví dụ đặt hàng của khách hàng:

Trong ví dụ trên, OrderMicroservice nhận được yêu cầu đặt hàng (createOrder). Đầu tiên, nó bắt đầu một giao dịch cục bộ để tạo một đơn đặt hàng và sau đó phát ra một sự kiện OrderCreated. Dịch vụ khách hàng lắng nghe sự kiện này và cập nhật quỹ khách hàng sau khi nhận được sự kiện. Nếu một khoản khấu trừ được thực hiện thành công, thì một sự kiện CustomerFundUpdated sẽ được phát ra, trong ví dụ này có nghĩa là kết thúc giao dịch. Nếu bất kỳ dịch vụ nhỏ nào không hoàn thành giao dịch cục bộ của mình, các dịch vụ nhỏ khác sẽ chạy các xử lý rollback để phục hồi các thay đổi. Dưới đây là sơ đồ của mẫu Saga khi một dịch vụ bị lỗi và các dịch vụ khác thực hiện rollback:

Ưu điểm của Saga pattern

Saga pattern có một lợi thế lớn là nó hỗ trợ các giao dịch dài hạn. Vì mỗi microservice chỉ tập trung vào giao dịch nguyên tử cục bộ của riêng mình, nên các dịch vụ nhỏ khác sẽ không bị chặn nếu microservice chạy trong một thời gian dài. Điều này cũng cho phép các giao dịch tiếp tục chờ đợi đầu vào của người dùng. Ngoài ra, bởi vì tất cả các giao dịch cục bộ của từng service đang diễn ra song song nên sẽ không có đối tượng nào bị lock như ở mô hình 2pc hay các hệ thống đơn thuần.

Ông 2pc pattern có nhược điểm. Ông Saga pattern có hay không?

Câu trả lời là có.
Mô hình Saga rất khó để gỡ lỗi, debug, đặc biệt là khi hệ thống có nhiều service liên quan. Ngoài ra, các thông báo sự kiện giữa các service có thể trở nên khó maintain nếu hệ thống trở nên phức tạp. Một nhược điểm khác của mẫu Saga là nó không có cách ly đọc. Ví dụ, khách hàng có thể thấy đơn hàng được tạo khi service A xử lý xong, nhưng trong giây tiếp theo nếu service B trong chuỗi xử lý bị lỗi thì đơn hàng sẽ bị xóa do tất cả các service trong chuỗi xử lý transaction phân tán đều rollback.

Thêm người quản lý quy trình xử lý đối với Saga pattern

Để giải quyết vấn đề phức tạp của mẫu Saga, việc thêm một trình quản lý quy trình làm việc giống như người điều phối dàn nhạc giao hưởng là điều khá bình thường. Người quản lý quy trình chịu trách nhiệm lắng nghe các sự kiện và kích hoạt các sự kiện tiếp theo đảm bảo chuỗi sự kiện diễn ra luột là theo thứ tự.

Conlusion

Theo các đánh giá thì mẫu Saga là một cách tốt hơn để giải quyết các vấn đề giao dịch phân tán cho kiến trúc microservices. Tuy nhiên, nó cũng giới thiệu một loạt các vấn đề mới, chẳng hạn như làm thế nào để cập nhật cơ sở dữ liệu và quản lý các sự kiện phát đi từ các service. Việc áp dụng mô hình Saga đòi hỏi một sự thay đổi trong tư duy. Đây có thể là một thách thức cho một đội không quen thuộc với pattern này.