Tại sao team Discord chuyển từ Go sang Rust?

Tác giả: Jesse Howarth

Ngôn ngữ Rust đang dần trở thành sự lựa chọn hàng đầu cho rất nhiều domain. Điển hình với Discord, chúng ta có thể thấy thành công của Rust trên cả client side và server side. Ví dụ, chúng tôi dùng nó cho đường pipeline mã hoá video từ Go Live (client side) và dùng cho Elixir NIFs (server side). Mới đây nhất, chúng tôi đã tiến hành cấp tốc cải thiện hiệu suất dịch vụ bằng cách chuyển từ Go sang Rust. Bài viết này sẽ giải thích thêm tại sao nó lại thích hợp với Discord chúng tôi, quy trình nó thế nào và kết quả hậu chuyển đổi ra sao.

The Read States service

Discord là một công ty thiên sản phẩm, nên hãy bắt đầu câu chuyện bằng một số chi tiết về product. Mảng dịch vụ mà chúng tôi thay Go bằng Rust được gọi là dịch vụ “Read States” – Theo dõi trạng thái đọc tin nhắn. Mục đích tối thượng của nó là theo dõi xem bạn đã đọc những tin nhắn và kênh nào. Cứ mỗi khi bạn mở Discord thì “Read States” sẽ được kích hoạt với mỗi khi tin nhắn được gửi đi hoặc được đọc.

Ngày trước khi ứng dụng Go, Read States không thể hỗ trợ một số yêu cầu về sản phẩm. Thường thì nó khá nhanh, nhưng cứ lâu lâu sẽ lại bắt gặp những khoảng chậm ảnh hưởng xấu đến trải nghiệm người dùng. Sau khi tìm hiểu chúng tôi đã hiểu ra các đợt nhiễu này là do feature chính của Go: Model bộ nhớ và Garbage Collector (GC) – bộ phận gom rác của nó.

Tại sao Go không đạt được kỳ vọng của chúng tôi?

Để giải thích cụ thể hơn tại sao Go không thể giúp chúng tôi đạt được thứ mình muốn, hãy cùng “đào mộ” sâu hơn về cấu trúc dữ liệu, quy mô, các access pattern, và kiến trúc của dịch vụ.

Cấu trúc data mà chúng ta hay dùng để lưu trữ thông tin trạng thái đọc thường gọi cho thuận là “Read State” (kiểu trạm Read State chứa read states…). Discord có cả tỉ cái Read State này. Cứ mỗi User một kênh là sẽ có một Read State. Mỗi Read State có rất nhiều counter cần được update hết và thường sẽ reset về lại 0. Ví dụ, một trong các counter sẽ là bạn có bao nhiêu lần @được_tag trên một kênh.

Để có được update counter nhanh gọn, mỗi Read State server đều có một cache Least Recently Used – LRU (cache ít được dùng nhất gần đây) trên Read States. Có đến hàng triệu users trên mỗi cache, và cả triệu Read States trên mỗi cache. Và chưa kể là cả trăm ngàn cái update cache mỗi giây. 

Cụ thể hơn, chúng tôi đã hỗ trợ cache bằng một cái database cluster Cassandra. Một khi cache key đã bỏ, chúng tôi sẽ gửi Read States của bạn về database. Chúng tôi còn set thời gian cho mỗi lần gửi về database mỗi 30 giây trong tương lai bất cứ khi nào Read State được cập nhật. Có khoảng cả mười ngàn database viết đè mỗi giây.

Trong ảnh bên dưới, bạn sẽ thấy thời gian phản hồi và cpu hệ thống trong thời gian đỉnh điểm khi còn dùng Go. Nếu bạn để ý thì cứ 2 phút sẽ có một đợt nhiễu CPU và chậm trễ khá mạnh.

Vậy tại sao lại là 2 phút?

Về việc dùng Go, mỗi khi bỏ cache key, bộ nhớ vẫn chưa trống ngay. Thay vào đó, bộ phận gom rác (garbage collector) sẽ chạy liên tục để tìm xem có phần bộ nhớ nào không có references và giải phóng nó. Nói cách khác, thay vì giải phóng phần bộ nhớ không còn dùng nữa, bộ nhớ sẽ còn ở đó một lát cho đến khi bộ phận gom rác xác định được có cần dùng đến nó nữa không. Trong thời gian này, Go sẽ phải làm rất nhiều thứ để xác định bộ nhớ nào làm chậm chương trình và cần giải phóng. 

