Có một nghịch lý ai làm hệ thống phân tán cũng gặp: đường truyền băng thông cả gigabit, mà tải một tệp từ máy chủ bên kia địa cầu vẫn ì ạch vài MB/s. Băng thông rộng thênh thang nhưng tốc độ thật thì thấp. Thủ phạm không phải băng thông — mà là RTT, thời gian một vòng khứ hồi. Bài này dựng một đường mạng thật trong container, tăng RTT bằng netem, rồi đo chính xác thông lượng tụt theo RTT như thế nào.

Vì sao RTT chặn thông lượng

Gốc rễ nằm ở cách TCP kiểm soát luồng. Một luồng TCP không được phép bắn dữ liệu vô hạn ra dây rồi mới chờ; nó chỉ được có tối đa một cửa sổ byte "đang bay" mà chưa nhận được xác nhận (ACK). Gửi hết cửa sổ, nó phải dừng lại chờ ACK quay về mới gửi tiếp — và ACK quay về mất đúng một RTT. Nghĩa là cứ mỗi RTT, luồng chỉ đẩy đi được nhiều nhất một cửa sổ dữ liệu:

thông lượng  <=  cửa sổ / RTT

Đây là công thức gốc của tích băng thông–độ trễ (bandwidth-delay product). Nếu cửa sổ cố định, thì RTT nằm dưới mẫu số: RTT gấp đôi, thông lượng còn một nửa — bất kể băng thông vật lý rộng bao nhiêu. Băng thông là đường ống to cỡ nào; RTT là đường ống dài bao nhiêu; và một cửa sổ cố định chỉ đổ được ngần ấy nước cho mỗi vòng đi-về.

Đo: cửa sổ cố định, thông lượng tỉ lệ nghịch RTT

Để thấy công thức trần trụi, tôi cố định cửa sổ nhận bằng cách đặt SO_RCVBUF trên máy khách (tắt cơ chế tự nới của nhân), rồi tăng dần RTT bằng netem và đo thông lượng của một luồng tải liên tục trong 3 giây:

ip link add veth0 type veth peer name veth1     # khách <-> chủ
tc qdisc replace dev veth0 root netem delay ${d}ms
ip netns exec srv tc qdisc replace dev veth1 root netem delay ${d}ms
# máy chủ blast dữ liệu; máy khách đặt SO_RCVBUF=64KB, đo bytes/thời gian

Tôi thêm độ trễ ở cả hai chiều nên RTT xấp xỉ gấp đôi độ trễ mỗi chiều, và đo RTT thật bằng ping chứ không tự suy. Lấy trung vị ba lần chạy:

RTT (ms) Thông lượng (MB/s) thông lượng × RTT
4 37,0 ~145 KB
27 3,0 ~81 KB
60 1,5 ~87 KB
112 0,79 ~87 KB
208 0,39 ~81 KB

Đọc cột cuối trước: từ RTT 27 ms trở lên, tích thông lượng × RTT gần như là một hằng số ~85 KB — chính là cửa sổ hiệu dụng. Đó là bằng chứng trực tiếp cho công thức thông lượng = cửa sổ / RTT: cửa sổ không đổi, nên tích không đổi, nên thông lượng phải tụt đúng theo tỉ lệ nghịch của RTT. Nhìn cột giữa: RTT từ 27 lên 60 (khoảng gấp đôi) thì thông lượng từ 3,0 xuống 1,5 (đúng một nửa); 112 lên 208 thì 0,79 xuống 0,39. Mỗi lần RTT gấp đôi, tốc độ mất một nửa.

Riêng dòng RTT 4 ms lệch khỏi quy luật (tích 145 KB, cao hơn hẳn): ở RTT quá nhỏ, cửa sổ 85 KB chưa phải nút thắt — thông lượng lúc đó bị chặn bởi tốc độ xử lý chứ không phải bởi cửa sổ/RTT. Công thức chỉ khống chế khi đường đủ dài để cửa sổ trở thành trần, và bảng cho thấy nó bắt đầu khống chế từ khoảng RTT 25 ms.

Cái chặn là cửa sổ, không phải RTT tự thân

