Đóng một kết nối TCP không phải là xong ngay. Một trong hai đầu phải nằm lại trong trạng thái TIME_WAIT khoảng một phút sau khi kết nối đã đóng — giữ chỗ một cổng, ngốn một chút bộ nhớ, và ở quy mô lớn có thể làm cạn cổng cả một máy chủ. Bài này dựng kết nối thật trong container rồi đo ba điều: ai là người phải gánh TIME_WAIT, nó kéo dài chính xác bao lâu, và vì sao một chỉnh sửa mà ai cũng nghĩ sẽ rút ngắn nó lại chẳng có tác dụng gì.
Vì sao phải có TIME_WAIT
Khi một kết nối đóng, bên chủ động đóng — tức bên gửi gói FIN trước và hoàn tất bắt tay đóng bốn bước — không chuyển thẳng sang trạng thái "đã đóng", mà dừng ở TIME_WAIT một khoảng bằng hai lần MSL (Maximum Segment Lifetime). Có hai lý do:
- Bảo đảm gói ACK cuối tới nơi. Trong bắt tay đóng, bên chủ động gửi ACK cuối cùng cho
FINcủa bên kia. Nếu ACK đó lạc, bên kia sẽ phát lạiFIN, và phải còn ai đó ở trạng thái TIME_WAIT để trả lời. Đóng ngay lập tức thì góiFINphát lại kia sẽ rơi vào hư không, để bên kia treo mãi. - Để các gói cũ chết hẳn trước khi tái dùng bộ bốn. Một gói đi lạc từ kết nối vừa đóng có thể còn lảng vảng trên mạng. Nếu bạn mở ngay một kết nối mới trùng đúng bộ bốn (IP + cổng nguồn/đích), gói cũ đó có thể chen vào và làm hỏng dữ liệu. TIME_WAIT giữ bộ bốn "bị cấm" đủ lâu để mọi gói cũ hết hạn.
Đây là cơ chế bảo vệ đúng đắn, nhưng cái giá của nó — một cổng bị giữ suốt thời gian chờ — mới là thứ gây đau đầu khi vận hành.
Ai gánh TIME_WAIT: bên đóng trước
Câu hỏi thực tế đầu tiên: trong một kết nối khách–chủ, ai là người phải chịu TIME_WAIT? Câu trả lời là bên chủ động đóng, và tôi đo trực tiếp. Tôi mở 500 kết nối rồi đóng, một lần cho khách đóng trước, một lần cho chủ đóng trước, và đếm số socket TIME_WAIT ở mỗi đầu:
| Ai đóng trước | TIME_WAIT ở khách | TIME_WAIT ở chủ |
|---|---|---|
| Khách đóng trước | 499 | 0 |
| Chủ đóng trước | 0 | 500 |
Kết quả sạch sẽ: bên nào gửi FIN trước thì bên đó ôm trọn TIME_WAIT, bên kia không phải chờ gì. (Con số 499 thay vì 500 chỉ là một kết nối vừa kịp hết hạn trong lúc đếm.)
Điều này có một hệ quả thiết kế quan trọng. Nếu máy chủ là bên đóng kết nối trước, nó sẽ tự tích lũy hàng loạt socket TIME_WAIT trên chính mình — và một máy chủ phục vụ hàng vạn kết nối mỗi phút có thể chất đống hàng trăm nghìn socket TIME_WAIT, ăn bộ nhớ và cạn cổng. Ngược lại, nếu để khách đóng trước, gánh nặng dồn về phía khách, nơi mỗi khách có cả một dải cổng tạm để đốt và chỉ nói chuyện với vài máy chủ. Đây là lý do một nguyên tắc thiết kế giao thức phổ biến là: để phía client chủ động đóng kết nối bất cứ khi nào có thể.
TIME_WAIT kéo dài đúng 60 giây
Câu hỏi thứ hai: bao lâu? Tôi mở một kết nối, đóng nó (phía khách chủ động), rồi theo dõi socket đó trong bảng ss cho tới khi nó biến mất:
t=0s: TIME_WAIT xuất hiện
t~60s: TIME_WAIT biến mất
Đúng khoảng 60 giây. Trên Linux, con số này là 2 × MSL với MSL được đặt là 30 giây, thành 60 giây. Trong 60 giây đó, bộ bốn của kết nối vừa đóng không dùng lại được cho một kết nối ra mới tới cùng đích.
Một hiểu lầm phổ biến tôi kiểm lại
Ở đây tôi kiểm chứng một niềm tin mà rất nhiều bài hướng dẫn trên mạng lặp lại: rằng chỉnh tcp_fin_timeout sẽ rút ngắn TIME_WAIT. Tôi hạ nó từ 60 xuống 15 giây rồi đo lại thời lượng TIME_WAIT:
tcp_fin_timeout = 15 s
t=0s: TIME_WAIT xuất hiện
t~60s: TIME_WAIT biến mất (KHÔNG đổi!)
TIME_WAIT vẫn kéo dài đúng 60 giây, bất chấp tcp_fin_timeout đã là 15. Niềm tin kia sai, và nó sai vì lẫn lộn hai trạng thái khác nhau. tcp_fin_timeout điều khiển trạng thái FIN_WAIT_2 — khoảng chờ FIN của đầu kia sau khi bạn đã gửi FIN và nhận ACK — chứ không phải TIME_WAIT. Thời lượng TIME_WAIT trên Linux bị đóng cứng ở 60 giây bằng một hằng số biên dịch tên TCP_TIMEWAIT_LEN; không có sysctl nào chỉnh được nó mà không biên dịch lại nhân.
Bài học đo lường: một con số hay bị gán nhầm cho một cái nút không liên quan, và cách duy nhất để biết là đo. Nếu tôi tin theo lời khuyên phổ biến và hạ tcp_fin_timeout để "chữa" TIME_WAIT, tôi đã đổi một tham số chẳng ăn nhập, khiến FIN_WAIT_2 ngắn đi (có thể gây hại) mà vấn đề TIME_WAIT vẫn nguyên. Đo trước, tin lời khuyên sau.
Cạn cổng: bài toán tốc độ
Ghép TIME_WAIT với chuyện cổng tạm ở bài về cổng và socket ra một giới hạn cụ thể. Ở đó tôi đo được một máy khách chỉ có khoảng 28.232 cổng tạm để mở kết nối tới một đích. Mỗi kết nối chủ động đóng giữ một cổng trong TIME_WAIT 60 giây. Vậy tốc độ mở-đóng bền vững tối đa tới một đích là:
28.232 cổng / 60 giây ≈ 470 kết nối/giây
Vượt quá ngưỡng đó — một dịch vụ mở kết nối mới cho mỗi request tới cùng một backend, ở tốc độ cao hơn 470/giây — và bạn sẽ cạn cổng, nhận lỗi Cannot assign requested address, dù mỗi kết nối chỉ sống vài mili giây. Cổng không cạn vì bạn đang dùng chúng, mà vì chúng còn kẹt trong TIME_WAIT của những kết nối đã xong từ lâu.
Có ba cách gỡ, và không cái nào là vặn tcp_fin_timeout. Thứ nhất và tốt nhất: tái dùng kết nối (connection pool, keep-alive) để không mở-đóng liên tục — cùng lời khuyên đã gặp ở nhiều phần trước. Thứ hai: bật tcp_tw_reuse, cho phép một kết nối ra mới tái dùng một socket đang TIME_WAIT khi timestamp bảo đảm an toàn — nó nâng trần tốc độ ở phía client. Thứ ba, cho phía server: SO_REUSEADDR để server khởi động lại được ngay dù cổng nghe còn vướng kết nối cũ ở TIME_WAIT (nhưng nó không giải quyết cạn cổng tạm phía client). Chọn đúng công cụ đòi hiểu TIME_WAIT nằm ở đâu và ai gánh — đúng những thứ vừa đo.
Vì sao điều này quan trọng khi lập trình
Triệu chứng kinh điển: một máy chủ hoặc một proxy dưới tải cao bắt đầu ném lỗi cạn cổng, và bảng ss đầy socket TIME_WAIT. Gần như luôn luôn, gốc rễ là một mẫu "mở kết nối mới cho mỗi thao tác rồi đóng", cộng với việc chính nó là bên đóng trước. Hai chỉnh sửa đúng là: chuyển sang tái dùng kết nối, và sắp xếp để bên kia (thường là client) đóng trước nếu có thể. Vặn tcp_fin_timeout — thứ mà nửa số câu trả lời trên mạng khuyên — không đụng tới TIME_WAIT chút nào.
Con số mang theo: bên chủ động đóng phải giữ cổng trong TIME_WAIT đúng 60 giây trên Linux, không chỉnh bằng tcp_fin_timeout được; điều đó đặt một trần tốc độ mở-đóng khoảng 470 kết nối/giây tới mỗi đích từ một máy khách. Hiểu ai gánh và bao lâu là biết chính xác chỗ để sửa khi cổng bắt đầu cạn.
Thử ba mươi giây
Mở một kết nối rồi đóng nó từ phía bạn (ví dụ curl http://mot-may-chu rồi để nó kết thúc), sau đó chạy ngay ss -tan state time-wait và tìm dòng tới máy chủ đó — bạn sẽ thấy socket của mình nằm trong TIME_WAIT. Ghi lại giờ, đợi rồi kiểm lại: nó biến mất sau đúng khoảng một phút. Muốn thấy áp lực thật, chạy một vòng lặp curl nhanh tới cùng một đích và đếm ss -tan state time-wait | wc -l tăng lên — đó là số cổng đang bị TIME_WAIT giam giữ.