Skip to content

Latency ​

Latency là thời gian một request đi hết hệ thống, tính từ lúc client gửi tới lúc client nhận đủ kết quả. Thời gian đó gồm ba phần:

latency = thời gian trên mạng + thời gian xếp hàng + thời gian xử lý

Trang này là trang chính về cách đo latency và vì sao latency tăng. Các cách giảm cụ thể (index, cache, connection pool…) đã có trang riêng; bảng ở mục Giảm latency dẫn tới từng trang. Phần lớn số liệu trong trang là số đo thật bằng các script Node.js trong lab/latency/.

Latency và throughput ​

Throughput là số request hệ thống xử lý xong trong một giây. Hai chỉ số này hay bị gộp chung thành “nhanh”, nhưng có lúc tăng cái này thì phải hy sinh cái kia.

Ví dụ dễ thấy nhất là ghi theo lô. Gom 1.000 dòng vào một câu INSERT cho throughput cao hơn nhiều so với 1.000 câu riêng lẻ, vì bớt được 999 lần round trip và 999 lần commit. Nhưng dòng đến đầu tiên phải chờ lô đầy mới được ghi, nên latency của nó tăng (Xử lý theo lô). Kafka producer có linger.ms và TCP có thuật toán Nagle cũng đổi latency lấy throughput theo cách này.

Vì vậy, khi được yêu cầu “làm hệ thống nhanh lên”, câu hỏi đầu tiên là: latency của request nào, ở percentile nào, hay là throughput? Hai chỉ số nối với nhau qua hàng đợi, như mục Thời gian xếp hàng giải thích.

Đo latency ​

Vì sao không dùng trung bình ​

Giả sử một API nhận 1.000 request, 980 request mất 20 ms còn 20 request mất 2 giây:

  • Trung bình = (980 × 20 + 20 × 2.000) / 1.000 ≈ 60 ms. Con số này trông ổn, nhưng không request nào thực sự mất 60 ms.
  • p50 = 20 ms, còn p99 = 2.000 ms. Cứ 100 request thì có 2 request chờ 2 giây, và con số trung bình che mất chuyện đó.

Percentile pX là mức mà X% request nhanh hơn hoặc bằng. p50 (median) cho biết trải nghiệm thông thường. p99 và p99,9 cho biết phần đuôi (tail latency), tức những request chậm nhất.

Phần đuôi đáng để ý vì ba lý do:

  • Số người gặp không nhỏ. API nhận 1.000 request mỗi giây thì p99 là 10 request mỗi giây, tức 36.000 request mỗi giờ.
  • Người dùng quan trọng hay rơi vào đuôi. Tài khoản có nhiều đơn hàng nhất, giỏ hàng lớn nhất thường là khách mua nhiều nhất.
  • Một trang gọi nhiều request. Trang phải chờ request chậm nhất, nên người dùng gặp phần đuôi thường hơn nhiều so với 1%. Mục Fan-out đo chuyện này.

Không lấy trung bình của các percentile ​

p99 của cả hệ thống không bằng trung bình p99 của từng server, và p99 của một request cũng không bằng tổng p99 của từng bước bên trong nó. Lý do là mỗi con số chỉ tóm tắt một phân phối; khi đã tóm tắt thành một số thì không ghép lại được nữa. Ví dụ server A xử lý 900 request với p99 50 ms, server B xử lý 100 request với p99 800 ms. Trung bình 425 ms không có ý nghĩa gì, còn p99 thật của 1.000 request phụ thuộc vào bao nhiêu request của B chậm hơn mức nào.

Muốn tính percentile cho nhiều server hay nhiều khoảng thời gian thì phải giữ histogram, tức số request rơi vào từng khoảng latency, rồi cộng histogram lại và tính percentile sau. Prometheus làm vậy với kiểu histogram và hàm histogram_quantile(). Kiểu summary tính percentile sẵn trên từng instance nên không cộng được.

Fan-out làm phần đuôi lan rộng ​

Một request của người dùng thường gọi song song nhiều backend: nhiều service, nhiều shard, nhiều key cache. Request đó chỉ xong khi backend chậm nhất trả lời. Nếu mỗi backend chậm với xác suất p và gọi N backend, thì xác suất request của người dùng bị chậm là:

1 − (1 − p)^N

Với p = 1% và N = 100, kết quả là 63%. Đây cũng là ví dụ trong bài The Tail at Scale của Google: mỗi server thường trả lời trong 10 ms nhưng có p99 một giây, và nếu request phải chờ 100 server như vậy thì 63% request của người dùng mất hơn một giây.

Đo thật với một server Node.js trả lời sau 2–6 ms, riêng 1% request trả lời sau 150 ms (chậm ngẫu nhiên, độc lập giữa các request). Mỗi dòng gồm 300 request của người dùng, và số liệu là khoảng giá trị của hai lượt chạy:

Số backend gọi song songp50 của người dùngp99 của người dùngRequest của người dùng chậm hơn 100 msCông thức
14,8–4,9 ms8,6–11,5 ms0,7–1%1%
106,5–6,6 ms154 ms9,3%9,6%
509,8–9,9 ms156–157 ms38%39,5%
100154 ms159 ms57–65%63,4%

Với 300 mẫu mỗi dòng thì tỉ lệ dao động vài điểm phần trăm giữa các lượt, nhưng vẫn bám theo công thức. Kéo thử số backend và tỉ lệ chậm, rồi bật hedged request (giải thích ở mục bên dưới):

Thử: fan-out và hedged request

Mô hình: các lần chậm độc lập với nhau, hedged request giả định bản thứ hai về trước bản chậm. Chấm tròn là số đo thật với 1% backend chậm (hai lượt; chỉ hiện khi kéo tỉ lệ về 1%).

39%người dùng gặp chậm, gọi thường
0%25%50%75%100%150100150200người dùng gặp chậmsố backend
gọi thườngsố đo thật

Mỗi backend chỉ chậm 1% số lần, nhưng request gọi 50 backend thì 39% người dùng gặp chậm. Bật hedged request để xem phần đuôi co lại.

