"TCP thì tin cậy, UDP thì nhanh" là câu ai cũng thuộc, nhưng nó che mất phần thú vị nhất: khi mạng hoàn hảo, hai giao thức gần như giống hệt nhau. Khác biệt chỉ lộ ra khi có mất gói. Bài này dựng hai máy ảo trong container, cố tình làm rơi 10% số gói bằng netem, rồi đo cả độ tin cậy lẫn độ trễ của TCP và UDP trên cùng một đường.

TCP so với UDP

Khi không mất gói, cả hai như nhau

Tôi tạo một cặp veth, chạy một máy chủ vọng lại cả TCP (cổng 9000) lẫn UDP (cổng 9001), rồi gửi 300 bản tin nhỏ qua mỗi giao thức và đo thời gian đi-về từng bản:

ip link add veth0 type veth peer name veth1
# veth0 = 10.10.0.1/24 (khách), veth1 = 10.10.0.2/24 (chủ) ở netns khác

Phép đo cụ thể thế này: với mỗi bản tin, máy khách ghi mốc thời gian trước khi gửi, chờ máy chủ vọng đúng nội dung đó về, rồi lấy hiệu làm RTT. Với UDP, một bản tin không thấy vọng về trong 1 giây thì tính là mất; với TCP thì mọi bản tin đều tới nên phần "mất" luôn bằng 0, cái phải đo là nó tới muộn bao nhiêu. Tôi không lấy trung bình — số đo micro giây rất nhiễu — mà xếp toàn bộ RTT rồi lấy trung vị, phân vị thứ 95 (p95) và giá trị lớn nhất (max). Ba con số đó nhìn vào ba chỗ khác nhau của phân bố, và như sẽ thấy, chọn nhầm con số là rút ra kết luận ngược.

Trên đường sạch, kết quả gần như trùng khít:

                MẤT    trung vị   p95    max
  UDP           0,0%     0,03     0,04   0,14   ms
  TCP           0,0%     0,03     0,03   0,06   ms

Cả hai giao đủ 300/300, độ trễ vài chục micro giây. Nếu bạn chỉ đo trên máy lập trình viên — nơi mạng không bao giờ rơi gói — bạn sẽ kết luận TCP và UDP tương đương, và kết luận đó sai hoàn toàn với mạng thật.

Thêm 10% mất gói: hai con đường tách ra

Tôi thêm một hàng đợi netem làm rơi ngẫu nhiên 10% số gói đi theo chiều khách → chủ:

tc qdisc add dev veth0 root netem loss 10%

Rồi chạy lại đúng phép đo. Đây là kết quả một lần (chạy ba lần cho ra cùng một hình dạng):

                MẤT    trung vị    p95      max
  UDP          ~9,0%     0,12      0,40     0,50   ms
  TCP           0,0%     0,12    208,63   842,42   ms

Hai dòng này kể toàn bộ câu chuyện.

UDP mất khoảng 9% bản tin. Một gói rơi là mất luôn — không ai gửi lại. Nhưng những bản tin tới được thì vẫn nhanh: p95 chỉ 0,40 ms, max 0,50 ms. UDP không chờ đợi gì cả; nó bắn gói đi rồi quên.

TCP mất 0%. Cả 300 bản tin đều tới, không thiếu một cái. Nhưng nhìn cột p95max: 208 mili giây và 842 mili giây. Cùng một đường, cùng một mất gói, mà đuôi trễ của TCP lớn hơn UDP gần hai nghìn lần.

Cái giá của "giao đủ": phát lại

Con số 208 ms không ngẫu nhiên. Khi một segment TCP rơi, bên gửi không biết ngay; nó phải đợi hết thời gian chờ phát lại (RTO) rồi mới gửi lại. Trên Linux, RTO có một sàn tối thiểu khoảng 200 mili giây. Nên mỗi bản tin xui xẻo trúng đúng gói bị rơi phải treo trọn ~200 ms trước khi được phát lại và cuối cùng tới nơi. Đó chính là cái đuôi p95 ~208 ms: khoảng 10% số bản tin rơi vào cảnh này.

Còn con số max thì đáng chú ý hơn: qua ba lần chạy nó là 216, rồi 424, rồi 842 mili giây — gần như gấp đôi mỗi lần. Đó là lùi lũy thừa (exponential backoff): nếu một segment rơi lần đầu, TCP đợi ~200 ms rồi thử lại; nếu lần phát lại đó cũng rơi, nó đợi gấp đôi ~400 ms, rồi ~800 ms. Xác suất một gói rơi hai, ba lần liên tiếp là nhỏ nhưng khác không, và khi xảy ra thì bản tin đó treo gần một giây. TCP không bao giờ bỏ cuộc — nó đổi việc "giao đủ" lấy một cái đuôi trễ có thể rất dài.

