Harbor

Bình luận livestream

Trung bìnhFan-out real-timeWebSocket/SSEPub/subLấy mẫuGiống Facebook Live, TikTok Live, YouTube Live chat

Hiểu đề

Bình luận livestream là gì? Tính năng bình luận chạy bên cạnh một buổi livestream: người xem gửi bình luận và thấy bình luận của người khác hiện lên gần như ngay lập tức. Khó nhất là các buổi live lớn có hàng triệu người xem cùng lúc.

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

  1. Người xem có thể gửi bình luận vào một buổi live
  2. Người xem thấy bình luận mới của người khác trong vòng vài giây
  3. Người mới vào thấy ngay các bình luận gần nhất và có thể cuộn xem bình luận cũ hơn
  4. Chủ kênh có thể bật chế độ chậm (slow mode) và ẩn bình luận vi phạm

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

  • Bình luận hiện trên màn hình người xem khác p95 < 2 s
  • Chịu được buổi live 5 triệu người xem và 5.000 bình luận/s
  • Mỗi người xem nhận tối đa khoảng 10 bình luận/s, người gửi luôn thấy bình luận của mình
  • Thứ tự bình luận nhất quán giữa mọi người xem; không mất bình luận đã nhận 201
  • Gửi bình luận p95 < 300 ms, độ sẵn sàng 99,9%

Ngoài phạm vi: Phát video live (ingest RTMP, transcode, CDN video); Thả tim, quà tặng, donate; Trả lời lồng nhau và reaction cho từng bình luận; Mô hình kiểm duyệt nội dung bằng AI (chỉ nhắc ở mức hook lọc spam).

Thực thể và API

LiveVideo
Buổi live: video_id, chủ kênh, trạng thái LIVE/ENDED, thời điểm bắt đầu, cấu hình slow mode.
Comment
Bình luận: comment_id (sắp xếp được theo thời gian), video_id, user_id, nội dung, offset so với lúc bắt đầu live, trạng thái hiển thị.
Viewer connection
Kết nối SSE/WebSocket đang mở: connection_id, user_id, video_id, gateway đang giữ. Chỉ nằm trong RAM của gateway.
CommentBatch
Gói bình luận đã lấy mẫu của một video trong 0,5-1 s, gửi xuống mọi người xem.
User
Người dùng: tên, avatar, cờ đã xác minh, vai trò (chủ kênh, mod).
MethodEndpointGhi chú
POST/v1/live/{videoId}/commentsGửi bình luận, header Idempotency-Key. Trả comment_id để client hiện ngay bản của mình.
GET/v1/live/{videoId}/streamMở kết nối SSE (hoặc WebSocket) nhận CommentBatch. Gửi lại Last-Event-ID khi kết nối lại.
GET/v1/live/{videoId}/comments?before={commentId}&limit=50Lịch sử, phân trang bằng cursor comment_id.
DELETE/v1/live/{videoId}/comments/{commentId}Chủ kênh/mod ẩn bình luận; phát sự kiện comment_hidden.
PATCH/v1/live/{videoId}/settingsBật slow mode (ví dụ 1 bình luận/10 s mỗi người).

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 xem có thể gửi bình luận

Gửi bình luận là request HTTP thường, tách khỏi kênh nhận. Comment API kiểm tra rate limit, gán ID có thứ tự, lưu rồi phát sự kiện cho phần fan-out.

  1. 1Client POST bình luận kèm Idempotency-Key, hiện ngay bản của mình ở trạng thái đang gửi
  2. 2Comment API kiểm tra slow mode và rate limit theo user, lọc spam cơ bản
  3. 3Gán comment_id kiểu Snowflake (thời gian + máy + số thứ tự)
  4. 4Lưu vào DB và đẩy vào kênh/stream của video, trả 201

Nguồn: Grokking Modern System Design · ch.19 Rate Limiter ↗ · bài "Design of a Rate Limiter" · mục "Detailed design" ; Grokking Modern System Design · ch.12 Sequencer ↗ · mục "Twitter Snowflake" (ID có timestamp đứng đầu nên sắp được theo thời gian) ; Grokking Modern System Design · ch.23 Distributed Task Scheduler ↗ · mục "Task idempotency" (idempotence key)

2) Người xem thấy bình luận mới trong vài giây

