Khi chương trình của bạn gọi send(sock, "x", 1), một byte dữ liệu ấy không đi một mình trên dây. Nó được bọc lần lượt trong ba lớp header, và tổng phần bọc thường lớn hơn nhiều so với phần ruột. Bài này không nói lý thuyết: tôi dựng hai đầu mạng thật trong một container, bắt một gói thật bằng tcpdump, rồi đếm từng byte của từng tầng.
Dựng một đường mạng thật để đo
Để có header Ethernet thật (chứ không phải loopback lo vốn không có tầng 2), tôi tạo một cặp veth nối hai không gian mạng, chạy một máy chủ vọng lại ở một đầu, và bắt gói ở đầu kia. Tất cả nằm gọn trong một container dùng một lần, tự dọn khi thoát — không đụng tới hệ thống của ai khác.
ip netns add srv
ip link add veth0 type veth peer name veth1
ip link set veth1 netns srv
ip addr add 10.10.0.1/24 dev veth0 && ip link set veth0 up
ip netns exec srv ip addr add 10.10.0.2/24 dev veth1
ip netns exec srv ip link set veth1 up
# máy chủ vọng lại ở 10.10.0.2:9000, rồi bắt gói:
tcpdump -i veth0 -w cap.pcap tcp
Máy khách bật TCP_NODELAY và gửi đúng những cỡ dữ liệu định trước — 1, 100, 500, 1000 và 1448 byte — mỗi cỡ trên một kết nối riêng để mỗi lần gửi thành đúng một segment. Sau đó tôi tự viết một đoạn đọc thẳng file .pcap, bóc từng khung Ethernet, đọc chiều dài header IP từ trường IHL và chiều dài header TCP từ trường Data Offset, rồi tính phần dữ liệu bằng tổng dài IP − IHL − TCP. Không thư viện, không đoán: con số lấy trực tiếp từ byte trên dây. Tôi chạy đủ ba lần và kết quả giống hệt nhau tới từng byte.
Ba tầng, ba con số
Đây là kích thước header đo được trên một gói mang 1448 byte dữ liệu:
| Tầng | Header | Nó thêm gì |
|---|---|---|
| Ethernet | 14 B | địa chỉ MAC nguồn/đích + kiểu khung |
| IP | 20 B | địa chỉ IP nguồn/đích, TTL, tổng dài |
| TCP | 32 B | cổng nguồn/đích, số thứ tự, cờ, cửa sổ, tùy chọn |
| Tổng đầu | 66 B | cố định mỗi gói |
Hai con số đầu đúng như sách vẽ. Con số thứ ba thì không.
Bất ngờ: TCP không phải 20 byte
Mọi tài liệu nhập môn đều vẽ header TCP dài 20 byte. Nhưng gói thật của tôi đo được 32 byte. Phần chênh 12 byte là các tùy chọn TCP, mà chủ yếu là dấu thời gian (TCP timestamps): nhân Linux hiện đại bật nó theo mặc định để đo vòng khứ hồi và chống số thứ tự quấn vòng. Mười byte cho dấu thời gian, cộng hai byte đệm để căn cho tròn bốn — thành đúng 12 byte thừa ra so với hình vẽ trong sách.
Gói SYN mở kết nối còn nặng hơn nữa: tôi đo được 40 byte tùy chọn TCP, vì lúc bắt tay hai bên còn phải khai báo MSS, cho phép SACK, dấu thời gian và cửa sổ tỉ lệ. Cái header "20 byte" mà ai cũng nhớ thực ra gần như không bao giờ xuất hiện trên một kết nối thật.
Điểm đáng nhớ ở đây: nếu bạn tính ngân sách băng thông bằng con số 20 byte trong sách, bạn đã đếm thiếu 12 byte mỗi gói — và ở quy mô hàng triệu gói mỗi giây, sai số đó không nhỏ.
Overhead lấn át khi gói nhỏ
66 byte header là cố định, không đổi dù phần dữ liệu là 1 byte hay 1448 byte. Điều đó nghĩa là tỷ lệ overhead phụ thuộc hoàn toàn vào cỡ dữ liệu:
| Cỡ dữ liệu | Trên dây | Overhead |
|---|---|---|
| 1 B | 67 B | 98,51% |
| 100 B | 166 B | 39,76% |
| 500 B | 566 B | 11,66% |
| 1.000 B | 1.066 B | 6,19% |
| 1.448 B | 1.514 B | 4,36% |
Đọc dòng đầu cho kỹ: gửi một byte dữ liệu thì 98,51% những gì thực sự chạy trên dây là header. Bạn trả tiền băng thông cho 67 byte để chuyển đi 1 byte có ích. Tới dòng cuối — một gói sát ngưỡng MTU 1500 — phần header co lại còn 4,36%.
Đây chính là lý do vật lý đằng sau một lời khuyên mà bạn nghe khắp nơi: gộp nhiều thứ vào một gói lớn luôn rẻ hơn rải ra nhiều gói nhỏ. Không phải vì "ít lời gọi hệ thống hơn" (dù điều đó cũng đúng), mà vì mỗi gói nhỏ kéo theo trọn vẹn 66 byte thuế cố định. Một nghìn gói 1 byte tốn 67.000 byte trên dây; gộp một nghìn byte đó vào một gói chỉ tốn 1.066 byte. Chênh nhau 63 lần.
Một lần đo sai của chính tôi
Lần chạy đầu tiên, bảng kết quả của tôi thiếu đúng dòng 1448 byte — cỡ gói quan trọng nhất, vì nó cho con số overhead tốt nhất. Ba cỡ nhỏ hiện ra bình thường, riêng gói sát MTU biến mất khỏi bản bắt.
Nguyên nhân là offload. Nhân Linux mặc định bật TSO và GSO: thay vì tự cắt dữ liệu thành từng segment vừa MTU, nó đẩy nguyên một khối lớn xuống card mạng (hoặc xuống veth) và để tầng dưới cắt. Hậu quả là tcpdump — vốn bắt gói trước khi việc cắt xảy ra — nhìn thấy một "siêu gói" gộp lại, chứ không thấy đúng gói 1448 byte sẽ thật sự đi trên dây. Đoạn phân tích của tôi lọc theo đúng cỡ payload nên bỏ sót nó.
Cách chữa là tắt offload trên cả hai đầu veth trước khi đo:
for f in tso gso gro; do ethtool -K veth0 $f off; done
Sau khi tắt, gói 1448 byte hiện ra ngay, và con số 4,36% mới xuất hiện. Bài học: khi bạn muốn thấy đúng những gì trên dây, hãy tắt offload — nếu không tcpdump sẽ cho bạn xem thứ nhân dự định gửi, không phải thứ thật sự được gửi.
Còn 4 byte nữa mà tcpdump không thấy
Cột "trên dây" ở trên vẫn chưa phải con số cuối cùng. tcpdump bắt gói từ header Ethernet trở đi, nhưng nó không thấy 4 byte FCS (chuỗi kiểm tra khung) mà card mạng gắn vào cuối mỗi khung. Ở tầng vật lý còn thêm 8 byte mở đầu và 12 byte khoảng trống giữa các khung nữa. Nghĩa là thuế cố định thật sự trên một sợi dây Ethernet còn cao hơn 66 byte — và với gói 1 byte, tỷ lệ header thực tế còn tệ hơn con số 98,51% tôi đo được.
Con số bạn nên mang theo từ bài này rất đơn giản: mỗi gói TCP/IP trên Ethernet gánh khoảng 66 byte header cố định, và đó là lý do kích thước gói quyết định gần như toàn bộ hiệu quả băng thông của một dịch vụ nói chuyện qua mạng. Gói to thì thuế loãng ra; gói nhỏ thì thuế nuốt hết.
Thử ba mươi giây
Chạy tcpdump -i eth0 -c 1 -v 'tcp' trên một máy đang có kết nối, rồi nhìn dòng options. Nếu bạn thấy TS val trong đó, header TCP của bạn đang là 32 byte chứ không phải 20 — và mỗi gói dịch vụ của bạn gửi đi đang cõng thêm 12 byte mà hình vẽ trong sách không hề nhắc tới.