Cải Tiến Từ Phiên Bản Cache Server Của Skynet - nói dối e blog

Cải Tiến Từ Phiên Bản Cache Server Của Skynet

Cache server do chúng tôi tự phát triển đã vận hành ổn định trong thời gian dài. Lần sự cố gần nhất xảy ra vào tháng 2 khi làm việc từ xa. Trong tháng này, một sự cố tương tự lại tái diễn - vẫn là hiện tượng sụp đổ do tràn bộ nhớ (OOM). Ban đầu tôi rất bối rối vì tưởng rằng đã xử lý xong tất cả các trường hợp cực đoan. Về lý thuyết, đây là hệ thống trọng I/O nhẹ bộ nhớ, không nên xuất hiện vấn đề OOM.

Thông qua việc bổ sung log chi tiết và phân tích hậu kỳ, tôi đã tìm ra nguyên nhân cốt lõi và tiến hành điều chỉnh tương ứng. Vấn đề lần trước nằm ở cơ chế gửi dữ liệu quá tải của server: khi truyền lượng lớn dữ liệu, tốc độ gửi vượt quá khả năng nhận của đầu đối phương, dẫn đến tình trạng tích tụ dữ liệu trong tầng底层 skynet. Đây là hệ quả từ giả định thiết kế ban đầu của skynet:

  1. Skynet giả định nghiệp vụ chủ yếu là giao tiếp hai chiều, ít có trường hợp truyền tải dữ liệu chiều xuống lớn
  2. Skynet cho phép nhiều service gửi dữ liệu đến cùng một socket, tầng底层 đảm bảo tính nguyên tử của gói tin - dữ liệu từ service A sẽ không bị chèn ngang bởi dữ liệu từ service B
  3. API gửi dữ liệu của skynet là không chặn (non-blocking), việc gọi luôn trả về thành công, tránh gây ra hiện tượng treo dịch vụ dẫn đến việc xử lý lại tin nhắn

Giả định thứ hai bắt nguồn từ mong muốn tách biệt xử lý thông tin chat và logic game sang các service khác nhau, không cần tập trung dữ liệu về một điểm phát. Dù sau này tối ưu này không còn quan trọng, nhưng tính năng vẫn được giữ lại. Giả định thứ ba thực chất là hệ quả tất yếu để đáp ứng yêu cầu thứ hai, đồng thời phù hợp với nguyên tắc API tin nhắn nội bộ skynet: thao tác gửi không nên bị chặn.

Kết quả là API socket của skynet có hành vi khác biệt so với hệ thống gốc. Trong giao thức TCP, việc gửi dữ liệu sẽ được điều phối dựa trên khả năng nhận của đối phương, còn skynet lại tích tụ dữ liệu trong tầng底层 vượt xa giới hạn buffer hệ thống. Nhờ giả định đầu tiên, vấn đề này thường không bộc lộ.

Tuy nhiên, khi sử dụng skynet cho nghiệp vụ kiểu server file, chúng ta cần xem xét kỹ lưỡng. Vấn đề tháng 2 được giải quyết nhờ cơ chế cảnh báo bổ sung sau này - dùng tin nhắn skynet thông báo khi buffer gửi quá lớn, từ đó kiểm soát tốc độ truyền từ tầng trên. Nhưng giải pháp này chưa triệt để.

Một điểm thiết kế quan trọng khác: dữ liệu chiều lên (upstream) từ client được luồng socket chuyển tiếp không giới hạn đến service, dựa trên giả định khả năng xử lý của service luôn lớn hơn tốc độ nhận dữ liệu. Trong bối cảnh game, giả định này thường đúng. Ngay cả với dịch vụ truyền file, tốc độ IO đĩa thường vượt tốc độ mạng. Việc upload đơn thuần khó gây tràn buffer mạng.

