Giao diện
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ần | Giải quyết vấn đề gì | Đổi lại |
|---|---|---|
| Load balancer | Chia request cho nhiều server, bỏ server chết ra khỏi vòng | Thêm một tầng mạng, phải tự có dự phòng |
| Reverse proxy, API gateway | Một cửa vào chung: TLS, xác thực, giới hạn tải, định tuyến | Dễ 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ùng | Nội dung cũ cho tới khi hết hạn hoặc bị xoá |
| Cache | Giảm tải đọc và latency | Dữ liệu cũ, thêm một thứ có thể hỏng |
| Database, replica, sharding | Lưu dữ liệu đúng và bền | Replica có độ trễ, sharding mất join và transaction chéo |
| Hàng đợi, log | Tách việc chậm khỏi request, chịu đỉnh tải | Bất đồng bộ, message có thể đến nhiều lần |
| Object storage | Lưu file lớn rẻ và bền | Không sửa được một phần file, latency cao hơn đĩa |
| Search engine | Tìm kiếm văn bản, lọc nhiều chiều | Mộ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ổng | URL, header, cookie, nội dung request |
| Chia theo | Connection | Từng request |
| Làm được thêm | Rấ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ế độ TCP | AWS 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ách | Làm thế nào | Ai dùng làm mặc định |
|---|---|---|
| Round-robin | Lần lượt từng server | nginx (weighted round-robin), AWS ALB |
| Random | Chọn ngẫu nhiên | |
| Least connections / least outstanding requests | Chọn server đang có ít request chưa xong nhất | nginx 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ơn | Envoy (least request khi các server cùng trọng số) |
| Hash theo key | Cùng key luôn tới cùng server | Khi 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ử
| Cách chia | p50 | p99 | Phần request mỗi backend |
|---|---|---|---|
| Round-robin | 6,0 ms | 33,7 ms | 25% 25% 25% 25% |
| Random | 8,3 ms | 52,0 ms | 25% 26% 24% 25% |
| Least outstanding | 4,6 ms | 30,1 ms | 24% 26% 25% 25% |
| Power of two choices | 5,3 ms | 31,6 ms | 24% 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ớiCache-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ước | Giải quyết | Đổi lại |
|---|---|---|
| Index, sửa query | Query chậm | Ghi chậm hơn một chút, tốn chỗ |
| Cache | Đọc nhiều | Dữ liệu cũ |
| Read replica | Đọc nhiều, cần dữ liệu mới hơn cache | Replication lag: đọc ngay sau khi ghi có thể không thấy (Tách đọc ghi) |
| Partition theo thời gian | Bảng quá lớn, dữ liệu cũ ít đọc | Query phải có điều kiện thời gian mới nhanh (MySQL chương 10) |
| Sharding | Ghi vượt sức một primary, dữ liệu vượt một máy | Mấ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 đọc | Mỗ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ừ đầu | Không | Được, bằng cách lùi offset |
| Hợp với | Chia việc cho worker | Luồ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:
- 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 (
PUTvào một key cụ thể) trong một khoảng thời gian. - Client upload thẳng lên object storage bằng URL đó.
- Client báo server upload xong (hoặc object storage gửi sự kiện). Server ghi metadata vào database.
- 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ần | Dùng khi | Trang |
|---|---|---|
| Rate limiter | Chặn một client gửi quá nhiều, bảo vệ dependency | Rate limiter |
| Sinh ID | Cần ID duy nhất không qua một database trung tâm, có thứ tự theo thời gian | Sinh ID |
| Distributed lock | Chỉ một process được làm một việc tại một thời điểm | Distributed lock |
| etcd, ZooKeeper, Consul | Cấu hình dùng chung, bầu leader, danh sách service đang sống | Consensus, Membership |
Ghi nhớ
- 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.
- 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.
- 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.
- 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.
- Object storage lưu file, database lưu metadata; file đi thẳng giữa client và storage qua presigned URL.
- 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ồn | Dùng cho phần |
|---|---|
| Envoy docs, Supported load balancers | Least 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_module | Mặc định chia theo weighted round-robin; có least_conn |
| AWS docs, Target group attributes của ALB | Round robin là mặc định; có least outstanding requests và weighted random |
| AWS docs, Application Load Balancer, Network Load Balancer | ALB chạy ở tầng 7, NLB ở tầng 4 của mô hình OSI |
| MDN, Cache-Control | immutable: 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 URLs | Strong 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 chia | p50 | p99 | max |
|---|---|---|---|
| Round-robin | 7,0–7,6 ms | 45,9–51,6 ms | 88–93 ms |
| Random | 9,1–9,4 ms | 58,9–63,5 ms | 98–113 ms |
| Least outstanding requests | 5,2–5,3 ms | 26,7–27,6 ms | 50–52 ms |
| Power of two choices | 6,1–6,2 ms | 33,4–50,6 ms | 53–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 chia | p50 | p99 | Phần request của server chậm |
|---|---|---|---|
| Round-robin | 7,1 ms | 4.390–4.436 ms | 25% |
| Random | 9,1–9,7 ms | 4.064–4.267 ms | 25% |
| Least outstanding requests | 5,0 ms | 46,2–50,9 ms | 13–14% |
| Power of two choices | 6,0–6,1 ms | 56,2–60,7 ms | 14% |
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` : ''}`)
}
}