Vì sao một gói rơi lại giữ chân cả hàng

Có một tầng nữa của cái giá TCP mà bảng số ở trên gợi ra nhưng chưa nói hết. TCP không chỉ giao đủ, nó còn giao đúng thứ tự. Nếu bản tin số 100 rơi và phải chờ ~200 ms để phát lại, thì các bản tin 101, 102, 103 — dù đã tới nơi từ lâu và đang nằm trong bộ đệm của nhân — vẫn không được giao cho ứng dụng cho tới khi số 100 điền vào chỗ trống. Đây là hiện tượng chặn đầu hàng (head-of-line blocking).

Tôi thấy nó rõ trong chính số liệu của mình. Với UDP, khi bản tin số 100 rơi, số 101 vọng về ngay lập tức — ứng dụng đọc được nó mà chẳng bận tâm số 100 đâu. Với TCP thì khác: trong cửa sổ ~200 ms chờ phát lại bản tin 100, mọi bản tin sau nó cũng bị treo theo, nên cái đuôi p95 ~208 ms không chỉ là một bản tin chậm mà là một chùm bản tin cùng chậm vì kẹt sau một gói rơi. Một gói mất không làm chậm một bản tin, nó làm chậm cả đoàn tàu phía sau.

Đây là lý do một tải trang web trên mạng yếu đôi khi "đứng hình" hẳn một nhịp rồi mới nhảy vọt: một segment rơi đã chặn mọi thứ đến sau nó trên cùng kết nối TCP. Và đây cũng chính là điểm HTTP/2 vấp phải — dồn nhiều luồng vào một kết nối TCP thì một gói rơi làm nghẽn tất cả các luồng, chứ không riêng luồng có gói rơi. QUIC ra đời một phần để gỡ đúng nút này: nó chạy trên UDP và tự quản thứ tự theo từng luồng, nên một luồng rơi gói không kéo các luồng khác xuống theo.

Suýt nữa tôi kết luận sai

Lần đầu nhìn bảng kết quả, tôi đọc cột trung vị trước: UDP 0,12 ms, TCP 0,12 ms — bằng nhau. Tôi suýt viết "mất gói không làm TCP chậm hơn UDP", một câu vừa nghe hợp lý vừa hoàn toàn sai.

Sai ở chỗ trung vị chỉ nhìn bản tin điển hình. Với TCP, 90% bản tin đi qua đường không rơi gói nên nhanh y hệt UDP, kéo trung vị xuống thấp. Toàn bộ cái giá của TCP nằm ở 10% còn lại — và chúng chỉ hiện ra ở p95max, tức ở đuôi phân bố. Nếu tôi báo cáo bằng trung bình hay trung vị, tôi đã giấu mất chính điều bài này muốn đo. Bài học đo lường: dưới mất gói, độ trễ không còn là một con số — nó là một phân bố có đuôi dài, và đuôi mới là nơi sự thật nằm. Luôn nhìn p95, p99, max, đừng chỉ nhìn trung bình.

Chọn cái nào, và vì sao

Đánh đổi giờ đã rõ ràng và đo được: TCP mua độ tin cậy bằng độ trễ đuôi; UDP mua độ trễ thấp đều bằng việc chấp nhận mất mát. Không có cái nào "tốt hơn" — chúng tối ưu cho hai thứ khác nhau.

Chọn TCP khi mọi byte đều phải tới và tới đúng thứ tự: tải tệp, truy vấn cơ sở dữ liệu, trang web, API. Một trang HTML thiếu vài byte là hỏng; chờ thêm 200 ms còn hơn nhận nội dung sai.

Chọn UDP khi dữ liệu cũ đi rất nhanh và trễ quan trọng hơn đủ: thoại và video thời gian thực, game, DNS, đo lường. Trong cuộc gọi video, một khung hình đến muộn 200 ms là vô dụng — thà bỏ nó và vẽ khung tiếp theo. Chấp nhận mất vài phần trăm đổi lấy trễ thấp đều là đánh đổi đúng ở đây, y như số đo phía trên đã cho thấy.

Thử ba mươi giây

Nếu máy bạn có tc, chạy tc qdisc add dev lo root netem loss 25% rồi ping -c 20 127.0.0.1: vài dòng ping sẽ biến mất hẳn khỏi kết quả — ICMP không phát lại, nên mất là thấy ngay, đúng kiểu UDP. Giờ tải một thứ qua TCP trên cùng loopback đó (một curl tới máy chủ local chẳng hạn): nó vẫn xong đủ, chỉ thỉnh thoảng khựng lại — TCP giấu mất gói bằng cách phát lại. Xong nhớ tc qdisc del dev lo root để trả lo về bình thường.