Chơi cờ online
Hiểu đề
Yêu cầu chức năng
- Người chơi có thể tìm ván với đối thủ cùng trình độ theo thể loại thời gian (bullet 1+0, blitz 3+2, rapid 10+0)
- Người chơi đi nước theo thời gian thực, server kiểm tra nước hợp lệ và giữ đồng hồ
- Người chơi mất mạng có thể vào lại đúng ván, đúng thế cờ và thời gian còn lại
- Người khác có thể xem ván đang diễn ra
- Hết ván thì cập nhật rating và lưu lại ván (PGN) để xem lại
Yêu cầu phi chức năng
- Nước đi đến đối thủ p95 < 150 ms trong cùng khu vực
- Đồng hồ do server quyết định, sai lệch với người chơi < 100 ms ngoài phần bù trễ mạng
- Không mất nước đi đã xác nhận kể cả khi game server chết
- 90% người chơi được ghép trong < 5 s ở giờ cao điểm
- Một ván hot chịu được 1 triệu người xem, trễ hiển thị ≤ 2 s
Ngoài phạm vi: Máy phân tích (engine) và chấm điểm nước đi; Phát hiện gian lận bằng engine (chỉ nhắc ngắn); Giải đấu, chat, bài tập cờ (puzzle); Các biến thể cờ khác.
Thực thể và API
| Method | Endpoint | Ghi chú |
|---|---|---|
| POST | /v1/seeks | Vào hàng chờ với thể loại thời gian. Kết quả ghép trả qua WebSocket. |
| DELETE | /v1/seeks/{id} | Hủy tìm ván. |
| WS | /v1/games/{id}/ws | Kênh chơi: gửi move {ply, uci}, nhận ack, move đối thủ, clock, resign, offer draw. |
| GET | /v1/games/{id}?sincePly={n} | Trạng thái ván và các nước từ ply n, dùng khi vào lại. |
| GET | /v1/games/{id}/watch | Kênh SSE/WS chỉ đọc cho người xem. |
| GET | /v1/users/{id}/games?cursor= | Lịch sử ván, phân trang theo thời gian. |
Thiết kế tổng thể
Đi lần lượt từng yêu cầu chức năng. Mục tiêu ở đây là chạy đúng, việc tối ưu để dành cho phần đào sâu.
1) Người chơi có thể tìm ván cùng trình độ
Mỗi thể loại thời gian là một pool riêng. Matchmaker ghép người chờ có rating gần nhau, nới dần khoảng cách nếu chờ lâu.
- 1Client gọi POST /v1/seeks với thể loại 3+2
- 2Matchmaker thêm người chơi vào pool, xếp theo rating
- 3Vòng ghép mỗi 0,5 s chọn cặp có rating gần nhất trong cửa sổ cho phép
- 4Tạo Game, chọn game server giữ ván, báo cả hai qua WebSocket kèm gameId
Nguồn: Redis in Action ↗ · ch.3 · §3.5 Sorted sets (tr. 50–54) ; Redis in Action ↗ · ch.6 · §6.2 Distributed locking (tr. 116–127) ; Chưa có nguồn trong thư viện: luật cờ, hệ số Glicko-2 và logic đồng hồ cờ không có trong sách của thư viện; chỉ là thiết kế của khoá
2) Người chơi đi nước theo thời gian thực
Server là nguồn sự thật cho thế cờ và đồng hồ. Client chỉ gửi ý định đi nước; server kiểm tra, trừ thời gian theo giờ của mình rồi phát cho đối thủ.
- 1Client gửi {ply: 23, uci: 'g1f3'} qua WebSocket
- 2Game server kiểm tra đúng lượt, đúng ply mong đợi, nước hợp lệ (chess.js)
- 3Trừ thời gian = lúc nhận trừ lúc bắt đầu lượt, có bù trễ giới hạn; lưu nước vào log bền
- 4Gửi ack cho người đi, gửi nước + đồng hồ cho đối thủ và người xem
- 5Đặt hẹn giờ hết thời gian cho bên vừa tới lượt; hết giờ thì server xử thua
Nguồn: Grokking Modern System Design · ch.36 WhatsApp ↗ · bài "Detailed Design of WhatsApp", mục "Connection with a WebSocket server" và "Send or receive messages" ; Microservices Patterns ↗ (.zip) · ch.3 · §3.3.6 Handling duplicate messages (nước trùng ply chỉ ack lại, tr. 95–97) ; Chưa có nguồn trong thư viện: luật cờ, hệ số Glicko-2 và logic đồng hồ cờ không có trong sách của thư viện; chỉ là thiết kế của khoá
3) Người chơi mất mạng có thể vào lại ván
Mỗi nước có số ply nên client chỉ cần báo nước cuối cùng mình biết. Đồng hồ vẫn chạy trong lúc mất mạng, giống ván cờ ngoài đời.
- 1Client mở lại WebSocket kèm gameId và lastPly
- 2Router đưa kết nối về game server đang giữ ván
- 3Server gửi các nước sau lastPly và đồng hồ hiện tại
- 4Đối thủ thấy thông báo mất kết nối; quá 60 s chưa về thì có thể xin xử thắng
Nguồn: Grokking Modern System Design · ch.36 WhatsApp ↗ · bài "Detailed Design of WhatsApp", mục "Connection with a WebSocket server" (người dùng ↔ server qua WebSocket, ánh xạ người dùng với server) ; Node.js Design Patterns 2e ↗ (.zip) · ch.10 Scalability and Architectural Patterns · mục "Dealing with stateful communications" › "Sticky load balancing" (tr. 401–404)
4) Người khác có thể xem ván và hết ván thì cập nhật rating
Người xem chỉ nhận, không gửi gì, nên đi qua đường riêng để không làm chậm hai người chơi. Hết ván thì việc nặng như tính rating, lưu PGN chạy bất đồng bộ.
- 1Người xem mở /watch, nhận thế cờ hiện tại rồi nhận từng nước mới
- 2Game server phát sự kiện move và game_over
- 3Worker nhận game_over: tính rating Glicko-2 cho 2 người, lưu PGN, cập nhật thống kê
Nguồn: Grokking Modern System Design · ch.18 Pub-sub ↗ · bài "Introduction to Pub-sub", mục "Use cases of pub-sub" (nhiều view cùng một cuộc hội thoại có thể là subscriber) ; Grokking Modern System Design · ch.17 Distributed Messaging Queue ↗ · bài "System Design: The Distributed Messaging Queue", mục "Messaging queue use cases" (xử lý hậu kỳ bất đồng bộ) ; Chưa có nguồn trong thư viện: luật cờ, hệ số Glicko-2 và logic đồng hồ cờ không có trong sách của thư viện; chỉ là thiết kế của khoá
Hạ tầng theo quy mô
Cùng một sản phẩm, ba mức người dùng. Chọn từng mức để xem sơ đồ, phép tính và cấu hình server thay đổi thế nào.
Một CLB cờ online. Hai instance Node vừa phục vụ API vừa giữ WebSocket, Postgres là nguồn sự thật cho mọi nước đi; LISTEN/NOTIFY báo nước mới sang instance kia nên chưa cần định tuyến theo ván.
Ước lượng
- Ván: 10.000 × 5 ván / 2 người = 25.000 ván/ngày
- Đồng thời: 25.000 × 10 phút / 1.440 phút ≈ 174 ván, đỉnh ×3 ≈ 521 ván ≈ 1.042 người chơi
- Nước đi: 25.000 × 80 nửa nước = 2.000.000 / 86.400 ≈ 23 nước/s, đỉnh ×3 ≈ 69 nước/s
- Kết nối: ≈ 1.042 người chơi + ~1.000 người xem ≈ 2.000 WebSocket, 1 instance chứa 20.000
- Lưu trữ: 25.000 ván × 1 KB (PGN + metadata) = 25 MB/ngày × 365 ≈ 9,1 GB/năm
Nguồn con số: Grokking Modern System Design · ch.5 Back-of-the-envelope Calculations ↗ · bài "Examples of Resource Estimation", mục "Number of servers required", "Bandwidth requirements" ; Quy tắc ước lượng của khoá học · /hoc/chon-server (500 req/s mỗi vCPU, chạy 60% CPU, Redis ×1,3; số ván/ngày, thời lượng ván, số nước mỗi ván, 20.000 kết nối mỗi pod và 100.000 ops/s mỗi Redis là giả định của khoá học)
Bảng hạ tầng
| Thành phần | Chọn | Số lượng | Cấu hình | Vì sao |
|---|---|---|---|---|
| CDN | Cloudflare | 1 | JS, ảnh quân cờ, âm thanh | Tài nguyên tĩnh không chạm server. |
| App (API + WebSocket) | Node.js (Fastify + ws), sau load balancer hỗ trợ WebSocket | 2 | 2 vCPU · 4 GB RAM | 2.000 kết nối và 69 nước/s chỉ cần 1 instance, chạy 2 để dự phòng. |
| Database | PostgreSQL managed | 1 | 2 vCPU · 8 GB · 50 GB SSD | 69 nước/s, mỗi nước một transaction khóa hàng game; 9 GB/năm rất nhỏ. |
| Đồng bộ giữa instance | Postgres LISTEN/NOTIFY | 1 (dùng DB sẵn có) | Kênh theo gameId | Hai người chơi có thể nằm ở 2 instance; NOTIFY báo nước mới, chưa cần Redis. |
| Job nền | Vòng ghép cặp + kiểm tra hết giờ trong app | 1 (advisory lock) | Chạy mỗi 0,5-1 s | Chỉ một instance giữ advisory lock và chạy, không ghép trùng. |
- Điểm xuất phát: app Node giữ WebSocket, Postgres giữ mọi nước đi
- Server đã quyết định đồng hồ và kiểm tra nước hợp lệ ngay từ đầu
- Ghép cặp bằng bảng seeks và query theo khoảng rating
Khi lên hàng nghìn nước/s, mỗi nước là một transaction và một NOTIFY gửi tới mọi instance khiến Postgres thành nút cổ chai; kết nối WebSocket cũng vượt sức 2 instance.
Cấu hình và chi phí là ước lượng để học và trình bày trong phỏng vấn. Xem cách tính ở bài Chọn server.
So sánh nhanh ba mức
| Thành phần | Mức 1 | Mức 2 | Mức 3 |
|---|---|---|---|
| Người dùng | 10.000 DAU · ≈ 520 ván cùng lúc lúc đỉnh | 1 triệu DAU · ≈ 52.000 ván cùng lúc lúc đỉnh | 20 triệu DAU · ≈ 1 triệu ván cùng lúc · ván hot 1 triệu người xem |
| Chi phí | ≈ 50–150 USD/tháng (ước lượng) | ≈ 1.500–4.000 USD/tháng (ước lượng) | ≈ 40.000–100.000 USD/tháng (ước lượng) |
| CDN | Cloudflare 1 × JS, ảnh quân cờ, âm thanh | Cloudflare / CloudFront 1 × Tài nguyên tĩnh, trang hồ sơ công khai | Chưa cần |
| App (API + WebSocket) | Node.js (Fastify + ws), sau load balancer hỗ trợ WebSocket 2 × 2 vCPU · 4 GB RAM | Chưa cần | Chưa cần |
| Database | PostgreSQL managed 1 × 2 vCPU · 8 GB · 50 GB SSD | PostgreSQL HA 1 primary + 1 read replica × 4 vCPU · 16 GB · 1 TB SSD | PostgreSQL HA (user, rating) 1 primary + 2 replica × 8 vCPU · 32 GB · 1 TB SSD |
| Đồng bộ giữa instance | Postgres LISTEN/NOTIFY 1 (dùng DB sẵn có) × Kênh theo gameId | Chưa cần | Chưa cần |
| Job nền | Vòng ghép cặp + kiểm tra hết giờ trong app 1 (advisory lock) × Chạy mỗi 0,5-1 s | Chưa cần | Chưa cần |
| Router | Chưa cần | Envoy / NGINX, định tuyến WebSocket theo gameId 2 × 2 vCPU · 4 GB | Chưa cần |
| API | Chưa cần | Node.js (NestJS), stateless 2–4 (autoscale) × 2 vCPU · 4 GB RAM | Node.js trên Kubernetes 35 pod (HPA) × 2 vCPU · 4 GB mỗi pod |
| Game server | Chưa cần | Node.js có trạng thái (StatefulSet) 8 pod × 2 vCPU · 4 GB mỗi pod | Node.js có trạng thái, 3 region 105 pod × 2 vCPU · 4 GB mỗi pod |
| Matchmaker | Chưa cần | Node.js + Redis sorted set, lease theo pool 2 × 1 vCPU · 2 GB | Node.js, mỗi pool (region × thể loại) một matcher có lease 6 pod × 2 vCPU · 4 GB mỗi pod |
| Redis | Chưa cần | ElastiCache / Memorystore 1 primary + 1 replica × 2 vCPU · 4 GB | Chưa cần |
| Queue + worker | Chưa cần | SQS / Pub/Sub + Node.js consumer 1 queue + 2 worker × 1 vCPU · 2 GB mỗi worker | Chưa cần |
| Edge + router | Chưa cần | Chưa cần | GeoDNS + CDN + Envoy định tuyến theo bảng gameId 30 pod Envoy (10 mỗi region) × 4 vCPU · 8 GB mỗi pod |
| Broadcast | Chưa cần | Chưa cần | Node.js SSE/WS chỉ đọc, fan-out dạng cây 80 pod × 2 vCPU · 4 GB mỗi pod |
| Redis Cluster | Chưa cần | Chưa cần | ElastiCache Redis Cluster, 2 shard mỗi region 6 shard × (1 primary + 1 replica) × 4 vCPU · 8 GB mỗi node |
| Kafka | Chưa cần | Chưa cần | MSK, mỗi region một cụm 3 cụm × 3 broker × 32 partition tổng, RF 3 |
| Lưu ván | Chưa cần | Chưa cần | ScyllaDB / Cassandra, khóa (user_id, ended_at) 12 node × 8 vCPU · 32 GB · 8 TB NVMe |
Đào sâu
Mỗi câu hỏi đi từ cách đơn giản đến cách người phỏng vấn mong đợi ở cấp senior.
1) Ghép cặp theo rating thế nào để nhanh mà vẫn cân bằng?
Ghép tức thì nhưng người mới gặp cao thủ và thua liên tục, cao thủ thắng dễ không vui. Người chơi bỏ đi nhanh.
Đánh đổi: Nhanh nhất nhưng trải nghiệm tệ.
⚠ Chưa có nguồn trong thư viện: luật cờ, hệ số Glicko-2 và logic đồng hồ cờ không có trong sách của thư viện; chỉ là thiết kế của khoá
SELECT ... WHERE pool = ? AND rating BETWEEN ? AND ? ORDER BY created_at FOR UPDATE SKIP LOCKED để hai matcher không lấy trùng một người. Đủ cho vài chục lượt ghép mỗi giây.
Đánh đổi: Poll DB liên tục; cửa sổ cố định làm người rating rất cao hoặc rất thấp chờ mãi.
⚠ Chưa có nguồn trong thư viện: FOR UPDATE SKIP LOCKED là tính năng của PostgreSQL, thư viện không có sách PostgreSQL ; Chưa có nguồn trong thư viện: luật cờ, hệ số Glicko-2 và logic đồng hồ cờ không có trong sách của thư viện; chỉ là thiết kế của khoá
Mỗi pool (region × thể loại thời gian) là một sorted set với score = rating (Redis in Action §3.5). Chỉ một matcher giữ lease cho mỗi pool nên không ghép trùng. Mỗi 0,5 s, với người chờ lâu nhất, lấy ZRANGEBYSCORE trong cửa sổ 50 + 25 × số giây đã chờ (tối đa 400), chọn người gần nhất, xóa cả hai bằng một Lua script. Người có RD cao (ít ván) được nới cửa sổ nhanh hơn.
Đánh đổi: Matcher của một pool là điểm đơn nên cần lease và chuyển nhanh khi chết; pool ít người vẫn phải chờ lâu.
Nguồn: Redis in Action ↗ · ch.3 · §3.5 Sorted sets (tr. 50–54) ; Redis in Action ↗ · ch.6 · §6.2.5 Locks with timeouts (tr. 126–127) ; Redis in Action ↗ · ch.11 · §11.2 Rewriting locks and semaphores with Lua (script Lua nguyên tử, tr. 255–260) ; Chưa có nguồn trong thư viện: luật cờ, hệ số Glicko-2 và logic đồng hồ cờ không có trong sách của thư viện; chỉ là thiết kế của khoá
2) Đồng hồ cờ chạy ở đâu để vừa chống gian lận vừa công bằng với người mạng chậm?
Client sửa được số và đồng hồ hai máy lệch nhau. Không có nguồn sự thật để xử hết giờ.
Đánh đổi: Gian lận dễ dàng, tranh chấp không phân xử được.
Nguồn: Grokking Modern System Design · ch.3 Abstractions ↗ · bài "Spectrum of Consistency Models", mục "Strict consistency aka linearizability" (nguồn sự thật duy nhất để xếp thứ tự) ; Chưa có nguồn trong thư viện: luật cờ, hệ số Glicko-2 và logic đồng hồ cờ không có trong sách của thư viện; chỉ là thiết kế của khoá
Server ghi lúc bắt đầu lượt và lúc nhận nước, phần chênh là thời gian bị trừ. Không ai sửa được đồng hồ.
Đánh đổi: Người có độ trễ 200 ms bị trừ thêm 200 ms mỗi nước, trong ván bullet 1 phút thì rất bất công.
⚠ Chưa có nguồn trong thư viện: luật cờ, hệ số Glicko-2 và logic đồng hồ cờ không có trong sách của thư viện; chỉ là thiết kế của khoá
Server đo RTT qua ping trên WebSocket. Khi nhận nước, thời gian bị trừ là thời gian server đo được trừ đi phần bù trễ, phần bù không vượt nửa RTT ước lượng và có trần mỗi nước (ví dụ 300 ms) cùng tổng hạn mức bù cho cả ván. Mỗi nước gửi kèm thời gian còn lại; client tự đếm ngược để hiển thị mượt. Hết giờ do hẹn giờ trên game server quyết định.
Đánh đổi: Logic phức tạp hơn và vẫn có thể lợi dụng chút ít trong phạm vi trần bù; cần ghi log đồng hồ để phân xử khiếu nại.
Nguồn: Grokking Modern System Design · ch.36 WhatsApp ↗ · bài "Detailed Design of WhatsApp", mục "Connection with a WebSocket server" (WebSocket hai chiều, độ trễ thấp) ; Chưa có nguồn trong thư viện: luật cờ, hệ số Glicko-2 và logic đồng hồ cờ không có trong sách của thư viện; chỉ là thiết kế của khoá
3) Người chơi rớt mạng hoặc game server chết giữa ván. Khôi phục thế nào?
Rớt mạng thì mở lại được nếu server còn sống, nhưng server restart khi deploy là mọi ván trên đó mất.
Đánh đổi: Mỗi lần deploy làm hỏng hàng nghìn ván.
Nguồn: Grokking Modern System Design · ch.4 Non-functional System Characteristics ↗ · bài "Fault Tolerance", mục "Fault tolerance techniques" (replication, checkpointing)
Dữ liệu bền, server nào cũng dựng lại được ván từ DB. Client tải lại toàn bộ danh sách nước và đồng hồ.
Đánh đổi: Ở hàng trăm nghìn nước/s, mỗi nước là một transaction DB thì quá tải; client gửi lại nước khi chưa nhận ack có thể bị tính hai lần nếu không kiểm ply.
Nguồn: Grokking Modern System Design · ch.9 Databases ↗ · bài "Types of Databases", mục "Relational databases" (hiệu năng ghi từng transaction) ; Microservices Patterns ↗ (.zip) · ch.3 · §3.3.6 Handling duplicate messages (tr. 95–97)
Server chỉ nhận nước có ply bằng ply mong đợi; nước trùng ply đã nhận thì gửi lại ack cũ nên client thử lại an toàn. Nước được ghi vào Redis (có replica) hoặc Kafka trước khi ack. Client vào lại báo lastPly và nhận phần chênh. Game server chết thì router giao gameId cho server khác, server đó dựng lại ván từ log và hẹn lại đồng hồ. DB chỉ nhận PGN khi hết ván.
Đánh đổi: Có một khoảng vài giây khi chuyển server; cần quy tắc rõ là đồng hồ có dừng trong lúc sự cố phía server hay không.
Nguồn: Microservices Patterns ↗ (.zip) · ch.3 · §3.3.6 Handling duplicate messages, mục "Writing idempotent message handlers" (tr. 96) ; Microservices Patterns ↗ (.zip) · ch.6 · §6.1.2 Overview of event sourcing (dựng lại trạng thái từ log, tr. 186–193) ; Redis in Action ↗ · ch.4 · §4.2 Replication (tr. 70–75) ; Grokking Modern System Design · ch.4 ↗ · bài "Fault Tolerance", mục "Replication" và "Checkpointing"
4) Scale game server thế nào, và một ván có 1 triệu người xem thì sao?
Người chơi kết nối server nào cũng được, server lọc nước của ván mình quan tâm. Mỗi server phải xử lý toàn bộ 138.889 nước/s dù chỉ cần một phần nhỏ.
Đánh đổi: Thêm server không giảm tải mà còn nhân thêm lưu lượng pub/sub.
Nguồn: Grokking Modern System Design · ch.18 Pub-sub ↗ · bài "Design of a Pub-sub System", mục "First design" ; Node.js Design Patterns 2e ↗ (.zip) · ch.10 Scalability and Architectural Patterns · mục "Dealing with stateful communications" › "Sticky load balancing" (tr. 401–404)
Router băm gameId để đưa WebSocket về đúng server (sticky theo ván, Node.js Design Patterns ch.10, mục "Sticky load balancing"). Trạng thái ván nằm trong RAM một chỗ, không cần đồng bộ giữa server.
Đánh đổi: Ván hot có 1 triệu người xem làm sập đúng server đó; thêm/bớt server làm đổi vị trí ván nếu băm đơn giản.
Nguồn: Node.js Design Patterns 2e ↗ (.zip) · ch.10 Scalability and Architectural Patterns · mục "Dealing with stateful communications" › "Sticky load balancing" (tr. 401–404) ; Grokking Modern System Design · ch.36 WhatsApp ↗ · bài "Detailed Design of WhatsApp", mục "Connection with a WebSocket server"
Router đọc bảng gameId → server trong Redis nên drain một server để deploy mà không đổi chỗ ván khác. Game server phát nước vào Kafka/pub-sub theo gameId. Tầng broadcast subscribe đúng các ván có người xem và fan-out: mỗi nước chỉ vài chục byte, ván hot chia ra nhiều node broadcast theo dạng cây. Ván giải lớn phát trễ có chủ đích vài phút để người chơi không xem được phân tích trực tiếp.
Đánh đổi: Thêm một tầng và một bảng định tuyến phải giữ đúng; người xem trễ hơn người chơi 1-2 s.
Nguồn: Grokking Modern System Design · ch.36 WhatsApp ↗ · bài "Detailed Design of WhatsApp", mục "Connection with a WebSocket server" và "Put everything together" (bảng ánh xạ người dùng → server) ; Redis in Action ↗ · ch.3 · §3.6 Publish/subscribe (tr. 54–57) ; Grokking Modern System Design · ch.18 Pub-sub ↗ · bài "Design of a Pub-sub System", mục "Broker", "Cluster manager" ; Chưa có nguồn trong thư viện: broadcast dạng cây và phát trễ có chủ đích cho giải đấu là thiết kế của khoá
Trong phỏng vấn
- “Server là nguồn sự thật cho thế cờ và đồng hồ; client chỉ gửi ý định và hiển thị.”
- “Mỗi nước có số ply nên thử lại và vào lại đều idempotent, client chỉ cần báo ply cuối mình biết.”
- “Một ván gắn với một game server có trạng thái trong RAM, router định tuyến theo gameId.”
- “Người xem đi đường broadcast riêng để một ván hot không làm chậm hai người chơi.”
- Để client giữ đồng hồ hoặc tự báo thời gian còn lại.
- Ghi mỗi nước vào DB quan hệ ở quy mô hàng trăm nghìn nước/s.
- Broadcast mọi nước tới mọi server qua một kênh pub/sub chung.
- Quên rằng đồng hồ vẫn chạy khi người chơi mất kết nối.
Đọc thêm
Sách trong thư viện của bạn. Số chương có thể lệch nhẹ so với bản PDF.