Phần 27 kết thúc ở chỗ dùng lại kết nối tiết kiệm một vòng khứ hồi. Bài này đo cái xảy ra khi bạn không dùng lại: hết cổng.

Cạn cổng và TIME_WAIT

Cạn trong 22 giây

Vòng lặp mở kết nối tới cùng một đích, gửi một byte, đọc trả lời, đóng:

dải cổng: 32768-60999 = 28232 cổng
Số kết nối TIME_WAIT Thời gian
2.000 2.003 0,4 s
10.000 10.003 2,4 s
20.000 20.003 10,8 s
28.000 28.003 22,0 s
28.231 → dừng 28.234 22,2 s — [Errno 99] Cannot assign requested address

Dừng ở 28.231 với dải 28.232 cổng. Khớp chính xác.

Con số quan trọng là 22 giây so với 60 giây — thời gian mỗi cổng bị giữ ở TIME_WAIT. Cổng bị tiêu nhanh gấp gần ba lần tốc độ được trả lại, nên không có cách nào chờ cho qua.

Ngưỡng thực dụng: một tiến trình mở hơn khoảng 470 kết nối mỗi giây tới cùng một đích (28.232 chia 60) sẽ cạn cổng. Con số đó thấp hơn nhiều so với trực giác của phần lớn người viết dịch vụ.

Một dòng mã làm vấn đề biến mất — theo cách sai

Lần đo đầu tiên tôi viết vòng lặp thế này:

s.connect((H, P)); s.sendall(b"x"); s.close()

Chạy 20.000 lần: TIME_WAIT đứng yên ở 3, không lỗi gì cả.

Thêm đúng một lời gọi:

s.connect((H, P)); s.sendall(b"x"); s.recv(1); s.close()

Chạy lại: cạn cổng ở 28.231 như bảng trên.

Đóng ổ cắm khi còn dữ liệu chưa đọc thì nhân gửi RST chứ không gửi FIN. Máy chủ đã vọng lại một byte; ở bản đầu tôi không đọc nó, nên khi close() được gọi, bộ đệm nhận vẫn còn dữ liệu. Nhân coi đó là "hủy bỏ" và cắt ngang bằng RST — và RST bỏ qua TIME_WAIT hoàn toàn.

Nhìn thì như đã chữa được vấn đề. Thực ra nó là một lỗi khác: RST cắt ngang kết nối, và dữ liệu mà bên kia vừa gửi có thể mất mà không ai biết. Bạn đổi một lỗi cạn tài nguyên lấy một lỗi mất dữ liệu im lặng.

Ghi lại vì hai lý do. Thứ nhất, đây là cách một phép đo có thể "thành công" mà đo nhầm thứ. Thứ hai, nếu dịch vụ của bạn không thấy TIME_WAIT tích tụ trong khi lẽ ra phải thấy, kiểm tra xem nó có đang đóng ổ cắm với dữ liệu chưa đọc không.

TIME_WAIT không phải rác

Nó tồn tại vì hai lý do cụ thể:

Nuốt gói đến muộn. Một gói của kết nối cũ đi lạc trên mạng có thể quay về sau vài chục giây. Nếu bộ bốn (IP nguồn, cổng nguồn, IP đích, cổng đích) đã được cấp cho kết nối mới, gói lạc đó sẽ chen vào giữa luồng dữ liệu mới. TIME_WAIT giữ bộ bốn đó không dùng lại cho tới khi mọi gói lạc chắc chắn đã chết.

Đảm bảo bên kia nhận được ACK cuối. Nếu ACK cuối bị mất, bên kia gửi lại FIN và cần có ai đó còn sống để trả lời. Ổ cắm đã biến mất hoàn toàn thì bên kia sẽ nhận RST và ghi log một lỗi không có thật.

Nên mọi lời khuyên kiểu "tắt TIME_WAIT đi" đều đang đề nghị bạn đổi lấy hai lớp lỗi này.

Ba cách chữa, xếp theo mức đúng đắn

Một — dùng lại kết nối. Đây là cách chữa thật, và nó cũng loại luôn chi phí bắt tay ở phần 27. Nếu dịch vụ của bạn mở 470 kết nối mỗi giây tới cùng một cơ sở dữ liệu, cái nó cần là một bể kết nối, không phải một tham số nhân.