Ở 100 backend thì ngay cả p50 cũng là thời gian của request chậm, tức phần đuôi của từng backend đã thành trải nghiệm bình thường của người dùng. Trang Cache có ví dụ tương tự khi một trang đọc 50 key cache. Có hai cách chống: giảm số backend phải chờ, và giảm phần đuôi của từng backend (mục Hedged request bên dưới).

Đo sai vì client chờ: coordinated omission ​

Nhiều công cụ benchmark làm việc theo kiểu closed-loop, tức là mỗi connection gửi request, chờ trả lời xong mới gửi request tiếp. Khi server treo một giây, client cũng ngừng gửi trong một giây đó, nên chỉ ghi nhận một mẫu chậm. Còn ngoài đời người dùng không chờ nhau, nên trong một giây đó có hàng trăm request đến và tất cả đều chậm. Gil Tene gọi lỗi đo này là coordinated omission, tức client vô tình phối hợp với server để bỏ sót đúng những mẫu xấu nhất.

Đo thật trên một server trả lời gần như ngay nhưng cứ 5 giây lại treo event loop 1 giây (giống GC pause dài), chạy 20 giây:

Cách đoSố mẫup50p99p99,9max
Closed-loop, 1 connectionkhoảng 385.0000,04 ms0,06–0,07 ms0,14–0,15 ms1.000 ms
Open-loop, 200 request/giây theo lịch4.0001,4 ms932–934 ms996–998 ms997–999 ms

Server treo 20% thời gian, vậy mà closed-loop báo p99 chưa tới 0,1 ms; chỉ cột max cho thấy có lần treo. Lý do là trong lúc server chạy bình thường, client closed-loop gửi được hàng trăm nghìn request nhanh, còn mỗi lần treo chỉ góp một mẫu. Open-loop gửi theo lịch cố định và tính latency từ thời điểm lẽ ra phải gửi, nên mọi request rơi vào lúc treo đều bị tính là chậm. README của wrk2 có ví dụ tương tự: server treo 1,4 giây làm p99 đo đúng là 1,27 s, còn đo kiểu cũ chỉ ra 6 ms.

Kéo độ dài mỗi lần treo để xem hai cách đo ghi nhận khác nhau thế nào:

Closed-loop386.367 mẫuOpen-loop4.000 mẫu0 s5 s10 s15 s20 s
lúc server treorequest nhanhrequest chậm (chiều cao là latency)
Cách đoSố mẫup50p99p99,9max
Closed-loop, 1 connection386.3670,04 ms0,04 ms0,04 ms1.000 ms
Open-loop, 200 request/giây4.0001,4 ms936 ms996 ms1.001 ms

Closed-loop chỉ ghi được 3 mẫu chậm, vì trong lúc server treo nó cũng đứng chờ. Giữa hàng trăm nghìn mẫu nhanh, 3 mẫu đó không chạm được tới p99,9, nên chỉ cột max cho thấy có lần treo. Open-loop gửi theo lịch nên mọi request lẽ ra phải gửi trong lúc treo đều bị tính chậm, và p99 phản ánh đúng việc server treo 15% thời gian.

Mô hình lấy thông số từ số đo thật ở bảng trên (treo 1 giây: closed-loop khoảng 385.000 mẫu, open-loop p99 khoảng 930 ms). Request nhanh của closed-loop mất 0,044 ms, của open-loop mất 1,4 ms.

Khi load test, chọn chế độ open-loop: wrk2 với -R, k6 với executor constant-arrival-rate hoặc ramping-arrival-rate (các executor còn lại của k6 là closed-loop).

Đo ở đâu ​

Thời gian đo bên trong handler chỉ là thời gian xử lý. Nó không gồm thời gian request chờ ở load balancer, chờ trong hàng đợi accept của hệ điều hành, hay chờ event loop rảnh để chạy callback. Lúc hệ thống quá tải thì chính các khoảng chờ đó tăng mạnh nhất, nên log của server có thể vẫn báo 20 ms trong khi người dùng chờ 3 giây. Vì vậy cần đo ở nhiều chỗ: ở client hoặc load balancer cho latency thật, trong handler cho thời gian xử lý, và đo thêm event loop delay của Node.js (xem Bài toán).

Latency gồm những gì ​

Thời gian trên mạng ​

Ánh sáng đi trong cáp quang khoảng 200.000 km/s, tức mỗi 1.000 km mất 5 ms một chiều và 10 ms cho một round trip (RTT). Đây là giới hạn vật lý, nên không có cách tối ưu code nào vượt qua được. Mức dưới tính theo đường chim bay:

TuyếnKhoảng cáchRTT tối thiểu
Cùng data centerkhoảng 0,5 ms (số đo, bảng của Jeff Dean)
TP.HCM – Singapore~1.100 km~11 ms
Hà Nội – Tokyo~3.700 km~37 ms
Hà Nội – bờ đông nước Mỹ~13.000 km~130 ms

RTT thật luôn cao hơn, vì cáp không đi đường thẳng và gói tin còn đi qua nhiều router.

Một request không chỉ tốn một RTT, vì có thêm các bước bắt tay trước khi gửi dữ liệu:

BướcSố RTT
TCP handshake1
TLS 1.22
TLS 1.31 (0 RTT khi nối lại session, với rủi ro replay)
QUIC (HTTP/3)1, gộp chung bắt tay transport và TLS

Connection HTTPS mới qua TCP và TLS 1.3 mất 2 RTT trước khi gửi request, cộng thêm 1 RTT cho chính request đó. Nếu RTT là 200 ms thì request đầu tiên mất ít nhất 600 ms. Cách chống là giữ connection lâu (keep-alive, connection pool), và đặt điểm kết thúc TLS gần người dùng (CDN, edge).

Chỗ dễ bỏ sót hơn là gọi nhiều lần nối tiếp nhau. 100 query tuần tự trong cùng data center tốn 100 × 0,5 ms = 50 ms tiền mạng. Nhưng nếu app đặt ở Việt Nam mà database ở Singapore, cùng 100 query đó tốn 100 × 30 ms = 3 giây. Lỗi N+1 query (Bẫy của ORM) vì vậy đắt hơn nhiều khi app và database không cùng chỗ.

