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.

Ảnh chụp đoạn mã Go nền tối minh hoạ WebSocket server thật trong Go nâng cấp HTTP giao tiếp hai chiều, bắt tay nâng cấp từ HTTP GET lên WebSocket var upgrader bằng websocket Upgrader mux HandleFunc ws func w http ResponseWriter r con trỏ http Request HTTP GET cộng header Upgrade chuyển sang WS 101 conn err bằng upgrader Upgrade w r nil nếu err khác nil return defer conn Close, server vòng lặp đọc ghi trên kết nối bền for mt msg err bằng conn ReadMessage đọc blocking nếu err khác nil return client đóng thoát conn WriteMessage mt append byte server nhận msg mỗi client bằng 1 goroutine giữ kết nối gửi nhận bất cứ lúc nào, client quay số WS hai chiều trên một kết nối c resp err bằng websocket DefaultDialer Dial ws ws nil resp StatusCode bằng 101 Switching Protocols c WriteMessage websocket TextMessage byte chào underscore reply err bằng c ReadMessage server đẩy được bất cứ lúc nào, khác HTTP thường HTTP client hỏi server đáp rồi đóng request-response WebSocket kết nối mở liên tục cả hai đẩy tin bất cứ lúc nào hợp cho chat thông báo đẩy giá real-time game collab không cần polling hỏi lại liên tục ít trễ ít lãng phí

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):

Ảnh chụp bảng kết quả đo thật nền tối bắt tay 101 hai chiều trên kết nối bền message nhanh hơn HTTP polling 23x, go run cộng go test bench Go 1.23 gorilla websocket arm64 10 core, server cộng client chạy thật bắt tay HTTP status 101 101 bằng 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 3 message gửi nhận trên cùng một kết nối đã mở không mở lại, benchmark 1 message WS vs 1 request HTTP polling WS message kết nối đã mở 3.271 ns 1.088 byte 5 alloc HTTP POST mỗi lần không keepalive 76.704 ns 17.136 byte 115 alloc WS nhanh hơn 23x ít bộ nhớ 16x mỗi tin qua WS chỉ là ghi frame lên kết nối sẵn còn HTTP polling mở kết nối cộng parse header mỗi lần keepalive sẽ thu hẹp khoảng cách nhưng vẫn tốn header, cơ chế bắt tay HTTP GET cộng Upgrade header server trả 101 đổi giao thức sau đó là frame WebSocket hai chiều trên chính kết nối TCP đó server đẩy được tin mà không cần client hỏi khác HTTP mỗi client thường một goroutine đọc cộng kênh ghi riêng, cốt lõi nâng cấp HTTP GET cộng Upgrade 101 WebSocket trên cùng TCP hai chiều cả server lẫn client đẩy tin bất cứ lúc nào bền kết nối mở liên tục không mở lại mỗi tin nhanh 1 tin 23x HTTP polling 16x ít bộ nhớ đo thật đánh đổi giữ kết nối tốn tài nguyên client cần ping-pong sticky LB

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ề

  1. 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).
  2. 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.
  3. Đổ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.