Elixir như một hệ thống fanout
- Mỗi khi có chuyện xảy ra trên Discord, như tin nhắn được gửi hoặc ai đó tham gia kênh thoại, UI phải được cập nhật trên client của mọi người dùng đang online trong cùng máy chủ đó (còn gọi là "guild")
- Discord dùng một tiến trình Elixir cho mỗi guild làm điểm định tuyến trung tâm cho mọi thứ diễn ra trong guild đó, và dùng một tiến trình khác ("session") cho client của từng người dùng đang kết nối
- Tiến trình guild theo dõi các session của người dùng là thành viên của guild đó và chịu trách nhiệm phát tán công việc tới các session tương ứng
- Khi session nhận được bản cập nhật, nó chuyển tiếp tới client qua kết nối WebSocket
- Một số thao tác áp dụng cho mọi người trong máy chủ, trong khi một số khác cần kiểm tra quyền, nên phải biết vai trò của người dùng cũng như thông tin về vai trò và kênh trong máy chủ đó
- Mức độ hoạt động của guild tỷ lệ với số người trong máy chủ, và khối lượng công việc cần để fanout một tin nhắn cũng tỷ lệ với số người dùng đang online trong máy chủ đó
- Nói cách khác, khối lượng công việc cần để xử lý một máy chủ Discord tăng theo lũy thừa bậc 4 của quy mô máy chủ
- Nếu một máy chủ có 1.000 người đang online và tất cả đều nói "Tôi thích thạch" một lần, hệ thống phải xử lý 1 triệu thông báo
- Nếu là 10.000 người thì phát sinh 100 triệu thông báo, còn 100.000 người thì phải chuyển 10 tỷ thông báo
- Ngoài bài toán thông lượng tổng thể, một số thao tác còn chậm đi khi máy chủ càng lớn
- Để máy chủ tạo cảm giác phản hồi tốt — như người khác phải thấy tin nhắn ngay khi được gửi, hoặc ai đó có thể bắt đầu tham gia ngay khi vào kênh thoại — gần như mọi thao tác đều phải được xử lý nhanh
- Nếu phải mất vài giây để xử lý các thao tác tốn kém, trải nghiệm người dùng sẽ đi xuống
- Dù có những vấn đề đó, Discord vẫn phải hỗ trợ máy chủ Midjourney với hơn 10 triệu thành viên, trong đó hơn 1 triệu người luôn online như thế nào?
- Trước tiên, điều quan trọng là phải hiểu hiệu năng của hệ thống
- Sau khi có dữ liệu, họ tìm ra các cơ hội để cải thiện cả thông lượng lẫn độ phản hồi
Hiểu hiệu năng hệ thống
- Phân tích wall time:
- Dùng
Process.info(pid, :current_stacktrace) để trace stack
- Đo vòng lặp xử lý sự kiện để ghi nhận số lần nhận từng loại thông điệp và thời gian tối đa/tối thiểu/trung bình/tổng để xử lý chúng
- Bỏ qua mọi tác vụ chiếm dưới 1% tổng thời gian, trừ khi chúng bùng nổ ở mức cực đoan
- Loại bỏ các tác vụ rẻ, nhấn mạnh những tác vụ đắt nhất
- Phân tích bộ nhớ heap của process
- Hiểu cách hệ thống dùng bộ nhớ cũng rất quan trọng
- Thay vì xem mọi phần tử một cách thủ công, họ viết một thư viện helper để lấy mẫu các map và list lớn (không phải struct) nhằm ước lượng mức sử dụng bộ nhớ
- Thư viện này không chỉ giúp hiểu hiệu năng GC mà còn hữu ích trong việc tìm ra trường nào đáng để tối ưu và trường nào rốt cuộc không liên quan
- Sau khi xác định tiến trình guild đang tiêu tốn thời gian ở đâu, họ có thể xây dựng chiến lược để guild process không bận 100% liên tục
- Trong một số trường hợp, chỉ cần viết lại phần triển khai kém hiệu quả theo cách hiệu quả hơn là đủ
- Nhưng cách đó cũng chỉ đi được tới một giới hạn. Cần những thay đổi mang tính nền tảng hơn
Session thụ động - tránh công việc không cần thiết
- Một trong những cách tốt nhất để giải quyết nút thắt thông lượng là giảm lượng công việc
- Một cách để làm điều đó là xem xét yêu cầu thực tế của ứng dụng client
- Trong topology ban đầu, mọi người dùng đều nhận mọi hành động mà họ có thể nhìn thấy trong tất cả guild mà họ tham gia
- Nhưng một số người dùng thuộc nhiều guild và thậm chí có thể không bấm vào để xem chuyện gì đang xảy ra ở một số guild nào đó
- Nếu không gửi toàn bộ nội dung cho đến khi người dùng bấm vào thì sao? Khi đó không cần kiểm tra quyền cho từng tin nhắn, và kết quả là lượng dữ liệu gửi tới client cũng giảm đáng kể
- Discord gọi đây là kết nối 'Passive', và giữ chúng trong một danh sách riêng với các kết nối 'Active' phải nhận toàn bộ dữ liệu
- Kết quả là ở các máy chủ lớn, khoảng 90% kết nối người dùng-guild là kết nối thụ động, giúp giảm 90% chi phí fanout
- Điều này giúp hệ thống có thêm dư địa, nhưng khi cộng đồng tiếp tục tăng trưởng, đương nhiên chỉ như vậy vẫn chưa đủ
(giảm khối lượng công việc 10 lần có thể mang lại lợi ích khoảng 3 lần ở quy mô cộng đồng lớn nhất)
Relay - chia fanout ra nhiều máy
- Một kỹ thuật tiêu chuẩn để mở rộng giới hạn thông lượng của một lõi đơn là chia công việc ra nhiều luồng hơn (hoặc theo thuật ngữ Elixir là nhiều process hơn)
- Dựa trên ý tưởng đó, Discord xây một hệ thống gọi là 'relay' nằm giữa guild và session người dùng
- Thay vì xử lý toàn bộ công việc phục vụ session trong một process duy nhất, họ chia nó ra nhiều relay để một guild đơn lẻ có thể dùng nhiều tài nguyên hơn nhằm phục vụ cộng đồng quy mô lớn
- Một số tác vụ vẫn phải chạy trong guild process chính, nhưng cách này giúp họ xử lý được các cộng đồng có hàng trăm nghìn thành viên
- Để triển khai điều này, họ phải xác định tác vụ nào quan trọng cần chạy ở relay, tác vụ nào phải ở guild, và tác vụ nào có thể chạy ở cả hai hệ thống
- Sau khi hiểu rõ nhu cầu, họ bắt đầu refactor để tách phần logic có thể dùng chung giữa các hệ thống
- Ví dụ, phần lớn logic về cách thực hiện fanout được refactor thành thư viện dùng chung cho cả guild và relay
- Một số logic không thể chia sẻ như vậy cần giải pháp khác; việc quản lý trạng thái thoại về cơ bản được triển khai bằng cách để relay proxy mọi thông điệp về guild với thay đổi tối thiểu
- Một quyết định thiết kế thú vị khi họ phát hành relay ban đầu là đưa toàn bộ danh sách thành viên vào trạng thái của từng relay
- Đây là quyết định tốt về mặt đơn giản hóa vì mọi thông tin thành viên cần thiết đều luôn sẵn có
- Nhưng ở quy mô Midjourney với hàng triệu thành viên, thiết kế này dần trở nên không còn hợp lý
- Không chỉ có việc hàng chục bản sao của thông tin hàng chục triệu thành viên bị giữ trong RAM, mà mỗi khi tạo relay mới còn phải serialize toàn bộ dữ liệu thành viên rồi gửi sang relay mới, khiến guild bị trễ hàng chục giây
- Để giải quyết, họ thêm logic xác định những thành viên mà relay thực sự cần để hoạt động, và đó chỉ là một phần cực nhỏ của toàn bộ tập thành viên
Duy trì độ phản hồi của máy chủ
- Ngoài việc duy trì trong giới hạn thông lượng, họ còn phải giữ được độ phản hồi của máy chủ
- Ở đây, việc xem dữ liệu thời gian cũng tiếp tục hữu ích
- Sẽ hiệu quả hơn nếu tập trung vào những tác vụ có thời gian mỗi lần gọi dài, thay vì chỉ nhìn vào tổng thời lượng
- Worker process + ETS
- Một trong những nguyên nhân lớn nhất khiến hệ thống mất phản hồi là các tác vụ phải chạy trong guild và phải lặp qua toàn bộ thành viên
- Những trường hợp này rất hiếm nhưng vẫn xảy ra. Ví dụ, khi ai đó dùng ping @everyone, hệ thống phải biết tất cả những ai trong máy chủ có thể nhìn thấy tin nhắn đó
- Nhưng các phép kiểm tra này có thể mất vài giây. Vậy xử lý thế nào?
- Lý tưởng nhất là chạy logic này trong lúc guild vẫn tiếp tục xử lý các tác vụ khác, nhưng các process Elixir không chia sẻ bộ nhớ tốt. Vì vậy cần một giải pháp khác
- Một công cụ trong Erlang/Elixir cho phép process lưu dữ liệu vào vùng nhớ có thể chia sẻ là ETS
- Đây là cơ sở dữ liệu in-memory hỗ trợ nhiều process Elixir truy cập an toàn
- Nó kém hiệu quả hơn so với truy cập dữ liệu trên heap của process, nhưng vẫn rất nhanh. Đồng thời còn có lợi ích là giảm kích thước heap của process, từ đó giảm độ trễ GC
- Họ quyết định tạo một cấu trúc lai để lưu danh sách thành viên:
- Lưu danh sách thành viên trong ETS để process khác cũng có thể đọc, đồng thời giữ các thay đổi gần đây (thêm, cập nhật, xóa) trên heap của process
- Vì đa số thành viên không liên tục được cập nhật, tập thay đổi gần đây chỉ là một phần rất nhỏ so với toàn bộ tập thành viên
- Giờ đây họ có thể tạo worker process dùng dữ liệu thành viên trong ETS và truyền cho nó định danh bảng ETS để xử lý khi có tác vụ chi phí cao
- Worker process có thể xử lý phần tốn kém trong khi guild vẫn tiếp tục các tác vụ khác. Bài gốc cũng có một cách đơn giản để làm việc này (kèm code snippet)
- Một ví dụ dùng cách này là khi phải chuyển guild process từ máy này sang máy khác, thường để bảo trì hoặc triển khai
- Trong quá trình đó, họ tạo process mới trên máy mới để xử lý guild, sau đó sao chép trạng thái từ guild process cũ sang process mới, kết nối lại toàn bộ session tới guild process mới, rồi xử lý backlog tích lũy trong lúc chuyển
- Nhờ worker process, phần lớn dữ liệu thành viên (có thể là vài GB) có thể được truyền đi trong khi guild process cũ vẫn tiếp tục làm việc, giúp giảm độ trễ kéo dài hàng phút mỗi lần handoff
- Offload sang manifold
- Một ý tưởng khác để cải thiện độ phản hồi và vượt qua giới hạn thông lượng là mở rộng manifold để dùng một process "sender" riêng thực hiện fanout tới các node nhận, thay vì để guild process tự fanout
- Cách này không chỉ giảm khối lượng công việc của guild process, mà còn bảo vệ nó khỏi backpressure của BEAM nếu một trong các kết nối mạng giữa guild và relay tạm thời bị nghẽn (BEAM là máy ảo chạy mã Elixir)
- Về lý thuyết, có vẻ đây là vấn đề dễ giải quyết, nhưng tiếc là khi thử dùng tính năng này (gọi là manifold offload), họ phát hiện hiệu năng thực tế giảm mạnh
- Tại sao lại như vậy? Theo lý thuyết thì khối lượng công việc giảm đi, sao process lại bận hơn?
- Khi xem kỹ hơn, họ nhận ra phần lớn công việc phát sinh thêm liên quan đến garbage collection
- Lúc này hàm
erlang.trace xuất hiện như vị cứu tinh
- Hàm này cho phép thu thập dữ liệu mỗi khi guild process thực hiện garbage collection, nhờ đó họ hiểu không chỉ tần suất GC mà cả nguyên nhân kích hoạt GC
- Dựa trên thông tin trace này, họ xem mã GC của BEAM và phát hiện rằng khi bật manifold offload, điều kiện kích hoạt major (full) garbage collection lại là virtual binary heap
- Virtual binary heap là một cơ chế được thiết kế để giải phóng bộ nhớ mà các chuỗi không lưu trong heap của process sử dụng, ngay cả khi process đó chưa cần chạy garbage collection
- Không may là mẫu sử dụng của Discord khiến hệ thống liên tục kích hoạt GC để thu hồi vài trăm KB bộ nhớ trong khi phải trả giá bằng việc sao chép heap cỡ hàng GB, rõ ràng là một sự đánh đổi không đáng
- May mắn là trong BEAM có thể điều chỉnh hành vi này bằng process flag
min_bin_vheap_size
- Khi tăng giá trị này lên vài MB, hành vi GC bệnh lý biến mất, và họ có thể bật manifold offload đồng thời thấy hiệu năng cải thiện đáng kể
9 bình luận
Elixir cố lên
Phiên thụ động về mặt kỹ thuật thì không có gì quá đặc biệt, nhưng có vẻ là một ý tưởng hay.
Chắc chắn có thể giảm tải đáng kể.
Không chỉ Discord mà có lẽ những nơi khác cũng đã triển khai tính năng này, nên tôi tò mò không biết giữa từng dịch vụ sẽ có những điểm khác biệt nào.
Quá đỉnh luôn ghê
Dường như đích đến của streaming SSR trong Next.js đang rất nổi tiếng dạo này cũng là framework Phoenix của Elixir. Xét trên nhiều phương diện, Elixir có vẻ đang đứng ở tuyến đầu của các ngôn ngữ lập trình hiện đại.
Elixir cố lên
Vài năm trước, tôi đã tham khảo blog kỹ thuật của Discord để đưa Elixir vào một dịch vụ thời gian thực, và từ tốc độ phát triển đến độ an toàn, cả tôi lẫn lãnh đạo phụ trách đều rất hài lòng khi ra mắt dịch vụ, nên đến giờ vẫn còn nhiều ký ức tốt đẹp.
Mong Elixir sẽ trở nên phổ biến hơn.
Dạo này có vẻ không còn đến mức mấy công ty như Naver, Kakao nữa, mà đúng hơn là các startup vừa và nhỏ mới là nơi Spring gần như độc quyền. Vì phần lớn quản lý ở các startup đó đều là người chuyên về Spring nên cũng khó tránh khỏi.
Mọi sự kém hiệu quả đều có thể giải quyết bằng tiền và quy mô. Dù sao thì công ty cũng đâu thực sự hiểu rõ.