Skip to content

Building blocks ​

Phần lớn thiết kế trong phỏng vấn được ghép từ một bộ thành phần giống nhau: load balancer, cache, database, hàng đợi, object storage. Trang này tóm tắt từng thành phần giải quyết vấn đề gì, đánh đổi lấy gì, và dẫn sang trang chuyên sâu. Thành phần nào chưa có trang riêng (load balancer, API gateway, CDN, object storage) thì được viết kỹ hơn ở đây. Nguyên tắc chung nằm ở Phương pháp: chỉ thêm một thành phần khi có lý do từ con số.

Thành phầnGiải quyết vấn đề gìĐổi lại
Load balancerChia request cho nhiều server, bỏ server chết ra khỏi vòngThêm một tầng mạng, phải tự có dự phòng
Reverse proxy, API gatewayMột cửa vào chung: TLS, xác thực, giới hạn tải, định tuyếnDễ thành điểm nghẽn và chỗ chứa logic lẫn lộn
CDNĐưa nội dung tới gần người dùngNội dung cũ cho tới khi hết hạn hoặc bị xoá
CacheGiảm tải đọc và latencyDữ liệu cũ, thêm một thứ có thể hỏng
Database, replica, shardingLưu dữ liệu đúng và bềnReplica có độ trễ, sharding mất join và transaction chéo
Hàng đợi, logTách việc chậm khỏi request, chịu đỉnh tảiBất đồng bộ, message có thể đến nhiều lần
Object storageLưu file lớn rẻ và bềnKhông sửa được một phần file, latency cao hơn đĩa
Search engineTìm kiếm văn bản, lọc nhiều chiềuMột bản dữ liệu nữa phải đồng bộ

Load balancer ​

Load balancer đứng trước một nhóm server giống nhau và chia request cho chúng. Nó làm ba việc: chia tải để thêm server là tăng được sức chịu, health check để ngừng gửi request tới server không trả lời, và giấu danh sách server sau một địa chỉ duy nhất nên thêm bớt server không ảnh hưởng client.

Tầng 4 hay tầng 7 ​

Tầng 4 (TCP/UDP)Tầng 7 (HTTP)
Nhìn thấy gìIP, cổngURL, header, cookie, nội dung request
Chia theoConnectionTừng request
Làm được thêmRất nhanh, chuyển cả giao thức không phải HTTP (database, MQTT)Định tuyến theo đường dẫn hay header, kết thúc TLS, retry, nén, ghi log từng request
Ví dụAWS Network Load Balancer, HAProxy chế độ TCPAWS Application Load Balancer, nginx, Envoy, HAProxy chế độ HTTP

Chia theo connection có một hệ quả dễ bỏ sót. Connection HTTP/2 và gRPC sống rất lâu và chở mọi request của một client, nên load balancer tầng 4 chỉ chia lúc mở connection. Server mới thêm vào gần như không nhận request nào cho tới khi client mở connection mới. Module cluster của Node.js cũng chia theo connection (Scale Node.js). Với gRPC, người ta dùng load balancer tầng 7 hoặc để client tự chia request.

Chia request theo cách nào ​

CáchLàm thế nàoAi dùng làm mặc định
Round-robinLần lượt từng servernginx (weighted round-robin), AWS ALB
RandomChọn ngẫu nhiên
Least connections / least outstanding requestsChọn server đang có ít request chưa xong nhấtnginx least_conn, ALB có tuỳ chọn least outstanding requests
Power of two choices (P2C)Chọn ngẫu nhiên 2 server, lấy cái có ít request chưa xong hơnEnvoy (least request khi các server cùng trọng số)
Hash theo keyCùng key luôn tới cùng serverKhi cần server giữ trạng thái theo key, như cache; xem Consistent hashing