Nếu thông lượng tụt vì cửa sổ cố định quá nhỏ so với RTT, thì nới cửa sổ ra phải gỡ được nút thắt. Linux hiện đại làm đúng thế một cách tự động: auto-tuning liên tục tăng cửa sổ nhận cho tới khi lấp đầy tích băng thông–độ trễ. Tôi bỏ SO_RCVBUF cố định, để nhân tự lo, rồi đo lại ở RTT thấp:

RTT ~4 ms, cửa sổ CỐ ĐỊNH 128 KB  ->    37 MB/s
RTT ~4 ms, cửa sổ TỰ NỞ           ->  1510 MB/s

Cùng một đường, chỉ khác ở chỗ cho cửa sổ nở hay không, thông lượng chênh nhau hơn 40 lần. Điều này khẳng định: RTT không tự nó giết thông lượng — cái giết thông lượng là một cửa sổ quá nhỏ so với RTT. Cho cửa sổ đủ lớn, một đường RTT thấp đạt tốc độ khổng lồ. Đây cũng là lý do vì sao "window scaling" (tùy chọn TCP cho phép cửa sổ vượt mốc 64 KB của thiết kế gốc) là bắt buộc trên mọi đường truyền tốc độ cao ngày nay.

Cú vấp vì slow start

Nhưng câu chuyện auto-tuning có một cái bẫy, và tôi rơi vào nó. Lần đầu đo với cửa sổ mặc định ở RTT cao (~286 ms), trong đúng 3 giây, tôi được 0,40 MB/s — thấp thảm hại, gần như bằng với cửa sổ cố định. Tôi suýt viết "auto-tuning cũng bó tay khi RTT cao, nới cửa sổ chẳng cứu được gì".

May là con số quá thấp khiến tôi nghi, nên tôi kéo dài phép đo trên cùng một đường:

RTT ~286 ms, cửa sổ tự nở:
  đo trong  3 giây  ->   0,40 MB/s
  đo trong  8 giây  ->  13,2  MB/s
  đo trong 20 giây  ->  16,8  MB/s

Thông lượng leo dần theo thời gian đo. Nguyên nhân là khởi động chậm (slow start): TCP không mở cửa sổ lớn ngay, mà tăng dần qua từng RTT — mỗi vòng khứ hồi thành công thì cửa sổ mới nới thêm. Ở RTT 286 ms, 3 giây chỉ chứa được khoảng mười vòng khứ hồi, chưa đủ để cửa sổ nở tới mức lấp đầy đường ống. Đo càng lâu, cửa sổ càng kịp nở, thông lượng càng cao.

Có hai lớp đáng rút ra. Thứ nhất về phương pháp: đo thông lượng trong một cửa sổ thời gian ngắn là trộn lẫn giai đoạn khởi động chậm với thông lượng ổn định — con số nhận được không phải tốc độ thật của đường mà là trung bình của một quá trình đang tăng tốc. Muốn đo thông lượng ổn định thì phải để luồng chạy đủ lâu, hoặc bỏ đi giai đoạn ramp ban đầu. Thứ hai về bản chất: ngay cả khi cửa sổ có thể nở đủ lớn, RTT vẫn phạt bạn — vì thời gian để nở cửa sổ cũng đo bằng số RTT. Đường càng xa, càng tốn nhiều giây thực để đạt tốc độ tối đa. RTT nằm ở mẫu số của cả thông lượng ổn định lẫn tốc độ khởi động.

Vì sao điều này quan trọng khi lập trình

Điều lớn nhất: khoảng cách địa lý là một giới hạn hiệu năng cứng, không phải thứ mua thêm băng thông là xong. Một dịch vụ đặt máy chủ ở Mỹ phục vụ người dùng Việt Nam (RTT ~200 ms) sẽ thấy mỗi luồng TCP bị bó ở vài MB/s trừ khi cửa sổ được nới rất lớn — và ngay cả khi nới được, các truy vấn ngắn vẫn trả giá bằng thời gian khởi động chậm. Đây là lý do vật lý đằng sau sự tồn tại của CDN và máy chủ biên: đưa dữ liệu lại gần người dùng để cắt RTT, vì RTT là thứ không thể "tăng băng thông" mà giải quyết.

