Suốt sê-ri này, tcpdump là con mắt giúp tôi thấy thứ mà mọi thư viện giấu đi: những gói tin thật chạy trên dây. Ở phần gần cuối này, tôi để chính việc bắt gói làm chủ đề — chụp lại toàn bộ một luồng TCP đơn giản nhất (một lần HTTP GET) và đọc từng gói một. Bởi vì cách chắc chắn nhất để hiểu mạng không phải là đọc mô tả, mà là nhìn tận mắt cái gì thực sự đi qua. Và ngay trên bản bắt gói của mình, tôi vấp một chi tiết suýt làm tôi kết luận sai — đúng kiểu bẫy mà bất cứ ai đọc gói tin cũng nên biết.

Một luồng TCP

tcpdump và một luồng đầy đủ

tcpdump (và người anh đồ họa Wireshark) sao chép lại từng gói tin đi qua một giao diện mạng, rồi in ra dạng người đọc được. Mỗi dòng là một gói, với những thông tin cốt lõi: cờ TCP ([S]=SYN mở kết nối, [S.]=SYN-ACK, [.]=ACK thuần, [P.]=PSH-ACK có dữ liệu, [F.]=FIN đóng), số thứ tự (seq — byte này là byte thứ mấy trong luồng), số báo nhận (ack — tôi đã nhận tới byte nào, đang chờ byte tiếp), và độ dài dữ liệu.

Tôi dựng hai namespace client–server nối bằng veth, thêm RTT giả lập 50 ms, cho server trả một HTTP 200 nhỏ rồi đóng kết nối (Connection: close để thấy trọn cả phần đóng). Rồi tôi gắn tcpdump xong mới kích hoạt một lần curl GET — bắt trọn luồng từ gói đầu tới gói cuối.

Đo: mười gói cho một lần GET

Đây là toàn bộ luồng, mười gói:

# Chiều Cờ Ý nghĩa
1 c → s [S] SYN — xin bắt tay (kèm mss, wscale, SACK)
2 s → c [S.] SYN-ACK — đồng ý
3 c → s [.] ACK — bắt tay xong
4 c → s [P.] GET / HTTP/1.1dữ liệu 72 byte
5 s → c [.] ACK cho request
6 s → c [P.] HTTP/1.1 200 OKdữ liệu 60 byte
7 s → c [F.] FIN — server đóng
8 c → s [.] ACK
9 c → s [F.] FIN — client đóng
10 s → c [.] ACK cuối

Đếm ra: 10 gói cho một lần GET, mà chỉ 2 gói chở dữ liệu HTTP thật (gói 4 và gói 6). Tám gói còn lại là nghi thức thuần túy: ba gói bắt tay (1-2-3), một ACK báo nhận request (5), và bốn gói đóng kết nối (7-8-9-10). Để chuyển vỏn vẹn 132 byte nội dung, đường dây phải tải mười gói, tám trong số đó không mang một byte ứng dụng nào.

Một lần tôi đọc hớ: byte ma của SYN và FIN

Con số mười gói tự nó đã đáng nhớ, nhưng chỗ tôi thực sự vấp là khi soi các số thứ tự. Gói 1 là SYN với length 0 — không chở byte dữ liệu nào. Nhưng gói 2, SYN-ACK, lại báo ack = seq + 1. Phản xạ của tôi là kêu lên: "sai rồi, gói SYN có mang byte nào đâu mà báo nhận cộng một? ack phải bằng đúng seq chứ".

Tôi sai, và may là kiểm lại trước khi viết. Trong TCP, gói SYN và gói FIN mỗi cái chiếm một số thứ tự dù không mang dữ liệu — như thể chúng có một "byte ma". Đây không phải lỗi; nó là chủ đích của thiết kế: nhờ SYN và FIN tiêu tốn một số thứ tự, việc bắt tay và chia tay cũng được báo nhận đáng tin cậy y như dữ liệu thật — nếu SYN mất, bên kia không ACK cái số thứ tự đó, và nó được gửi lại. Cùng lý do ở gói 9-10: client gửi FIN (length 0) tại seq X, server ACK X+1. Nếu tôi cứ khăng khăng "length 0 thì ack không được tăng", mọi số seq/ack trong bản bắt sẽ trông như sai lệch một đơn vị, và tôi sẽ đi tìm một cái lỗi không tồn tại.

