WebSocket Heartbeat Ping/Pong và Auto Reconnect: Loại bỏ Zombie Connections
Hướng dẫn production xử lý WebSocket Heartbeat, Ping/Pong, Zombie Connections và tự động reconnect với Node.js, browser, backoff, jitter, resume và observability.
ShanverseUpdated 13 min read— viewsLoading views
WebSocket
Resilience
Zombie Connection là gì?
Zombie Connection, half-open connection hoặc stale socket là kết nối mà một phía vẫn nghĩ đang hoạt động trong khi phía còn lại hoặc đường mạng đã biến mất.
Các nguyên nhân phổ biến:
Laptop sleep rồi đổi Wi-Fi.
Điện thoại chuyển giữa 4G/5G và Wi-Fi.
NAT, firewall hoặc reverse proxy xóa connection idle.
Container/server bị kill mà không gửi TCP FIN hay WebSocket Close frame.
Network partition làm packet biến mất nhưng TCP chưa timeout.
Tab browser bị freeze hoặc process client crash.
Nếu không thu hồi, server tiếp tục giữ:
Comments
Socket object và event listeners.
User/session data gắn trên connection.
Room membership và subscription.
Buffer đang chờ gửi.
Timer, closure và reference tới object khác.
File descriptor và kernel socket buffer.
Một zombie socket nhỏ có thể không đáng kể; hàng chục nghìn socket sẽ làm tăng RAM, file descriptors và thời gian broadcast.
Idle Connection không phải Zombie Connection
Cần phân biệt:
Idle nhưng khỏe: không có business message nhưng vẫn trả lời heartbeat.
Zombie: không còn phản hồi trong deadline cho phép.
Slow connection: vẫn phản hồi nhưng latency cao hoặc outbound buffer tăng.
Không nên đóng socket chỉ vì người dùng chưa gửi tin nhắn. Hãy đóng khi không nhận được bằng chứng kết nối còn sống trong khoảng thời gian đã thiết kế.
Ping/Pong hoạt động ở hai tầng
WebSocket protocol Ping/Pong
WebSocket có control frames Ping và Pong. Khi endpoint nhận Ping, nó phải phản hồi Pong. Các thư viện server như ws cho phép gửi ping() và lắng nghe pong.
Browser tự xử lý protocol Ping từ server, nhưng JavaScript WebSocket API không cung cấp socket.ping() hay event pong cho ứng dụng.
Application-level heartbeat
Ứng dụng gửi message bình thường như:
Server trả:
Cách này cần thiết khi browser phải chủ động phát hiện đường truyền chết, đo RTT hoặc khi protocol đi qua một abstraction không expose control frames.
Hai tầng có thể cùng tồn tại:
Server dùng protocol Ping/Pong để dọn client chết.
Browser dùng application Ping/Pong để tự phát hiện và reconnect nhanh.
Server Node.js với thư viện ws
Ví dụ production-oriented sử dụng một global timer, không tạo một setInterval cho mỗi socket:
Thư viện ws tự động gửi Pong khi nhận Ping theo WebSocket specification. Cờ isAlive được đặt về false trước lần Ping mới và chỉ trở lại true khi nhận Pong.
close() hay terminate()?
close(code, reason) thực hiện WebSocket Close handshake lịch sự và chờ peer phản hồi.
terminate() đóng TCP socket ngay.
Với heartbeat timeout, peer đã được xem là không phản hồi nên terminate() thường phù hợp để giải phóng tài nguyên. Với deploy, logout hoặc policy violation khi đường truyền còn khỏe, ưu tiên close() với close code hợp lệ.
Vì sao không tạo một timer cho mỗi connection?
10.000 socket với 10.000 setInterval gây nhiều callback, closure và khó cleanup. Một global sweep đơn giản hơn, dùng ít object hơn và dễ quan sát.
Để tránh gửi Ping đồng loạt trong cụm lớn, có thể:
Chia socket thành nhiều bucket và quét theo từng tick.
Thêm jitter lúc xếp lịch heartbeat.
Quét dựa trên lastActivityAt thay vì Ping mọi socket cùng lúc.
Xem mọi inbound traffic là bằng chứng kết nối còn sống nếu phù hợp.
Không thêm jitter ngẫu nhiên mới cho từng socket ở mỗi vòng nếu điều đó làm deadline khó dự đoán; nên chọn mô hình bucket có giới hạn rõ ràng.
Browser heartbeat và auto reconnect
Một client production cần state machine, không chỉ gọi new WebSocket() trong onclose.
Đây là khung tham khảo; dự án thật cần schema validation, authentication refresh, queue policy và tích hợp lifecycle của framework.
Exponential Backoff và Jitter
Reconnect ngay lập tức theo vòng lặp sẽ tạo reconnect storm khi server hoặc mạng gặp sự cố. Hàng chục nghìn client cùng handshake có thể hạ gục hệ thống vừa phục hồi.
Công thức đơn giản:
Full jitter phân tán các client trên toàn cửa sổ. Cũng cần:
Reset attempt sau khi connection thực sự ổn định, không nhất thiết ngay khi vừa open.
Dừng reconnect khi logout, token bị thu hồi hoặc close code là lỗi vĩnh viễn.
Pause khi browser báo offline.
Đặt upper bound và telemetry cho số lần thử.
Refresh access token trước lần reconnect nếu token đã hết hạn.
Không phải close code nào cũng nên reconnect
Một policy tham khảo:
Close code 4000–4999 dành cho ứng dụng tự định nghĩa. Client và server phải thống nhất ý nghĩa.
Reconnect không đồng nghĩa Resume
Kết nối mới không tự biết:
Client đã join room nào.
Event cuối cùng client nhận là gì.
Request nào đã được xử lý trước khi disconnect.
Presence cũ đã được cleanup hay chưa.
Sau reconnect cần một resume protocol:
Server xác thực lại, restore subscription và gửi event bị thiếu từ database/stream. Redis Pub/Sub không có replay, nên dữ liệu quan trọng phải nằm trong database, Redis Streams hoặc durable broker.
Mỗi command có side effect nên có idempotencyKey để retry không tạo đơn hàng, thanh toán hoặc message trùng.
Presence và cleanup đúng cách
Đừng chỉ đặt online = false trong close, vì process crash không phát sự kiện cleanup. Presence nên có TTL:
Refresh TTL theo heartbeat hoặc activity.
Xóa key khi close bình thường.
Để TTL tự hết hạn khi node chết.
Nhiều tab/thiết bị phải có connection ID riêng.
User chỉ offline khi không còn connection hợp lệ nào.
Hàm cleanup phải idempotent và chạy cho mọi đường đóng socket: close, error, heartbeat timeout, auth revocation và graceful shutdown.
Backpressure cũng tạo “zombie về hiệu năng”
Một client vẫn trả Pong nhưng đọc message quá chậm có thể làm bufferedAmount tăng liên tục. Đây không phải zombie mạng, nhưng vẫn ngốn RAM.
Cần:
Đặt giới hạn outbound buffer.
Drop/coalesce event có thể thay thế, như cursor position hoặc progress.
Không drop event nghiệp vụ quan trọng; chuyển sang cơ chế durable delivery.
Đóng slow consumer khi vượt budget.
Theo dõi p95/p99 bufferedAmount và số socket bị đóng vì backpressure.
Phối hợp với Nginx và Cloudflare
Heartbeat interval phải nhỏ hơn idle timeout thấp nhất trên đường truyền:
Ví dụ nếu ứng dụng Ping mỗi 30 giây nhưng Nginx proxy_read_timeout là 20 giây, proxy có thể đóng connection trước heartbeat.
Không đặt timeout vô hạn. Chọn:
Cần tính cả mobile radio, battery, lượng connection và traffic heartbeat. Heartbeat quá dày làm tăng CPU, bandwidth và đánh thức thiết bị di động.
Socket.IO/Engine.IO đã có heartbeat và client reconnection. Hãy cấu hình thay vì chồng thêm nhiều cơ chế trùng lặp:
Vẫn cần resume nghiệp vụ, idempotency và restore room sau reconnect. Heartbeat của transport không lưu event bị mất.
Graceful shutdown
Khi deploy:
Đánh dấu node unready để Load Balancer ngừng đưa connection mới vào.
Dừng nhận command mới nếu cần.
Gửi close code 1012 hoặc event yêu cầu reconnect có kiểm soát.
Drain trong thời gian giới hạn.
Cleanup room/presence và đóng socket còn lại.
Client reconnect với jitter để tránh cùng đổ vào node mới.
Không gọi terminate() toàn bộ hàng chục nghìn socket cùng lúc nếu có thể drain theo batch.
Metrics và cảnh báo
Theo dõi:
websocket_connections_active
websocket_connections_opened_total
websocket_connections_closed_total{code}
websocket_heartbeat_timeout_total
websocket_pong_rtt_ms p50/p95/p99
websocket_reconnect_attempt_total
websocket_reconnect_success_total
websocket_buffered_bytes
websocket_slow_consumer_total
Event-loop lag, RSS, heap, external memory và file descriptors
Cảnh báo khi active connections chỉ tăng mà close/TTL cleanup không tương ứng, heartbeat timeout tăng theo một ISP/region, hoặc RAM trên mỗi connection tăng dần sau nhiều chu kỳ reconnect.
Kịch bản kiểm thử bắt buộc
Rút mạng client nhưng không gọi close().
Chuyển Wi-Fi sang 4G/5G và ngược lại.
Cho laptop sleep lâu hơn heartbeat deadline.
Kill process server bằng SIGKILL.
Restart Nginx, Redis và Load Balancer.
Block traffic một chiều để mô phỏng half-open connection.
Tạo 10.000 client và kill một node để đo reconnect storm.
Làm client đọc chậm để kiểm tra backpressure.
Reconnect lặp lại và kiểm tra listener/timer/RAM có bị leak.
Xác minh resume không mất hoặc nhân đôi event quan trọng.
Anti-patterns
Tin rằng readyState === OPEN chứng minh mạng còn sống.
Chỉ dựa vào TCP keepalive với timeout mặc định dài.
Tạo một setInterval cho mỗi socket mà không cleanup.
Ping tất cả client cùng một millisecond trên mọi node.
Reconnect ngay lập tức không backoff/jitter.
Reconnect cả khi logout hoặc bị cấm vĩnh viễn.
Queue vô hạn message trong lúc offline.
Xem Pong là bằng chứng user đã xác thực.
Lưu presence vĩnh viễn không TTL.
Chỉ đo connection count mà không đo buffer, RSS và file descriptors.
Checklist production
Server có protocol Ping/Pong và deadline rõ ràng.
Browser có application heartbeat nếu cần tự phát hiện nhanh.
Heartbeat nhỏ hơn proxy idle timeout.
Một global scheduler hoặc bucket scheduler thay cho timer/socket.
Socket không phản hồi được terminate() và cleanup.
Auto reconnect có exponential backoff và jitter.
Close code quyết định có reconnect hay không.
Logout/manual close không reconnect.
Resume protocol restore subscription và event bị thiếu.
Command retry có idempotency key.
Presence dùng TTL và connection ID.
Outbound buffer có budget.
Có graceful shutdown và connection draining.
Metrics bao phủ heartbeat, reconnect, memory và backpressure.
Đã test half-open, sleep, network switch và reconnect storm.
Kết luận
Heartbeat và reconnect giải quyết hai phần khác nhau:
Heartbeat biến một lỗi mạng im lặng thành tín hiệu có deadline, giúp server dọn socket và RAM.
Reconnect tạo đường truyền mới, nhưng phải có backoff, jitter, authentication và resume state.
Một hệ thống ổn định không chỉ “luôn reconnect”. Nó biết khi nào connection đã chết, khi nào nên dừng thử, cách lấy lại dữ liệu bị lỡ và cách giới hạn tài nguyên của client chậm.
Hướng dẫn xử lý transaction storm trong flash sale và quyết toán lương với RabbitMQ/Kafka, rate limiting, backpressure, circuit breaker, load shedding, idempotency và capacity planning.