Hỏi một đầu bếp "bữa ăn mất bao lâu", họ sẽ tính từ lúc chảo nóng tới lúc đĩa thức ăn xong — mười phút. Nhưng thực khách đo khác: họ tính từ lúc ngồi xuống, đợi phục vụ ghi món, đợi món ra, đợi mang tới bàn — bốn mươi phút. Cả hai đều thành thật, cả hai đều bấm giờ chính xác, nhưng họ đang đo hai quãng thời gian khác nhau, và cái đầu bếp không nhìn thấy mới là cái thực khách sống cùng. Bài này tôi đo đúng khoảng cách đó ở một máy chủ web: nó tự báo bao nhiêu, người dùng thật chờ bao nhiêu, và cái hố giữa hai con số sâu tới đâu khi mạng có độ trễ.
Phần trước đo khoảng cách giữa span db.query ở ứng dụng và con số PostgreSQL tự báo, thấy 88% thời gian nằm ngoài cơ sở dữ liệu. Lần này tôi làm đúng phép đo ấy ở tầng ngoài cùng. (Các con số là độ trễ đo trong container với tc netem, không phải perf counter phần cứng.)
Bảng số liệu
Một máy chủ HTTP tự đo thời gian xử lý của chính nó và báo qua header, cộng một client đo tổng thời gian. Độ trễ mạng tạo bằng tc netem, mỗi con số là trung vị của 25–60 lượt.
| Hoàn cảnh | Máy chủ tự báo | Client, giữ kết nối | Client, kết nối mới | Máy chủ mù |
|---|---|---|---|---|
| 1 KB, mạng không trễ | 0,115 ms | 0,206 ms | 0,409 ms | 71,9% |
| 100 KB, mạng không trễ | 0,117 ms | 0,219 ms | 0,418 ms | 72,1% |
| 1 KB, trễ 50 ms khứ hồi | 0,661 ms | 33,103 ms | 66,237 ms | 99,0% |
| 100 KB, trễ 50 ms khứ hồi | 0,673 ms | 33,135 ms | 151,255 ms | 99,6% |
Bốn điều bảng này phơi ra
Nhìn dòng cuối trước, vì nó là cú sốc: với độ trễ mạng thật, máy chủ mù 99,6%. Nó báo 0,673 ms trong khi người dùng chờ 151,255 ms — gấp 225 lần. Mọi bảng điều khiển dựng trên thời gian xử lý phía máy chủ sẽ xanh mượt suốt cả quãng một trang mất một phần bảy giây để tải. Không cảnh báo nào nổ, vì thứ chậm nằm ở chỗ máy chủ không nhìn tới.
Nhưng đây mới là điều làm tôi ngồi thẳng lên: ngay cả khi mạng không trễ, máy chủ vẫn chỉ thấy 28% thời gian thật — 0,115 so với 0,409 ms. Khoảng cách ấy không đến từ mạng chậm mà từ bắt tay TCP và việc truyền byte, những thứ luôn có dù mạng nhanh cỡ nào.
Rồi tới cái bẫy tinh vi nhất: kích thước phản hồi chỉ lộ ra khi mạng có độ trễ. Trên mạng nhanh, 1 KB và 100 KB gần như bằng nhau — 0,409 so với 0,418 ms, chênh vỏn vẹn 2,2%. Thêm 50 ms độ trễ vào thì cùng hai con số đó thành 66,2 và 151,3 ms — chênh 128%. Đây chính xác là lý do thử nghiệm trên máy lập trình viên không bao giờ bắt được một phản hồi quá khổ: trên máy đó, mọi kích thước đều nhanh như nhau.
Cuối cùng, một con số gọn gàng: giữ kết nối sẵn tiết kiệm đúng 33,134 ms — chính xác một vòng khứ hồi. Đó là toàn bộ giá trị của keep-alive, đo ra thành một con số duy nhất.
Vì sao cái hố sâu đến vậy
Máy chủ đếm từ lúc nó đọc xong dòng đầu của yêu cầu tới lúc ghi xong byte cuối vào socket. Mọi thứ trước và sau khoảng đó nằm ngoài tầm nhìn của nó: phân giải DNS, bắt tay TCP, bắt tay TLS, gói tin đi trên đường, và bộ đệm gửi của hệ điều hành xả dần ra mạng.
Với độ trễ 50 ms khứ hồi, một yêu cầu HTTP đơn giản cần hai vòng: một cho bắt tay TCP, một cho yêu cầu và phản hồi. Đo được 66,237 ms — khớp đúng hai vòng cộng chi phí xử lý. Còn chuyện 100 KB đắt gấp đôi 1 KB thì đến từ TCP khởi động chậm: kết nối mới không tống ngay toàn bộ dữ liệu mà bắt đầu với một cửa sổ nhỏ, khoảng mười gói tin, rồi tăng dần sau mỗi vòng xác nhận. 1 KB nằm gọn trong cửa sổ đầu tiên nên đi trong một vòng; 100 KB cần thêm vài vòng, mỗi vòng tốn 50 ms. Trên mạng không trễ, mỗi vòng gần như miễn phí nên số vòng chẳng quan trọng — và đó chính là điều làm cái bẫy này nguy hiểm: nó chỉ xuất hiện ở nơi bạn không đo.
Rút ra cho công việc thì rõ ràng. Đừng đặt SLO dựa trên thời gian xử lý phía máy chủ — nó trả lời câu "mã của tôi chạy hết bao lâu", không trả lời câu "người dùng chờ bao lâu". Nếu chưa có đo từ trình duyệt, hãy dùng kiểm tra tổng hợp từ nhiều vùng địa lý làm thay tạm: nó không cho bạn trải nghiệm thật, nhưng nó thấy được mạng — thứ mà máy chủ hoàn toàn mù. Giảm kích thước phản hồi là tối ưu bị đánh giá thấp nhất, vì nó không hiện ra ở bất kỳ phép đo nào trên máy phát triển nhưng ở người dùng thật thì nó là 85 mili giây. Hãy đo với độ trễ nhân tạo trước khi phát hành — một dòng tc qdisc add dev eth0 root netem delay 25ms biến máy phát triển thành thứ gần thực tế hơn nhiều và bắt được đúng loại lỗi mà không phép thử nào khác bắt được. Và kiểm tra keep-alive thật sự đang bật: 33 ms mỗi lời gọi là con số lớn, mà nó biến mất im lặng nếu proxy hay cân bằng tải đóng kết nối sau mỗi yêu cầu.
Có một cách nhìn làm rõ vì sao khoảng cách này khó chịu đến vậy. Ba con số trong bảng không phải ba mức chính xác khác nhau của cùng một đại lượng — chúng là ba đại lượng khác nhau. Máy chủ trả lời "mã của tôi chạy hết bao lâu". Client giữ kết nối trả lời "lần gọi thứ hai trở đi mất bao lâu". Client mở kết nối mới trả lời "người dùng mở trang lần đầu mất bao lâu". Cả ba đều là câu hỏi hợp lệ, và mỗi đội thường chỉ có sẵn một trong ba: đội hạ tầng nhìn con số thứ nhất, đội API nhìn con số thứ hai, còn người dùng sống với con số thứ ba. Khi có người báo "trang chậm" và biểu đồ ghi 0,7 ms, không ai nói dối cả — họ chỉ đang đọc ba câu trả lời cho ba câu hỏi khác nhau và tưởng là một.
Mẫu số chung
Cái bẫy "đo quãng mình nhìn thấy rồi tưởng đó là quãng người dùng sống cùng" không riêng gì HTTP — nó là một sai lầm về ranh giới đo lặp ở mọi tầng.
Trong thế giới Java, đây đúng là khác biệt giữa thời gian một @Controller chạy (Micrometer @Timed bọc quanh method) và thời gian thật của một RestTemplate/WebClient phía client — cái sau bao gồm cả kết nối, TLS, truyền byte. Ai từng chỉ đo bên trong Tomcat rồi ngạc nhiên vì khách than chậm đều vấp đúng cái hố này; câu trả lời là đo ở client của lời gọi, không chỉ ở server.
Trong Go, thư viện chuẩn phơi bày cái hố này đẹp đến mức đáng khen: net/http/httptrace cho bạn từng mốc — DNSDone, ConnectDone, TLSHandshakeDone, GotFirstResponseByte — đúng những quãng mà header Server-Timing của máy chủ không bao giờ thấy. Nó là bản curl -w dựng sẵn trong ngôn ngữ, và nó tồn tại chính vì "server nghĩ nó nhanh" là một hiểu lầm phổ biến.
Còn ở cơ sở dữ liệu, đây đúng là bài học của phần trước lặp lại: thời gian một câu query chạy trong engine (EXPLAIN ANALYZE, pg_stat_statements) gần như luôn nhỏ hơn thời gian ứng dụng cảm nhận, vì phần chênh nằm ở lấy kết nối từ pool, truyền kết quả về, và độ trễ mạng giữa app và DB. Đo ở trong DB rồi tưởng đó là độ trễ người dùng là đúng cùng một lỗi ranh giới.
Mẫu số chung: mỗi tầng chỉ đo được quãng nằm giữa hai cái mốc của chính nó, và quãng đó gần như luôn là một tập con của quãng người dùng thật sự chờ — nên một con số "nhanh" chỉ có nghĩa khi bạn biết nó đo từ mốc nào tới mốc nào. Vì thế câu hỏi đúng khi nhìn một biểu đồ độ trễ không phải "nhanh hay chậm" mà là "cái đồng hồ này bấm start và stop ở đâu, và người dùng của tôi bấm start sớm hơn bao nhiêu" — càng ra ngoài rìa, con số càng thật, và cái rìa ngoài cùng là trình duyệt.
Muốn tự thấy cái hố, biến máy của bạn thành máy của người dùng ở xa trong ba mươi giây:
# Bien may cua ban thanh may cua nguoi dung o xa, trong 30 giay
docker run --rm --cap-add NET_ADMIN python:3.12-slim sh -c '
apt-get -qq update >/dev/null && apt-get -qq install -y iproute2 curl >/dev/null
D=https://example.com
echo "khong do tre:"
curl -s -o /dev/null -w " tong %{time_total}s | ket noi %{time_connect}s | TLS %{time_appconnect}s\n" $D
tc qdisc add dev eth0 root netem delay 50ms
echo "them 50 ms moi chieu:"
curl -s -o /dev/null -w " tong %{time_total}s | ket noi %{time_connect}s | TLS %{time_appconnect}s\n" $D
'
So hai dòng. Phần tăng thêm không nằm ở máy chủ — máy chủ làm đúng một lượng việc như nhau ở cả hai lần. Đó là phần mà mọi biểu đồ phía máy chủ của bạn không bao giờ vẽ.