Tổng Kết Về Vấn Đề Máy Chủ Cache - nói dối e blog

Tổng Kết Về Vấn Đề Máy Chủ Cache

Tuần này, dịch vụ máy chủ cache của chúng ta đã đối mặt với hàng loạt thách thức nghiêm trọng. Tổng tài nguyên đã vượt quá 30GB, với hàng chục người dùng sử dụng đồng thời. Mỗi ngày đều phải thực hiện công việc chuyển đổi phiên bản (dẫn đến việc tải lên/tải xuống lại toàn bộ 30GB dữ liệu). Trong quá trình vận hành, tôi đã liên tục vá víu chương trình máy chủ cache và cuối cùng đã duy trì được hoạt động ổn định.

Phân tích kỹ lưỡng, tôi đi đến kết luận rằng cả giao thức thiết kế máy chủ cache lẫn triển khai phía client Unity đều tồn tại những điểm yếu nghiêm trọng. Những vấn đề này không thể giải quyết triệt để chỉ bằng cách cải tiến phía máy chủ, mà chỉ có thể áp dụng các biện pháp giảm thiểu tạm thời. Giải pháp bền vững thực sự cần chờ phía client Unity nhận thức rõ vấn đề và tiến hành cải tiến.

Giao thức hiện tại có thiết kế cực kỳ sơ khai - đơn thuần là xử lý các yêu cầu theo trình tự tuần tự, mỗi yêu cầu được phản hồi theo thứ tự tương ứng. Các yêu cầu này bao gồm hai loại chính: GET (yêu cầu tải file) hoặc PUT (yêu cầu tải lên file). Đặc biệt trong giao thức, các yêu cầu PUT không bắt buộc phải có phản hồi xác nhận.

Việc thiếu xác nhận phản hồi với PUT khiến client không thể xác định chính xác thời điểm upload hoàn tất. Nếu muốn xác thực, client buộc phải gửi thêm một yêu cầu GET sau khi kết thúc PUT. Chỉ khi nhận được phản hồi GET này mới có thể suy luận PUT đã hoàn thành. Tuy nhiên trên thực tế, client Unity không thực hiện cơ chế xác nhận này - theo phân tích log, nó chỉ đơn giản chờ một khoảng thời gian cố định sau PUT cuối cùng rồi mới ngắt kết nối.

Vấn đề thực sự không nằm ở PUT mà là ở chính cơ chế giao thức phụ thuộc vào thứ tự nghiêm ngặt này. Khi đối mặt với hiện trạng lượng dữ liệu không cân bằng giữa hai phía và tốc độ mạng không đồng bộ, việc xây dựng một hệ thống vận hành ổn định là cực kỳ thách thức.

Hãy xem qua một cài đặt server cơ bản:

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

Mô hình này dùng vòng lặp vô hạn để lần lượt xử lý yêu cầu mạng, tạo phản hồi và gửi về client. Về mặt lý thuyết phù hợp với giao thức, nhưng triển khai thực tế có thể gây sự cố.

Giả sử get_requestput_response đều là hàm chặn mạng (blocking I/O), điều này bắt buộc client phải tuân thủ nghiêm ngặt: chỉ được gửi yêu cầu mới sau khi nhận xong phản hồi cũ. Nếu client vi phạm bằng cách gửi liên tiếp hai yêu cầu, máy chủ có thể bị tắc nghẽn (deadlock): Server đang chờ ghi phản hồi yêu cầu đầu tiên, trong khi client vẫn đang ghi yêu cầu thứ hai - cả hai bên đều không đọc dữ liệu của nhau, dẫn đến treo hoàn toàn.

Tuy nhiên nhờ vào kiến trúc đa luồng hiện đại, các framework server thường tách biệt luồng đọc/ghi mạng, giúp tránh deadlock. Máy chủ có thể xử lý yêu cầu mới đồng thời với việc ghi phản hồi cũ vào bộ đệm chờ client đọc. Tuy nhiên điều này lại tiềm ẩn nguy cơ tràn bộ nhớ (OOM): chỉ cần client gửi hàng loạt yêu cầu GET, mỗi yêu cầu vài chục byte nhưng phản hồi có thể lên đến hàng trăm megabyte, lượng dữ liệu tích tụ trong hàng đợi gửi đi có thể nhanh chóng tiêu hao toàn bộ RAM.

Để xử lý vấn đề này, hàm put_response cần phải chặn lại trước khi RAM cạn kiệt, điều này lại đưa chúng ta trở về vấn đề ban đầu. Giải pháp hợp lý là tách biệt hoàn toàn luồng xử lý yêu cầu và phản hồi thành hai luồng độc lập.

Tôi đã nghiên cứu phiên bản máy chủ cache đầu tiên của Unity, vốn chỉ là một file js đơn giản chạy trên nền Node.js. Với cơ chế callback của Node.js, mỗi yêu cầu GET sẽ tạo ra một đối tượng mới đưa vào hàng đợi, và sự kiện “socket có thể ghi” sẽ kích hoạt việc truyền dữ liệu từ file đến socket. Quá trình này có thể kéo dài (phụ thuộc vào tốc độ nhận dữ liệu phía client). Nếu client cứ liên tục gửi yêu cầu mà không tiêu thụ phản hồi, hàng đợi sẽ phình to đến mức gây OOM.

Phiên bản cache server hiện tại dù phức tạp hơn nhưng vẫn ẩn chứa rủi ro tương tự. Trong tập tin server/command_processor.js, hàm _onGet đẩy các item cần phản hồi vào hàng đợi (this[kSendFileQueue].push(item);) mà không có giới hạn kích thước.

Hệ thống của chúng ta cũng áp dụng cơ chế tương tự:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
-- Luồng yêu cầu
while true do
  local req = get_request(fd)
  push_queue(q, req)
end
-- Luồng phản hồi
while true do
  local req = pop_queue(q)
  local resp = handle_request(req)
  put_response(fd, resp)
end

Hàm push_queue sẽ chặn khi vượt quá kích thước hàng đợi đã định (8192). Sau khi áp dụng, chúng ta nhận phản hồi từ người dùng rằng quá trình build bình thường trước đây (dễ dẫn đến OOM) giờ lại bị treo khi giao tiếp với cache server. Qua giám sát trực tiếp (dùng giao diện debug của skynet), phát hiện hàng đợi thường đầy và phải chờ pop_queue, trong khi luồng xử lý pop_queue lại bị chặn ở put_response do client Unity không tiêu thụ 8000 phản hồi đã gửi.

Có thể suy luận rằng trong tình huống biên, client Unity đã gửi hàng vạn (thậm chí cả trăm nghìn) yêu cầu dồn dập, nhưng trước khi hoàn tất gửi toàn bộ yêu cầu đã không xử lý phản hồi, khiến dữ liệu tắc nghẽn ở tầng mạng. Để tránh tràn bộ nhớ, máy chủ buộc phải tạm dừng nhận yêu cầu mới, dẫn đến hiện tượng treo.

Nói cách khác, khi đối mặt với hành vi client “phi lý trí” - liên tục gửi yêu cầu và từ chối xử lý phản hồi, máy chủ chỉ có ba lựa chọn: chấp nhận tràn RAM, bị treo, hoặc chủ động ngắt kết nối để client buộc phải reconnect.

Giải pháp cuối cùng là tối ưu hóa hàng đợi: chỉ lưu ID yêu cầu trong hàng đợi (mỗi mục

0%