Round-robin chia số request đều nhau, chứ không chia lượng việc đều nhau. Hai chuyện đó chỉ trùng khi mọi request tốn như nhau và mọi server mạnh như nhau, mà thực tế hiếm khi vậy. Một request tốn 50 ms làm server kẹt lại, và request tiếp theo round-robin gửi vào đó phải chờ, dù server bên cạnh đang rảnh. Tệ hơn, nếu một server chậm hẳn (máy cũ, bị process khác tranh CPU, đang GC), round-robin vẫn gửi cho nó đúng phần như mọi server khác. Phần đó vượt sức nó, nên hàng đợi của nó dài ra mãi.

Cách least outstanding requests nhìn vào số request đang dở ở mỗi server, tức là nhìn thẳng vào hàng đợi. Server chậm tự nhiên có nhiều request dở hơn, nên tự nhận ít đi. Trên lab, 4 server với một server chậm gấp 3 lần, cả cụm mới bận khoảng 60%:

  • Round-robin và random vẫn gửi mỗi server 25% số request. Server chậm phải chạy khoảng 150% sức, nên p99 lên hơn 4 giây chỉ sau 8 giây chạy, và còn tăng tiếp nếu chạy lâu hơn.
  • Least outstanding requests và P2C tự giảm phần của server chậm xuống 13–15%, nên p99 chỉ khoảng 50–60 ms.

Ngay cả khi 4 server như nhau, ở mức bận 70%, round-robin có p99 khoảng 46–52 ms còn least outstanding requests chỉ khoảng 27 ms.

P2C gần tốt bằng least outstanding requests mà không cần xem hết mọi server. Điều này quan trọng khi có nhiều load balancer cùng chạy, mỗi cái chỉ biết số request do chính nó gửi đi. Khi đó mọi load balancer cùng thấy một server đang rảnh và cùng dồn request vào nó, còn chọn ngẫu nhiên hai server thì tránh được việc dồn đó. Tài liệu Envoy dẫn kết quả của Mitzenmacher rằng cách này gần tốt bằng quét toàn bộ.

Kéo các thanh để thấy khi nào cách chia quan trọng. Thử tăng độ bận lên 90%, hoặc thêm server chậm:

Thử
1,0 ms10,0 ms100 ms1,0 s10,0 sRound-robin33,7 msRandom52,0 msLeast outstanding30,1 msPower of two choices31,6 ms
Cách chiap50p99Phần request mỗi backend
Round-robin6,0 ms33,7 ms25% 25% 25% 25%
Random8,3 ms52,0 ms25% 26% 24% 25%
Least outstanding4,6 ms30,1 ms24% 26% 25% 25%
Power of two choices5,3 ms31,6 ms24% 25% 26% 25%

Các backend như nhau mà round-robin vẫn có p99 33,7 ms, vì thời gian xử lý mỗi request dao động: request đến đúng lúc backend đang kẹt một việc dài thì phải chờ, dù backend bên cạnh đang rảnh. Least outstanding tránh được chuyện đó (p99 30,1 ms).

Thanh là p99 của mô phỏng (trục log); chấm tròn là số đo hai lượt của lab khi cấu hình trùng với lab. Dấu * là backend chậm. Giả định: backend thường tốn trung bình 4 ms CPU mỗi request, phân phối mũ, request đến theo Poisson, chạy 8 giây (700 request/s); không giới hạn số connection mỗi backend.

Những điểm khác hay được hỏi ​

  • Health check nên kiểm tra server còn phục vụ được, không nên gọi sang database: database chậm một lúc mà mọi server cùng bị rút khỏi vòng thì cả hệ thống ngừng (Liveness và readiness).
  • Sticky session (cùng người dùng luôn tới cùng server) chỉ nên là giải pháp tạm. Ứng dụng stateless thì không cần nó (Ứng dụng phải stateless).
  • Bản thân load balancer cũng phải có dự phòng. Dịch vụ của cloud đã tự chạy nhiều bản; tự dựng thì cần hai máy với một IP chuyển qua lại, hoặc nhiều máy sau DNS.
  • Rút server khi deploy: load balancer cần thời gian để ngừng gửi request, nên server phải tắt có thứ tự (Graceful shutdown).
  • Giữa nhiều vùng, việc chia thường làm ở tầng DNS: trả về địa chỉ của vùng gần người dùng nhất, hoặc anycast (nhiều nơi cùng quảng bá một địa chỉ IP).