Những đợt nhiễu chắc chắn đã biết được tác động của hiệu suất của bộ phận gom rác, tuy nhiên chúng tôi đã viết code Go rất hiệu quả rồi và có rất ít allocation (chỉ định). Chúng tôi không tạo quá nhiều rác.

Sau khi đào sâu hơn vào trong source code Go, chúng tôi nhận ra rằng Go sẽ buộc bộ phận gom rác này phải chạy mỗi 2 phút (tối thiểu). Nói cách khác, nếu bộ phận gom rác không hoạt động trong 2 phút, mặc cho có heap growth, Go vẫn bắt nó phải chạy.

Chúng tôi tìm ra rằng có thể điều chỉnh bộ phận gom rác để hoạt động thường xuyên hơn để ngăn chặn các trường hợp nhiễu lớn, vì thế chúng tôi ứng dụng một điểm endpoint lên dịch vụ để thay đổi ngay phần gom rác GC Percent. Đáng tiếc là dù cho chúng tôi định hình GC Percent như thế nào chẳng có gì thay đổi cả. Tại sao? Hoá ra lý do là vì chúng tôi chưa định vị bộ nhớ đủ nhanh để buộc garbage collector phải chạy thường xuyên hơn. 

Chúng tôi tiếp tục đào sâu hơn và hiểu ra rằng các đợt nhiễu lớn không phải do khối lượng lớn bộ nhớ cần giải phóng, mà là do garbage collector cần phải scan cả LRU cache để xác định xem bộ nhớ có hoàn toàn không được dùng đến nữa không. Ngay khi chúng tôi đã tìm được một cache LRU nhỏ hơn để nhanh hơn vì garbage collector chỉ sẽ scan ít hơn. Từ đó chúng tôi add thêm một setting mới vào service để thay đổi size của LRU cache và thay đổi cả kiến trúc để có thể chia ra nhiều cache LRU trên một server.

Và chúng tôi đã đúng! Với cache LRU nhỏ hơn, garbage collector gây ra các đoạn nhiều nhỏ hơn. 

Không may thay, cái phải đánh đổi cho việc dùng cache LRU nhỏ hơn đó là thời gian trễ cao hơn. Bởi vì nếu cache nhỏ hơn thì Read State của user có thể sẽ không nằm trên cache đó. Nếu vậy đồng nghĩa rằng sẽ phải lội vào database load …

Sau một lượng load đáng kể thử nhiều cache capacity khác nhau, chúng tôi tìm được một setting khá oke. Không thật sự xuất sắc, nhưng vừa-đủ-xài và với quy mô ngày càng lớn, chúng tôi để cho nó chạy như thế một thời gian.

Trong thời gian đó chúng tôi thấy được ngày càng nhiều công ty thành công với Rust ở những phần khác của Discord và cuối cùng đã quyết định sẽ tạo nên một framework và thư viện cần để build đầy đủ các dịch vụ trong Rust. Đây là ứng cử viên sáng giá để chuyển sang Rust vì nó nhỏ gọn, nhưng tôi cũng mong rằng Rust sẽ sửa được các lỗi nhiễu này. Vì thế chúng tôi tiến hành nhiệm vụ mới: chuyển Read State sang Rust, hi vọng rằng Rust đúng là một ngôn ngữ dịch vụ và cải thiện được trải nghiệm người dùng.

  Git - Học nghiêm túc một lần (Phần 1)
  Cấu hình SSH Key cho Github

Quản lý bộ nhớ trong Rust

Rust rất nhanh và thân thiện với bộ nhớ: không có runtime và garbage collector, nó có thể thúc đẩy các dịch vụ quan trọng về hiệu suất, chạy trên dịch vụ nhúng, và dễ tích hợp với các ngôn ngữ khác.

Rust không có bộ phận garbage collection, nên chúng ta sẽ tìm hiểu xem liệu nó có cùng nhiễu chậm như Go.