Mạng còn mất gói. Khi mất gói, TCP phải chờ hết thời gian timeout rồi mới gửi lại (trên Linux mức tối thiểu khoảng 200 ms), nên một request bình thường 20 ms có thể thành hơn 200 ms. Đây là một nguồn phần đuôi mà code ứng dụng không nhìn thấy.

Thời gian xếp hàng ​

Hàng đợi có ở khắp nơi: hàng đợi accept của socket, event loop của Node.js, thread pool của libuv (mặc định 4 thread), connection pool của driver database, hàng đợi CPU của hệ điều hành, hàng đợi của disk. Lúc tải thấp thì gần như không ai phải xếp hàng. Lúc tải gần sát năng lực xử lý thì thời gian xếp hàng lớn hơn thời gian xử lý rất nhiều.

Định luật Little. Với hệ thống ở trạng thái ổn định:

L = λ × W

Trong đó L là số request đang nằm trong hệ thống (đang chờ cộng đang xử lý), λ là số request đến mỗi giây, W là thời gian trung bình một request ở trong hệ thống. Định luật này đúng với mọi hệ thống, không cần giả định gì về phân phối.

Dùng định luật này theo hai chiều:

  • Ước lượng tài nguyên. 2.000 request/giây, mỗi request giữ connection database 5 ms, thì trung bình chỉ có 2.000 × 0,005 = 10 connection bận (MySQL chương 10).
  • Tìm giới hạn. Pool có 10 connection và mỗi request giữ 5 ms thì pool xử lý tối đa 10 / 0,005 = 2.000 request/giây. Request đến nhanh hơn thế thì hàng chờ connection dài thêm mãi, và latency tăng cho tới khi request bắt đầu bị timeout. Thêm server ứng dụng lúc này không giúp gì, vì tất cả đang chờ cùng một pool.

Thử áp định luật Little cho connection pool của MySQL (cùng mô phỏng ở MySQL chương 10). Đáng thử nhất là preset gọi API 200 ms trong transaction:

Thử: tính connection cần dùng

Giả định DB có 16 core. Request phân bố đều; thực tế có đỉnh nên cần dư thêm.

10connection bận cùng lúc (trung bình)
100connection mở tới DB
151max_connections
Tổng 100 connection nằm dưới max_connections (151).
Pool dư: trung bình chỉ khoảng 10% connection bận.
Số query đồng thời vừa sức DB 16 core.

Độ bận và latency. Utilization là tỉ lệ thời gian server bận. Với mô hình hàng đợi đơn giản nhất (M/M/1: request đến ngẫu nhiên, thời gian xử lý ngẫu nhiên, một worker), thời gian trung bình trong hệ thống là:

R = S / (1 − U)

S là thời gian xử lý trung bình, U là độ bận. Ở U = 50% thì R gấp đôi S, ở 80% gấp 5 lần, ở 90% gấp 10 lần, ở 95% gấp 20 lần. Đường cong đi ngang rất lâu rồi dựng đứng ở đoạn cuối. Lý do là request không đến đều đặn: có giây không ai gọi, có giây rất nhiều người gọi cùng lúc. Server càng ít thời gian rảnh thì càng ít cơ hội xử lý hết phần dồn lại sau mỗi đợt dồn.

Thử: độ bận và latency
Số worker dùng chung hàng đợi

Mô hình M/M/c: request đến ngẫu nhiên (Poisson), thời gian xử lý ngẫu nhiên (phân phối mũ), mọi worker lấy việc từ một hàng đợi chung. Số liệu là công thức, không phải số đo; số đo thật ở bảng bên dưới.

60request/giây
25,0 mstrung bình
115 msp99
Bắt đầu chậm
0501001502002500%20%40%60%80%100%latency (ms)
p99trung bình

Ở độ bận 60%, 60% request phải xếp hàng, p99 gấp 2,5 lần lúc rảnh. Thử tăng số worker: cùng một độ bận, nhiều worker dùng chung hàng đợi thì đường cong dựng đứng muộn hơn.

Đo thật với một server Node.js một thread, mỗi request tốn trung bình 5 ms CPU (phân phối mũ), client gửi theo phân phối Poisson, mỗi mức chạy 30 giây:

Độ bận đặtĐộ bận đo đượcTrung bình đoTrung bình theo công thứcp99 đop99 theo công thức
30%30–31%8,6–9,6 ms7,1 ms35–37 ms33 ms
50%50%10,9–11,0 ms10 ms46–48 ms46 ms
70%69–73%16,7–19,4 ms16,7 ms78–82 ms77 ms
80%80–82%28,5–28,9 ms25 ms130–142 ms115 ms
90%89–90%42–48 ms50 ms157–202 ms230 ms
95%93–96%67–133 ms100 ms288–553 ms461 ms

Hai lượt chạy ngày 2026-10-20. Ở độ bận thấp, số đo cao hơn công thức khoảng 1–2 ms, vì mỗi request còn tốn thời gian HTTP và độ trễ timer của client. Từ 30% tới 80%, số đo bám sát công thức. Ở 90–95% thì hai lượt lệch nhau nhiều: lượt đầu đo được độ bận 93% và trung bình 67 ms, lượt sau đo được 96% và trung bình 133 ms. Chỉ ba điểm phần trăm độ bận đã làm latency gấp đôi, và hàng đợi ở mức này cần rất lâu mới ổn định. Vậy ở vùng này latency không chỉ cao mà còn khó đoán trước, và đó là lý do thứ hai để không chạy server ở đây.

Khi thử với sim, có ba điều đáng chú ý:

  • Nhiều worker dùng chung một hàng đợi chịu được độ bận cao hơn nhiều so với một worker. Ở 80% độ bận, 16 worker có p99 gần như lúc rảnh, còn 1 worker có p99 gấp 5 lần. Nhưng tới gần 100% thì cả hai đều vọt lên.
  • Tải tăng một chút, latency tăng rất nhiều. Ở độ bận 80% với 1 worker, traffic tăng 20% đẩy độ bận lên 96% và p99 tăng 5 lần.
  • Hệ quả thực tế là phải chừa khoảng trống (headroom). Nhiều đội đặt ngưỡng autoscale khoảng 60–70% CPU, vì lúc chạm 90% thì latency đã hỏng trước khi máy mới kịp lên. Ngưỡng cụ thể nên lấy từ load test: tăng tải dần cho tới khi p99 bắt đầu vọt, rồi đặt ngưỡng thấp hơn điểm đó.