Điều thứ hai, cho thiết kế giao thức: giao thức "lắm lời" (nhiều vòng khứ hồi) chết trên đường xa. Một thao tác cần 10 vòng đi-về tuần tự tốn 10×RTT chỉ riêng độ trễ — ở RTT 200 ms là 2 giây, bất kể mỗi vòng truyền ít dữ liệu đến đâu. Đây là lý do người ta gộp nhiều truy vấn thành một (batch), dùng pipelining, HTTP/2 ghép luồng, và tránh cái vòng lặp "hỏi một câu, chờ một câu" qua mạng xa. Nó cũng liên quan tới thuế bắt tay đã đo ở bài về bắt tay TCP: mỗi kết nối mới là thêm một RTT trước khi byte đầu tiên đi.

Điều cuối, cho vận hành: nếu một luồng đơn qua đường xa chậm dù băng thông rộng, hãy nhìn vào cửa sổ trước tiên — tcp_rmem/tcp_wmem, window scaling, và các thuật toán kiểm soát tắc nghẽn như BBR vốn được thiết kế để mở cửa sổ nhanh và đúng hơn trên đường có RTT cao. Gói lại bằng số: với cửa sổ cố định, thông lượng tỉ lệ nghịch với RTT (thông lượng×RTT ≈ hằng số 85 KB, RTT gấp đôi → tốc độ nửa); nới cửa sổ gỡ được trần đó (37 → 1510 MB/s, gấp 40 lần ở RTT thấp), nhưng RTT vẫn phạt bạn qua thời gian khởi động chậm (RTT 286ms: 3s→0,40, 20s→16,8 MB/s). Băng thông cho bạn biết đường ống to cỡ nào; RTT quyết định bạn thật sự rót được bao nhiêu qua nó.

Cái phản xạ "một hằng số nhỏ dưới mẫu số nhân với số vòng đi-về quyết định mọi thứ, và đo trong một cửa sổ ngắn thì bắt trúng giai đoạn khởi động chứ không phải trạng thái ổn định" là kiến thức hiệu năng áp thẳng vào code mọi tầng. Trong Java, đây đúng là chuyện độ trễ per-round-trip của JDBC/JPA: một vòng lặp gọi query cho mỗi phần tử (N+1) tốn N×RTT-tới-DB, và trên một DB ở vùng khác thì con số RTT đó giết throughput — giải pháp là batch/fetch join, đúng như gộp round-trip ở đây; và cái bẫy "đo quá ngắn bắt trúng ramp" song hành với JMH warm-up (đo trước khi JIT ổn định cho số vô nghĩa) và với connection pool chưa warm. Go có phiên bản với mọi client gọi API/DB qua mạng xa: một for gọi tuần tự là N×RTT, giải pháp là gọi song song hoặc pipeline. Cơ sở dữ liệu sống bằng bài học này: replication lag và query latency đều bị RTT chi phối trên geo-distributed DB, và người ta đặt replica gần đọc (cắt RTT) đúng như CDN cắt RTT mạng. Mẫu số chung: trên bất kỳ kênh nào có độ trễ đi-về, hiệu năng bị số vòng khứ hồi × RTT thống trị chứ không phải bởi lượng dữ liệu mỗi vòng — nên tối ưu đúng là giảm số vòng (batch, pipeline, song song) hoặc giảm RTT (đặt gần), không phải tăng băng thông; và khi benchmark bất cứ thứ gì có giai đoạn khởi động (cửa sổ TCP nở, JIT warm-up, cache warm, pool fill), phải đo đủ lâu để qua khỏi ramp, kẻo con số bạn có là trung bình của một hệ đang tăng tốc chứ không phải tốc độ thật của nó.

Muốn tự thấy thì trên máy có tc, thêm độ trễ vào loopback rồi đo cảm giác: tc qdisc add dev lo root netem delay 100ms, sau đó curl -o /dev/null -s -w '%{time_total}\n' http://localhost:<cổng> tới một máy chủ local — bạn sẽ thấy thời gian nhảy vọt dù dữ liệu chẳng đi đâu xa. Với một tải lớn, so thời gian tải trước và sau khi thêm delay để thấy thông lượng tụt. Xong nhớ tc qdisc del dev lo root trả lo về bình thường.