Công nghệ cần "hype"
"Microservice Envy" là một thuật ngữ được Thoughtworks giới thiệu trong Technology Radar của họ (chi tiết tại đây).
Hiện tượng chạy theo xu hướng xuất hiện ở hầu hết mọi công nghệ "hot" trong ngành. Ví dụ phổ biến như Machine Learning & AI, Blockchain, Serverless Computing,... và Microservice Architecture cũng đang là như vậy, và có lẽ nó sẽ còn như vậy trong một vài năm nữa.
Thông thường, các công nghệ sẽ có những giai đoạn phát triển điển hình: từ "hot" - gây bão, khiến mọi người nghĩ rằng nó có thể làm mọi thứ, cho tới "đỉnh của sự kỳ vọng" để rồi họ nhận ra rằng nó không thật sự được như vậy, khi đó công nghệ "down trend" và dần tới phát triển ổn định, thực tế hơn khi người ta dần hiện thực hóa nó.
Trên đây là "hype cycle" mà Gartner thường niên khảo sát về các loại công nghệ khác nhau.
Với kinh nghiệm bản thân, về Microservices, tôi thấy hầu như ngày nay các lập trình viên luôn nghĩ về. Họ tiếp nhận yêu cầu viết ra một hệ thống mới (gọi là greenfield) hay khi họ cần refactoring một ứng dụng monolith nguyên khối (gọi là brownfield) với một "big ball of mud" rất khó gỡ đều với mục tiêu là một hệ thống với kiến trúc Microservices chuẩn chỉ. Điều này không có gì lạ vì công nghệ là vậy: ta cần phải có một tư duy cấp tiến, chính điều đó sẽ dẫn ta tới những thách thức khi triển khai cái mới đó và để rồi bản thân sẽ phát triển hơn để lan tỏa những kinh nghiệm đó tới những người khác.
Vậy những thách thức đó là gì? Việc chuyển đổi sang Microservice từ một "big ball of mud" thực sự là một trong những thách thức lớn nhất mà tôi phải đối diện trong các dự án phần mềm. Bạn thử tưởng tượng xem, ứng dụng monolith chứa hàng nghìn cho tới hàng chục nghìn class với sự phân lớp dù là rõ ràng nhưng lại có sự phụ thuộc chằng chịt khiến cho các ranh giới về chức năng bị nhạt nhòa đi. Liệu bạn có thể gom các ranh giới xuất hiện rải rác khắp ứng dụng lại để khiến nó được tô đậm hơn với những phụ thuộc rõ ràng hơn được không? Có lẽ, một bước trung gian chuyển sang Microservices là cần thiết hơn việc chuyển hẳn sang nó: Ta cần module hóa ứng dụng monolith trước để khi đó các module sẽ được hiển thị rõ ràng trong ứng dụng và từ đó quyết định về module nào được bóc tách ra thành một microservice được tạo ra sẽ dễ dàng và rõ ràng hơn.
Quay lại hiện tượng "Microservice Envy". Thoughtworks đưa ra hiện tượng này là vì họ thấy một xu hướng các tổ chức áp dụng kiến trúc Microservices một cách không cần thiết hoặc quá mức, chỉ vì nó đang là xu hướng, mà không xem xét kỹ lưỡng xem liệu nó có phù hợp với nhu cầu và khả năng của họ hay không. Tâm lý con người có lẽ là vậy, đối thủ của ta đang chuyển sang kiến trúc này, ta mà vẫn ở lại Monolith thì chẳng phải ta thua kém họ sao?
Hãy cùng tóm lược một số vấn đề nhé.
Monolith
Ứng dụng sinh ra để phục vụ các bài toán kinh doanh. Thông thường, trong thời gian đầu tiên của tổ chức, họ cần thử nghiệm, cần cập nhật chức năng liên tục để đáp ứng được những yêu cầu kinh doanh, cạnh tranh của họ. Vì thế, tính cấp thiết của các bản cập nhật ứng dụng là rất cao. Có thể khi ứng dụng được sinh ra ban đầu có tổ chức source code rất rõ ràng, dù là monolithic nhưng sự rõ ràng này mang lại rất nhiều lợi ích.
Vì sao lại thường lại monolith?
- Vì ban đầu, ít người nghĩ rằng ứng dụng sẽ phát triển mạnh mẽ về sau này. Vì họ đang thử nghiệm cho một hướng kinh doanh mới mà. Nên làm đơn giản để vừa nhanh lại vừa đỡ tốn kém.
- Vì yếu tố "ngày xưa". Thời "ngày xưa" đó, những kiến trúc phân tán mà phổ biến hiện nay chưa thịnh hành.
- Vì thuận tiện. Mọi thứ nằm trong một ứng dụng sẽ mang lại nhiều lợi thế như data flow được xuyên suốt, tính dùng lại dễ dàng, test full luồng cũng dễ dàng thực hiện,...
"Nhưng", nếu may mắn, hướng kinh doanh đó của tổ chức lại thành công qua đó mở ra cánh cửa bùng nổ về chức năng và cơ cấu tổ chức. Thật sự thì, trên quan điểm của một thành viên trong tổ chức, tôi thấy may mắn, hào hứng với việc đó. Còn gì bằng khi bản thân mình đóng vai trò trong guồng phát triển và thành công của tổ chức?
Khi cả một tấn chức năng đổ tới, với yêu cầu thời gian gấp gáp, và năng lực phát triển phần mềm của tổ chức thì không phải ai cũng là senior, thì tổ chức source code trong ứng dụng sẽ dần lộn xộn, kéo theo ranh giới giữa các thành phần vốn đang rõ ràng trở nên mờ đi và cuối cùng là hòa vào nhau.
Những dự án như vậy, các chức năng sẽ mang tính cạnh tranh và thử nghiệm cao. Khi cơ hội kinh doanh tới, tổ chức cần phải triển khai một nhóm chức năng để nhanh chóng vượt lên đối thủ. Sau một thời gian, đối thủ có thể đã bắt chước được hoặc có những chức năng vượt lên tổ chức của ta. Khi đó, tổ chức tiếp tục phát triển những chức năng mới để mong lại vượt lên. Và cứ như thế, các chức năng sẽ trôi dần vào quên lãng nhưng thực tế chúng vẫn nằm ở đó, trong ứng dụng của ta hoặc ít nhất ở trong source code. Điều này làm cho source code chỉ có thể tăng chứ không phản ánh tương xứng với chức năng hiện hành. Các vấn đề về maintenance càng trầm trọng hơn.
Khi tổ chức phát triển, chức năng ứng dụng tăng lên thì kích thước nhân sự cũng tăng lên. Với Monolith, có nhiều team cùng phát triển trên một project dẫn tới nhiều rủi ro về việc phân chia trách nhiệm, xung đột code và khó khăn trong các bản build. Ví dụ, khi ứng dụng còn có kích thước nhỏ cho tới trung bình, các team cùng làm việc trên một dự án. Họ phân chia công việc vào đầu tuần rồi tự do phát triển phiên bản của họ với các nhánh khác nhau. Sau đó, vào thứ 6, họ dành thời gian để merge và gỡ conflict. Khi ứng dụng còn nhỏ, việc này chỉ kéo dài khoảng nửa ngày là team sẽ có một bản build thành công. Nhưng dần dần, số lượng source code tăng lên nhanh chóng khiến cho việc merge code và giải quyết xung đột tốn nguyên một ngày thứ 6. Tất nhiên, không có giới hạn trên nào cho điều này. Công việc này dần dần dẫn tới team phải OT vào tối thứ 6, lan sang thứ 7, thậm chí mất hai ngày là thứ 5 và thứ 6 để giải quyết.
Chắc không hiếm những tình huống mà để cập nhật một chức năng, lập trình viên phải dò tìm từ nguồn (ví dụ, các controller class), đi tới các phần nghiệp vụ (như các service class) và rồi dẫn tới những class trung gian ở cấp sâu hơn nữa. Anh ấy tìm được nơi sửa và sửa logic tại những nơi đó. Sau khi pass qua các bài kiểm thử, chức năng được triển khai bằng cách đóng gói toàn bộ ứng dụng monolith này. Nhưng sau vài tiếng triển khai, người dùng báo cáo về việc ứng dụng bị lỗi ở một số chức năng khác. Team dự án tiếp nhận và thực hiện các bước khám nghiệm ngay sau đó. Cũng là một con đường rất ngoằn ngoèo được vẽ ra và xác định được điểm then chốt gây ra lỗi là một trong những file source code mà anh lập trình viên trước đã sửa. Đây là một trong những đặc trưng của monolith mà ở đó, ranh giới giữa các module không được xác định rõ ràng mặc dù có những class đã làm có vẻ đúng nguyên lý Single Responsiblity Principle: một class chỉ làm một nhiệm vụ (thực ra nguyên lý này không phát biểu như vậy, ta nên hiểu là "một class chỉ có một lý do để thay đổi". Phần bàn về SRP sẽ ở bài viết khác).
Dù nhược điểm trên là rõ ràng nhưng ưu điểm của nó cũng cần được nêu ra. Tại sao ranh giới giữa các module lại mờ như vậy? Vì việc tái sử dụng code là dễ dàng. Ta phát triển một chức năng và thấy rằng để làm được chức năng này, ta có thể cần phải trải qua nhiều bước, trong đó, có một số bước đã có những class khác đảm nhiệm rồi. Chả vì lẽ gì mà ta không tái sử dụng nó. Đây là một điều tốt chứ không phải xấu. Việc tái sử dụng sẽ mang lại hiệu suất cao cho tổ chức, code tái sử dụng còn có chất lượng cao vì đã chạy thành công ở chức năng cũ. (Một lần nữa, theo SRP, khi này, ta đã gán thêm cho class dùng chung này một lý do nữa để nó thay đổi)
Lợi thế của Monolith trong việc phát triển không chỉ dừng lại ở đó. Việc kiểm thử end-to-end (kiểm thử đầu-cuối) cũng dễ dàng và "truyền thống" hơn so với hệ thống phân tán, nhất là phân tán qua hướng sự kiện (event-driven). Bạn chỉ cần build và deploy ứng dụng lên, chạy lại các bài kiểm thử là có thể rà soát lại chất lượng của những phần bạn mong muốn, thậm chí là toàn bộ ứng dụng một cách nhanh chóng. Đối với hệ thống phân tán hướng sự kiện thì phức tạp hơn đáng kể vì chúng giao tiếp với nhau một cách bất đồng bộ. Bạn sẽ dần phải phân rã các bài test end-to-end ra thành các bài test từng phần và phải chỉnh sửa lại quy trình kiểm thử của mình.
Nói tới Monolith, không thể không nhắc tới database vì nó thường là lý do lớn nhất để mọi người coi đây là một kiến trúc "one-fits-none" (không phù hợp trong bất kỳ trường hợp nào). Nhừng đầu tiên, ta nên nói về những điều tích cực đã.
Tính chất giao dịch ACID được đảm bảo chặt chẽ vì thông thường, các ứng dụng Monolith triển khai cho những bài toán OLTP (OnLine Transaction Processing) có sử dụng những cơ sở dữ liệu quan hệ (relational database) có tính nhất quán mạnh mẽ. Dữ liệu còn có được sự tập trung giúp thuận tiện hơn khi phát triển các chức năng mới vì dữ liệu đều ở đó.
Nhưng database lại thường là lý do chính để ta từ bỏ Monolith. Mọi dữ liệu thường tập trung tại một nơi giúp sử dụng dễ dàng gây ra sự phụ thuộc chồng chéo giữa các chức năng. Khi quá nhiều thứ cùng sử dụng những phần dữ liệu giống nhau, ta rất khó để tách các chức năng này cùng với dữ liệu của chúng ra với nhau. Đây là một thách thức thật sự.
Khi hiệu năng của database đến giới hạn, ta thường sẽ chỉ có lựa chọn là "scale-up" nó lên bằng cách bổ sung thêm CPU, RAM,... hoặc đưa nó sang một server mới xịn sò hơn. Nhưng kiểu mở rộng này chắc chắn đi đến giới hạn của nó và điều này đã được thực tế chứng minh. Người ta cần "scale-out" nó ra bằng cách hoặc sử dụng các database có khả năng này (như NoSQL) hoặc bằng kiến trúc phân tán. NoSQL gặp thách thức với những bài toán đỏi hỏi tính ACID chặt chẽ nên thực tế nó không được sử dụng nhiều trong các bài toán OLTP (ngoài ra thì còn các vấn đề liên quan đến hệ sinh thái nữa).
Microservices và Event-Driven
Đầu tiên phải nói rằng Microservices (và cả Event-Driven Microservices) không phải là giải pháp "one-fits-all".
Sự phức tạp tăng lên đáng kể so với Monolith. Nếu ở trường hợp tổ chức startup bên trên, nơi mà họ còn phải "survive" bằng mọi cách, họ vẫn đang tìm hiểu sản phẩm nào hoạt động và sản phẩm nào không, cần nhanh chóng ra thị trường, cần đổi mới, và có thể cần tái cấu trúc phần lớn giải pháp, thì việc phân tách quá sớm Microservices lại khiến chi phí tăng lên quá mức, kéo theo cả thời gian phát triển, và đòi hỏi đội ngũ có chuyên môn cao hơn. Những điều này có thể không cần thiết trong thời gian đầu.
Ngoài ra, do sự phân tách giữa các microservice là vật lý nên nếu bài toán kinh doanh vẫn chưa định hình cơ bản, tức sự hiểu biết về domain và các ranh giới của nó chưa rõ ràng, thì việc thay đổi ranh giới vật lý là không thể tránh được. Nhưng thay đổi vật lý có thể tốn kém và khó khăn. Vậy ta thấy rằng, chuyển sang Microservices thường phù hợp hơn với những bài toán đã xác định khá rõ về domain, hoặc cần refactoring một Monolith để giải quyết những vấn đề mà bản thân Monolith không thể giải quyết được, như một số tình huống đã nêu bên trên.
Điều quan trọng là phải hiểu tại sao chúng ta muốn chuyển sang kiến trúc phân tán này. Và khi đã có lý do chính, ta nên đặt nó lên hàng đầu để dẫn lối cho việc chuyển đổi.
Microservices mang lại nhiều ưu điểm mà có lẽ tôi không nên nói chi tiết ở đây nữa vì các bạn đều đã từng nghe nói. Tôi chỉ tóm tắt lại một số ý chính:
- Phân tách ranh giới rõ ràng. Khi domain tăng lên, nó sẽ được chia thành các subdomain để phụ trách những nhiệm vụ khác nhau. Mỗi subdomain lại có thể chứa đựng một hoặc nhiều bounded context. Thông thường, mỗi bounded context sẽ tương ứng với một microservice. Đây là tôi đang mô tả cách tiếp cận Domain-Driven Design (DDD). Nhưng mục tiêu cuối cùng vẫn là phân tách hệ thống ra thành các dịch vụ nhỏ hơn sao cho mỗi dịch vụ đảm nhận những nghiệp vụ khác nhau. Sự phân tách này là tự nhiên và mang lại nhiều lợi ích:
- Các dịch vụ có thể phát triển song song mà không ảnh hưởng đến nhau. Nhất là với kiến trúc Event-Driven, ta còn có thể bổ sung thêm dịch vụ mới để nó sử dụng những event đã có mà không làm thay đổi bất cứ dịch vụ nào đang có.
- Tối ưu hóa nguồn lực. Tổ chức có thể dồn nguồn lực vào những dịch vụ trọng điểm, mang tính chiến lược trong giai đoạn đó. Những dịch vụ này phải có những người giỏi nhất, áp dụng những gì tinh túy nhất và đầu tư thời gian nhất.
- Dễ dàng áp dụng CI/CD vì mỗi dịch vụ có phạm vi nhất định và không ảnh hưởng tới dịch vụ khác (với Event-Driven).
- Sự tự chủ của từng team. Ví dụ về bản build hàng tuần vào ngày thứ 6 được giải quyết bởi hướng này. Thay vì toàn bộ làm việc trong một ứng dụng duy nhất, các team nhỏ sẽ sở hữu và phát triển độc lập những dịch vụ khác nhau, miễn sao theo quy tắc: một dịch vụ chỉ có một team phụ trách. Họ sẽ làm việc với nhau qua contract - hợp đồng (thường là khuôn dạng event, API), do đó, giảm thiểu sự phụ thuộc lên nhau.
- Nợ kỹ thuật cũng được giảm do các khoản nợ này bị giới hạn trong một phạm vi đủ nhỏ và không lan truyền sang phạm vi khác.
- Tự do lựa chọn công nghệ.
- Các vấn đề về hiệu năng vì Event-Driven Microservices giúp mở rộng hệ thống theo chiều ngang.
Cái gì cũng có 2 mặt. Cơ bản là ta đang ở tình thế nào và mặt nào sẽ tốt hơn cho ta. Đây là mặt hạn chế của kiến trúc Microservices (hướng sự kiện).
Như đã đề cập ở đầu bài viết, "Microservices Envy" phản ánh mặt hạn chế của kiến trúc này khi ta không áp dụng tại nơi và thời điểm phù hợp.
Kelsey Hightower có nói: "Bạn chưa thực sự làm chủ một công cụ cho đến khi hiểu khi nào không nên sử dụng nó". Câu nói này thể hiện rất rõ vấn đề mà ta đang bàn tới.
- Xác định đúng kích thước và phạm vi của các dịch vụ. Đây thường là điều khó khăn và mang tính chất chiến lược trong thiết kế hệ thống. Tuy nhiên, việc này lại không có công thức cố định và do đó, có thể mang tính chủ quan. DDD có hướng dẫn ta với bộ công cụ gọi là "Strategic Design", nhưng đòi hỏi ta phải nắm vững miền nghiệp vụ của bài toán.
- Tính nhất quán cuối cùng trong giao dịch phân tán. Xem thêm ở Saga.
- Thay đổi tư duy phát triển hệ thống với việc Event Schema trở thành trung tâm của thiết kế.
- Tracing, monitoring hệ thống cũng cần thêm công cụ và kỹ năng.