Hãy hình dung một người giao hàng chỉ được phép cầm theo N kiện hàng chưa có chữ ký xác nhận. Giao xong một lô, anh ta phải đứng đợi giấy ký từ khách gửi ngược về mới được đi tiếp. Nếu khách ở gần, giấy về nhanh, anh ta chạy như con thoi. Nhưng nếu khách ở tận đầu kia thành phố, mỗi tờ giấy mất nửa ngày mới về — thì dù anh ta lái một chiếc xe tải khổng lồ, phần lớn thời gian nó vẫn đứng yên chờ giấy. Và nếu một kiện thất lạc, anh ta hoảng, tự giảm nửa số kiện được phép cầm để "cho chắc". Đó chính xác là cách TCP hành xử, và bài này đo xem cái quy tắc "chờ chữ ký" ấy tốn của bạn bao nhiêu.

Phần 26 đo bộ đệm ổ cắm và kết luận rằng không bộ đệm nào chữa được mất gói. Ở đây tôi đo xem mất gói thật ra lấy đi bao nhiêu.

Bảng số

Cùng chương trình, cùng máy, đổi điều kiện đường truyền bằng tc netem. Ba lần đo mỗi điều kiện, mỗi lần 6–12 giây liên tục, đồng thời đọc cwnd từ ss -ti:

Đường truyền Thông lượng (MB/s) cwnd lớn nhất cwnd lúc kết thúc
0 ms, mất 0% 10.403 / 10.435 / 10.337 3.779–4.812 y nguyên
20 ms, mất 0% 155,3 / 148,7 / 150,4 6.232–6.836 y nguyên
20 ms, mất 0,1% 65,5 / 46,3 / 52,1 6.046–7.466 361–727
20 ms, mất 1% 8,5 / 1,1 / 1,9 46–136 10–26
20 ms, mất 3% 0,8 / 0,6 / 0,6 14–203 5–11

Độ trễ một mình đã lấy 69 lần

Nhìn dòng thứ hai: không có gói nào bị mất. Máy không đổi, đường truyền không đổi, phần mềm không đổi. Chỉ mỗi thời gian đi về. Vậy mà 10.403 tụt xuống 150 MB/s — 69 lần.

Lý do nằm ở một giới hạn duy nhất: TCP chỉ cho phép một lượng dữ liệu nhất định đang bay mà chưa được báo nhận. Lượng đó là cửa sổ tắc nghẽn — số kiện hàng chưa có chữ ký mà người giao được cầm. Sau khi gửi đầy cửa sổ, bên gửi phải chờ ACK, và ACK mất một vòng khứ hồi.

thong luong toi da = cua so / vong khu hoi

Không cách nào lách. Muốn nhanh hơn thì cửa sổ phải lớn hơn, và cửa sổ lớn hơn cần bộ nhớ ở cả hai đầu — đúng phần mà tcp_rmem/tcp_wmem của phần 26 giới hạn.

Rồi mất 1% gói lấy thêm 40 lần nữa

Tỷ lệ mất Thông lượng So với không mất
0% ~150 MB/s 1×
0,1% ~55 MB/s 3× chậm hơn
1% ~4 MB/s 40× chậm hơn
3% ~0,7 MB/s 210× chậm hơn

Mất một phần nghìn số gói làm thông lượng còn một phần ba. Đây là câu đáng nhớ nhất của cả bài, nên tôi nói thẳng: mất gói không làm mất 1% dữ liệu — nó làm sập cửa sổ tắc nghẽn.

Câu "chỉ mất có 1% thôi" trong một báo cáo sự cố là một trong những câu nguy hiểm nhất tôi từng nghe, vì nó nghe như một sai số cỏn con trong khi nó là nguyên nhân của việc mọi thứ chậm đi 40 lần.

Cột cwnd giải thích toàn bộ

Không mất gói: cwnd leo đều tới 6.232–6.836 gói rồi ở nguyên đó. Không có gì kéo nó xuống.

Mất 1%: cwnd không bao giờ vượt quá 136 và kết thúc ở 10–26 gói.