Reverse proxy và API gateway ​

Reverse proxy (nginx, Envoy, HAProxy, Caddy) đứng trước server ứng dụng và nhận request thay nó. Ngoài việc chia tải, nó còn kết thúc TLS, nén response, phục vụ file tĩnh, và đệm request, response cho client chậm. Một client mạng yếu tải response trong 10 giây thì proxy giữ connection đó, còn server ứng dụng gửi xong cho proxy trong vài ms rồi rảnh tay phục vụ request khác.

API gateway là reverse proxy làm thêm những việc chung cho mọi service phía sau: xác thực token, giới hạn tải theo người dùng (Rate limiter), định tuyến theo đường dẫn tới đúng service, ghi log và trace, đổi định dạng request. Nhờ vậy từng service không phải tự làm lại các việc này.

Cái giá là mọi request đi qua một chỗ. Gateway phải có nhiều bản và phải nhanh, nếu không nó thành điểm nghẽn và điểm lỗi chung. Một lỗi hay gặp là để logic nghiệp vụ lọt dần vào gateway (gộp dữ liệu nhiều service, kiểm tra quy tắc đơn hàng), cho tới khi gateway thành một monolith mới mà không team nào sở hữu. Gateway nên chỉ làm việc chung, không biết nghiệp vụ. Khi mỗi loại client (web, mobile) cần dữ liệu ghép khác nhau, người ta dùng một lớp riêng cho từng loại client gọi là BFF (backend for frontend). Cách tổ chức service phía sau gateway nằm ở Microservices.

CDN ​

CDN là mạng máy chủ đặt ở nhiều thành phố, giữ bản sao nội dung gần người dùng. Request tới máy CDN gần nhất thay vì đi tới server gốc. Lợi ích lớn nhất là latency: một round trip từ Hà Nội tới bờ đông nước Mỹ tối thiểu khoảng 130 ms, còn tới máy CDN trong nước chỉ vài ms (Thời gian trên mạng). Lợi ích thứ hai là server gốc chỉ phục vụ những lần CDN chưa có nội dung.

  • Nội dung tĩnh (ảnh, video, JS, CSS) là thứ hợp nhất. Cách chuẩn là đặt mã băm nội dung vào tên file (app.3f9a1c.js) rồi cho cache rất lâu với Cache-Control: max-age=31536000, immutable. Nội dung đổi thì tên file đổi, nên không bao giờ phải xoá cache. Tài liệu MDN gọi cách này là cache busting.
  • Nội dung động cache được nếu giống nhau cho nhiều người (trang sản phẩm, bảng xếp hạng) với TTL ngắn. Nội dung riêng từng người thì không cache ở CDN, nhưng vẫn có lợi khi đi qua CDN: TLS bắt tay ở máy gần người dùng, còn đường từ CDN về server gốc dùng connection giữ sẵn.
  • Xoá nội dung đã cache (purge) mất từ vài giây tới vài phút để lan khắp các máy CDN, nên không dùng CDN cho thứ phải luôn mới.
  • Pull hay push: CDN kiểu pull tự lấy từ server gốc ở lần đầu có người hỏi; kiểu push thì mình chủ động đưa nội dung lên trước. Phần lớn dùng pull; push hợp với file rất lớn biết trước sẽ được tải nhiều.

Các khái niệm hit rate, TTL, chống dồn request khi hết hạn áp dụng giống cache nói chung (Cache).

Cache ​

Cache giữ kết quả đọc gần nơi cần dùng để không phải hỏi database mỗi lần. Một cache nhỏ gánh được phần lớn tải vì dữ liệu được đọc thường tập trung vào một phần nhỏ. Có nhiều chỗ đặt cache: trong process, Redis dùng chung, CDN (Đặt cache ở đâu). Phần khó không nằm ở chỗ thêm cache, mà ở ba câu hỏi đi kèm:

  • Khi dữ liệu đổi thì cache cập nhật thế nào, và chấp nhận cũ bao lâu (Nhất quán dữ liệu).
  • Khi một key nóng hết hạn, hàng nghìn request cùng hỏi database một lúc (Sự cố cache).
  • Khi cache chết, database có chịu nổi toàn bộ tải không.