Khi đã quá tải thì xếp hàng thêm chỉ làm mọi request cùng chậm. Hai cách chặn:

  • Giới hạn độ dài hàng đợi và từ chối sớm (load shedding). Trả 503 ngay cho một phần request tốt hơn để tất cả cùng timeout.
  • Bỏ request đã quá hạn. Request đã chờ lâu hơn timeout của client thì client đã bỏ đi rồi, xử lý tiếp chỉ tốn tài nguyên.

Thời gian xử lý ​

Phần này dễ can thiệp nhất vì nằm trong code, nhưng gồm nhiều thứ hơn thời gian chạy thuật toán:

  • CPU. Thuật toán, serialize JSON, regex, mã hoá.
  • Chờ I/O. Phần lớn thời gian xử lý của một API backend thực ra là chờ database, cache hay service khác trả lời, còn CPU thì rảnh trong lúc đó.
  • GC pause. Garbage collector phải dừng chương trình trong những khoảng ngắn để dọn bộ nhớ. GC hiện đại (V8 của Node.js, Go, ZGC và G1 của Java) làm phần lớn việc song song với chương trình nên các lần dừng thường ngắn, nhưng heap lớn và nhiều object sống lâu thì dừng lâu hơn. Discord từng tốn nhiều công tinh chỉnh GC của JVM cho Cassandra vì GC pause gây các đợt latency vọt lên, có lúc phải khởi động lại node bằng tay. Sau khi chuyển sang ScyllaDB (viết bằng C++, không có GC), p99 đọc tin nhắn cũ giảm từ 40–125 ms xuống 15 ms.
  • Event loop bị chặn (riêng Node.js). Một callback chạy CPU 200 ms thì mọi request khác đang chờ trong event loop cùng chậm thêm 200 ms. Đây vừa là thời gian xử lý của request đó, vừa là thời gian xếp hàng của các request còn lại.

Bài The Tail at Scale liệt kê thêm các nguồn gây phần đuôi mà không nằm trong code của request: daemon chạy nền trên cùng máy, việc bảo trì định kỳ (compaction, GC), tài nguyên dùng chung (CPU, cache CPU, băng thông), và hàng đợi ở nhiều tầng.

Giảm latency: bản đồ các cách ​

Nguyên tắc chung là cách nhanh nhất để làm một việc là không phải làm việc đó. Các cách chia theo phần latency mà nó cắt giảm:

NhómCáchTrang chi tiết
Bớt đường điĐặt app và database cùng region; CDN cho nội dung tĩnhThời gian trên mạng
Replica đặt gần người dùng, đọc từ replicaReplication, Tách đọc / ghi
Cache trong process, cache dùng chungĐặt cache ở đâu
Bớt việcIndex đúng, viết WHERE dùng được indexIndex, Viết WHERE
Gộp query, tránh N+1, chỉ lấy cột cầnBẫy của ORM, Thuật toán join
Tính một lần, lưu kết quảChiến lược cache
Dùng lại connection thay vì mở mớiConnection pool
Bớt chờTransaction ngắn, không gọi API bên ngoài khi đang mở transactionGiữ transaction ngắn, Bẫy của ORM
Chia dữ liệu để bớt tranh chấp lockPartitioning
Giữ headroom, giới hạn hàng đợiThời gian xếp hàng
Giấu latencyLàm việc chậm sau khi đã trả lời người dùngHàng đợi bằng database
Gọi song songChạy song song
Hedged requestHedged request
Gộp các lần đọc cùng key khi cache miss (singleflight)Thundering herd
Tải trước thứ người dùng sắp cầnPrefetch

Ba mục cuối của trang đi vào các cách chưa có trang riêng.

Chạy song song và giới hạn của nó ​

Trang hồ sơ người dùng cần ba lời gọi độc lập: thông tin cơ bản 50 ms, danh sách bạn bè 100 ms, lịch sử đơn hàng 150 ms. Gọi lần lượt mất 300 ms, còn gọi song song bằng Promise.all chỉ mất 150 ms, bằng lời gọi chậm nhất.

js
const [user, friends, orders] = await Promise.all([
  getUser(id),
  getFriends(id),
  getOrders(id)
])

Song song hoá có hai giới hạn:

  • Phần không song song được quyết định mức tăng tốc tối đa (định luật Amdahl). Nếu tỉ lệ P của công việc chạy song song được trên N worker, thì tốc độ tăng tối đa là 1 / ((1 − P) + P / N).
  • Càng nhiều nhánh thì càng dễ trúng phần đuôi, như mục Fan-out đã đo.
Phần song song được (P)2 worker10 worker100 workerVô hạn
50%1,33×1,82×1,98×2×
90%1,82×5,26×9,17×10×
99%1,98×9,17×50,3×100×

Với P = 90%, từ 10 lên 100 worker chỉ tăng tốc thêm chưa tới 2 lần, nên trước khi thêm máy cần tối ưu phần tuần tự (thường là chỗ chờ lock, chờ một query lớn, hay gom kết quả).

Thử: định luật Amdahl
4,71×nhanh hơn một worker
10,0×trần khi N vô hạn
59%hiệu suất mỗi worker
0,00×3,00×6,00×9,00×12,0×1248163264128256số worker (thang log)
P = 90%lý tưởng (P = 100%)trần 10,0×

Với 90% chạy song song được, 8 worker nhanh hơn 4,71 lần, và dù thêm bao nhiêu worker cũng không vượt quá 10,0 lần. Phần tuần tự 10% quyết định trần đó, nên tối ưu phần tuần tự có lợi hơn thêm máy.

Hedged request ​

Ý tưởng của hedged request là gửi cùng một request tới hai replica rồi lấy kết quả nào về trước. Gửi đôi mọi request thì gấp đôi tải, nên cách làm trong bài The Tail at Scale là chỉ gửi bản thứ hai khi bản thứ nhất đã chờ quá mức p95 của loại request đó. Như vậy tải chỉ tăng khoảng 5%, mà phần đuôi ngắn đi nhiều. Cách này có tác dụng vì request chậm thường không phải do bản thân nó nặng, mà do máy nhận nó đang bận việc khác (GC, compaction, request khác). Google đo trên BigTable: đọc 1.000 key nằm trên 100 server, gửi bản thứ hai sau 10 ms làm p99,9 giảm từ 1.800 ms xuống 74 ms, trong khi chỉ gửi thêm 2% request.

