Hành Trình Vật Lộn Với Cơ Sở Dữ Liệu Trong Dự Án Momo Tranh Bá (Phần Kiếm Cuồng)
Khi công ty chúng tôi bắt đầu tiếp xúc với MongoDB, đó không phải là kết quả của một cuộc chọn lựa kỹ lưỡng về công nghệ, mà bắt nguồn từ việc tựa game đầu tiên chúng tôi hợp tác phát triển - “Kiếm Cuồng” - đã chọn hệ thống này. Trong suốt giai đoạn hợp tác phát triển kéo dài gần cả năm trời, chúng tôi đã đối mặt với hàng loạt vấn đề liên quan đến cơ sở dữ liệu. Những trải nghiệm này khiến đội ngũ kỹ thuật buộc phải làm quen thuộc với MongoDB, và cũng chính vì thế mà khi xây dựng nền tảng vận hành, chúng tôi tiếp tục lựa chọn hệ thống này để đơn giản hóa công tác bảo trì.
Qua tìm hiểu, tôi nhận thấy rất nhiều studio game tại Trung Quốc đều đi theo con đường tương tự. Điều này khiến tôi tự hỏi: vì sao MongoDB lại được ưa chuộng đến vậy? Theo góc nhìn cá nhân, có ba lý do chính:
Thứ nhất, ngành game vốn dĩ chứa đựng những yêu cầu luôn thay đổi, khiến việc thiết kế cấu trúc dữ liệu ngay từ đầu trở thành việc gần như bất khả thi. Thứ hai, nhiều lập trình viên game lại không có nền tảng chuyên sâu về cơ sở dữ liệu - bởi họ thường dành phần lớn thời gian để thiết kế UI, xây dựng hệ thống tương tác bàn phím/chuột hay tạo hình nhân vật. Thứ ba, MongoDB với tư cách là một hệ quản trị NoSQL mang đến giải pháp lý tưởng: không cần thiết kế bảng cứng nhắc, cho phép lưu trữ dữ liệu dạng tài liệu linh hoạt, đặc biệt phù hợp với các ngôn ngữ lập trình động.
Nghe thật tuyệt vời phải không? Bạn cứ việc nhét bất kỳ cấu trúc dữ liệu nào vào, rồi cầu trời mong cơ sở dữ liệu tự xử lý phần còn lại. Nếu hệ thống chạy chậm, đó là lỗi của MongoDB chứ không phải do lập trình viên. Cộng thêm những kết quả benchmark đầy hứa hẹn về hiệu năng, dường như chẳng có gì phải lo lắng.
Tuy nhiên, bất kể hệ thống nào, khi được dùng trong môi trường yêu cầu hiệu năng cao, mà cứ xem như “hộp đen” thì đều tiềm ẩn rủi ro. Đặc biệt trong game, nơi mà việc lưu toàn bộ biến đổi dữ liệu lên cơ sở dữ liệu là điều gần như không thể. Các hệ thống truyền thống vốn không được thiết kế dành cho game.
Ví dụ: Làm sao bạn truy vấn danh sách người chơi gần một vị trí nhất định, khi mà toàn bộ tọa độ đang được cập nhật liên tục? MongoDB có hỗ trợ kiểu dữ liệu geo với các lệnh near/within, nhưng liệu nó chịu đựng nổi tần suất cập nhật 10 lần/giây? Hay khi bạn muốn lưu các công thức buff của nhân vật, làm sao truy vấn được kết quả sau khi áp dụng các hiệu ứng này?
Dù MongoDB có giúp bạn thực hiện được những việc trên, hiệu năng cũng là vấn đề lớn. Khi chúng ta cố gắng giải quyết từng bài toán cụ thể trên chính hệ thống này, cuối cùng nó sẽ dần trở thành một máy chủ game thu nhỏ.
Trong dự án Kiếm Cuồng, bạn Tuyệt - người phụ trách xây dựng nền tảng của công ty - đã chia sẻ với tôi rất nhiều câu chuyện thú vị về những sai lầm trong việc sử dụng MongoDB:
Giai đoạn đầu, toàn bộ hệ thống không có bất kỳ chỉ mục nào. Khi dữ liệu còn ít, dù mọi truy vấn đều phải quét toàn bộ, hệ thống vẫn chạy ổn. Nhưng khi lượng người chơi tăng vọt, hiệu năng tụt dốc không phanh. Đến khi nhận ra vấn đề, đội ngũ lại tạo ra vô số chỉ mục vô ích và các chỉ mục phức hợp sai lầm. Tình trạng này giống như kiểu: “Chỗ nào chạy chậm thì thêm chỉ mục” - một căn bệnh phổ biến vào giai đoạn cuối phát triển dự án.
Giải pháp thực ra rất đơn giản: Người thiết kế chỉ cần bình tĩnh phân tích, xem xét cơ sở dữ liệu như một module quản lý dữ liệu khép kín. Nếu bạn tự quản lý dữ liệu này, cấu trúc nào phù hợp nhất với yêu cầu truy vấn? Cần những chỉ mục phụ trợ nào? Cuối cùng, tất cả đều quay về bài toán cơ bản về thuật toán và cấu trúc dữ liệu - bạn không cần phải tự viết chúng, nhưng cần hiểu rõ cách chúng vận hành.
Một vấn đề khác là tính đồng thời (concurrency). MongoDB thiếu hỗ trợ transaction nguyên bản, buộc người dùng phải mô phỏng thông qua tính nguyên tử của thao tác tài liệu. Điều này cực kỳ nguy hiểm khi rơi vào tay lập trình viên thiếu kinh nghiệm. Một bug kinh điển đã xảy ra trong Kiếm Cuồng: Khi người dùng đăng ký, hệ thống kiểm tra tên tồn tại bằng cách SELECT trước rồi mới INSERT. Kết quả là chỉ sau một ngày vận hành, hàng loạt tài khoản trùng tên đã xuất hiện do hiện tượng race condition.
Vì yêu cầu dự án, tôi đã thêm driver MongoDB cho framework Skynet. Thành thật mà nói, những gì tôi trải nghiệm khi làm việc với MongoDB chỉ khiến tôi cảm thấy chán ngán. Ngay cả phần giao thức底层 (底层 phải dịch là底层, nhưng đây là tiếng Trung) giao tiếp底层 đã rối rắm khó hiểu, nhưng tôi vẫn quyết định tự viết thay vì dùng driver có sẵn.
Các driver chính thức của MongoDB đều tích hợp sẵn module socket, khiến việc tách phần phân tích giao thức để tích hợp vào mô hình IO của dự án trở nên khó khăn. Trái ngược hoàn toàn với Redis, nơi giao thức đơn giản đến mức bạn có thể tự viết client trong vài chục dòng code.
Máy chủ Kiếm Cuồng sử dụng boost.asio cho IO, và cách họ tích hợp MongoDB driver thật sự gây nhiều vấn đề. Họ đã mở một thread riêng để xử lý MongoDB, rồi truyền dữ liệu giữa thread. Một lỗi nghiêm trọng đã xảy ra khi các lập trình viên hiểu sai cách hoạt động của cursor: Họ tưởng tượng rằng các kết quả truy vấn luôn được trả về đầy đủ ngay từ đầu, nhưng thực tế MongoDB chỉ trả về từng phần, và khi kết thúc một nhóm kết quả, findnext sẽ tự động gửi yêu cầu mới - điều này xảy ra khi object đã không còn ở thread MongoDB ban đầu nữa.
Nếu bạn từng làm việc với C++, hãy tưởng tượng việc review code dự án bạn không trực tiếp tham gia sẽ phức tạp đến mức nào. Nhất là khi luồng xử lý bị chia nhỏ bởi hàng loạt callback của boost.asio. Đó là lý do tại sao có giai đoạn chúng tôi phải tạm dừng mọi công việc khác để đọc lại hàng vạn dòng code C++.
Hãy tạm gác lại các câu chuyện ngoài lề, nói về chính những sai lầm của chúng tôi:
Vụ tai nạn đầu tiên của Momo Tranh Bá xảy ra vào một ngày Chủ Nhật giữa tháng 1 năm 2014. Dù không có dữ liệu người chơi bị mất hay máy chủ phải dừng khẩn cấp, đây là lần đầu tiên chúng tôi nhận ra những thiếu sót trong thiết kế ban đầu.
Chiều ngày 12/1, SA Aply phát hiện ra hệ thống log vận hành bị trễ 3 tiếng so với thực tế.