Một Redis làm được vài trăm nghìn lệnh mỗi giây (Redis).

Database ​

Database quan hệ là lựa chọn mặc định: transaction, ràng buộc, join, và một máy đi được xa hơn nhiều người nghĩ. Khi một máy không đủ, đi theo thang từ rẻ tới đắt (Phân tích bài toán database):

BướcGiải quyếtĐổi lại
Index, sửa queryQuery chậmGhi chậm hơn một chút, tốn chỗ
CacheĐọc nhiềuDữ liệu cũ
Read replicaĐọc nhiều, cần dữ liệu mới hơn cacheReplication lag: đọc ngay sau khi ghi có thể không thấy (Tách đọc ghi)
Partition theo thời gianBảng quá lớn, dữ liệu cũ ít đọcQuery phải có điều kiện thời gian mới nhanh (MySQL chương 10)
ShardingGhi vượt sức một primary, dữ liệu vượt một máyMất join và transaction giữa các shard, chọn shard key khó (Partitioning, Sharding)

NoSQL (key-value, document, wide-column) đáng chọn khi cách truy cập đơn giản và biết trước, cần ghi rất nhiều hay chia dữ liệu tự động. Đổi lại là ít ràng buộc và ít cách truy vấn hơn (SQL hay NoSQL).

Hàng đợi và log ​

Hàng đợi message tách bên gửi khỏi bên xử lý. Bên gửi đặt việc vào hàng đợi rồi trả lời người dùng ngay, còn worker xử lý sau theo tốc độ của mình. Có ba lý do để dùng:

  • Đưa việc chậm ra khỏi request: gửi email, tạo ảnh thu nhỏ, gọi API bên ngoài.
  • Chịu đỉnh tải: đỉnh 10.000 request mỗi giây trong một phút được xếp hàng rồi xử lý dần, thay vì làm sập database.
  • Tăng độ sẵn sàng: service nhận việc có ngừng một lúc thì việc chỉ chờ trong hàng đợi. Nó ra khỏi đường request nên không còn bị nhân vào độ sẵn sàng (Tính số 9).

Có hai kiểu khác nhau:

Hàng đợi (RabbitMQ, SQS)Log (Kafka, Redis Stream)
Message sau khi xử lýBị xoáVẫn giữ tới hết thời hạn lưu
Nhiều nhóm cùng đọcMỗi message chỉ một consumer xử lýMỗi nhóm consumer đọc toàn bộ, độc lập với nhau
Thứ tựGần đúng, có thể đảo khi retryĐúng thứ tự trong từng partition
Đọc lại từ đầuKhôngĐược, bằng cách lùi offset
Hợp vớiChia việc cho workerLuồng sự kiện cho nhiều hệ thống, xử lý lại, CDC

Cả hai chỉ đảm bảo at-least-once: một message có thể được giao nhiều lần, nên consumer phải idempotent (Exactly-once thực chất là gì). Ghi database và gửi message cùng lúc thì dùng outbox (Transactional outbox). Lượng việc vừa phải thì một bảng trong database làm hàng đợi là đủ (Hàng đợi bằng database). Chi tiết từng loại ở Kafka và Hàng đợi message.

Object storage ​

Object storage (S3, Google Cloud Storage, MinIO) lưu file theo key qua HTTP: đặt cả file, lấy cả file, xoá. Không có thư mục thật, không sửa được một phần file, không đổi tên mà không chép lại. Đổi lại, nó rẻ, bền và gần như không giới hạn dung lượng, nên là chỗ mặc định cho ảnh, video, file người dùng tải lên, bản backup, log cũ. Theo tài liệu AWS, S3 đảm bảo strong read-after-write consistency, tức đọc ngay sau khi ghi hay xoá luôn thấy kết quả mới. Một object tối đa 48,8 TiB.

