Hãy nhớ lại cảm giác gọi một cuộc điện thoại đường dài thời xưa: quay số, chờ đổ chuông, đầu kia nhấc máy "a lô?", bạn đáp "a lô", rồi mới bắt đầu nói được câu mình cần hỏi. Mỗi lượt "a lô" qua lại ngốn một khoảng chờ — và nếu gọi sang tận nửa vòng trái đất, mỗi lượt ấy kéo dài thấy rõ. Bây giờ tưởng tượng bạn phải gác máy sau mỗi câu hỏi rồi quay số gọi lại từ đầu cho câu tiếp theo. Đó chính xác là cái giá của việc mở kết nối TCP mới cho mỗi lần gọi, và bài này đo nó bằng đơn vị duy nhất mang đi được từ máy này sang máy khác: vòng khứ hồi.

"Dùng bể kết nối" là lời khuyên ai cũng biết. Ở đây tôi đo xem nó đáng bao nhiêu.

Ba phép đo, hai mức độ trễ

Hai container. Đo bằng cách lặp 200 lần và lấy trung vị.

Phép đo Cùng máy Thêm 20 ms độ trễ
Bắt tay TCP (connect + close) 762,3 µs 21.564 µs = 1 RTT
connect + một vòng gửi nhận 1.836,0 µs 43.492 µs = 2 RTT
Vòng gửi nhận trên kết nối có sẵn 262,2 µs 21.113 µs = 1 RTT

Cột thứ hai là cột đáng đọc. Với độ trễ đủ lớn để lấn át mọi thứ khác, ba con số rơi vào đúng bội số nguyên của vòng khứ hồi. Mở kết nối mới rồi hỏi một câu tốn hai vòng. Hỏi cùng câu đó trên kết nối đã mở tốn một vòng. Đúng một nửa, chính xác, và lặp lại được — gác máy gọi lại tốn gấp đôi giữ máy sẵn.

Vì sao đúng một vòng thêm

Bắt tay ba bước:

may khach  --- SYN ------------>  may chu
may khach  <-------- SYN+ACK ---  may chu
may khach  --- ACK ------------>  may chu     <- luc nay moi gui duoc du lieu

connect() trả về sau bước hai — nghĩa là sau một vòng khứ hồi. Gói ACK ở bước ba đi cùng dữ liệu đầu tiên, nên nó không tốn thêm vòng nào.

Vậy trước khi byte dữ liệu đầu tiên rời máy, bạn đã tiêu một vòng. Rồi câu trả lời cần một vòng nữa. Tổng: hai. Đúng ba lượt "a lô" trước khi vào việc.

Con số đó có nghĩa gì tuỳ vào bạn ở đâu

Khoảng cách Một vòng khứ hồi Tiết kiệm được nếu dùng lại kết nối
Cùng máy 0,26 ms 0,26 ms
Cùng vùng sẵn sàng ~0,5 ms 0,5 ms
Khác vùng sẵn sàng 1–2 ms 1–2 ms
Khác châu lục 21 ms (đo được) 21 ms mỗi lần gọi

Đây là lý do bể kết nối trông như một tối ưu vặt trên máy lập trình viên nhưng lại là thay đổi lớn nhất trong sản phẩm. Không thể đánh giá nó bằng cách chạy ở local — cột "cùng máy" nói rằng bạn tiết kiệm được một phần tư mili giây, nghe chẳng bõ. Nhưng một dịch vụ gọi 20 lượt tới cơ sở dữ liệu ở vùng khác cho mỗi yêu cầu: mở kết nối mới mỗi lần cộng thêm 20 × 21 ms = 420 ms, mà máy chủ không hề chậm một nhịp nào. Gần nửa giây bốc hơi vào ba lượt "a lô" lặp đi lặp lại.

Các chặng của một yêu cầu HTTPS mới

Chặng Vòng khứ hồi
Tra DNS 0 (đã cache) hoặc 1
Bắt tay TCP 1 (đo được)
Bắt tay TLS 1.3 1
Gửi yêu cầu, nhận trả lời 1
Tổng 3–4 thay vì 1

Giữ kết nối sống bỏ được ba chặng đầu.

Tôi không đo được chặng TLS — máy chủ thử nghiệm tôi dựng không nhận kết nối và tôi không truy ra được nguyên nhân trong khuôn khổ bài này. Con số "1 RTT" cho TLS 1.3 là theo đặc tả (TLS 1.2 tốn 2 RTT), không phải phép đo của tôi. Ghi rõ để phân biệt với những con số còn lại.

Ba cách bỏ bớt chặng

Giữ kết nối sống. Cách hiệu quả nhất và không có nhược điểm nào đáng kể:

Connection: keep-alive

Trong bể kết nối, hai tham số cần chú ý là kích thước tối thiểu (đừng để bể co về 0 lúc rảnh rồi phải bắt tay lại khi tải quay lại) và tuổi thọ tối đa (đừng giữ kết nối lâu hơn thời gian sống của bản ghi DNS phía sau).

TCP Fast Open — gửi dữ liệu ngay trong gói SYN:

sysctl -w net.ipv4.tcp_fastopen=3     # 1=may khach, 2=may chu, 3=ca hai

Bỏ được một vòng ở lần kết nối thứ hai trở đi (lần đầu phải lấy cookie). Nhược điểm: nhiều tường lửa và bộ cân bằng tải giữa đường bỏ hoặc làm hỏng gói SYN có dữ liệu, và triệu chứng khi đó là kết nối treo chứ không phải lỗi rõ ràng.

QUIC / HTTP/3 — gộp bắt tay vận chuyển và bắt tay mã hoá làm một, còn 1 RTT, và 0 RTT khi nối lại phiên.

