Hãy nghĩ về một chiếc điều hoà đời mới có cảm biến: nó đo nhiệt độ phòng liên tục và tự tăng giảm công suất theo trời nóng hay dịu. Bây giờ tưởng tượng bạn tháo cảm biến ra và khoá cứng công suất ở một mức "an toàn" bạn tự chọn. Chắc chắn có lúc nó sai — quá yếu giữa trưa hè, quá mạnh lúc đêm mát — vì một con số cố định không thể theo kịp một môi trường luôn đổi. Bộ đệm mạng của Linux cũng có đúng một cảm biến như vậy, và bài này đo cái giá của việc tháo nó ra để đóng đinh một con số.
Hai container, một mạng Docker, một luồng gửi và một luồng nhận. Tôi đo hai nút mà người ta hay chỉnh: kích thước mỗi lần send, và SO_SNDBUF.
Kích thước mỗi lần send: 36 lần
Gửi 256 MB, bộ đệm để mặc định, chỉ đổi số byte mỗi lần gọi send. Ba lần đo mỗi mức:
Mỗi lần send |
Ba lần đo (MB/s) |
|---|---|
| 512 B | 323,5 / 341,4 / 342,8 |
| 1 KB | 410,8 / 430,2 / 420,7 |
| 4 KB | 1.024,6 / 1.067,7 / 1.037,8 |
| 16 KB | 4.594,8 / 4.300,3 / 4.145,4 |
| 64 KB | 9.217,8 / 10.492,8 / 9.595,7 |
| 256 KB | 10.354,8 / 10.358,2 / 11.338,9 |
| 1 MB | 11.426,2 / 12.196,9 / 12.277,1 |
36 lần giữa 512 byte và 1 MB, và đường cong gãy rõ ở 64 KB.
Đây là cùng một hiện tượng phần 21 đã đo với ổ đĩa: mỗi lời gọi hệ thống có chi phí cố định, và với gói nhỏ thì chi phí đó áp đảo. Phần 24 đo được giá của một lời gọi hệ thống là khoảng 1.000 ns; gửi 512 byte mỗi lần nghĩa là trả 1.000 ns cho 512 byte. Trên 64 KB thì gần như không thêm được gì — đó là điểm dừng đáng nhớ.
Trong ứng dụng thật, việc này thường không nằm ở lời gọi send mà nằm ở tầng trên:
w := bufio.NewWriterSize(conn, 64*1024) // Go
f = sock.makefile("wb", buffering=65536) # Python
Một dịch vụ ghi từng dòng JSON thẳng ra socket đang ở đúng dòng đầu của bảng trên — trả phí giao hàng cố định cho từng mẩu tí xíu.
Rồi SO_SNDBUF đặt tay làm chậm đi
Cùng phép đo, chunk cố định 64 KB, chỉ đổi bộ đệm gửi:
| Xin | Nhận cấp thực tế | Ba lần đo (MB/s) |
|---|---|---|
| mặc định | 46.080 | 10.420,0 / 11.457,6 / 10.737,0 |
| 4 KB | 8.192 | 2.089,6 / 2.226,6 / 2.073,1 |
| 16 KB | 32.768 | 2.336,2 / 2.230,5 / 1.807,8 |
| 64 KB | 131.072 | 3.760,1 / 3.919,5 / 2.943,5 |
| 256 KB | 425.984 | 11.483,0 / 9.496,9 / 8.718,4 |
| 1 MB | 425.984 | 11.240,7 / 11.263,6 / 9.405,7 |
| 4 MB | 425.984 | 8.992,2 / 9.723,4 / 11.056,4 |
Bộ đệm mặc định chỉ 46 KB — nhỏ hơn mọi giá trị đặt tay từ 64 KB trở lên — mà nhanh bằng hoặc hơn tất cả. Đặt 4 KB làm chậm khoảng năm lần; đặt một con số "an toàn" 64 KB vẫn làm chậm gần ba lần so với để yên.
Lý do: gọi setsockopt(SO_SNDBUF) tắt cơ chế tự điều chỉnh của nhân. Đó chính là việc tháo cảm biến khỏi điều hoà. Bình thường nhân theo dõi độ trễ và mức mất gói của kết nối rồi co giãn bộ đệm trong dải tcp_wmem:
tcp_wmem = 4096 16384 4194304
^toi thieu ^khoi dau ^toi da
Nó điều chỉnh liên tục theo tình hình thật. Một con số cố định do bạn chọn không thể theo kịp, và nó luôn sai ở ít nhất một trong hai chiều — quá nhỏ khi mạng nhanh, quá lớn khi mạng chậm. Đây là điều ngược với hầu hết lời khuyên tinh chỉnh trên mạng, nên tôi nói thẳng: để yên SO_SNDBUF và SO_RCVBUF là lựa chọn đúng trong gần như mọi trường hợp.
Hai điều ngạc nhiên nhỏ trong bảng
Nhân cấp gấp đôi số bạn xin. Xin 4 KB được 8.192; xin 16 KB được 32.768. Phần thêm là chỗ cho siêu dữ liệu của gói (sk_buff), không phải dữ liệu của bạn. Nên khi đọc getsockopt, chia đôi mới ra dung lượng dùng được.
Trần bị chặn ở 425.984. Xin 1 MB hay 4 MB đều nhận đúng con số đó, vì:
net.core.wmem_max = 212992 -> 212992 x 2 = 425984
Xin nhiều hơn wmem_max không báo lỗi. setsockopt trả về thành công, và bạn nhận được ít hơn nhiều so với tưởng. Luôn đọc lại bằng getsockopt nếu thật sự cần con số đó.
Khi nào đặt tay là đúng
Đúng một trường hợp: đường truyền có tích số băng thông × độ trễ lớn hơn trần tự điều chỉnh. Ví dụ đường 10 Gbps xuyên lục địa, độ trễ khứ hồi 150 ms:
10 Gbps x 0,15 s / 8 = 187,5 MB
Cần bộ đệm cỡ đó mới lấp đầy được đường truyền. Trần mặc định 4 MB của tcp_wmem chặn ở khoảng 200 Mbps.
Cách đúng là nâng trần để nhân vẫn tự điều chỉnh, chứ không phải đóng đinh một con số — tức là nới khoảng cảm biến được phép hoạt động, không phải khoá cứng công suất:
sysctl -w net.core.wmem_max=134217728
sysctl -w net.core.rmem_max=134217728
sysctl -w net.ipv4.tcp_wmem="4096 87380 134217728"
sysctl -w net.ipv4.tcp_rmem="4096 87380 134217728"
Với mạng nội bộ trung tâm dữ liệu (độ trễ dưới 1 ms), tích số đó chỉ vài chục KB và mặc định đã dư.
Ba chỗ khác đáng nhìn hơn
ss -tim state established | head -20
ss -i hiện cwnd, rtt, retrans cho từng kết nối. retrans khác 0 là mất gói thật, và không bộ đệm nào chữa được mất gói.
netstat -s | grep -iE 'retransmit|overflow|dropped|pruned'
cat /proc/net/softnet_stat | awk '{print " drop:", $2}' | head -4
listen queue overflow nghĩa là backlog quá nhỏ — dòng này gây ra kết nối bị từ chối trong lúc tải cao, và nó không liên quan gì tới SO_SNDBUF:
sysctl -w net.core.somaxconn=4096
# va trong ma nguon: listen(fd, 4096)
Cả hai chỗ phải cùng tăng; listen() bị cắt xuống theo somaxconn.
Giới hạn của phép đo này
Hai container trên cùng một máy, qua cầu nối Docker. Độ trễ gần bằng 0 và không có mất gói. Đó là lý do thông lượng lên tới 12 GB/s — cao hơn mọi card mạng thật. Nghĩa là kết luận "chunk 64 KB là điểm gãy" và "đừng đặt tay SO_SNDBUF" vẫn đứng vững (chúng nói về chi phí lời gọi hệ thống và về cơ chế tự điều chỉnh), còn phần bàn về tích số băng thông × độ trễ thì tôi không đo được và chỉ trình bày phép tính.
Mẫu số chung: đừng khoá cứng cái mà hệ thống đang tự điều chỉnh giỏi hơn bạn
Cái lõi ở đây không riêng gì bộ đệm TCP: rất nhiều hệ thống có một cơ chế tự điều chỉnh theo tải thật, và can thiệp bằng một con số cố định thường tắt cơ chế đó rồi làm mọi thứ tệ hơn — vì con số tĩnh không thể theo kịp môi trường động.
Trong thế giới Java, đây đúng là câu chuyện tinh chỉnh GC và heap: người ta đặt tay hàng tá cờ -XX:... mà quên rằng G1/ZGC đã tự điều chỉnh kích thước vùng và ngưỡng theo hành vi thật của ứng dụng; đóng đinh NewRatio hay SurvivorRatio thường làm GC tệ hơn mặc định. Cách đúng gần như luôn là đặt trần (-Xmx) rồi để bộ thu gom tự lo phần trong — đúng như nâng wmem_max rồi để nhân co giãn.
Trong Go, GOGC và GOMEMLIMIT là hình dạng y hệt: runtime tự cân nhịp GC theo tốc độ cấp phát, và đặt GOGC cứng một giá trị "cho chắc" thường làm tệ hơn; cái người ta thật sự cần đặt là giới hạn trên (GOMEMLIMIT), để runtime vẫn tự điều chỉnh bên dưới nó.
Còn ở cơ sở dữ liệu, đây là chuyện query planner: một FORCE INDEX hay hint đóng đinh kế hoạch thực thi có thể nhanh hôm nay và thảm hoạ sáu tháng sau khi phân bố dữ liệu đổi — trong khi planner tự chọn lại dựa trên thống kê cập nhật. Cách đúng thường là cho planner thống kê tốt hơn (analyze, cột thống kê mở rộng), không phải khoá cứng kế hoạch.
Mẫu số chung: nhiều hệ thống có bộ điều chỉnh thích ứng đọc trạng thái thật liên tục, và một hằng số bạn đặt tay không những kém hơn mà còn vô hiệu hoá bộ điều chỉnh đó — nên can thiệp đúng chỗ là nới ranh giới (trần/sàn, thống kê) để cơ chế tự lo bên trong, chứ không phải thay nó bằng một con số chết. Nên câu hỏi đúng trước khi chỉnh một tham số hiệu năng không phải "đặt bao nhiêu là tối ưu" mà là "cái này có đang được tự điều chỉnh không, và nếu có thì tôi nên nới trần cho nó hay đang định khoá chết nó" — vì rất thường, câu trả lời tốt nhất là để yên.
Muốn tự soi, xem kết nối của bạn đang dùng bộ đệm bao nhiêu và có mất gói không:
ss -tim state established 2>/dev/null | head -20
echo "--- tran he thong ---"
sysctl net.core.{wmem_max,rmem_max,somaxconn} net.ipv4.tcp_{wmem,rmem}
echo "--- dau hieu nghen ---"
netstat -s 2>/dev/null | grep -iE 'listen queue|overflow|retransmit' | head -5
Nếu ss -i hiện retrans lớn dần, vấn đề của bạn là mất gói, không phải bộ đệm — và mọi giờ bỏ ra chỉnh SO_SNDBUF sẽ không đổi được gì.
Phần sau: TCP — bắt tay, cửa sổ trượt, và những gì xảy ra ở một kết nối chậm.