Ghi Chú Phát Triển (15): Cập Nhật Nóng
Trong vài ngày qua, công việc chính của tôi là thiết kế hệ thống cập nhật nóng cho máy chủ trò chơi trong tương lai.
Trong quá khứ, tôi từng thử nghiệm phương pháp này trong một dự án khác. Khi đó, qua những trao đổi với các nhóm dự án tại NetEase Quảng Châu, thiết kế này đã tạo ra ảnh hưởng nhất định đến hệ thống của họ. Sau đó, các nhóm dự án khác cũng dần phát triển những hệ thống cập nhật nóng riêng dựa trên thực tiễn vận hành.
Gần đây, tôi quay lại phân tích các giải pháp cũ như điều chỉnh chiến lược tải module Lua, thêm các tầng trung gian để hỗ trợ cập nhật mã nguồn. Tuy nhiên, tôi nhận thấy cách tiếp cận này vẫn chưa đủ chặt chẽ. Sau hai ngày suy ngẫm, tôi đã xây dựng một bản phác thảo hệ thống mới theo ý tưởng cá nhân.
Trong các ngôn ngữ lập trình hàm, cập nhật nóng thường dễ thực hiện hơn. Erlang và Lisp thậm chí xem nâng cấp mã nguồn trực tiếp là tính năng cốt lõi. Những ngôn ngữ có ít tác động phụ (side effect) càng dễ thực hiện: bạn chỉ cần thay thế hàm mới là xong. Với các dịch vụ kiểu “yêu cầu-phản hồi”, việc cập nhật cũng đơn giản. Ví dụ như các web server dùng giao thức REST, việc khởi động lại server thường không ảnh hưởng người dùng vì trạng thái được lưu trong cơ sở dữ liệu. Chỉ cần thay đổi mã nguồn, tắt dịch vụ cũ và bật phiên bản mới là có thể vận hành ổn định.
Với thiết kế hiện tại của máy chủ trò chơi, nhiều dịch vụ cũng tuân theo cấu trúc này, nên các module hệ thống có thể tái khởi động dễ dàng. Tuy nhiên, phần logic liên quan đến gameplay lại phức tạp hơn nhiều.
Tôi chọn cách phát triển hệ thống theo từng giai đoạn, bắt đầu từ chức năng cập nhật nóng đơn giản nhất, sau đó mới hoàn thiện dần. Việc kỳ vọng mọi phần hệ thống đều có thể cập nhật mà không dừng máy từ đầu là không thực tế, bởi điều đó sẽ khiến hệ thống trở nên quá phức tạp và thiếu ổn định.
Giai đoạn đầu tiên, tôi tập trung vào cập nhật nóng các mã nguồn liên quan trực tiếp đến logic trò chơi, tạm thời bỏ qua các module khung máy chủ. Tôi gọi giai đoạn này là “sửa lỗi nóng” (hot fix) chứ không phải “nâng cấp nóng” (hot update). Mục tiêu là sửa lỗi vận hành ngay lập tức mà không dừng máy, chứ chưa giải quyết việc triển khai phiên bản mới hoàn toàn. Trong giai đoạn này, tôi cũng chưa xét đến việc cập nhật giao thức giao tiếp giữa các dịch vụ, chỉ tập trung vào logic xử lý các giao thức đó.
Với những ràng buộc trên, hệ thống cập nhật nóng trở nên đơn giản hơn nhiều.
Đầu tiên, mỗi dịch vụ đóng vai trò như một bộ phân phối. Khi khởi động, code chính sẽ tải các hàm xử lý theo module vào khung hệ thống. Khung này phân phối các gói tin thông qua bộ phân phối để xử lý. Cập nhật nóng thực chất cũng được kích hoạt bởi một gói tin đặc biệt. Vì chúng tôi sử dụng coroutine của Lua để xử lý từng gói tin, nên trong đa số trường hợp, các gói tin trước đó đã xử lý xong khi nhận yêu cầu cập nhật. Lúc này chỉ cần đặt lại bộ phân phối, chèn các hàm xử lý phiên bản mới vào là xong. Tuy nhiên, vẫn có ngoại lệ.
Hệ thống của chúng tôi chia thành nhiều module dịch vụ, phụ thuộc nhiều vào RPC để tương tác. Vì vậy, khi một gói tin xử lý gọi phương thức ở module khác qua RPC, hàm xử lý đang bị tạm treo. Việc bỏ qua trực tiếp có thể gây hậu quả (nếu không có tác động phụ, chúng ta có thể lưu trữ tham số và gọi lại hàm mới). Giải pháp hợp lý hơn là chờ tất cả các yêu cầu RPC treo này hoàn tất trước khi bắt đầu quy trình cập nhật nóng.
Việc thực hiện không quá phức tạp: khi nhận yêu cầu cập nhật, chúng ta thay thế bộ phân phối, chặn các yêu cầu mới và lưu vào hàng đợi. Sau đó lọc các phản hồi RPC cũ để xử lý. Khi xác nhận mọi yêu cầu cũ đã hoàn tất, chuyển lại bộ phân phối bình thường và cập nhật tất cả hàm xử lý bằng phiên bản mới.
Dĩ nhiên, đây là tình huống lý tưởng. Nếu cập nhật đồng thời nhiều dịch vụ có RPC phụ thuộc lẫn nhau, có thể gây tắc nghẽn. Tuy nhiên, tôi cho rằng lúc này chưa cần giải quyết trước các tình huống hiếm gặp này. Hãy để hệ thống phát triển dần khi gặp vấn đề thực tế. Điều này không phải vì không thể thiết kế hệ thống hoàn hảo ngay từ đầu, mà là để kiểm soát độ phức tạp ở mức hợp lý. Hơn nữa, với mục tiêu hạn chế trong sửa lỗi nóng, giải pháp hiện tại là hoàn toàn khả thi.
Với các hàm callback đặc biệt như timer, tôi thiên về việc dừng tất cả callback hiện tại, để phiên bản mới khởi tạo lại timer trong quá trình khởi động. Cách này gọn gàng và dễ quản lý.
Điểm thứ hai quan trọng là kế thừa dữ liệu trạng thái.
Khi khởi tạo, các module thường tạo các bảng môi trường để lưu trạng thái xử lý tin nhắn. Ví dụ như danh sách theo dõi của agent đã đề cập. Do đó, thay vì tạo trực tiếp bảng trong code khởi tạo, chúng ta nên dùng API do khung cung cấp để tạo bảng có tên. Nhờ đó, hệ thống có thể kế thừa trực tiếp các bảng này trong cập nhật nóng, tránh phải tạo lại.
Trong tương lai khi có yêu cầu cập nhật cấu trúc dữ liệu, chúng ta cần xây dựng cơ chế chuyển đổi trong giai đoạn khởi tạo cập nhật. Tuy nhiên, tôi vẫn giữ nguyên quan điểm: không nên tích hợp mọi thứ ngay từ đầu. Hãy đợi đến khi có nhu cầu thực tế mới phân tích cách phát triển hệ thống. Những yêu cầu cụ thể lúc đó sẽ giúp đánh giá giải pháp tốt hơn là suy đoán trước.
Vấn đề thứ ba liên quan đến các module phụ xử lý logic trò chơi, khi mã nguồn quá lớn cần chia nhỏ.
Các module này có thể không liên quan trực tiếp đến mô hình phân phối tin nhắn, chỉ giải quyết các bài toán con, nhưng chưa đủ để tách thành dịch vụ độc lập.
Tôi không muốn sửa đổi cơ