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.
Bài tập làm thử
Bài 1 (đọc hiểu). Cho đoạn mã server:
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil { return }
defer conn.Close()
for {
mt, msg, err := conn.ReadMessage()
if err != nil { return }
conn.WriteMessage(mt, append([]byte("server nhận: "), msg...))
}
Mã HTTP status nào server trả về khi Upgrade thành công, và tại sao sau đó "đây không còn là HTTP nữa"?
Đáp án
Server trả 101 Switching Protocols. Sau bắt tay này, kết nối TCP chuyển hẳn sang giao thức WebSocket — không còn theo mô hình request-response của HTTP nữa. Cả hai đầu giờ giao tiếp bằng frame WebSocket qua vòng lặp ReadMessage/WriteMessage trên cùng một kết nối đã mở, và quan trọng nhất: server có thể WriteMessage (đẩy dữ liệu) bất cứ lúc nào mà không cần chờ client gửi request trước — điều HTTP thuần không làm được.
Bài 2 (sửa lỗi). Server WebSocket sau không bao giờ phát hiện được khi client mất kết nối đột ngột (rút mạng mà không đóng đúng cách), khiến goroutine và tài nguyên bị giữ mãi:
for {
mt, msg, err := conn.ReadMessage()
if err != nil { return }
conn.WriteMessage(mt, msg)
}
Bài viết gợi ý cơ chế nào để giải quyết, và tại sao?
Đáp án
Cần thêm cơ chế heartbeat (ping/pong): server gửi frame ping định kỳ (ví dụ conn.WriteMessage(websocket.PingMessage, nil) trong một goroutine/timer riêng), và nếu không nhận được pong phản hồi trong một khoảng thời gian đặt trước, server chủ động đóng kết nối và dọn tài nguyên. Lý do: một kết nối TCP có thể "chết lặng lẽ" (client mất mạng đột ngột) mà server không hề biết nếu chỉ dựa vào ReadMessage — nó sẽ block mãi hoặc không trả lỗi ngay, khiến goroutine và tài nguyên bị giữ vô ích ("rò rỉ kết nối ma" như bài viết gọi).
Bài 3 (vận dụng). Viết đoạn mã client dùng gorilla/websocket để quay số tới ws://localhost:8080/ws, gửi một message text "chào", rồi đọc lại phản hồi.
Đáp án
c, resp, err := websocket.DefaultDialer.Dial("ws://localhost:8080/ws", nil)
if err != nil { log.Fatal(err) }
defer c.Close()
fmt.Println(resp.StatusCode) // 101
err = c.WriteMessage(websocket.TextMessage, []byte("chào"))
if err != nil { log.Fatal(err) }
_, reply, err := c.ReadMessage()
if err != nil { log.Fatal(err) }
fmt.Println(string(reply)) // "server nhận: chào"
resp.StatusCode xác nhận bắt tay thành công (101). Sau đó client gửi và nhận trên cùng một kết nối đã mở, không mở lại lần nào — đúng đo thật trong bài (gửi "chào" -> nhận "server nhận: chào").
Bài 4 (bẫy/đánh đổi). Đo thật cho thấy một message WebSocket (3.271 ns, kết nối đã mở) nhanh hơn một HTTP POST không keepalive (76.704 ns) khoảng 23 lần. Nếu HTTP polling được bật keepalive, khoảng cách này có biến mất hoàn toàn không? Vì sao vẫn còn một số khác biệt cơ bản?
Đáp án
Không biến mất hoàn toàn — bài viết nói rõ "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". Dù tránh được chi phí mở kết nối TCP mới, mỗi request HTTP vẫn phải gửi và parse toàn bộ header ở cả hai chiều, trong khi WebSocket chỉ ghi một frame gọn lên kết nối sẵn có. Quan trọng hơn, HTTP polling — dù có keepalive — vẫn là mô hình client hỏi trước, server mới đáp; nó không cho server chủ động đẩy dữ liệu bất cứ lúc nào như WebSocket, nên với dữ liệu thay đổi bất định (chat, thông báo real-time), polling vẫn luôn có độ trễ và lãng phí thăm dò mà WebSocket không có.
Bài 5 (đọc hiểu/đánh đổi). Vì sao bài viết nói "load balancing phải sticky" cho WebSocket, và giải pháp thường dùng để nhiều server WebSocket chia sẻ trạng thái là gì?
Đáp án
Vì mỗi kết nối WebSocket gắn chặt với một server cụ thể suốt vòng đời của nó (server đó giữ goroutine và trạng thái kết nối) — load balancer không thể chia từng message của cùng một client tới các server khác nhau theo kiểu round-robin như HTTP request-response, mà phải định tuyến toàn bộ frame của một client về đúng server đang giữ kết nối đó (sticky session). Khi cần mở rộng ngang (nhiều server) và một client ở server A cần nhận tin do client ở server B gửi, bài viết gợi ý dùng thêm một lớp pub/sub (Redis, NATS) để các server chia sẻ trạng thái/broadcast tin nhắn cho nhau.
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.