Mẫu thiết kế chuẩn là database chỉ lưu metadata (key của file, kích thước, chủ sở hữu), còn nội dung file đi thẳng giữa client và object storage, không qua server ứng dụng:

  1. Client xin upload. Server kiểm tra quyền, sinh một presigned URL: URL có chữ ký, chỉ cho phép một thao tác (PUT vào một key cụ thể) trong một khoảng thời gian.
  2. Client upload thẳng lên object storage bằng URL đó.
  3. Client báo server upload xong (hoặc object storage gửi sự kiện). Server ghi metadata vào database.
  4. Khi tải về, server sinh presigned URL cho GET, hoặc phục vụ qua CDN.

Nhờ vậy server ứng dụng không phải giữ connection hàng phút để chuyển file hàng GB, và không phải trả băng thông hai lần. File lớn thì chia thành nhiều phần upload song song và upload lại từng phần khi lỗi. Với S3, cách này gọi là multipart upload: tối đa 10.000 phần, mỗi phần từ 5 MiB tới 5 GiB. Khi phải chuyển file qua server Node.js thì dùng stream, không nạp cả file vào bộ nhớ (Stream).

Search engine ​

Database quan hệ có full-text search đủ cho nhiều trường hợp. Search engine riêng (Elasticsearch, OpenSearch, Meilisearch) đáng dùng khi cần xếp hạng theo độ liên quan, gợi ý khi gõ, sửa lỗi chính tả, lọc và đếm theo nhiều chiều trên dữ liệu lớn (Khi nào chuyển sang search engine riêng).

Search engine gần như luôn là bản dữ liệu thứ hai, không phải nơi lưu chính. Database vẫn là nguồn đúng, còn search engine được cập nhật theo, thường qua CDC hoặc qua event (MySQL chương 9). Vì vậy kết quả tìm kiếm có thể chậm vài giây so với dữ liệu thật, và phải có cách dựng lại index từ đầu khi lệch.

Các thành phần nhỏ khác ​

Thành phầnDùng khiTrang
Rate limiterChặn một client gửi quá nhiều, bảo vệ dependencyRate limiter
Sinh IDCần ID duy nhất không qua một database trung tâm, có thứ tự theo thời gianSinh ID
Distributed lockChỉ một process được làm một việc tại một thời điểmDistributed lock
etcd, ZooKeeper, ConsulCấu hình dùng chung, bầu leader, danh sách service đang sốngConsensus, Membership

Ghi nhớ

  1. Mỗi thành phần giải quyết một vấn đề cụ thể và kéo theo một cái giá; chỉ thêm khi con số cho thấy cần.
  2. Load balancer tầng 4 chia theo connection, tầng 7 chia theo request. Round-robin chia đều số request, không chia đều lượng việc: một server chậm bị dồn quá sức. Least outstanding requests và power of two choices tự tránh server chậm.
  3. CDN cho nội dung tĩnh: tên file chứa mã băm và cache rất lâu, nên không bao giờ phải xoá cache.
  4. Hàng đợi xoá message sau khi xử lý, log giữ lại cho nhiều nhóm đọc; cả hai chỉ at-least-once nên consumer phải idempotent.
  5. Object storage lưu file, database lưu metadata; file đi thẳng giữa client và storage qua presigned URL.
  6. Search engine là bản dữ liệu thứ hai, cập nhật theo database và có thể chậm vài giây.

Nguồn tham khảo ​

Đã đối chiếu (đọc nguồn ngày 2026-10-28):