Mỗi người xem giữ một kết nối SSE tới gateway. Gateway subscribe kênh pub/sub của các video mà người xem của nó đang xem, nhận gói bình luận và đẩy xuống từng kết nối.

  1. 1Client mở GET /stream, gateway ghi nhận kết nối vào danh sách của video
  2. 2Gateway subscribe kênh video:{id} nếu là người xem đầu tiên của video trên gateway đó
  3. 3Sampler gom bình luận của video mỗi 0,5-1 s, chọn tối đa ~10/s, publish một CommentBatch
  4. 4Gateway nhận batch, ghi một frame xuống mọi kết nối của video đó

Nguồn: Grokking Modern System Design · ch.18 Pub-sub ↗ · bài "System Design: The Pub-sub Abstraction" (publisher/subscriber qua topic) ; Node.js Design Patterns 2e ↗ (.zip) · ch.11 Messaging and Integration Patterns · mục "Using Redis as a message broker" (tr. 447) ; Grokking Modern System Design · ch.36 Design WhatsApp ↗ · mục "Connection with a WebSocket server" (kết nối lâu dài thay cho polling) ; Chưa có nguồn trong thư viện: SSE và sampler gom batch 0,5-1 s là thiết kế của khoá học

3) Người mới vào thấy bình luận gần nhất và cuộn xem cũ hơn

50 bình luận gần nhất của mỗi video đang live được giữ sẵn trong Redis để người vào phòng đọc nhanh. Cuộn lên thì phân trang bằng cursor comment_id trên DB chính.

  1. 1Client gọi GET /comments?limit=50, API đọc list recent:{videoId} trong Redis
  2. 2Client mở SSE với Last-Event-ID là comment_id mới nhất vừa nhận để không hụt
  3. 3Cuộn lên: GET /comments?before={id}, DB đọc theo khóa (video_id, comment_id) giảm dần
  4. 4Client gộp và bỏ trùng theo comment_id

Nguồn: Redis in Action ↗ · ch.3 · §3.2 Lists ; MongoDB: The Definitive Guide 2e ↗ (.zip) · ch.4 Querying · mục "Avoiding Large Skips" (tr. 70) ; Chưa có nguồn trong thư viện: Last-Event-ID của SSE

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, buổi live lớn nhất 2.000 người xem
Tải đỉnh
≈ 6,7 bình luận/s, ≈ 4.000 frame/s đẩy xuống
Chi phí ước lượng
≈ 100–250 USD/tháng (ước lượng)

Livestream bán hàng hoặc lớp học online. Một ứng dụng Node vừa nhận POST vừa giữ kết nối SSE, chạy 2 instance, dùng Redis pub/sub để hai instance thấy bình luận của nhau, Postgres lưu lịch sử.

Người dùngNgười xemCân bằng tảiLoad balancerDịch vụApp Node (SSE)CacheRedis pub/subDatabasePostgreSQLPOST + SSEpub/sublưu, lịch sử

Ước lượng

  • Bình luận: 2.000 người xem × 10% có bình luận × 1 lần/30 s = 200 / 30 ≈ 6,7 bình luận/s
  • Gửi hết, không lấy mẫu: 6,7 × 2.000 ≈ 13.333 tin/s; gom batch 0,5 s → 2 × 2.000 = 4.000 frame/s
  • Kết nối: 2.000 / 20.000 mỗi instance → 1, chạy 2 để dự phòng
  • Lưu trữ: 10.000 DAU × 5% bình luận × 10 lần = 5.000 bình luận/ngày × 300 B = 1,5 MB/ngày ≈ 548 MB/năm
  • Redis pub/sub: 6,7 publish/s × 2 subscriber, không đáng kể

Nguồn con số: Grokking Modern System Design · ch.5 Back-of-the-envelope Calculations ↗ · mục "Requests estimation", "Storage requirements", "Bandwidth requirements" ; Quy tắc ước lượng của khoá học · /hoc/chon-server (số kết nối mỗi gateway 20.000-40.000, 600 req/s mỗi instance, 10 MB/s mỗi partition, Redis ×1,3 là giả định của khoá học, cần load test)

Bảng hạ tầng

Thành phầnChọnSố lượngCấu hìnhVì sao
Load balancerALB / Cloud Load Balancing1Hỗ trợ kết nối dài, idle timeout 1 giờSSE cần load balancer không cắt kết nối sau 60 s.
App (API + SSE)Node.js (Fastify), Docker22 vCPU · 4 GB RAM2.000 kết nối và 4.000 frame/s dư sức cho 1 instance, chạy 2 để không chết khi 1 cái lỗi.
Pub/subRedis managed11 vCPU · 1 GBĐể bình luận gửi vào instance A tới được người xem đang nối với instance B.
DatabasePostgreSQL managed12 vCPU · 8 GB · 20 GB SSD6,7 ghi/s và 548 MB/năm là rất nhỏ; index (video_id, comment_id) cho phân trang.
Thay đổi so với mức trước
  • Điểm xuất phát: một ứng dụng Node vừa nhận bình luận vừa giữ SSE
  • Gửi mọi bình luận xuống mọi người xem, chưa cần lấy mẫu
  • Redis pub/sub chỉ để nối 2 instance với nhau
