Đây là một con số làm nhiều người ngạc nhiên: một đường truyền chỉ mất 1% số gói — tức 99% gói vẫn tới nơi — có thể chạy ở dưới 3% băng thông của nó. Không phải vì đường tắc, mà vì cách TCP phản ứng với mất gói. Bài này dựng một đường mạng thật trong container, cố tình thêm mất gói bằng netem, rồi đo đường cong thông lượng sập xuống như thế nào — và vì sao một chút mất mát lại gây thiệt hại lớn đến vậy.
Kiểm soát tắc nghẽn dựa trên mất gói
Mỗi luồng TCP giữ một cửa sổ tắc nghẽn (cwnd) — số byte nó dám gửi trước khi chờ xác nhận. Thuật toán mặc định của Linux là CUBIC (trước đó là Reno), và cả hai thuộc họ dựa trên mất gói: chúng tăng cwnd dần dần khi mọi thứ trơn tru, và cắt mạnh cwnd (Reno cắt còn một nửa) mỗi khi phát hiện một gói rơi. Kết quả là một đồ thị hình răng cưa: bò lên từ từ, gặp mất gói thì tụt xuống, rồi lại bò lên.
Logic đằng sau rất hợp lý cho mục đích gốc: mất gói thường nghĩa là một hàng đợi đâu đó trên đường đã đầy và bắt đầu vứt gói — tức đường đang tắc — nên lùi lại là đúng để không làm tắc thêm. Cả Internet chia sẻ băng thông hòa bình được là nhờ mọi luồng đều tự giác lùi khi thấy mất gói.
Nhưng có một lỗ hổng chí mạng: giao thức không phân biệt được gói rơi vì tắc với gói rơi vì nhiễu ngẫu nhiên. Một khung wifi bị nhiễu, một bit lỗi trên đường truyền dài, một hàng đợi rung nhẹ — tất cả đều hiện ra với TCP y hệt như "đường tắc", và nó phanh lại dù đường thật sự đang trống trơn.
Đo: đường cong sập theo mất gói
Tôi dựng một đường 200 Mbit/s (~25 MB/s) với RTT ~66 ms bằng netem, rồi thêm mất gói ở nhiều mức và đo thông lượng của một luồng tải CUBIC, mỗi mức đo 15 giây:
tc qdisc add dev veth1 root netem delay 30ms loss 1% rate 200mbit
# máy khách đặt thuật toán bằng TCP_CONGESTION rồi đo bytes/thời gian
Kết quả vẽ ra một cú sập dốc đứng:
| Mất gói | Thông lượng | So với trần 25 MB/s |
|---|---|---|
| 0 % | ~22 MB/s | ~88% (gần đầy) |
| 0,5 % | 1,7 MB/s | ~7% |
| 1 % | 0,65 MB/s | ~3% |
| 2 % | 0,37 MB/s | ~1,5% |
| 5 % | 0,19 MB/s | ~0,8% |
Đọc dòng 1% cho kỹ: đường mất 1% gói — tức 99 trên 100 gói vẫn tới — nhưng thông lượng chỉ còn 0,65 MB/s, sập khoảng 30 lần so với lúc không mất gói. Đường ống 25 MB/s đó đang chạy ở 3% công suất; 97% băng thông nằm không, không phải vì thiếu chỗ mà vì TCP tự phanh. Đây chính là cái giá của việc đọc mọi gói rơi thành tín hiệu tắc.
Vì sao mất gói nhỏ hại lớn: căn bậc hai
Hình dạng của cú sập không tuyến tính — nó tuân theo công thức Mathis, mô tả thông lượng của kiểm soát tắc nghẽn dựa trên mất gói:
thông lượng ~ MSS / ( RTT × căn(p) )
với p là tỉ lệ mất gói. Điểm mấu chốt là căn bậc hai của p nằm ở mẫu số. Vì có căn, mất gói tăng thì thông lượng giảm theo một đường cong dốc: p gấp 4 lần thì tốc độ chỉ còn một nửa. Ngược lại, đi từ 0% lên 0,5% — một thay đổi tưởng như không đáng kể — đã đủ kéo tốc độ từ 22 xuống 1,7 MB/s. Căn bậc hai khiến vùng mất-gói-thấp cực kỳ nhạy: những phần trăm đầu tiên gây thiệt hại nặng nhất. Bảng đo của tôi khớp đúng hình dạng đó — dốc nhất ở đầu, rồi thoải dần.
Công thức còn cho thấy RTT nhân với căn(p) ở mẫu số: đường vừa xa (RTT cao) vừa hơi mất gói là thảm họa kép, vì cả hai thừa số cùng kéo thông lượng xuống. Đó là lý do một đường xuyên lục địa chỉ mất 0,5% gói có thể chậm hơn cả chục lần so với con số băng thông trên hợp đồng.
Một lần tôi đo hớ vì đo quá ngắn
Lần đầu tôi đo mỗi mức trong 6 giây, và ở mức mất gói rất thấp (0,1%), con số nhảy loạn: ba lần chạy giống hệt nhau cho ra 1,9 rồi 15,1 rồi 17,9 MB/s — chênh gần 10 lần. Tôi bối rối: sao cùng một cấu hình mà kết quả lại bấp bênh đến thế?
Nguyên nhân nằm ở bản chất răng cưa của cwnd. Ở mất gói thấp, các cú rơi rất thưa và đến vào những thời điểm ngẫu nhiên. Nếu trong 6 giây đo mà không gặp cú rơi nào sớm, cửa sổ kịp nở to và thông lượng cao; nếu xui gặp một cú rơi ngay lúc cửa sổ vừa lớn, nó bị cắt mạnh và cả phép đo tụt xuống. Sáu giây chỉ chứa vài chu kỳ răng cưa, nên kết quả phụ thuộc vào may rủi của lần chạy chứ không phản ánh đường truyền.
Cách sửa là kéo dài phép đo lên 15 giây, gộp đủ nhiều chu kỳ để trung bình hóa — và con số ổn lại ngay. Bài học đo lường quan trọng: thông lượng dưới mất gói không phải một con số mà là một phân bố. Ở mất gói thấp, phân bố đó rộng, và đo trong một cửa sổ ngắn là bốc trúng một mẫu ngẫu nhiên của nó. Muốn con số có nghĩa thì phải đo đủ lâu để nhiều chu kỳ tăng-giảm được san phẳng, đúng như khi đo bất kỳ đại lượng nhiễu nào khác.
Chọn thuật toán, và vì sao BBR ra đời
Tôi đo cả Reno lẫn CUBIC trên đường này và chúng sập gần như giống nhau — trên một đường RTT vừa phải, khác biệt giữa hai thuật toán dựa-trên-mất-gói không lớn (CUBIC ăn điểm hơn chủ yếu trên đường có tích băng thông–độ trễ rất lớn, nhờ cách nở cửa sổ theo hàm bậc ba nhanh hơn). Điểm chung quan trọng hơn: cả hai đều diễn giải mất gói thành tắc nghẽn, nên cả hai đều dính đúng cú sập trên bảng.
Đây chính là động lực đằng sau một họ thuật toán khác: BBR (do Google phát triển) không nhìn mất gói để đoán tắc, mà đo trực tiếp băng thông và RTT của đường rồi bơm dữ liệu theo mô hình đó. Vì không coi mỗi gói rơi là lệnh phanh, BBR giữ được thông lượng cao trên những đường mất gói ngẫu nhiên nơi CUBIC gục. (Nhân trong môi trường đo của tôi không nạp được mô-đun BBR nên tôi không chạy được phép so trực tiếp; đây là lý do thiết kế của nó, không phải số tôi tự đo.) Với người vận hành, việc đổi thuật toán kiểm soát tắc nghẽn sang BBR trên các đường dài và hơi mất gói thường là một trong những chỉnh sửa một-dòng có tác động lớn nhất.
Vì sao điều này quan trọng khi lập trình
Hệ quả lớn nhất: "mạng gần như đáng tin" là chưa đủ. Một đường 99,9% giao gói thành công nghe như hoàn hảo, nhưng 0,1% mất gói đó — nếu là mất ngẫu nhiên — vẫn đủ để bào mòn thông lượng của kiểm soát tắc nghẽn dựa-trên-mất-gói. Trên wifi, mạng di động, hay đường truyền xuyên quốc gia, mất gói ngẫu nhiên là chuyện thường ngày, và nó giải thích vì sao một tải tệp lớn qua wifi yếu bò chậm dù "vạch sóng vẫn đầy".
Hệ quả thứ hai nối với các bài trước: cú sập này chồng lên cả trần cửa sổ/RTT đã đo ở bài về cửa sổ TCP. Một đường vừa xa (RTT cao), vừa cần cửa sổ lớn, vừa hơi mất gói sẽ bị cả ba yếu tố cùng ghì xuống — và mất gói thường là yếu tố bị bỏ quên nhất khi gỡ lỗi hiệu năng. Nếu một luồng chạy chậm bất thường, hãy đo tỉ lệ mất gói (ví dụ bằng ss -ti xem retrans) trước khi đổ lỗi cho băng thông.
Hệ quả thứ ba, cho thiết kế: đây là một lý do nữa để các giao thức hiện đại như QUIC tự làm phần phục hồi mất gói tinh vi hơn ở tầng ứng dụng, và để nhiều hệ thống truyền dữ liệu qua đường xấu dùng nhiều luồng song song hoặc mã sửa lỗi trước (FEC) — tất cả đều là cách né cú sập mà bạn vừa thấy đo được. Con số mang theo: kiểm soát tắc nghẽn dựa-trên-mất-gói đánh đổi công bằng lấy sự mong manh trước nhiễu — chỉ 1% gói rơi ngẫu nhiên đủ để nó bỏ không phần lớn băng thông, và biết điều đó là biết chỗ để nhìn khi mạng nhanh trên giấy mà chậm trong thực tế.
Thử ba mươi giây
Trên máy có tc, thêm một chút mất gói vào loopback rồi tự thấy cú sập: tc qdisc add dev lo root netem loss 1%, sau đó đo tốc độ một tải lớn tới một máy chủ local (ví dụ curl). So với lúc chưa thêm mất gói, bạn sẽ thấy thông lượng tụt mạnh hơn nhiều so với con số 1% gợi ý. Xem ss -ti trong lúc tải để thấy cột retrans và giá trị cwnd nhảy xuống theo mỗi lần mất. Xong nhớ tc qdisc del dev lo root.