HTTP là mô hình hỏi-đáp: client hỏi, server đáp, kết nối đóng. Nhưng nhiều ứng dụng cần server chủ động đẩy dữ liệu tới client bất cứ lúc nào — chat, thông báo, giá cổ phiếu real-time, cộng tác tài liệu. Trước WebSocket, cách duy nhất là polling (client hỏi lại liên tục), vừa trễ vừa lãng phí. WebSocket giải bài này: nâng cấp một kết nối HTTP thành kênh hai chiều mở liên tục. Bài này dựng một WebSocket server + client chạy được trong Go, và đo thật vì sao nó vượt xa polling.
Bắt tay: nâng cấp HTTP lên WebSocket
WebSocket bắt đầu như một request HTTP GET bình thường với header Upgrade: websocket. Server "đồng ý" bằng cách trả 101 Switching Protocols, và từ đó kết nối TCP chuyển sang giao thức WebSocket. Trong Go (dùng gorilla/websocket):
var upgrader = websocket.Upgrader{}
mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil) // HTTP -> WS (101)
if err != nil { return }
defer conn.Close()
for {
mt, msg, err := conn.ReadMessage() // đọc (blocking)
if err != nil { return } // client đóng -> thoát
conn.WriteMessage(mt, append([]byte("server nhận: "), msg...))
}
})
Server chạy một vòng lặp ReadMessage/WriteMessage trên kết nối bền. Mỗi client thường là một goroutine giữ kết nối, có thể đọc và ghi bất cứ lúc nào.

Hình 1: WebSocket nâng cấp từ HTTP GET (header Upgrade → 101). Server vòng lặp ReadMessage/WriteMessage trên kết nối bền; client Dial rồi gửi/nhận hai chiều. Cả hai đầu đẩy tin bất cứ lúc nào, khác mô hình hỏi-đáp của HTTP.
Đo thật: hai chiều trên kết nối bền
Chạy thật (Go 1.23, gorilla/websocket): client quay số, gửi ba message, nhận vọng lại:
Bắt tay HTTP status: 101 (101 = Switching Protocols)
gửi "chào" -> nhận "server nhận: chào"
gửi "một tin nữa" -> nhận "server nhận: một tin nữa"
gửi "tạm biệt" -> nhận "server nhận: tạm biệt"
Ba message đi qua cùng một kết nối đã mở — không mở lại lần nào. Điểm mấu chốt: sau bắt tay 101, đây không còn là HTTP nữa; server có thể WriteMessage bất cứ lúc nào mà không cần client hỏi trước (điều HTTP không làm được).
Đo thật: nhanh hơn HTTP polling ~23 lần
Vì sao dùng WebSocket thay vì polling HTTP? Benchmark một message round-trip qua WebSocket (kết nối đã mở) so với một request HTTP POST mỗi lần (mô phỏng polling không giữ kết nối):

Hình 2: Bắt tay 101, ba message hai chiều trên kết nối bền. Benchmark: một message WS (kết nối đã mở) 3.271 ns so với một HTTP POST mỗi lần 76.704 ns — nhanh hơn ~23x, ít bộ nhớ ~16x (1.088 B so với 17.136 B).
- WS message (kết nối đã mở): 3.271 ns/op, 1.088 B, 5 cấp phát.
- HTTP POST mỗi lần (không keepalive): 76.704 ns/op, 17.136 B, 115 cấp phát.
WebSocket nhanh hơn ~23 lần và ít bộ nhớ ~16 lần. Lý do: mỗi message qua WS chỉ là ghi một frame lên kết nối đã sẵn, còn mỗi request HTTP polling phải mở kết nối mới, gửi và parse toàn bộ header. Cần thành thật: nếu HTTP dùng keepalive, khoảng cách thu hẹp lại (tránh được bắt tay TCP), nhưng HTTP vẫn tốn header cho mỗi request và không cho server đẩy chủ động — đây là so sánh với kịch bản polling thực tế nơi mỗi lần là một request đầy đủ.
Đánh đổi cần cân nhắc
WebSocket giữ kết nối mở = tốn tài nguyên trên server theo số client. Mỗi kết nối là một goroutine (hoặc hai: đọc + ghi), buffer, và một file descriptor giữ suốt thời gian kết nối. Với hàng chục nghìn client đồng thời, đây là bộ nhớ và goroutine đáng kể — phải thiết kế cho scale (giới hạn, dọn kết nối chết). HTTP request-response giải phóng tài nguyên ngay sau mỗi request.
Cần heartbeat (ping/pong) để phát hiện kết nối chết. Một kết nối TCP có thể "chết lặng lẽ" (client mất mạng) mà server không biết — nó cứ tưởng kết nối còn sống, giữ tài nguyên vô ích. WebSocket có frame ping/pong: server gửi ping định kỳ, client tự động trả pong; không nhận pong trong thời gian đặt trước thì đóng kết nối. Phải cài đặt điều này, không thì rò rỉ kết nối ma.
Load balancing phải sticky. Vì kết nối gắn với một server cụ thể suốt đời, load balancer phải định tuyến mọi frame của một client về đúng server đang giữ kết nối đó (sticky session) — không thể round-robin từng message như HTTP. Điều này làm scale ngang phức tạp hơn; thường cần một lớp pub/sub (Redis, NATS) để các server chia sẻ trạng thái khi một client kết nối server A cần nhận tin từ client ở server B.
Ba ý mang về
- WebSocket nâng cấp HTTP thành kênh hai chiều bền: bắt tay là HTTP GET + header Upgrade → server trả 101 Switching Protocols → kết nối TCP chuyển sang WebSocket; sau đó cả server lẫn client đẩy message bất cứ lúc nào (đo thật ba message hai chiều trên một kết nối).
- Nhanh hơn HTTP polling nhiều lần: đo thật một message WS 3.271 ns so với một HTTP POST 76.704 ns (~23x, ít bộ nhớ ~16x) vì WS ghi frame lên kết nối sẵn thay vì mở kết nối + parse header mỗi lần — hợp cho chat, thông báo đẩy, real-time.
- Đổi lại là chi phí giữ kết nối: mỗi client tốn goroutine + tài nguyên suốt thời gian kết nối, cần ping/pong phát hiện kết nối chết, và load balancing phải sticky (thường thêm lớp pub/sub) — cân nhắc khi scale tới nhiều nghìn kết nối.
Phần sau ta chuyển sang một mẫu hạ tầng tự viết để quản lý tài nguyên tốn kém: Phần sau dựng một connection pool từ đầu trong Go — tái dùng kết nối thay vì mở/đóng liên tục, với giới hạn, timeout, và dọn kết nối hỏng.