Cơ chế của CUBIC (và của Reno trước nó): mỗi lần phát hiện mất gói thì cắt đôi cửa sổ, rồi tăng lại từ từ — đúng anh giao hàng hoảng lên tự giảm nửa số kiện. Với vòng khứ hồi 20 ms và mất gói cứ vài trăm gói một lần, cửa sổ không bao giờ có đủ thời gian yên ổn để lớn lên.

Kết quả là một trạng thái cân bằng ở mức rất thấp — 10 gói, khoảng 14 KB đang bay. Chia cho 20 ms ra đúng khoảng 0,7 MB/s như đo được.

Dòng 0,1% cho thấy quá trình đó đang diễn ra: cwnd lớn nhất vẫn tới 6.046–7.466, nhưng lúc kết thúc chỉ còn 361–727. Cửa sổ leo lên được rồi bị đánh sập, nhiều lần, và trung bình theo thời gian thấp hơn nhiều so với đỉnh.

Đó cũng là lý do đo thông lượng bằng một lần truyền ngắn cho kết quả rất nhiễu: nó phụ thuộc vào việc gói bị mất rơi vào đầu hay cuối. Lần đo đầu tiên tôi truyền 8 MB và nhận được 8,1 rồi 37,1 rồi 26,0 MB/s cho cùng một điều kiện — dải bốn lần cho một thứ đáng lẽ ổn định. Chỉ khi chuyển sang đo theo thời gian, các con số mới đứng yên.

Chuyện này nghĩa là gì trong thực tế

Điều đầu tiên phải thấm: băng thông không phải thứ bạn thiếu. Một đường 10 Gbps giữa hai châu lục với 1% mất gói cho khoảng vài chục Mbps. Mua thêm băng thông không đổi được gì; sửa mất gói mới đổi.

Nên trước khi tinh chỉnh bất cứ thứ gì, kiểm tra mất gói:

ss -tin dst <dia-chi> | grep -oE 'retrans:[0-9/]*|rtt:[0-9.]*|cwnd:[0-9]*'
netstat -s | grep -iE 'retransmit|segments retransmited'

cwnd nhỏ (dưới vài trăm) trên một kết nối đang truyền nhiều dữ liệu là dấu hiệu rõ ràng nhất. Nó nói rằng vấn đề nằm ở đường truyền, không nằm ở ứng dụng.

Và nguồn mất gói thường không phải cáp hỏng. Trong trung tâm dữ liệu, mất gói gần như luôn là hàng đợi tràn ở một cổng chuyển mạch hoặc ở chính card mạng:

ip -s link show eth0 | grep -A1 -E 'RX|TX'    # cot dropped, overrun
ethtool -S eth0 2>/dev/null | grep -iE 'drop|discard|error' | grep -v ': 0$'

Đổi thuật toán tắc nghẽn

Máy tôi đo chỉ có reno và cubic:

cat /proc/sys/net/ipv4/tcp_available_congestion_control
cat /proc/sys/net/ipv4/tcp_congestion_control    # cubic

BBR — có trong nhân từ 4.9, cần nạp mô-đun — dùng cách tiếp cận khác hẳn: nó ước lượng băng thông và vòng khứ hồi thay vì coi mọi mất gói là dấu hiệu tắc nghẽn. Trên đường truyền có mất gói ngẫu nhiên (không do tắc nghẽn), khác biệt rất lớn.

modprobe tcp_bbr
sysctl -w net.ipv4.tcp_congestion_control=bbr

Tôi không đo được BBR — mô-đun không có sẵn trong nhân của môi trường thử nghiệm. Nên tôi không đưa ra con số nào cho nó; chỉ ghi rằng đây là chỗ đáng thử khi bảng trên khớp với triệu chứng của bạn.

Giới hạn của phép đo này

tc netem bỏ gói ngẫu nhiên và độc lập. Mất gói thật thường đến theo cụm — hàng đợi tràn thì tràn liên tục vài mili giây — và TCP phản ứng với cụm khác với phản ứng với mất rải rác.

