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.

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.1 — dữ liệu 72 byte
5 s → c [.] ACK cho request
6 s → c [P.] HTTP/1.1 200 OK — dữ 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. Lần đầu nhìn con số này bày ra thành bảng, tôi mới thấm vì sao dân mạng ám ảnh chuyện tái dùng kết nối đến vậy.

Byte ma của SYN và FIN — cái bẫy suýt lừa tôi

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.

Điều rút ra: 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

Điều đầ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.

Điều 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.

Điều cuối 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. Gói lại bằng số: 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 (72 + 60 = 132 byte), 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.

Cái "10 gói cho một GET, 7 gói là chi phí một lần mỗi kết nối" ấy chính là lý do kỹ thuật đằng sau mọi lớp pool mà bạn dùng hằng ngày mà có khi không để ý. Trong Java, một RestTemplate hay WebClient mở mới cho mỗi lần gọi sẽ trả cái giá bảy-gói này mỗi request; đặt một HttpClient (JDK 11+) hoặc OkHttp có connection pool phía sau, hoặc HikariCP cho JDBC, chính là để những gói bắt tay đó chỉ trả một lần rồi tái dùng — và khi HTTPS thêm mấy vòng bắt tay TLS nữa vào đầu luồng, cái giá mở mới còn đắt hơn hẳn. Go mặc định làm điều đúng: http.DefaultTransport giữ và tái dùng kết nối — nhưng chỉ khi bạn đọc hết và Close() body; quên là mỗi request lại è cổ chịu trọn mười gói. Cơ sở dữ liệu là nơi cái giá này lộ rõ nhất: mở một kết nối Postgres không chỉ là ba gói bắt tay TCP mà còn cả bắt tay TLS + xác thực, nên một pool (HikariCP, pgbouncer) không phải tối ưu vặt mà là điều kiện sống còn của throughput. Mẫu số chung: mọi kết nối đều có một khoản "phí vào cửa" cố định trả bằng những gói không chở nội dung, và toàn bộ nghệ thuật hiệu năng mạng gói gọn trong một câu — trả phí đó một lần rồi tái dùng, thay vì trả lại từ đầu cho mỗi request. Bản bắt gói cho bạn thấy tận mắt khoản phí đó to cỡ nào.

Muốn tự thấy thì 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.