NguồnDùng cho phần
Envoy docs, Supported load balancersLeast request khi các host cùng trọng số chọn ngẫu nhiên N host (mặc định 2) và lấy host có ít request đang chạy nhất; dẫn kết quả của Mitzenmacher; còn gọi là P2C
nginx docs, ngx_http_upstream_moduleMặc định chia theo weighted round-robin; có least_conn
AWS docs, Target group attributes của ALBRound robin là mặc định; có least outstanding requests và weighted random
AWS docs, Application Load Balancer, Network Load BalancerALB chạy ở tầng 7, NLB ở tầng 4 của mô hình OSI
MDN, Cache-Controlimmutable: response không đổi trong lúc còn mới; cache busting bằng mã băm trong tên file
AWS docs, What is Amazon S3, S3 quotas, Presigned URLsStrong read-after-write consistency; object tối đa 48,8 TiB; multipart tối đa 10.000 phần, mỗi phần 5 MiB tới 5 GiB; presigned URL cấp quyền có thời hạn cho một key và một method

Kiểm bằng lab: bốn cách chia request với server như nhau và với một server chậm. Từ kiến thức chung, chưa đối chiếu lần này: connection HTTP/2 và gRPC sống lâu làm load balancer tầng 4 chia lệch; nhiều load balancer cùng dồn vào một server khi dùng least connections; BFF; thời gian purge của CDN; sự khác nhau giữa hàng đợi và log (trang chính là hai trang Messaging, đang viết).

Số đo chi tiết ​

Kết quả chạy lab/building-blocks/lb.mjs (MacBook M2, Node 24.14, hai lượt)

4 server là process Node.js riêng, mỗi request tốn CPU theo phân phối mũ, xử lý lần lượt; request đến theo Poisson trong 8 giây; tối đa 32 connection mỗi server.

4 server như nhau, trung bình 4 ms mỗi request, 700 request/s (mỗi server bận khoảng 70%):

Cách chiap50p99max
Round-robin7,0–7,6 ms45,9–51,6 ms88–93 ms
Random9,1–9,4 ms58,9–63,5 ms98–113 ms
Least outstanding requests5,2–5,3 ms26,7–27,6 ms50–52 ms
Power of two choices6,1–6,2 ms33,4–50,6 ms53–102 ms

Một server chậm gấp 3 (12 ms), ba server 4 ms, 500 request/s (cả cụm bận khoảng 60%):

Cách chiap50p99Phần request của server chậm
Round-robin7,1 ms4.390–4.436 ms25%
Random9,1–9,7 ms4.064–4.267 ms25%
Least outstanding requests5,0 ms46,2–50,9 ms13–14%
Power of two choices6,0–6,1 ms56,2–60,7 ms14%

Lượt chạy đầu chưa giới hạn connection: round-robin dồn vào server chậm hàng trăm connection mới, server đang bận CPU không kịp nhận, macOS bỏ SYN khi backlog vượt 128 và client nhận ETIMEDOUT. Load balancer thật cũng giới hạn connection tới mỗi server, nên script đặt 32. Mô phỏng LoadBalancerSim không có giới hạn này và không tính phần xử lý HTTP; least outstanding requests và P2C khớp lab, round-robin khi các server như nhau thì thấp hơn lab (khoảng 34 so với 46–52 ms), một phần vì các core của M2 không cùng tốc độ.

Code đã dùng để chạy thử ​

Chạy bằng npm run lab -- building-blocks, chỉ cần Node.js, khoảng 1 phút. Server dùng lại lab/latency/server.mjs (chế độ cpu). Xem Lab.

lab/building-blocks/lb.mjs
js
// Trang system-design/building-blocks, phần load balancer. Chỉ cần Node.js, khoảng 1 phút.
// 4 backend, mỗi backend là server `cpu` của lab/latency chạy trong process con: một thread, mỗi request tốn CPU
// ngẫu nhiên theo phân phối mũ, xử lý lần lượt nên request đến lúc bận phải xếp hàng.
// Client tự chia request (như load balancer phía client, hay một proxy) theo bốn cách:
//   round-robin   lần lượt từng backend
//   random        chọn ngẫu nhiên
//   least         chọn backend đang có ít request chưa xong nhất (least outstanding requests)
//   p2c           chọn ngẫu nhiên 2 backend, lấy cái có ít request chưa xong hơn (power of two choices)
// Tải open-loop (Poisson), latency tính từ thời điểm theo lịch.
// Mỗi backend tối đa 32 connection, như pool connection của một load balancer thật; request vượt mức chờ ở phía
// load balancer (vẫn tính vào latency). Không giới hạn thì backend bị dồn mở hàng trăm connection, server đang bận CPU
// không kịp accept, macOS bỏ SYN khi backlog vượt 128 và client nhận ETIMEDOUT.
import http from 'node:http'
import { startServer, get, summary, now, sleep } from '../latency/util.mjs'