Chỗ hàng đợi kết nối tràn

Bắt tay có hai hàng đợi phía máy chủ, và cả hai đều tràn được:

ss -lnt                                    # cot Recv-Q = dang cho, Send-Q = tran backlog
netstat -s | grep -iE 'listen queue|SYNs to LISTEN'
Hàng đợi Đầy thì sao Chỉnh ở đâu
SYN queue (bắt tay chưa xong) Gói SYN bị bỏ, máy khách thử lại sau 1 giây net.ipv4.tcp_max_syn_backlog
Accept queue (đã bắt tay, chờ accept) Kết nối bị bỏ im lặng net.core.somaxconn và đối số của listen()

Dòng đầu là nguồn gốc của những độ trễ "đúng 1 giây" hoặc "đúng 3 giây" trong biểu đồ — đó là bộ hẹn giờ thử lại SYN, không phải máy chủ chậm.

Dòng thứ hai đòi hỏi sửa hai chỗ: somaxconn của hệ thống và tham số listen() trong mã nguồn. listen() bị cắt xuống theo somaxconn, nên tăng một bên là vô ích.

Còn TIME_WAIT

Bên nào đóng kết nối trước sẽ giữ trạng thái TIME_WAIT khoảng 60 giây. Với dịch vụ mở nhiều kết nối ngắn tới cùng một đích, số cổng nguồn có hạn (khoảng 28.000 theo mặc định) sẽ cạn:

ss -tan state time-wait | wc -l
sysctl net.ipv4.ip_local_port_range

Cách chữa đúng là dùng lại kết nối — cùng một cách chữa cho mọi thứ trong bài này. net.ipv4.tcp_tw_reuse=1 giúp phía máy khách và an toàn; còn tcp_tw_recycle đã bị gỡ khỏi nhân vì làm hỏng kết nối từ máy khách sau NAT, nên mọi hướng dẫn còn nhắc tới nó đều đã lỗi thời.

Mẫu số chung: chi phí thiết lập cố định biến "gọi nhiều lần" thành cái bẫy

Cái lõi ở đây không riêng gì TCP: bất cứ thứ gì có một chi phí thiết lập cố định trước mỗi lần dùng — và bạn trả nó lặp đi lặp lại vì không giữ lại thứ đã thiết lập — đều biến "gọi nhiều lần nho nhỏ" thành một cái bẫy độ trễ mà local không bao giờ lộ ra.

Trong thế giới Java, đây đúng là lý do mọi thứ đều được pool: tạo một kết nối JDBC mới cho mỗi truy vấn tốn cả bắt tay TCP lẫn bắt tay xác thực của CSDL, nên HikariCP giữ sẵn kết nối đã mở — đúng "giữ máy" thay vì "gọi lại". Cùng nguyên tắc với thread pool (tránh chi phí tạo luồng) và với việc tái dùng HttpClient thay vì dựng mới mỗi lần gọi.

Trong Go, http.Client tự pool kết nết qua Transport — nhưng chỉ khi bạn tái dùng một client và đọc hết rồi đóng body; quên đọc hết body là kết nối không được trả về pool, và bạn âm thầm bắt tay lại mỗi lần, đúng cái bẫy 2-RTT. Người ta vấp cái này suốt vì nó không báo lỗi, chỉ chậm.

Còn ở cơ sở dữ liệu, đây là lý do pgbouncer và connection pool tồn tại: mỗi kết nối Postgres mới phải fork một tiến trình nền và chạy xác thực, một chi phí thiết lập nặng; dịch vụ serverless mở-đóng kết nối mỗi lần gọi làm sập CSDL không phải vì truy vấn nặng mà vì thiết lập lặp lại. Và prepared statement là cùng ý tưởng ở tầng câu lệnh: trả chi phí phân tích/lập kế hoạch một lần, dùng lại nhiều lần.

Mẫu số chung: thứ gì có chi phí thiết lập cố định lớn hơn nhiều chi phí mỗi lần dùng thì phải được thiết lập một lần rồi tái dùng — và cái bẫy là chi phí ấy tỷ lệ với độ trễ/khoảng cách nên vô hình trên máy local và khổng lồ trong sản phẩm phân tán. Nên câu hỏi đúng khi một dịch vụ chậm mà từng thao tác trông vẫn nhanh không phải "thao tác này tốn bao nhiêu" mà là "tôi có đang thiết lập lại cùng một thứ (kết nối, phiên, câu lệnh) cho mỗi lần gọi không, và trong sản phẩm nó ở cách tôi bao xa" — vì đo ở local sẽ luôn nói với bạn rằng chẳng có vấn đề gì.

Muốn tự đo chi phí bắt tay tới một dịch vụ thật của bạn:

for i in 1 2 3 4 5; do
  curl -o /dev/null -s -w "dns %{time_namelookup}  tcp %{time_connect}  tls %{time_appconnect}  dau-byte %{time_starttransfer}  tong %{time_total}\n" \
    https://dich-vu-cua-ban/health
done

echo "--- so ket noi TIME_WAIT ---"
ss -tan state time-wait 2>/dev/null | wc -l

echo "--- hang doi accept co tran khong ---"
ss -lnt 2>/dev/null | awk 'NR==1 || $2>0 || $3>0'

time_connect trừ time_namelookup chính là một vòng khứ hồi của bạn. Nhân nó với số kết nối mới mà dịch vụ mở cho mỗi yêu cầu — con số đó là thời gian bạn lấy lại được, miễn phí, chỉ bằng việc dùng lại kết nối.

Phần sau: Nagle và delayed ACK — đo khi hai cái gặp nhau.