Đo thật với cùng server ở mục Fan-out (1% request chậm 150 ms), mỗi request của người dùng gọi 50 backend, 300 request của người dùng:

Cách gọip50 của người dùngp99 của người dùngRequest của người dùng chậm hơn 100 msRequest gửi thêm
Gọi thường9,6–10,8 ms156–157 ms39–49%0
Hedged, ngưỡng = p95 đo lúc gọi lẻ (6,9–7,0 ms)8,8–9,5 ms16,7–18,4 ms0,3%18–41%
Hedged, ngưỡng = p95 đo lúc fan-out (8,3–8,6 ms)9,3–10,1 ms16,4–24 ms0,3%4,5–6,5%

p99 giảm từ khoảng 156 ms xuống dưới 25 ms. Vẫn còn 0,3% request của người dùng chậm vì cả hai bản gửi đi đều rơi vào 1% chậm.

Hai hàng hedged cho thấy cái bẫy khi chọn ngưỡng. p95 đo lúc gọi lẻ trên server rảnh là khoảng 7 ms, nhưng khi 50 lời gọi chạy cùng lúc thì mỗi lời gọi đều chậm hơn một chút. Ngưỡng chỉ thấp hơn khoảng 1,5 ms mà số request gửi thêm đã tăng từ khoảng 5% lên 18–41%. Vì vậy ngưỡng phải lấy từ p95 đo trong đúng điều kiện tải, và tốt nhất là tính lại liên tục từ các lời gọi gần đây.

Code Node.js, rút gọn từ script đo:

js
async function hedged(callReplica, delayMs) {
  const c1 = new AbortController()
  const c2 = new AbortController()
  let timer
  const first = callReplica(0, c1.signal)
  const second = new Promise((resolve, reject) => {
    timer = setTimeout(() => callReplica(1, c2.signal).then(resolve, reject), delayMs)
  })
  try {
    return await Promise.any([first, second])
  } finally {
    clearTimeout(timer) // về trước ngưỡng thì không gửi bản thứ hai
    c1.abort()          // huỷ bản còn đang chạy
    c2.abort()
  }
}

Điều kiện để dùng:

  • Chỉ dùng cho request đọc hoặc request idempotent (gửi nhiều lần cho cùng kết quả như gửi một lần). Bài báo của Google cũng nói các kỹ thuật này chủ yếu dành cho thao tác không sửa trạng thái quan trọng. Gửi đôi một lệnh trừ tiền là trừ hai lần.
  • Phải huỷ được bản còn lại, không thì máy chủ vẫn làm đủ cả hai.
  • Không có tác dụng khi cả hệ thống quá tải. Chậm do mọi replica đều bận thì gửi thêm request chỉ làm tải tăng và chậm hơn, giống retry storm. Nên giới hạn tỉ lệ request được gửi đôi.

Bài báo còn mô tả tied request: gửi cùng lúc tới hai server, server nào bắt đầu xử lý trước thì báo server kia huỷ bản của nó. Cách này cần server hỗ trợ, nên ít gặp hơn ở tầng ứng dụng.

Prefetch ​

Prefetch là làm trước việc người dùng sắp yêu cầu, trong lúc họ còn đang làm việc khác. Latency không giảm, nhưng người dùng không phải chờ nó. Ví dụ:

  • Người dùng đang đọc trang 1 của danh sách thì frontend tải sẵn trang 2.
  • Form đăng ký kiểm tra username đã có người dùng chưa ngay khi người dùng rời ô username, nên lúc bấm đăng ký thì đã có kết quả.

Cái giá là tốn tài nguyên cho những lần đoán sai, nên chỉ prefetch khi khả năng dùng tới cao và việc tải trước rẻ.

Async processing cũng giấu latency theo cách tương tự. Upload video thì server nhận file, trả lời ngay “đang xử lý”, còn việc chuyển định dạng chạy sau trong hàng đợi (Hàng đợi bằng database).

Bài toán: p99 tăng vọt giờ cao điểm ​

API danh sách đơn hàng có p50 30 ms. Vào 20h mỗi tối, p99 tăng từ 200 ms lên 3 giây, trong khi p50 chỉ tăng lên 45 ms. CPU của các instance Node.js khoảng 85%.

Trình tự điều tra:

  1. Tách latency theo thành phần bằng tracing (OpenTelemetry) hoặc log thời gian từng bước. Cần biết 3 giây đó nằm ở chờ connection, chạy query, hay chờ event loop.

  2. Đo event loop delay. Nếu event loop delay cũng vọt lên vào 20h thì request đang xếp hàng trong process:

    js
    import { monitorEventLoopDelay } from 'node:perf_hooks'
    
    const RES = 10
    const h = monitorEventLoopDelay({ resolution: RES })
    h.enable()
    // Giá trị tính bằng nanosecond, và mỗi mẫu đã gồm cả resolution nên trừ đi
    const ms = (ns) => Math.max(0, ns / 1e6 - RES)
    setInterval(() => {
      console.log({ p50: ms(h.percentile(50)), p99: ms(h.percentile(99)), max: ms(h.max) })
      h.reset()
    }, 10_000)
  3. Đo thời gian chờ lấy connection từ pool, tách khỏi thời gian chạy query. Thời gian chờ lớn thì áp định luật Little: số request/giây nhân thời gian giữ connection đã chạm kích thước pool.

  4. Đối chiếu với đường cong độ bận. CPU 85% với một thread mỗi process là đã ở đoạn dựng đứng. Thêm instance để kéo về khoảng 60% sẽ giảm p99 rõ rệt, dù p50 gần như không đổi.

  5. Tìm việc thừa trong request chậm. Request chậm nhất thường là của khách có nhiều đơn nhất: thiếu index cho ORDER BY, phân trang bằng OFFSET lớn, N+1 khi lấy chi tiết đơn. Sửa những chỗ đó giảm cả thời gian xử lý lẫn độ bận.

