Harbor

Chơi cờ online

Trung bìnhReal-timeWebSocketGhép cặpServer có trạng tháiGiống chess.com, lichess

Hiểu đề

Chơi cờ online là gì? Nền tảng chơi cờ vua trực tuyến: người chơi được ghép với đối thủ cùng trình độ, đi nước theo thời gian thực với đồng hồ chính xác, người khác có thể vào xem. Mỗi người chơi trung bình vài ván mỗi ngày, mỗi ván từ 1 đến 30 phút.

Yêu cầu chức năng

  1. 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)
  2. 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ồ
  3. 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
  4. Người khác có thể xem ván đang diễn ra
  5. 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

Player
Người chơi: rating Glicko-2 theo từng thể loại (rating, độ lệch RD), region.
Seek
Yêu cầu tìm ván: player_id, thể loại thời gian, rating, thời điểm vào hàng chờ.
Game
Ván cờ: trắng, đen, thể loại, trạng thái (đang chơi/kết thúc), kết quả, game server đang giữ ván.
Move
Nước đi: game_id, ply (số thứ tự nửa nước), nước dạng UCI (e2e4), thời gian còn lại sau nước, timestamp server.
ClockState
Thời gian còn lại của 2 bên và thời điểm bắt đầu lượt hiện tại, do server giữ.
MethodEndpointGhi chú
POST/v1/seeksVà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}/wsKê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}/watchKê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.

  1. 1Client gọi POST /v1/seeks với thể loại 3+2
  2. 2Matchmaker thêm người chơi vào pool, xếp theo rating
  3. 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
  4. 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ủ.

  1. 1Client gửi {ply: 23, uci: 'g1f3'} qua WebSocket
  2. 2Game server kiểm tra đúng lượt, đúng ply mong đợi, nước hợp lệ (chess.js)
  3. 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
  4. 4Gửi ack cho người đi, gửi nước + đồng hồ cho đối thủ và người xem
  5. 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.

  1. 1Client mở lại WebSocket kèm gameId và lastPly
  2. 2Router đưa kết nối về game server đang giữ ván
  3. 3Server gửi các nước sau lastPly và đồng hồ hiện tại
  4. 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ộ.

  1. 1Người xem mở /watch, nhận thế cờ hiện tại rồi nhận từng nước mới
  2. 2Game server phát sự kiện move và game_over
  3. 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.

Người dùng
10.000 DAU · ≈ 520 ván cùng lúc lúc đỉnh
Tải đỉnh
≈ 69 nước/s, ≈ 2.000 kết nối WebSocket
Chi phí ước lượng
≈ 50–150 USD/tháng (ước lượng)

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.

Người dùngNgười chơiCDNCDNCân bằng tảiLoad balancerDịch vụApp Node + WSWorkerJob ghép cặpDatabasePostgreSQLtài nguyên tĩnhWebSocketnước đi + NOTIFYghép cặp

Ướ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ầnChọnSố lượngCấu hìnhVì sao
CDNCloudflare1JS, ảnh quân cờ, âm thanhTài nguyên tĩnh không chạm server.
App (API + WebSocket)Node.js (Fastify + ws), sau load balancer hỗ trợ WebSocket22 vCPU · 4 GB RAM2.000 kết nối và 69 nước/s chỉ cần 1 instance, chạy 2 để dự phòng.
DatabasePostgreSQL managed12 vCPU · 8 GB · 50 GB SSD69 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 instancePostgres LISTEN/NOTIFY1 (dùng DB sẵn có)Kênh theo gameIdHai người chơi có thể nằm ở 2 instance; NOTIFY báo nước mới, chưa cần Redis.
Job nềnVòng ghép cặp + kiểm tra hết giờ trong app1 (advisory lock)Chạy mỗi 0,5-1 sChỉ một instance giữ advisory lock và chạy, không ghép trùng.
Thay đổi so với mức trước
  • Đ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