Nút thắt tiếp theo

Khi một buổi live có 200.000 người xem, số tin cần đẩy nhân theo bình luận × người xem và vượt xa khả năng của 2 instance. Cần tách gateway, lấy mẫu bình luận và chia kênh theo video.

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, buổi live lớn nhất 2.000 người xem1 triệu DAU; buổi live hot 200.000 người xem, 300.000 người xem đồng thời toàn nền tảng50 triệu DAU; trận chung kết 5 triệu người xem, 10 triệu kết nối toàn nền tảng
Chi phí≈ 100–250 USD/tháng (ước lượng)≈ 2.000–5.000 USD/tháng (ước lượng)≈ 25.000–60.000 USD/tháng (ước lượng, băng thông xuống và gateway là phần lớn)
Load balancer
ALB / Cloud Load Balancing
1 × Hỗ trợ kết nối dài, idle timeout 1 giờ
ALB / Envoy
1 × Định tuyến /stream theo video_id
Chưa cần
App (API + SSE)
Node.js (Fastify), Docker
2 × 2 vCPU · 4 GB RAM
Chưa cầnChưa cần
Pub/sub
Redis managed
1 × 1 vCPU · 1 GB
Chưa cần
Redis Cluster sharded pub/sub
6 shard × (1 primary + 1 replica) × 2 vCPU · 8 GB mỗi node
Database
PostgreSQL managed
1 × 2 vCPU · 8 GB · 20 GB SSD
PostgreSQL managed (HA)
1 primary + 1 read replica × 4 vCPU · 16 GB · 200 GB SSD
Cassandra / ScyllaDB
6 node × 8 vCPU · 32 GB · 4 TB SSD
SSE gatewayChưa cần
Node.js SSE, container
18 × 2 vCPU · 4 GB RAM
Chưa cần
Comment APIChưa cần
Node.js (NestJS), autoscale
2-4 (autoscale) × 2 vCPU · 4 GB RAM
Node.js trên Kubernetes
34 pod (HPA) × 2 vCPU · 4 GB mỗi pod
SamplerChưa cần
Node.js worker đọc Redis Stream theo video
2 × 2 vCPU · 4 GB RAM
Node.js consumer group (hoặc Flink)
16 pod × 4 vCPU · 8 GB mỗi pod
RedisChưa cần
ElastiCache / Memorystore
1 primary + 1 replica × 2 vCPU · 8 GB
Chưa cần
Edge + LBChưa cầnChưa cần
GeoDNS + Envoy, 3 region
3 cụm × Định tuyến theo region rồi theo video_id
GatewayChưa cầnChưa cần
Node.js (uWebSockets.js) SSE/WebSocket trên Kubernetes
300 pod × 2 vCPU · 4 GB mỗi pod
KafkaChưa cầnChưa cần
MSK / Confluent
6 broker × Topic comments 64 partition theo video_id, RF 3
WriterChưa cầnChưa cần
Kafka consumer ghi Cassandra
6 pod × 2 vCPU · 4 GB mỗi pod

Đà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) Đẩy bình luận tới hàng triệu người xem đang mở cùng một buổi live thế nào?

Cách chưa tốt
Mọi client poll DB mỗi 2 giây

Client gọi GET /comments?after=... liên tục. 1 triệu người xem là 500.000 query/s vào DB, phần lớn trả về cùng một kết quả.

Đánh đổi: Dễ làm, ổn cho vài trăm người xem; ở quy mô lớn thì DB sập trước.

Nguồn: Grokking Modern System Design · ch.36 Design WhatsApp ↗ · mục "Connection with a WebSocket server", Point to Ponder "Why is WebSocket preferred over HTTP(S)?" (polling tốn tài nguyên và tăng độ trễ)

Cách tốt
WebSocket/SSE gateway, một kênh Redis pub/sub chung, mọi gateway nhận mọi thứ

Comment API publish mọi bình luận lên một kênh, mọi gateway subscribe và tự lọc theo video của kết nối mình đang giữ. Đây là mẫu chat dùng Redis làm broker trong Node.js Design Patterns.