Bài học đo lường: công cụ bắt gói không nói dối, nhưng đọc nó cần biết vài quy ước, kẻo bạn tự dựng ra một lỗi ma từ dữ liệu đúng. Byte ma của SYN/FIN là một; còn vài cái nữa dễ vấp: tcpdump mặc định in số thứ tự tương đối (bắt đầu từ 1 cho dễ đọc) chứ không phải số thật (một số 32-bit ngẫu nhiên, như 1061227579 khi tôi bật cờ tuyệt đối); và cờ [.] nghĩa là "ACK thuần không dữ liệu", dễ bị lướt qua. Đọc gói tin là một kỹ năng có luật chơi riêng — và cái bẫy nguy hiểm nhất không phải dữ liệu khó hiểu, mà là dữ liệu trông như mâu thuẫn với một giả định sai của chính mình.

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

Hệ quả đầu tiên là hiểu vì sao keep-alive và gộp kết nối đáng giá đến thế — giờ bằng con số gói cụ thể. Trong mười gói của một GET, chỉ ba gói (request, response, và ACK) là thứ bắt buộc phải có cho mỗi request; bảy gói kia (ba bắt tay, bốn đóng) là chi phí một lần cho mỗi kết nối. Tái dùng một kết nối cho request thứ hai, thứ ba... xoá sạch bảy gói đó mỗi lần — đúng cái mà keep-alive và bể kết nối làm. Nhìn luồng gói, cái lợi của việc "đừng mở kết nối mới cho mỗi request" hiện ra rõ ràng: bạn đang cắt 70% số gói.

Hệ quả thứ hai là bắt gói là công cụ gỡ lỗi mạnh nhất khi mọi tầng khác im lặng. Khi một request "treo", một kết nối "chập chờn", một API "thỉnh thoảng lỗi", các log ứng dụng thường chỉ nói "timeout" mà không nói vì sao. Bản bắt gói nói thẳng: có thấy SYN đi ra không (nếu không, vấn đề ở client/định tuyến)? Có SYN-ACK về không (nếu không, gói bị chặn hoặc server không nghe — nhớ luật DROP im lặng)? Có gói [R] (RST) đột ngột không (bên kia từ chối hoặc sập)? Có gói được gửi lại nhiều lần không (mất gói)? Mỗi triệu chứng trỏ tới một nguyên nhân, và chỉ bắt gói mới thấy được.

Hệ quả thứ ba là một thái độ, đúc kết cả sê-ri: khi nghi ngờ, hãy xuống tới lớp gói tin thật. Mọi trừu tượng — thư viện HTTP, ORM, service mesh — đều có thể che giấu hoặc trình bày sai điều đang xảy ra trên dây; gói tin là sự thật cuối cùng. Con số mang theo: một lần HTTP GET qua kết nối mới tốn 10 gói, chỉ 2 gói chở dữ liệu, 8 gói là bắt tay + ACK + đóng — nên tái dùng kết nối cắt được phần lớn số gói; và khi đọc bản bắt, nhớ SYN/FIN mỗi cái chiếm một số thứ tự, kẻo bạn tưởng dữ liệu đúng là sai. Học đọc gói tin một lần, và mạng thôi là hộp đen mãi mãi.

Thử ba mươi giây

Nếu máy bạn có tcpdump (hoặc cài Wireshark cho giao diện đồ họa), thử bắt một luồng thật: mở một terminal chạy sudo tcpdump -i any -nn 'tcp and host example.com', rồi ở terminal khác chạy curl -s https://example.com >/dev/null. Quay lại xem: bạn sẽ thấy đúng câu chuyện bài này đo — vài gói [S]/[S.]/[.] mở đầu (bắt tay, và với HTTPS còn thêm mấy vòng bắt tay TLS nữa), rồi các gói [P.] chở dữ liệu, rồi [F.] đóng lại. Đếm xem có bao nhiêu gói, và bao nhiêu trong số đó thật sự mang nội dung. Con số "gói nghi thức so với gói dữ liệu" đó, tận mắt trên máy bạn, là toàn bộ lý do vì sao dân mạng ám ảnh với việc tái dùng kết nối và giảm số vòng khứ hồi.