Rust sử dụng một phương pháp quản lý bộ nhớ rất độc mà kết hợp với dạng bộ nhớ “sở hữu”. Căn bản là, Rust sẽ theo dõi được ai có thể đọc và viết lên bộ nhớ. Nó hiểu rằng khi nào chương trình cần dùng bộ nhớ và giải phóng ngay lập tức khi nó không cần dùng đến nữa. Nó sẽ thúc đẩy quy luật của bộ nhớ trong thời gian compile, dường như không thể có bug runtime memory. Bạn sẽ không cần phải theo dõi bộ nhớ một cách thủ công – Compiler sẽ lo chuyện này.

Vậy trong phiên bản Rust của Read States services, khi Read State của một user đã bị xoá khỏi LRU cache nó sẽ lập tức được giải phóng khỏi bộ nhớ. Bộ nhớ read state không ở yên đợi garbage collector gom nó. Rust biết rằng nó không còn được dùng đến nữa và sẽ giải phóng nó ngay. Không có quá trình runtime nào để xác định nó có cần được giải phóng hay không.

Async Rust

Có một vấn đề với hệ sinh thái Rust. Vào thời gian này service đã được ứng dụng lại, Rust stable (**) không được thích hợp và thân thiện với Rust async cho lắm. Với dịch vụ network, lập trình bất đồng bộ (asynchronous programming) là một yêu cầu bắt buộc. Có rất ít các thư viện cộng đồng có thể kích hoạt được async Rust, nhưng nó sẽ đòi hỏi một lượng lớn thủ tục và các thông báo lỗi thì vô cùng khó hiểu.

May mắn thay, team Rust đã miệt mài ngày đêm để làm sao cho lập trình bất đồng bộ dễ dàng hơn, và nó đã có sẵn trên kênh của Rust. 

(**) Rust cung cấp 2 kênh distribution chính: nightly, beta, và stable. Các feature bất ổn định (unstable features) thì chỉ có trên nightly Rust.

Discord chưa bao giờ e dè trước công nghệ mới hứa hẹn cả. Ví dụ, chúng tôi là một trong những người đầu tiên áp dụng Elixir, React, React Native, và Scylla. Nếu có một phần công nghệ nào đó hứa hẹn, chúng tôi không ngại xử lý các khó khăn sẵn có và bất ổn định về edge. Đây là một trong những cách chúng tôi nhanh chóng đạt 250+ triệu user với chỉ dưới 50 kỹ sư.

Việc đón nhận các feature async mới trong Rust là một ví dụ điển hình cho thấy chúng tôi luôn sẵn lòng đón nhận các công nghệ mới và hứa hẹn. Là team kỹ thuật, chúng tôi quyết định chọn Rust nightly và sẽ tiếp tục chạy nightly cho đến khi async được hỗ trợ trên stable. Cứ thế chúng tôi đã cùng xử lý không biết bao nhiêu vấn đề, và đến hiện tại Rust stable đã hỗ trợ cho Rust async. Sự liều lĩnh của chúng tôi cuối cùng cũng xứng đáng.

Áp dụng, load testing và launch 

Việc viết lại code khá là nhanh gọn. Nó bắt đầu từ giai đoạn dịch, rồi gút nó lại sao cho có nghĩa. Ví dụ, Rust có hệ thống gõ rất tuyệt vời hỗ trợ rất tốt cho generic programming, nên chúng ta có thể vứt phần code Go trước đó tồn tại vì thiếu generics đi được rồi. Ngoài ra, Model bộ nhớ của Rust cũng có phần an toàn bộ nhớ trên các thread, nên chúng tôi mới có thể bỏ đi một số mục bảo vệ bộ nhớ thủ công trong Go.

Khi bắt đầu load testing, chúng tôi ngay lập tức hài lòng với kết quả. Độ trễ trong Rust thì tương đương với Go mà không có latency spikes nào!

Cái đáng nói là chúng tôi không phải tốn nhiều công sức vào việc tối ưu hoá vì bản Rust đã được viết sẵn rồi. Kể cả khi nó là tối ưu hoá căn bản, Rust cũng đã vượt trội hơn hẳn so với phiên bản cực kì thủ công của Go. Đây là một minh chứng tuyệt vời cho việc viết các chương trình hiệu quả với Rust dễ dàng như thế nào so với việc “đào bới” mà chúng tôi phải làm với Go.