Thứ tự này quan trọng. Thêm instance khi nút thắt là connection pool hay một query thiếu index thì không cứu được gì, mà còn làm database chịu thêm connection.

Ghi nhớ

  1. Latency = thời gian trên mạng + thời gian xếp hàng + thời gian xử lý. Trước khi tối ưu, xác định phần nào đang lớn.
  2. Nhìn p99 và p99,9, không nhìn trung bình. Muốn gộp percentile của nhiều server thì gộp histogram, không lấy trung bình các percentile.
  3. Fan-out làm phần đuôi lan rộng: 1% chậm ở mỗi backend thành 63% chậm khi chờ 100 backend.
  4. Load test phải chạy open-loop (wrk2 -R, k6 constant-arrival-rate), không thì coordinated omission giấu mất các lần server treo.
  5. Latency tăng chậm theo độ bận rồi dựng đứng ở đoạn cuối. Giữ headroom, đặt ngưỡng autoscale theo load test, và từ chối sớm khi quá tải.
  6. Hedged request cắt phần đuôi với ít tải thêm, nhưng chỉ cho request idempotent, với ngưỡng là p95 đo trong đúng điều kiện tải.

Nguồn tham khảo ​

Tài liệu chủ repo gửi: Quang Hoàng, A Latency Handbook for Software Engineers (ebook PDF, file A Latency Handbook for SWE - Quang Hoang.pdf ở thư mục cha của repo). Trang này lấy từ ebook khung ba phần của latency, các ví dụ fan-out, hedged request, singleflight, prefetch và cách chia nhóm các kỹ thuật. Có vài chỗ trang này không theo ebook:

  • Ebook nói với P = 1 thì thêm server thứ 101 gần như không có tác dụng. Thực ra P = 1 cho tăng tốc tuyến tính theo số server; đường cong chỉ đi ngang khi P < 1.
  • Ebook suy từ W = L / λ rằng λ tăng thì W phải tăng. Ý đúng là khi λ vượt năng lực xử lý thì hàng đợi dài thêm mãi. Đường cong 1 / (1 − U) cũng là đường hyperbol, không phải hàm mũ.
  • Bảng số latency trong ebook ghi là của Jeff Dean năm 2011, nhưng nhiều số khác bảng gốc (RAM 50 ns so với 100 ns, đọc 4 KB từ SSD 20 µs so với 150 µs, round trip trong data center 50 µs so với 500 µs). Trang này dùng số ở bảng gốc, và bảng đầy đủ nằm ở MySQL chương 1.
  • Ebook nói TLS cần thêm 2 RTT. Đó là TLS 1.2; TLS 1.3 chỉ cần 1 RTT.
  • Ebook gộp Java, Go, Python là GC stop-the-world. GC của V8, Go, ZGC, G1 làm phần lớn việc song song với chương trình; Python chủ yếu dùng reference counting.
  • Ebook nói DragonflyDB có latency thấp hơn Redis 25 lần. Con số 25 lần là benchmark của chính hãng Dragonfly và nói về throughput; trang này không dùng.
  • Ebook có vài chi tiết về index đã được trang Index viết kỹ hơn: điều kiện <= '2023-12-31 23:59:59' bỏ sót phần lẻ của giây (nên viết < '2024-01-01'), và MySQL 8.0.13 trở lên có skip scan nên câu “WHERE Firstname không dùng index” không còn đúng tuyệt đối.

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

NguồnDùng cho phần
Jeffrey Dean, Luiz André Barroso, The Tail at Scale, Communications of the ACM 56(2), 2013Ví dụ 100 server và 63%; hedged request sau p95, tải thêm khoảng 5%; số đo BigTable (1.800 ms xuống 74 ms, thêm 2% request); tied request; các kỹ thuật dành cho thao tác không sửa trạng thái quan trọng; các nguồn gây phần đuôi
wrk2 READMECoordinated omission, đo latency từ thời điểm lẽ ra phải gửi, ví dụ server treo 1,4 s (p99 1,27 s so với 6,04 ms)
k6 docs, Open and closed modelsClosed model làm giảm tốc độ gửi khi server chậm; chỉ constant-arrival-rate và ramping-arrival-rate là open model
Latency numbers every programmer should know (bảng của Jeff Dean, Peter Norvig tổng hợp)Số gốc để đối chiếu bảng trong ebook; round trip trong data center 500 µs; gói tin California – Hà Lan – California 150 ms
Discord, How Discord Stores Trillions of Messages (2023)GC pause của Cassandra, p99 trước và sau khi chuyển sang ScyllaDB

Từ kiến thức chung, chưa đối chiếu lần này:

  • John D. C. Little, A Proof for the Queuing Formula L = λW, Operations Research, 1961. Công thức M/M/1 và Erlang C (M/M/c) trong UtilizationSim lấy từ giáo trình lý thuyết hàng đợi; p99 của M/M/1 bằng R × ln(100) vì thời gian trong hệ thống có phân phối mũ.
  • Gene Amdahl, 1967 (định luật Amdahl).
  • RFC 8446 (TLS 1.3, handshake 1 RTT, 0-RTT có rủi ro replay), RFC 9000 và RFC 9001 (QUIC).
  • Mức timeout gửi lại tối thiểu của TCP trên Linux khoảng 200 ms.
  • Prometheus: histogram cộng được giữa các instance, summary thì không.
  • GC của V8 (dự án Orinoco), Go, ZGC và G1 làm phần lớn việc song song với chương trình.
  • monitorEventLoopDelay của Node.js trả giá trị theo nanosecond.
  • Khoảng cách địa lý trong bảng RTT là ước lượng đường chim bay.

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

Chạy bằng npm run lab -- latency (chỉ cần Node.js, không cần Docker, khoảng 5–6 phút). Số trong trang lấy từ hai lượt chạy ngày 2026-10-20 trên MacBook M2, xem Lab.

