Cải Tiến Phiên Bản Cache Server Của Skynet
Dự án cache server thay thế cho phiên bản Unity cache server do tôi phát triển từ năm ngoái đã dần thay thế hoàn toàn phiên bản chính thức của Unity trong nội bộ công ty. Hệ thống này nhận được phản hồi tích cực từ người dùng nhờ hiệu năng vượt trội, khả năng bảo trì dễ dàng và độ ổn định cao hơn đáng kể so với phiên bản gốc.
Trong bối cảnh dịch bệnh bùng phát nghiêm trọng gần đây, công ty đã yêu cầu toàn bộ nhân viên làm việc tại nhà. Hôm nay là ngày làm việc thứ ba kể từ khi bắt đầu giai đoạn làm việc từ xa. Hai ngày đầu tiên tương đối hỗn loạn do quyết định làm việc từ xa được đưa ra giữa kỳ nghỉ lễ, không có sự chuẩn bị trước. Hệ thống cache server của chúng tôi cũng gặp phải một vấn đề nghiêm trọng vào ngày đầu tiên, tuy nhiên chỉ là cảnh báo về việc sử dụng bộ nhớ vượt mức cho phép chứ không gây ra sự cố nghiêm trọng nào. Sau hơn một ngày phân tích và khắc phục, tôi nhận thấy đây là một trường hợp điển hình đáng được ghi chép lại.
Vào ngày thứ Hai khi toàn bộ nhân viên trở lại làm việc, lượng bộ nhớ sử dụng trên server cache trong mạng nội bộ công ty đột ngột tăng vọt vượt quá 20GB, gần chạm ngưỡng 32GB của cấu hình phần cứng. Hệ thống cảnh báo SA lập tức kích hoạt. May mắn thay, tình trạng này nhanh chóng được ổn định sau đó.
Hệ thống server này được xây dựng trên nền tảng skynet, ban đầu chỉ là một tệp tin Lua đơn giản khoảng 200 dòng lệnh. Tôi đánh giá thiết kế và cách triển khai ban đầu đều rõ ràng và đáng tin cậy. Khi thiết kế, chúng tôi đã tính đến các kịch bản ứng dụng quy mô lớn với hàng trăm kết nối đồng thời và lượng dữ liệu quản lý vượt quá 800GB trong môi trường sản xuất thực tế. Lý do chính để phát triển lại hệ thống là do phiên bản gốc tiêu tốn quá nhiều bộ nhớ, do đó việc tối ưu hóa việc sử dụng bộ nhớ luôn là ưu tiên hàng đầu.
Thay vì áp dụng mô hình “một kết nối - một agent”, tôi đã lựa chọn phương án sử dụng một số lượng agent cố định kết hợp với cơ chế cân bằng tải đơn giản để phân bổ các kết nối bên ngoài. Ngoài ra, tôi còn bổ sung một module C khoảng vài chục dòng lệnh để bỏ qua máy ảo Lua khi truyền các tệp tin lớn. Việc đọc các tệp tin lớn vào máy ảo Lua sẽ gây tăng gánh nặng cho bộ nhớ và quá trình thu gom rác (GC). Nhờ API C được cung cấp bởi skynet, chúng tôi có thể mở và đọc trực tiếp tệp tin từ tầng C sau đó truyền trực tiếp qua mạng, mang lại hiệu suất cao hơn đáng kể.
Đặc biệt với các tệp tin lớn, module C được thiết kế để xử lý theo từng khối 4KB thay vì đọc toàn bộ tệp tin vào bộ nhớ cùng lúc.
Dựa trên phân tích thực tế, tôi xác định nguyên nhân chính gây ra đỉnh điểm sử dụng bộ nhớ là do lượng lớn dữ liệu bị ùn tắc trong hàng đợi gửi đi của skynet. Đây là hệ quả từ thiết kế ban đầu của skynet khi hy sinh một số yếu tố để đạt được sự tiện lợi trong sử dụng. API mạng của skynet được thiết kế là không chặn (non-blocking), đảm bảo tính nguyên tử và khả năng gửi thành công khi kết nối chưa bị ngắt. Điều này giúp tầng nghiệp vụ sử dụng dễ dàng hơn mà không cần kiểm tra giá trị trả về như khi dùng hàm send tiêu chuẩn POSIX.
Trong hầu hết các ứng dụng của skynet, trọng tâm thường nằm ở xử lý nghiệp vụ chứ không phải IO, do đó tình trạng ùn tắc IO hiếm khi xảy ra. Tuy nhiên với cache server, đây lại là hệ thống điển hình có tải trọng IO cao. Mặc dù giao thức truyền thông là tuần tự (xử lý từng tệp tin một), nhưng phía client có thể gửi hàng loạt yêu cầu cùng lúc (mỗi yêu cầu chỉ vài chục byte) trong khi phản hồi lại là các tệp tin rất lớn. Kết hợp với việc API gửi dữ liệu không chặn, lượng lớn dữ liệu chờ truyền đã tích tụ trong tầng mạng của skynet.
Câu hỏi đặt ra là tại sao vấn đề này không xuất hiện trong suốt 6 tháng qua? Câu trả lời nằm ở việc chúng tôi chỉ sử dụng server này trong môi trường mạng nội bộ tốc độ cao (cũng là cách sử dụng được khuyến nghị). Hơn nữa, các yêu cầu luôn được thực hiện theo cách từ từ, không có tình trạng yêu cầu ồ ạt. Trong lần này, do số lượng lớn nhân viên làm việc từ xa qua kết nối VPN chậm, và môi trường phát triển tại nhà là hoàn toàn mới nên phải tải toàn bộ dữ liệu tài nguyên. Kết hợp nhiều yếu tố này đã khiến vấn đề bộc lộ khi gặp tình huống đồng thời cao với lượng dữ liệu lớn.
Thực tế vấn đề này đã từng được đề cập bởi người dùng khác trong một issue cụ thể. Mặc dù tôi đã hỗ trợ giải quyết yêu cầu của họ, nhưng bản thân chưa từng áp dụng trực tiếp trong trường hợp tương tự. Cách giải quyết không quá phức tạp: chỉ cần thiết lập callback thông qua socket.warning. Khi bộ đệm gửi dữ liệu bị quá tải, skynet sẽ gửi một thông báo “warning” đến dịch vụ gửi dữ liệu, lúc này tầng nghiệp vụ cần tạm dừng việc gửi dữ liệu (hoặc kiểm tra băng thông và giới hạn lưu lượng), đợi đến khi bộ đệm được giải phóng (cũng thông qua thông báo) rồi tiếp tục gửi.
Điều cần lưu ý là khi sử dụng cơ chế theo dõi socket.warning, không thể tiếp tục dùng cách gửi toàn bộ tệp tin trong hàm C như trước đây. Vì nếu tệp tin quá lớn, quá trình gửi sẽ không có cơ hội xử lý các thông báo mới hay tạm dừng luồng truyền.
Giải pháp tôi áp dụng là bổ sung tham số offset vào hàm gửi dữ liệu của C, đồng thời trả về số byte đã gửi thành công. Khi lượng dữ liệu cần gửi vượt quá ngưỡng nhất định, quá trình sẽ bị ngắt và trả về. Lúc này phía Lua sẽ quyết định có nên tạm dừng chờ đợi hay tiếp tục gửi. Chỉ với vài chục dòng mã bổ sung, vấn đề đã được giải quyết triệt để.
Cập nhật ngày 6/2: Trong hai ngày theo dõi môi trường sản xuất sau khi cập nhật phiên bản mới, đỉnh điểm sử dụng bộ nhớ của hệ thống luôn được kiểm soát dưới mức 30MB.