Đánh đổi: Mỗi gateway nhận bình luận của mọi video trên nền tảng; thêm gateway là thêm tải cho mọi gateway.

Nguồn: Node.js Design Patterns 2e ↗ (.zip) · ch.11 Messaging and Integration Patterns · mục "Running and scaling the chat application" (tr. 446), "Using Redis as a message broker" (tr. 447)

Cách tốt nhất
Pub/sub chia theo video, gateway chỉ subscribe video nó đang phục vụ, SSE xuống client

Mỗi video một kênh (Redis sharded pub/sub hoặc topic chia theo video_id, Grokking ch.18). Gateway subscribe kênh khi có người xem đầu tiên và hủy khi người cuối rời đi. Load balancer định tuyến theo video_id để người xem của video nhỏ dồn vào ít gateway; video lớn thì trải ra mọi gateway, nên một batch chỉ cần gửi tới vài trăm gateway rồi mỗi gateway tự fan-out xuống hàng chục nghìn kết nối. SSE đủ vì nhận là một chiều, gửi đi bằng POST thường, chạy trên HTTP/2 và tự kết nối lại với Last-Event-ID.

Đánh đổi: Gateway có trạng thái (kết nối, danh sách subscribe) nên deploy phải xả kết nối dần; khi một gateway chết, hàng chục nghìn client kết nối lại cùng lúc cần backoff ngẫu nhiên.

Nguồn: Grokking Modern System Design · ch.18 Pub-sub ↗ · bài "Design of a Pub-sub System" (topic, partition, broker) ; Grokking Modern System Design · ch.36 Design WhatsApp ↗ · mục "Connection with a WebSocket server" (WebSocket manager ánh xạ user và server) ; Chưa có nguồn trong thư viện: Redis sharded pub/sub, SSE trên HTTP/2, định tuyến LB theo video_id

2) Một buổi live có 5.000 bình luận/s. Làm sao để người xem đọc được và hệ thống không quá tải?

Cách chưa tốt
Gửi tất cả bình luận cho tất cả người xem

5.000 bình luận/s × 5 triệu người xem là 25 tỷ tin nhắn/s. Không ai đọc kịp 5.000 dòng mỗi giây, màn hình chỉ là một dòng chữ chạy.

Đánh đổi: Tốn băng thông và CPU nhất mà trải nghiệm vẫn tệ nhất.

⚠ Chưa có nguồn trong thư viện: phép tính fan-out 5.000 × 5 triệu là số học thuần túy trên giả định của bài

Cách tốt
Rate limit người gửi + bỏ ngẫu nhiên khi vượt ngưỡng

Mỗi người tối đa 1 bình luận/3 s (token bucket, Grokking ch.19), chủ kênh bật slow mode khi cần. Phía phát lấy ngẫu nhiên tối đa 10 bình luận/s mỗi video, phần còn lại vẫn lưu nhưng không đẩy real-time.

Đánh đổi: Giảm tải hiệu quả nhưng chọn ngẫu nhiên nên bình luận của chủ kênh, mod hay bạn bè cũng có thể bị bỏ.

Nguồn: Grokking Modern System Design · ch.19 Rate Limiter ↗ · bài "Rate Limiter Algorithms" · mục "Token bucket algorithm" ; Grokking Modern System Design · ch.19 Rate Limiter ↗ · bài "Requirements of a Rate Limiter's Design" · mục "Types of throttling"

Cách tốt nhất
Sampler theo video có ngân sách và ưu tiên, gom batch, cá nhân hóa ở gateway

Mỗi video có một sampler (một partition theo video_id) gom bình luận trong 0,5-1 s, chấm điểm (chủ kênh, mod, tài khoản xác minh, không phải spam, nội dung không trùng lặp) và chọn tối đa ~10/s vào CommentBatch. Gateway chèn thêm bình luận của bạn bè người xem, còn bình luận của chính người gửi luôn hiện nhờ hiện ngay ở client. Video nhỏ dưới ngân sách thì đi thẳng không lấy mẫu. Mọi bình luận vẫn được lưu đủ cho lịch sử và replay.

Đánh đổi: Người xem thấy tập bình luận khác nhau một chút và người gửi có thể nghĩ bình luận của mình bị ẩn; cần trạng thái sampler theo video và xử lý khi sampler chuyển máy.

⚠ Chưa có nguồn trong thư viện: lấy mẫu bình luận theo ngân sách và chấm điểm ưu tiên là thiết kế của khoá học, thư viện không có sách mô tả kỹ thuật này