Độ trễ cũng là hằng số, không có biến thiên. Biến thiên độ trễ làm bộ hẹn giờ truyền lại kém chính xác và thêm một lớp xấu nữa.

Cả hai đều làm số liệu thật tệ hơn bảng trên, không tốt hơn.

Mẫu số chung: quy tắc "gửi rồi chờ xác nhận" biến độ trễ và lỗi thành cái phanh nhân bội

Cái lõi ở đây không riêng gì TCP: bất cứ giao thức nào chỉ cho một lượng công việc đang chờ xác nhận rồi mới gửi tiếp đều bị chính hai thứ này bóp — độ trễ vòng khứ hồi và tỷ lệ lỗi — theo kiểu nhân bội, không phải cộng thêm.

Trong thế giới Java, đây đúng là bài toán của mọi client đồng bộ theo kiểu request–response một-lần-một: một dịch vụ gọi CSDL hay REST API tuần tự, mỗi lần chờ trả lời xong mới gửi tiếp, thì thông lượng của nó cũng đóng khung trong công thức số việc song song / vòng khứ hồi — đúng cửa sổ / RTT. Cách nới ra là tăng "cửa sổ": pipelining, batch, hoặc pool nhiều kết nối chạy song song; HTTP/2 multiplexing chính là "cửa sổ lớn hơn" cho tầng ứng dụng.

Trong Go, cùng nguyên tắc hiện ra ở kích thước channel/semaphore giới hạn số lời gọi ra ngoài đang bay: đặt quá nhỏ trên một đường trễ cao thì goroutine đứng chờ ACK, thông lượng sập y như cwnd nhỏ; và một chính sách retry ngây thơ khi có lỗi lại là "cắt đôi rồi bò lên" tệ hơn, vì nó dồn tải đúng lúc đường đang xấu.

Còn ở cơ sở dữ liệu, đây là lý do replication đồng bộ qua đường trễ cao giết thông lượng ghi: mỗi commit phải chờ bản sao xác nhận (một RTT), nên tốc độ commit đóng khung trong 1 / RTT bất kể đĩa nhanh cỡ nào — trừ khi gom nhiều commit thành nhóm (group commit), tức là lại "nới cửa sổ". Và một replica lag do mất gói làm sập luôn tốc độ, đúng như bảng trên.

Mẫu số chung: mọi cơ chế "gửi một lượng rồi chờ xác nhận mới gửi tiếp" đều có trần thông lượng bằng lượng-đang-bay chia cho vòng khứ hồi, và lỗi không làm giảm tuyến tính mà kích hoạt cơ chế lùi làm sập lượng-đang-bay — nên độ trễ và lỗi trừng phạt theo cấp số nhân, không phải cộng. Nên khi một thứ chậm qua mạng, câu hỏi đúng không phải "băng thông có đủ không" mà là "lượng việc đang bay của tôi có bao nhiêu, vòng khứ hồi bao lâu, và có lỗi nào đang âm thầm kích hoạt cơ chế lùi không" — vì gần như luôn, sửa độ trễ hoặc sửa mất gói đáng giá hơn mua thêm băng thông rất nhiều.

Muốn tự soi, xem kết nối đang truyền nhiều dữ liệu nhất của bạn có bị co cửa sổ không:

ss -tin state established 2>/dev/null | \
  paste - - 2>/dev/null | \
  grep -oE 'cwnd:[0-9]+|rtt:[0-9.]+|retrans:[0-9]+/[0-9]+|bytes_sent:[0-9]+' | \
  paste - - - - 2>/dev/null | head -10

echo "--- goi bi bo o card mang ---"
ip -s link show 2>/dev/null | grep -B1 -A2 'RX:' | grep -A1 'RX:' | tail -1

Một kết nối có cwnd dưới 100 mà đang gửi hàng megabyte là kết nối đang bị mất gói. Đừng chỉnh bộ đệm, đừng đổi thư viện — đi tìm chỗ gói bị rơi.

Phần sau: DNS — đo chi phí tra tên và chỗ nó âm thầm chậm.