Nút thắt tiếp theo

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ầnMức 1Mức 2Mức 3
Người dùng10.000 DAU · ≈ 520 ván cùng lúc lúc đỉnh1 triệu DAU · ≈ 52.000 ván cùng lúc lúc đỉnh20 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ầnChư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ầnChư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ầnChưa cần
RouterChưa cần
Envoy / NGINX, định tuyến WebSocket theo gameId
2 × 2 vCPU · 4 GB
Chưa cần
APIChư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 serverChư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
MatchmakerChư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
RedisChưa cần
ElastiCache / Memorystore
1 primary + 1 replica × 2 vCPU · 4 GB
Chưa cần
Queue + workerChư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 + routerChưa cầnChư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
BroadcastChưa cầnChư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 ClusterChưa cầnChưa cần
ElastiCache Redis Cluster, 2 shard mỗi region
6 shard × (1 primary + 1 replica) × 4 vCPU · 8 GB mỗi node
KafkaChưa cầnChư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ánChưa cầnChư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?

Cách chưa tốt
Ghép người đến trước với người đến ngay sau, bỏ qua rating

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á

Cách tốt
Bảng seeks trong Postgres, mỗi giây query rating ± 100

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á

Cách tốt nhất
Một matcher cho mỗi pool, Redis sorted set theo rating, cửa sổ nới dần theo thời gian chờ

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?

Cách chưa tốt
Đồng hồ chạy ở client, client báo thời gian còn lại

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á

Cách tốt
Server trừ thời gian theo lúc nhận nước

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á

Cách tốt nhất
Server là nguồn sự thật, bù trễ có giới hạn, client chỉ hiển thị

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?

Cách chưa tốt
Trạng thái ván chỉ nằm trong RAM của server

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)

Cách tốt
Mỗi nước ghi vào DB, client vào lại thì tải toàn bộ ván

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)

Cách tốt nhất
Nước đi có ply, log bền trước khi ack, đồng bộ lại từ ply cuối

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?

Cách chưa tốt
Mọi server subscribe một kênh pub/sub chung và nhận mọi nước đi

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)

Cách tốt
Ván gắn với một game server theo gameId, cả người chơi lẫn người xem kết nối vào đó

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"

Cách tốt nhất
Bảng định tuyến gameId → server, game server chỉ phục vụ 2 người chơi, tầng broadcast riêng cho người xem

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

Nên nói
  • “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.”
Bẫy hay gặp
  • Để 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

Cập nhật realtime
Đẩy thay đổi tới client trong vài trăm mili giây: chọn giữa polling, long polling, SSE, WebSocket, rồi fan-out qua nhiều gateway, xử lý reconnect và backpressure.
Redis
Redis là kho key-value trong RAM với nhiều cấu trúc dữ liệu: dùng làm cache, bộ đếm, leaderboard, hàng đợi, GEO và pub/sub. Bài này đi qua mô hình dữ liệu, persistence, replication, Cluster, Lua và cách tính RAM.
Consistent hashing
Consistent hashing chia key cho nhiều node sao cho khi thêm hoặc bớt node chỉ một phần nhỏ key phải di chuyển. Bài này đi từ vấn đề của hash modulo tới hash ring, virtual node, replication dọc vòng và một cài đặt TypeScript ngắn.
Kafka
Kafka là log phân tán bền vững: topic chia partition, consumer đọc theo offset và có thể đọc lại. Bài này đi qua thứ tự theo partition, consumer group, replication với ISR và acks, giới hạn của exactly-once, cách chọn số partition và so sánh với SQS, Pub/Sub, RabbitMQ.
Cassandra
Cassandra là database wide-column phân tán, ghi rất nhanh nhờ LSM tree, mở rộng gần tuyến tính bằng ring và consistent hashing. Bài này giải thích partition key, clustering column, cách mô hình hoá theo câu truy vấn, tunable consistency và những cái bẫy như tombstone.
PostgreSQL
PostgreSQL là lựa chọn mặc định an toàn cho phần lớn hệ thống: ACID, index phong phú, replication, partitioning và hệ extension mạnh. Bài này giải thích MVCC, các loại index, mức isolation, replication, connection pooling, bảng sizing theo cấu hình và dấu hiệu nên rời một node Postgres.

Sách trong thư viện của bạn. Số chương có thể lệch nhẹ so với bản PDF.