3) Giữ thứ tự bình luận nhất quán và phân trang lịch sử thế nào?

Cách chưa tốt
Sắp theo created_at của từng server, phân trang bằng OFFSET

Đồng hồ các server lệch nhau vài chục ms nên hai người thấy thứ tự khác nhau. OFFSET 10.000 phải quét qua 10.000 hàng, và bình luận mới chèn vào làm trang bị lệch, lặp hoặc hụt.

Đánh đổi: Chạy được cho bình luận bài viết thường, không hợp với luồng ghi liên tục.

Nguồn: Grokking Modern System Design · ch.12 Sequencer ↗ · mục "Use UNIX time stamps" và phần "Time in a Distributed System" (đồng hồ vật lý lệch nhau) ; MongoDB: The Definitive Guide 2e ↗ (.zip) · ch.4 Querying · mục "Avoiding Large Skips" (tr. 70)

Cách tốt
ID kiểu Snowflake và phân trang bằng cursor

comment_id = thời gian ms + id máy + số thứ tự (Grokking ch.12 Sequencer), sắp xếp được theo thời gian. Truy vấn WHERE video_id = ? AND comment_id < ? ORDER BY comment_id DESC LIMIT 50 dùng index nên luôn nhanh, không bị lệch trang.

Đánh đổi: Thứ tự vẫn chỉ gần đúng giữa các máy cùng một ms, nhưng mọi người xem thấy cùng một thứ tự vì cùng sắp theo ID.

Nguồn: Grokking Modern System Design · ch.12 Sequencer ↗ · mục "Twitter Snowflake" (Pros: sắp được theo thời gian) ; MongoDB: The Definitive Guide 2e ↗ (.zip) · ch.4 Querying · mục "Avoiding Large Skips" (tr. 70, phân trang theo khóa thay cho skip)

Cách tốt nhất
Một partition sở hữu mỗi video quyết định thứ tự, lưu theo (video_id, bucket thời gian)

Bình luận của một video đi vào cùng một partition Kafka (khóa video_id), sampler và writer đọc theo đúng thứ tự đó nên thứ tự phát ra và thứ tự lưu trùng nhau. Lưu trong Cassandra với partition key (video_id, giờ) để một video lớn không thành partition khổng lồ, clustering theo comment_id giảm dần. Client gộp theo comment_id và bỏ trùng; kết nối lại thì gửi Last-Event-ID để nhận phần thiếu. Khi xem lại video, bình luận được phát theo offset so với lúc bắt đầu live.

Đánh đổi: Một video cực hot dồn vào một partition, cần đủ năng lực cho partition đó (5.000 × 300 B = 1,5 MB/s vẫn dư); thứ tự hiển thị có thể lệch nhẹ so với thời điểm người dùng bấm gửi.

Nguồn: Grokking Modern System Design · ch.18 Pub-sub ↗ · bài "Design of a Pub-sub System" · Points to Ponder về strict ordering (tin cùng khóa vào cùng partition giữ thứ tự) ; Grokking Modern System Design · ch.9 Databases ↗ · mục "Columnar database", "Sharding" (partition key) ; Chưa có nguồn trong thư viện: Kafka và mô hình (video_id, giờ) của Cassandra

Trong phỏng vấn

Nên nói
  • “Tôi tách gửi và nhận: gửi là POST thường, nhận là SSE vì luồng chỉ một chiều.”
  • “Chi phí nằm ở fan-out bình luận × người xem, nên tôi lấy mẫu khoảng 10 bình luận/s mỗi video và gom batch.”
  • “Pub/sub chia theo video, gateway chỉ subscribe video mà người xem của nó đang xem.”
  • “Thứ tự do partition theo video_id quyết định, lịch sử phân trang bằng cursor comment_id.”
Bẫy hay gặp
  • Đẩy mọi bình luận tới mọi người xem rồi mới phát hiện con số nhân lên hàng tỷ.
  • Cho mọi gateway subscribe mọi bình luận của cả nền tảng.
  • Phân trang bằng OFFSET hoặc sắp theo đồng hồ của từng server.
  • Quên đợt kết nối lại hàng loạt khi một gateway hoặc region chết.

Đọ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.
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.
Scale ghi
Khi lượng ghi vượt sức một database: gom batch, ghi bất đồng bộ qua hàng đợi, chọn storage tối ưu cho ghi, chia dữ liệu theo key, xử lý partition nóng bằng salting, sharded counter và tổng hợp trước. Kèm con số cho từng quy mô.
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.

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