const MAX_CONN = 32
const ALGOS = {
  'round-robin': (b, st) => b[st.rr++ % b.length],
  random: (b) => b[Math.floor(Math.random() * b.length)],
  least: (b) => {
    // hoà nhau thì chọn ngẫu nhiên, không thì backend đầu danh sách luôn được ưu tiên
    const min = Math.min(...b.map((x) => x.inflight))
    const c = b.filter((x) => x.inflight === min)
    return c[Math.floor(Math.random() * c.length)]
  },
  p2c: (b) => {
    const i = Math.floor(Math.random() * b.length)
    let j = Math.floor(Math.random() * (b.length - 1)); if (j >= i) j++
    return b[i].inflight <= b[j].inflight ? b[i] : b[j]
  }
}

async function run(means, algo, rate, seconds) {
  const backends = []
  try {
    for (const m of means) {
      const srv = await startServer('cpu', String(m))
      srv.agent.destroy()
      srv.agent = new http.Agent({ keepAlive: true, maxSockets: MAX_CONN })
      backends.push({ ...srv, inflight: 0, sent: 0, mean: m })
    }
    // khởi động: mở sẵn connection và cho V8 chạy nóng
    for (const b of backends) await Promise.all(Array.from({ length: MAX_CONN }, () => get(b)))
    await sleep(200)
    const gaps = []
    for (let t = 0; t < seconds * 1000;) { t += -Math.log(1 - Math.random()) * (1000 / rate); gaps.push(t) }
    const st = { rr: 0 }
    const lat = []
    const pending = []
    let errors = 0
    const start = now()
    let i = 0
    await new Promise((done) => {
      const tick = () => {
        while (i < gaps.length && gaps[i] <= now() - start) {
          const due = start + gaps[i++]
          const b = ALGOS[algo](backends, st)
          b.inflight++; b.sent++
          pending.push(get(b).then(() => lat.push(now() - due), () => errors++).finally(() => b.inflight--))
        }
        if (i < gaps.length) setTimeout(tick, Math.max(0, gaps[i] - (now() - start)))
        else done()
      }
      tick()
    })
    await Promise.all(pending)
    return { ...summary(lat), errors, share: backends.map((b) => Math.round((b.sent / gaps.length) * 100)) }
  } finally {
    for (const b of backends) b.stop()
  }
}

const ms = (v) => (v >= 100 ? Math.round(v).toLocaleString('vi-VN') : v.toFixed(1).replace('.', ',')) + ' ms'
const SECONDS = 8
for (const [title, means, rate] of [
  ['4 backend như nhau, mỗi request trung bình 4 ms CPU, 700 request/s (mỗi backend bận khoảng 70%)', [4, 4, 4, 4], 700],
  ['Một backend chậm gấp 3 (12 ms), ba backend 4 ms, 500 request/s (cả cụm bận khoảng 60%)', [4, 4, 4, 12], 500]
]) {
  console.log(`\n=== ${title}, ${SECONDS} giây mỗi cách ===`)
  for (const algo of Object.keys(ALGOS)) {
    const r = await run(means, algo, rate, SECONDS)
    console.log(`${algo.padEnd(12)} p50 ${ms(r.p50).padStart(9)}  p99 ${ms(r.p99).padStart(9)}  max ${ms(r.max).padStart(9)}  phần request mỗi backend ${r.share.join(' / ')}%${r.errors ? `, ${r.errors} lỗi` : ''}`)
  }
}