Lần này lại khác. Vấn đề một phần do thiết kế giao thức cache server của Unity thiếu khái niệm phiên làm việc (session), mỗi yêu cầu tự nó tạo phản hồi riêng. (Đặc biệt, yêu cầu upload file lại không có phản hồi - đây là quyết định thiết kế thiếu cân nhắc, dễ dẫn đến triển khai server/client phi lý.)

Giả sử client yêu cầu một file cực lớn, server sẽ đáp ứng bằng cách gửi dần dữ liệu. Nhờ cải tiến tháng 2, quá trình này không còn gây tích tụ hàng đợi gửi trong skynet. Nhưng tôi đã bỏ sót một chi tiết quan trọng: TCP bản chất là song công, chiều lên và xuống hoàn toàn độc lập. Trong khi client từ từ nhận dữ liệu (chiều xuống), nó vẫn có thể đồng thời gửi tiếp các yêu cầu mới (chiều lên). Nếu lúc này client cố upload file lớn, do giao thức yêu cầu tuân thủ nghiêm ngặt thứ tự xử lý, service phải hoàn tất gửi dữ liệu trước mới xử lý yêu cầu sau, dẫn đến tích tụ dữ liệu chiều lên.

Giải pháp chúng tôi áp dụng cho cache server: Mô hình xử lý đơn giản trước đây:

1
2
3
4
while true do
 local req = get_request(fd)
 put_response(fd, req)
end

không còn phù hợp. Cần tách xử lý mỗi kết nối thành hai luồng xử lý độc lập: một cho chiều lên, một cho chiều xuống, chạy trên hai coroutine riêng biệt. Như vậy, việc chờ đợi do giới hạn băng thông khi gửi phản hồi (có thể là file lớn) sẽ không ảnh hưởng đến việc nhận yêu cầu mới. Cả hai quá trình get_requestput_response đều có thể bị chặn, nhưng sự chờ đợi của tiến trình này không nên ảnh hưởng đến tiến trình kia.

Tuy nhiên, giải pháp này vẫn chưa loại bỏ hoàn toàn nguy cơ OOM. Các yêu cầu chiều lên q1,q2,q3,q4… sẽ tạo ra phản hồi tương ứng r1,r2,r3,r4… phải được xử lý theo thứ tự. Nếu q1 là yêu cầu file lớn khiến r1 xử lý lâu, các yêu cầu tiếp theo q2, q3… vẫn phải lưu trong hàng đợi bộ nhớ (dù chưa cần tính toán r2, r3…). Hàng đợi này vẫn tiềm ẩn nguy cơ tràn bộ nhớ.

Giải pháp triệt để đòi hỏi bổ sung cơ chế kiểm soát lưu lượng (flow control) trong tầng socket底层 của skynet. Khi tầng nghiệp vụ không xử lý kịp dữ liệu nhận, cần thông báo cho luồng socket tạm dừng nhận, để giao thức TCP tự động chặn luồng dữ liệu, ngăn client tiếp tục đẩy dữ liệu ồ ạt. (Giải pháp cũ của skynet khá thô bạo: ngắt kết nối khi buffer đầy, không phù hợp với trường hợp này do không thể sửa client Unity.)

Trong commit gần nhất, tôi đã thêm lệnh điều khiển này, cho phép module socket kích hoạt tự động. Khi một service nhận quá nhiều dữ liệu mà không xử lý, cơ chế sẽ được kích hoạt.

Cuối cùng cần nói thêm: vấn đề này lộ diện do chúng tôi triển khai cache server tại Quảng Châu phục vụ nhóm phát triển ở Thượng Hải. Ban đầu cho rằng cache server thiết kế cho mạng nội bộ tốc độ cao, độ trễ thấp không phù hợp với môi trường công cộng, đặc biệt là liên thành phố. Tuy nhiên trải nghiệm vài tháng qua cho thấy hệ thống vẫn vận hành ổn, chỉ làm nổi bật thêm một số điểm cần hoàn thiện trong triển khai phần mềm khi đối mặt môi trường mạng phức

0%