Đầu kia của một kết nối TCP có thể biến mất mà không nói một lời — máy sập, dây mạng bị rút, một tường lửa ở giữa lặng lẽ chặn đường. Không có gói FIN, không có RST, chỉ là im lặng. Câu hỏi: phía bạn mất bao lâu để nhận ra kết nối đã chết? Câu trả lời mặc định làm nhiều người giật mình, và bài này đo nó chính xác trong container — kèm một cái bẫy trong chính code đo của tôi.
Mặc định: không bao giờ biết (khi đang rảnh)
Điều đầu tiên phải nhớ: một kết nối TCP rảnh (không gửi gì) không tự dò xem đầu kia còn sống hay không. Nếu peer biến mất im lặng, phía bạn vẫn thấy kết nối ở trạng thái ESTABLISHED — vô thời hạn. Tôi đo trực tiếp: mở một kết nối, làm cho đầu kia im lặng hoàn toàn (chặn mọi gói phản hồi bằng iptables), rồi để socket đó chờ nhận dữ liệu:
Keepalive TẮT: còn ESTABLISHED sau 20 giây, và mãi về sau
Không có gì phát hiện ra cái chết cả. Ứng dụng của bạn sẽ ngồi ôm một kết nối "ma" cho tới khi nào nó thử gửi dữ liệu — chỉ khi đó cơ chế phát-lại mới bắt đầu đếm giờ. Với một kết nối chỉ chờ nhận (một kết nối đăng ký nhận sự kiện, một socket long-poll, một kết nối trong bể chờ tái dùng), nó có thể chết mà bạn không bao giờ hay.
Đó là lý do keepalive tồn tại: một cơ chế của TCP tự gửi gói dò định kỳ trên kết nối rảnh để xác nhận đầu kia còn sống. Nhưng nó mặc định tắt, và ngay cả khi bật, các tham số mặc định của nó gần như vô dụng cho việc phát hiện nhanh.
Đo thời gian phát hiện: IDLE + CNT × INTVL
Keepalive có ba núm điều khiển, đặt trên từng socket: TCP_KEEPIDLE (rảnh bao lâu thì bắt đầu dò), TCP_KEEPINTVL (mỗi lần dò cách nhau bao lâu), và TCP_KEEPCNT (dò mấy lần không đáp thì coi là chết). Tôi bật keepalive với vài bộ tham số, cho peer biến mất im lặng, và đo thời gian tới lúc socket báo lỗi chết, mỗi bộ đo lặp lại cho ra cùng kết quả:
| Cấu hình (IDLE, INTVL, CNT) | Phát hiện chết | = IDLE + CNT × INTVL |
|---|---|---|
| Keepalive tắt | không bao giờ* | — |
| (3, 1, 3) | 6,1 s | 3 + 3×1 = 6 |
| (2, 1, 2) | 4,1 s | 2 + 2×1 = 4 |
| (5, 2, 3) | 11,4 s | 5 + 3×2 = 11 |
Công thức lộ ra rõ ràng và khớp tới từng giây: thời gian phát hiện = KEEPIDLE + KEEPCNT × KEEPINTVL. Đầu tiên chờ hết khoảng rảnh KEEPIDLE, rồi gửi KEEPCNT gói dò cách nhau KEEPINTVL giây; không gói nào được đáp thì kết nối bị tuyên bố chết với lỗi ETIMEDOUT. Với bộ (2,1,2), đúng 4 giây.
Bây giờ đặt con số mặc định của Linux vào công thức: KEEPIDLE = 7200 giây (2 giờ), KEEPINTVL = 75 giây, KEEPCNT = 9. Kết quả:
7200 + 9 × 75 = 7875 giây ≈ 2 giờ 11 phút
Nếu bạn chỉ bật SO_KEEPALIVE mà không chỉnh gì, một kết nối chết âm thầm phải mất hơn hai tiếng mới bị phát hiện. Đó là lý do keepalive "mặc định" nổi tiếng vô dụng: nó có đó, nhưng chậm tới mức gần như không giúp gì cho một dịch vụ cần biết nhanh. Muốn phát hiện trong vài giây, bạn phải tự đặt ba núm cho từng socket.
Một lần tôi đo hớ vì hai chữ "timeout"
Phép đo này suýt nữa cho tôi kết luận hoàn toàn sai. Lần chạy đầu tiên, code của tôi báo "không phát hiện sau 40 giây" cho mọi cấu hình — kể cả bộ (3,1,3) mà lẽ ra phải chết sau 6 giây. Tôi tưởng keepalive không hoạt động trong container.
Bắt gói mới lộ ra sự thật: keepalive có làm việc — tôi thấy các gói dò đi ra cách nhau đúng 1 giây, rồi một gói RST ở giây thứ 6. Kết nối đã được phát hiện chết đúng lúc. Bug nằm ở code đo của tôi, không ở TCP. Khi keepalive kết luận chết, lời gọi recv() ném lỗi ETIMEDOUT (errno 110). Nhưng trong Python, OSError với errno này chính là TimeoutError — mà TimeoutError lại trùng với socket.timeout, cái lỗi mà settimeout() ném ra khi hết giờ đọc. Nhánh except socket.timeout của tôi đứng trước, nên nó nuốt luôn lỗi chết thật và dán nhãn nhầm thành "hết giờ đọc, không phát hiện".
Cách sửa là phân biệt bằng errno: lỗi từ settimeout() có errno là None, còn lỗi keepalive báo chết có errno = 110. Đổi lại cách bắt lỗi, con số 6,1 giây hiện ra ngay. Bài học đo lường: hai thứ tên giống nhau chưa chắc cùng gốc. "Read timeout" (hết giờ đọc của tôi đặt) và "connection timeout" (keepalive tuyên bố chết) đều là TimeoutError trong Python, và gộp chúng làm một khiến tôi đo trúng cái chết rồi lại tự tay ném kết quả đi. Khi đo, luôn nhìn errno để biết loại lỗi, đừng chỉ nhìn tên lớp ngoại lệ.
Keepalive chỉ lo lúc rảnh; lúc gửi là chuyện khác
Một điểm hay bị lẫn: keepalive chỉ hoạt động khi kết nối rảnh. Nếu bạn đang gửi dữ liệu lúc đầu kia chết, thì không phải keepalive mà là cơ chế phát-lại (retransmission) phát hiện — TCP gửi lại gói không được ACK, tăng thời gian chờ theo cấp số, và sau khoảng tcp_retries2 lần (mặc định ~15 lần, tổng cộng ~15 phút) mới bỏ cuộc. Đó là một đường thời gian hoàn toàn khác, điều khiển bằng tham số khác. Hiểu nhầm hai cái này dẫn tới chỉnh sai chỗ: keepalive không giúp gì cho một kết nối đang bận gửi, và tcp_retries2 không giúp gì cho một kết nối đang rảnh.
Vì sao điều này quan trọng khi lập trình
Hệ quả lớn nhất là kết nối nửa-mở (half-open). Rất nhiều thiết bị trung gian — NAT gia đình, cân bằng tải, tường lửa doanh nghiệp — âm thầm bỏ một kết nối rảnh sau vài phút không có lưu lượng. Sau đó một bên tưởng kết nối còn sống, bên kia (hoặc thiết bị giữa) đã quên nó. Không có keepalive, phía "còn tưởng sống" sẽ ôm kết nối ma đó rất lâu; lần gửi tiếp theo mới nhận RST hoặc treo. Đây là gốc rễ của vô số lỗi "kết nối tự dưng đứng" với cơ sở dữ liệu, hàng đợi tin nhắn, hay WebSocket sau vài phút yên lặng.
Hệ quả thứ hai là cho bể kết nối (connection pool): một entry chết nằm trong bể, và request tiếp theo mượn phải nó sẽ thất bại hoặc treo. Nhiều thư viện pool vì thế bật keepalive với tham số chặt, hoặc tự kiểm tra sức khỏe kết nối trước khi giao ra.
Hệ quả thứ ba giải thích vì sao rất nhiều giao thức tự làm heartbeat ở tầng ứng dụng thay vì tin vào keepalive của TCP: WebSocket có ping/pong, gRPC có keepalive riêng, Redis có PING. Lý do là keepalive TCP mặc định tắt và quá chậm, lại không xuyên qua được mọi tầng proxy; một gói heartbeat ở tầng ứng dụng vừa giữ cho các thiết bị trung gian không bỏ kết nối rảnh, vừa cho ứng dụng quyền kiểm soát ngưỡng phát hiện. Con số mang theo: mặc định, TCP mất hơn 2 giờ mới phát hiện một peer chết im lặng trên kết nối rảnh; đặt TCP_KEEPIDLE/INTVL/CNT đưa nó xuống đúng IDLE + CNT × INTVL giây. Biết công thức đó là biết chỉnh chính xác bao lâu thì dịch vụ của bạn nhận ra một kết nối đã chết.
Thử ba mươi giây
Xem tham số keepalive mặc định của máy bạn: cat /proc/sys/net/ipv4/tcp_keepalive_time (giây trước gói dò đầu tiên — thường là 7200), cùng tcp_keepalive_intvl và tcp_keepalive_probes. Nhân ra bằng công thức time + probes × intvl để thấy máy bạn sẽ mất bao lâu mới phát hiện một kết nối rảnh bị chết. Trong code, nếu dùng một socket giữ lâu, hãy bật SO_KEEPALIVE rồi đặt TCP_KEEPIDLE xuống mức hợp lý (vài chục giây tới vài phút) — đừng để mặc định hai tiếng.