Hãy hình dung một quán ăn chỉ có 28.232 chiếc bàn, và một quy tắc lạ: mỗi lần một bàn được dọn xong, nó vẫn phải để trống thêm 60 giây "cho chắc" trước khi đón khách mới — phòng khi có người của lượt trước quay lại tìm đồ bỏ quên. Nếu khách vào chưa tới nửa phút một lượt thì chẳng sao. Nhưng nếu khách ùa vào nhanh hơn tốc độ bàn được giải phóng, thì đến một lúc cả quán trống trơn mà vẫn không còn bàn nào để xếp — mọi bàn đang trong 60 giây chờ. Đó chính xác là chuyện xảy ra với cổng TCP, và bài này đo xem cái quy tắc "chờ 60 giây" ấy làm cạn tài nguyên nhanh tới mức nào.
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. Ở đây tôi đo cái xảy ra khi bạn không dùng lại: hết cổng.
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 tới từng cái bàn.
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ả. Tôi suýt kết luận "chẳng có vấn đề gì" và đóng máy. May là thấy con số 3 nó quá đẹp, đâm nghi.
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.
Vì sao? Đó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, tệ hơn: 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 (ồn ào, dễ thấy) lấy một lỗi mất dữ liệu im lặng (kín, khó lần).
Tôi 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ứ — con số đẹp không phải bằng chứng đúng. 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
Nhiều người coi TIME_WAIT là thứ vô dụng cần tắt đi. Không phải — nó tồn tại vì hai lý do cụ thể.
Thứ nhất, 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 — đúng cái bàn để trống 60 giây phòng người cũ quay lại.
Thứ hai, đả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.
Mẫu số chung: mọi tài nguyên "trả lại có độ trễ" đều cạn theo tốc độ, không theo tổng
Cái lõi ở đây không phải riêng cổng TCP: bất cứ tài nguyên nào được cấp phát rồi trả lại sau một độ trễ cố định đều có một trần thông lượng ẩn bằng tổng / thời gian giữ — và bạn cạn nó theo tốc độ dùng, không phải theo tổng số bạn có.
Trong thế giới Java, đây đúng là bài toán connection pool: HikariCP có N kết nối, mỗi kết nối bị giữ trong suốt thời gian một truy vấn chạy, nên số truy vấn/giây bạn phục vụ được đóng khung ở N / thời gian giữ mỗi kết nối. Một truy vấn chậm bất thường giữ kết nối lâu hơn, và pool cạn dù tổng số kết nối trông vẫn dư — đúng cái bàn để trống. Cách chữa cũng cùng tinh thần "dùng lại": giảm thời gian giữ, không phải cứ nống N lên.
Trong Go, cùng hình dạng ở db.SetMaxOpenConns và ở chính việc tạo http.Client mới cho mỗi request (mỗi cái mở kết nối riêng rồi để TIME_WAIT tích lại) — lý do người ta nhắc mãi "tái dùng một http.Client" chính là để không rơi vào đúng vòng lặp cạn cổng của bài này.
Còn ở cơ sở dữ liệu, đây là lý do max_connections của Postgres không phải con số bạn cứ nống lên vô tư: mỗi kết nối tốn bộ nhớ và một tiến trình nền, nên thông lượng thật bị chặn bởi số kết nối / thời gian giữ, và pgbouncer tồn tại chính để dùng lại kết nối thay vì mở mới — đúng ba tầng cách chữa ở trên, xếp cùng thứ tự đúng đắn.
Mẫu số chung: tài nguyên nào được trả lại sau một độ trễ cố định (cổng ở TIME_WAIT, kết nối trong pool, khoá, thread, hạn mức) đều cạn theo tốc độ cấp phát chứ không theo tổng số — nên hệ thống chết dù đồng hồ tổng còn dư, và triệu chứng thường là một lỗi cấp-thấp không nhắc gì tới nguyên nhân thật. Nên câu hỏi đúng khi một tài nguyên như thế cạn không phải "làm sao có thêm nhiều hơn" mà là "tôi đang cấp phát bao nhiêu mỗi giây, mỗi cái bị giữ bao lâu, và tôi có đang dùng lại thay vì tạo mới không" — vì gần như luôn, rút ngắn thời gian giữ hoặc tái dùng đáng giá hơn nống trần lên.
Muốn tự soi, 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.