Tuy nhiên, chúng tôi không hài lòng chỉ đơn giản vì nó giống với hiệu suất của Go. Sau một thời gian kiểm tra và tối ưu hoá hiệu suất, chúng tôi đã vượt xa Go trên gần như mọi thông số hiệu suất. Tất cả từ độ trễ, CPU, và bộ nhớ trên Rust đều tốt hơn.

Tối ưu hoá hiệu suất của Rust bao gồm: 

  1. Chuyển sang BTreeMap thay vì HashMap trên LRU cache để tối ưu hoá dung lượng bộ nhớ.
  2. Thay thế bớt thư viện thông số đầu tiên và thay bằng cái được dùng trong Rust concurrency mới.
  3.  Giảm số bản sao bộ nhớ đang làm. 

Từ đó mà chúng tôi chính thức vận hành như thế.

Việc launch thì khá trơn tru vì chúng tôi đã cho chạy thử. Chúng tôi đưa nó vào một canary node duy nhất, tìm thấy một vài trường hợp còn thiếu và sửa chúng. Ngay sau đó chúng tôi đã đưa nó ra toàn bộ dịch vụ.

Bên dưới là kết quả thu được.

Go là màu tím, Rust là màu xanh.

Nâng khả năng của cache 

Sau khi service đã chạy khá thành công được vài ngày, chúng tôi quyết định đã đến lúc thử nâng năng suất của LRU cache lên lần nữa. Trên bản Go như đã đề cập, việc nâng mức trần của LRU cache sẽ dẫn đến việc thu gom rác lâu hơn. Chúng tôi không phải giải quyết với phần gom rác nữa, nên chúng tôi phải tìm cách tăng size của cache và đạt hiệu suất tốt hơn. Chúng tôi tăng bộ nhớ trong các box, tối ưu hoá cấu trúc data để dùng ít bộ nhớ hơn, và tăng khả năng cache lên đến 8 triệu Read States. 

Chỉ cần nhìn các kết quả bên dưới bạn sẽ nắm rõ hơn rất nhiều. Bạn có thể thấy, thời gian trung bình đang được đo bằng micro giây và tối đa số @mentions được đo bằng mili giây.

Hệ sinh thái cải tiến 

Finally, another great thing about Rust is that it has a quickly evolving ecosystem. Recently, tokio (the async runtime we use) released version 0.2. We upgraded and it gave us CPU benefits for free. Below you can see the CPU is consistently lower starting around the 16th.

Cuối cùng, một điều tuyệt vời khác về Rust là nó có một hệ sinh thái phát triển nhanh chóng. Gần đây, tokio (thời gian chạy async mà chúng tôi sử dụng) đã phát hành phiên bản 0.2. Chúng tôi đã nâng cấp nó và nhận được CPU miễn phí. Dưới đây bạn có thể thấy CPU luôn thấp hơn bắt đầu từ ngày 16.

Lời kết

Đến thời điểm này, Discord đang sử dụng Rust cho nhiều mảng xuyên suốt stack phần mềm. Chúng tôi dùng nó cho game SDK, thu video và mã hoá cho Go Live, Elixir NIFs, các dịch vụ backend và nhiều thứ khác.

Khi bắt đầu project mới hay thành phần phần mềm, chúng tôi luôn cân nhắc về Rust trước. Dĩ nhiên là chúng tôi chỉ dùng nó khi thật sự phù hợp.

Ngoài hiệu suất ra, Rust cũng có nhiều ưu điểm cho team kĩ sư sử dụng. Ví dụ, phần type safety và borrow checker của nó rất dễ cho kỹ sư có thể code tái cấu trúc khi yêu cầu về sản phẩm thay đổi hoặc ngôn ngữ mới được phát hiện cần học. Ngoài ra, hệ sinh thái và công cụ rất tuyệt vời và có một động lượng đáng kể đằng sau chúng.

Nếu đã đọc đến đây, hy vọng rằng bạn đã có chút hứng thú với Rust hoặc đã thích Rust được một thời gian. Nếu bạn muốn được giải quyết các vấn đề liên quan đến Rust một cách chuyên nghiệp, đừng ngần ngại cân nhắc apply vào Discord. 

Đừng bỏ lỡ những bài viết hay liên quan:

Xem thêm việc làm Software Developers hấp dẫn tại TopDev

Bài viết gốc được đăng tải tại Blog.Discorsapp