lab/latency/server.mjs: server giả lập ba chế độ
js
// Server HTTP giả lập cho trang system-design/latency. Chạy trong process con để
// không tranh event loop với client đo. Ba chế độ, chọn bằng argv[2]:
//   cpu   <S>         mỗi request tốn CPU ngẫu nhiên theo phân phối mũ, trung bình S ms (một thread, xử lý lần lượt)
//   tail  <p> <slow>  phần lớn trả lời sau 2–6 ms; tỉ lệ p trả lời sau `slow` ms (không tốn CPU, nên không xếp hàng)
//   pause <every>     trả lời ngay, nhưng cứ `every` ms thì treo event loop 1 giây (giống GC stop-the-world)
import http from 'node:http'
import { performance } from 'node:perf_hooks'

const [mode, a, b] = process.argv.slice(2)

function spin(ms) {
  const t0 = performance.now()
  while (performance.now() - t0 < ms) {}
}

// Tổng thời gian server thật sự bận (chế độ cpu), để tính độ bận đo được.
let busy = 0
const handlers = {
  cpu: (req, res) => {
    const t0 = performance.now()
    spin(-Number(a) * Math.log(1 - Math.random()))
    res.end('ok')
    busy += performance.now() - t0
  },
  tail: (req, res) => {
    const slow = Math.random() < Number(a)
    setTimeout(() => res.end(slow ? 'slow' : 'ok'), slow ? Number(b) : 2 + Math.random() * 4)
  },
  pause: (req, res) => res.end('ok')
}

if (mode === 'pause') setInterval(() => spin(1000), Number(a))

let served = 0
const server = http.createServer((req, res) => {
  served++
  handlers[mode](req, res)
})
server.keepAliveTimeout = 60_000
server.listen(0, '127.0.0.1', () => process.send({ port: server.address().port }))
process.on('message', (m) => m === 'count' && process.send({ served, busy }))
lab/latency/util.mjs: tải open-loop, closed-loop, percentile
js
// Hàm dùng chung: dựng server con, gửi request, tính percentile.
import http from 'node:http'
import { fork } from 'node:child_process'
import { performance } from 'node:perf_hooks'

export const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
export const now = () => performance.now()

export async function startServer(...args) {
  const child = fork(new URL('./server.mjs', import.meta.url), args, { stdio: 'inherit' })
  const { port } = await new Promise((r) => child.once('message', r))
  const agent = new http.Agent({ keepAlive: true, maxSockets: Infinity })
  // Số request server đã xử lý và tổng ms server bận, tính từ lúc khởi động.
  const served = () =>
    new Promise((r) => {
      child.once('message', r)
      child.send('count')
    })
  return { port, agent, served, stop: () => (agent.destroy(), child.kill()) }
}

// Gửi một GET, trả về promise xong khi đọc hết body. `signal` để huỷ request.
export function get({ port, agent }, signal, path = '/') {
  return new Promise((resolve, reject) => {
    const req = http.get({ host: '127.0.0.1', port, path, agent, signal }, (res) => {
      let body = ''
      res.on('data', (c) => (body += c))
      res.on('end', () => resolve(body))
    })
    req.on('error', reject)
  })
}

// Percentile theo cách nearest-rank: giá trị nhỏ nhất mà ít nhất p% mẫu nhỏ hơn hoặc bằng.
export function pct(sorted, p) {
  if (!sorted.length) return NaN
  return sorted[Math.min(sorted.length - 1, Math.ceil((p / 100) * sorted.length) - 1)]
}

export function summary(samples) {
  const s = [...samples].sort((x, y) => x - y)
  const mean = s.reduce((x, y) => x + y, 0) / s.length
  return { n: s.length, mean, p50: pct(s, 50), p95: pct(s, 95), p99: pct(s, 99), p999: pct(s, 99.9), max: s[s.length - 1] }
}

// Tải open-loop: request đến theo lịch định trước (Poisson với `rate` request/giây hoặc đều đặn),
// không chờ request trước xong. Latency tính từ thời điểm theo lịch, nên lúc server treo thì
// mọi request lẽ ra đã gửi trong lúc đó đều bị tính chậm.
export async function openLoop(srv, { rate, seconds, poisson = true, path = '/' }) {
  const gaps = []
  let t = 0
  while (t < seconds * 1000) {
    t += poisson ? -Math.log(1 - Math.random()) * (1000 / rate) : 1000 / rate
    gaps.push(t)
  }
  const start = now()
  const lat = []
  const pending = []
  let i = 0
  await new Promise((done) => {
    const tick = () => {
      const elapsed = now() - start
      while (i < gaps.length && gaps[i] <= elapsed) {
        const due = start + gaps[i++]
        pending.push(get(srv, undefined, path).then(() => lat.push(now() - due)))
      }
      if (i < gaps.length) setTimeout(tick, Math.max(0, gaps[i] - (now() - start)))
      else done()
    }
    tick()
  })
  await Promise.all(pending)
  return lat
}

// Tải closed-loop: một client gửi request, chờ xong mới gửi request tiếp. Đây là cách
// nhiều công cụ benchmark đơn giản hoạt động.
export async function closedLoop(srv, { seconds }) {
  const lat = []
  const end = now() + seconds * 1000
  while (now() < end) {
    const t0 = now()
    await get(srv)
    lat.push(now() - t0)
  }
  return lat
}

export const ms = (v) => (v >= 100 ? Math.round(v).toString() : v.toFixed(v < 1 ? 2 : 1)).replace('.', ',')
lab/latency/queue.mjs: độ bận và latency
js
// Độ bận (utilization) và latency. Server một thread, mỗi request tốn trung bình S = 5 ms CPU
// (phân phối mũ). Client gửi theo phân phối Poisson với tốc độ λ = U / S, tăng dần U.
// So số đo với công thức hàng đợi M/M/1: trung bình R = S / (1 − U), p99 = R × ln(100).
// Ở độ bận cao hàng đợi cần rất lâu mới ổn định, nên mỗi mức chạy 30 giây; số dao động giữa các lượt.
import { startServer, openLoop, summary, ms, sleep } from './util.mjs'

const S = 5
const SECONDS = Number(process.env.SECONDS || 30)
const LEVELS = [0.3, 0.5, 0.7, 0.8, 0.9, 0.95]

const srv = await startServer('cpu', String(S))
await openLoop(srv, { rate: 50, seconds: 2 }) // khởi động JIT, mở sẵn connection