Hai — tcp_tw_reuse. Cho phép dùng lại cổng đang ở TIME_WAIT cho kết nối đi ra mới, khi dấu thời gian TCP chứng minh gói cũ không thể lẫn vào.

sysctl -w net.ipv4.tcp_tw_reuse=1

Tôi đo lại vòng lặp trên với cờ này bật: vượt qua 34.000 kết nối mà không lỗi, và TIME_WAIT ổn định quanh 17.000–20.000 thay vì tăng tới trần. Nó có tác dụng thật.

Ba — nới dải cổng. Mua thêm thời gian, không giải quyết gì:

sysctl -w net.ipv4.ip_local_port_range="10000 65535"     # ~55.000 cong

Chỉ đẩy ngưỡng từ 470 lên khoảng 920 kết nối mỗi giây.

Cái không nên dùng

net.ipv4.tcp_tw_recycle đã bị gỡ khỏi nhân từ phiên bản 4.12. Nó từng làm hỏng kết nối từ các máy khách nằm sau cùng một NAT, vì nó lọc theo dấu thời gian mỗi địa chỉ IP — hai máy sau cùng NAT có đồng hồ khác nhau thì một trong hai bị chặn im lặng.

Mọi hướng dẫn tinh chỉnh còn nhắc tới nó đều viết trước năm 2017, và đó cũng là dấu hiệu để nghi ngờ phần còn lại của hướng dẫn đó.

Cạn cổng nhìn từ nơi khác

Điều khiến lỗi này khó chẩn đoán: TIME_WAIT là trạng thái của bộ bốn, không phải của riêng cổng. Cùng một cổng nguồn dùng được cho nhiều đích khác nhau.

Nghĩa là dịch vụ của bạn có thể mở 100.000 kết nối tới 10 máy chủ khác nhau mà không sao, rồi chết khi 10 máy chủ đó gộp lại sau một bộ cân bằng tải có một địa chỉ IP.

Cùng lượng tải, cùng mã nguồn, khác kiến trúc mạng — và triệu chứng là Cannot assign requested address, một thông báo không nhắc gì tới TCP.

# xem theo dich, khong xem tong
ss -tan state time-wait | awk '{print $5}' | cut -d: -f1 | sort | uniq -c | sort -rn | head

Dòng đầu ra của lệnh này là đích sắp làm bạn hết cổng.

Phía máy chủ thì khác hẳn

Máy chủ nghe trên một cổng, và bộ bốn của nó phân biệt bằng địa chỉ máy khách. Máy chủ không cạn cổng — nó cạn bộ mô tả tệp:

ulimit -n
cat /proc/sys/fs/file-max

Và nếu máy chủ là bên đóng trước — chuyện xảy ra mỗi lần nó trả lời rồi đóng kết nối HTTP — thì nó cũng tích TIME_WAIT, chỉ là không bị chặn bởi số cổng. Chi phí lúc đó là bộ nhớ và thời gian tra bảng kết nối.

Thử ba mươi giây

Xem bạn còn cách ngưỡng bao xa:

echo "dai cong : $(cat /proc/sys/net/ipv4/ip_local_port_range)"
echo "tw_reuse : $(cat /proc/sys/net/ipv4/tcp_tw_reuse)"
echo "TIME_WAIT: $(ss -tan state time-wait 2>/dev/null | wc -l)"

echo "--- dich nao chiem nhieu nhat ---"
ss -tan state time-wait 2>/dev/null | awk 'NR>1{print $NF}' | \
  cut -d: -f1 | sort | uniq -c | sort -rn | head -5

lo=$(awk '{print $1}' /proc/sys/net/ipv4/ip_local_port_range)
hi=$(awk '{print $2}' /proc/sys/net/ipv4/ip_local_port_range)
echo "--- tran ly thuyet: $(( (hi-lo+1) / 60 )) ket noi moi giay toi cung mot dich ---"

Dòng cuối là con số cần so với tốc độ mở kết nối thật của dịch vụ. Nếu bạn đang ở trong khoảng một nửa con số đó, hãy đi tìm chỗ nào chưa dùng lại kết nối — trước khi nó cạn lúc có sự kiện khuyến mãi.

Phần sau: DNS — đo chi phí tra tên và chỗ nó âm thầm chậm.