console.log(`\n=== Utilization: S = ${S} ms, mỗi mức chạy ${SECONDS} s ===`)
console.log('U đặt  U đo   λ (req/s)  số req  TB đo  TB lý thuyết  p50   p99 đo  p99 lý thuyết')
for (const U of LEVELS) {
  const rate = (U * 1000) / S
  const before = await srv.served()
  const t0 = performance.now()
  const r = summary(await openLoop(srv, { rate, seconds: SECONDS }))
  const after = await srv.served()
  const uMeasured = (after.busy - before.busy) / (performance.now() - t0)
  const R = S / (1 - U)
  console.log(
    [U.toFixed(2).padEnd(5), uMeasured.toFixed(2), String(Math.round(rate)).padStart(9), String(r.n).padStart(7), ms(r.mean).padStart(6),
      ms(R).padStart(13), ms(r.p50).padStart(5), ms(r.p99).padStart(7), ms(R * Math.log(100)).padStart(14)].join('  ')
  )
  await sleep(1000)
}
srv.stop()
lab/latency/tail.mjs: fan-out và hedged request
js
// Fan-out và hedged request. Mỗi server con trả lời sau 2–6 ms, riêng 1% request trả lời sau
// 150 ms (chậm ngẫu nhiên, độc lập giữa các request, giống GC hay một máy đang bận).
// Một request của user gọi N backend song song và phải chờ cái chậm nhất.
import { startServer, get, summary, ms, now } from './util.mjs'

const P_SLOW = 0.01, SLOW = 150
const USERS = Number(process.env.USERS || 300)
const srv = await startServer('tail', String(P_SLOW), String(SLOW))
for (let i = 0; i < 200; i++) await get(srv) // khởi động

async function userRequest(n, call) {
  const t0 = now()
  await Promise.all(Array.from({ length: n }, call))
  return now() - t0
}

console.log(`\n=== Fan-out: mỗi backend chậm ${SLOW} ms với xác suất ${P_SLOW * 100}%, ${USERS} request của user ===`)
console.log('N backend  p50 user  p99 user  % user chậm > 100 ms  lý thuyết 1 − 0,99^N')
for (const n of [1, 10, 50, 100]) {
  const lat = []
  for (let u = 0; u < USERS; u++) lat.push(await userRequest(n, () => get(srv)))
  const r = summary(lat)
  const slow = (lat.filter((x) => x > 100).length / lat.length) * 100
  console.log([String(n).padStart(9), ms(r.p50).padStart(8), ms(r.p99).padStart(9), (slow.toFixed(1) + '%').padStart(21),
    ((1 - 0.99 ** n) * 100).toFixed(1).padStart(20) + '%'].join('  '))
}

// Cách chọn D thứ nhất: p95 của từng lần gọi khi gọi lẻ, lúc server rảnh.
const single = []
for (let i = 0; i < 2000; i++) {
  const t0 = now()
  await get(srv)
  single.push(now() - t0)
}
const dIdle = summary(single).p95

// Cách thứ hai: p95 của từng lần gọi trong đúng điều kiện fan-out 50, đo trong lượt gọi thường.
const perCall = []
const timed = () => {
  const t0 = now()
  return get(srv).then(() => perCall.push(now() - t0))
}

// Hedged request: gửi lần một; quá D ms chưa xong thì gửi thêm lần hai; lấy kết quả nào về trước, huỷ cái còn lại.
let sent = 0
async function hedged(D) {
  const c1 = new AbortController(), c2 = new AbortController()
  sent++
  const first = get(srv, c1.signal)
  let timer
  const second = new Promise((resolve, reject) => {
    timer = setTimeout(() => {
      sent++
      get(srv, c2.signal).then(resolve, reject)
    }, D)
  })
  const body = await Promise.any([first, second])
  clearTimeout(timer)
  c1.abort()
  c2.abort()
  return body
}

console.log('\n=== Hedged request, N = 50 ===')
console.log('cách gọi                  p50 user  p99 user  max   % user chậm > 100 ms  request gửi thêm')
const runs = [
  ['thường', () => () => timed()],
  ['hedged, D = p95 lúc rảnh', () => (console.log(`  (D = ${ms(dIdle)} ms)`), () => hedged(dIdle))],
  ['hedged, D = p95 lúc tải', () => {
    const D = summary(perCall).p95
    console.log(`  (D = ${ms(D)} ms)`)
    return () => hedged(D)
  }]
]
for (const [name, make] of runs) {
  const call = make()
  sent = 0
  const lat = []
  for (let u = 0; u < USERS; u++) lat.push(await userRequest(50, call))
  const r = summary(lat)
  const slow = (lat.filter((x) => x > 100).length / lat.length) * 100
  const extra = name === 'thường' ? '0%' : (((sent - USERS * 50) / (USERS * 50)) * 100).toFixed(1) + '%'
  console.log([name.padEnd(24), ms(r.p50).padStart(9), ms(r.p99).padStart(9), ms(r.max).padStart(5),
    (slow.toFixed(1) + '%').padStart(21), extra.padStart(17)].join('  '))
}
srv.stop()
lab/latency/coordinated-omission.mjs
js
// Coordinated omission: cùng một server, đo bằng hai kiểu client cho ra p99 rất khác nhau.
// Server trả lời gần như ngay, nhưng cứ 5 giây lại treo event loop 1 giây (20% thời gian).
import { startServer, closedLoop, openLoop, summary, ms } from './util.mjs'

const SECONDS = Number(process.env.SECONDS || 20)
const RATE = 200

console.log(`\n=== Coordinated omission: server treo 1 s mỗi 5 s, đo ${SECONDS} s ===`)
console.log('client               số mẫu  p50   p99   p99,9  max')
for (const [name, run] of [
  ['closed-loop (1 conn)', (srv) => closedLoop(srv, { seconds: SECONDS })],
  [`open-loop ${RATE} req/s`, (srv) => openLoop(srv, { rate: RATE, seconds: SECONDS, poisson: false })]
]) {
  const srv = await startServer('pause', '5000')
  const r = summary(await run(srv))
  console.log([name.padEnd(20), String(r.n).padStart(6), ms(r.p50).padStart(4), ms(r.p99).padStart(5), ms(r.p999).padStart(6), ms(r.max).padStart(5)].join('  '))
  srv.stop()
}