Chat thời gian thực, thông báo đẩy, bảng giá nhảy liên tục, con trỏ của người khác trong Google Docs — tất cả đều cần server chủ động gửi dữ liệu cho client mà không đợi hỏi. HTTP thường không làm được điều đó: nó là request–response, client hỏi thì server mới trả. WebSocket sinh ra để lấp khoảng trống này. Nhưng có một hiểu lầm phổ biến: WebSocket không phải một giao thức hoàn toàn tách biệt — nó mọc lên từ HTTP, bắt đầu bằng đúng một request HTTP rồi "nâng cấp" kết nối. Bài này (phần 17 loạt "HTTP và web đằng sau") tự dựng cả server lẫn client bằng socket thuần để thấy từng byte của cái bắt tay đó.

Bắt tay: một request HTTP xin nâng cấp

Kết nối WebSocket khởi đầu là một request HTTP GET bình thường, nhưng kèm hai header đặc biệt: Upgrade: websocket và Connection: Upgrade. Cộng thêm Sec-WebSocket-Key — 16 byte ngẫu nhiên mã hoá Base64 — và Sec-WebSocket-Version: 13:

GET /ws HTTP/1.1
Host: 127.0.0.1:8094
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: <16 byte ngẫu nhiên, Base64>
Sec-WebSocket-Version: 13

Điểm khác biệt lớn nhất so với request thường: đáp lại không phải 200 OK mà là 101 Switching Protocols — mã trạng thái ít gặp báo hiệu "từ đây trở đi kết nối TCP này không còn nói HTTP nữa".

Ảnh chụp đoạn mã nền tối minh hoạ WebSocket nâng cấp từ HTTP lên kênh hai chiều thường trực, một bắt tay client gửi một HTTP GET xin nâng cấp GET ws HTTP 1.1 Host Upgrade websocket Connection Upgrade Sec-WebSocket-Key 16 byte ngẫu nhiên Base64 Sec-WebSocket-Version 13, hai server chứng minh hiểu giao thức trả 101 không phải 200 Accept bằng base64 sha1 key cộng GUID cố định GUID 258EAFA5 HTTP 1.1 101 Switching Protocols Upgrade websocket Connection Upgrade Sec-WebSocket-Accept giá trị tính được, ba sau 101 không còn request response chỉ còn frame hai chiều cùng một kết nối TCP cả hai bên gửi bất cứ lúc nào frame từ client bắt buộc mask XOR 4 byte theo RFC 6455 frame từ server không mask FIN cộng opcode 0x1 text 0x2 binary 0x8 close 0x9 ping 0xA pong

Hình 1: Bắt tay WebSocket là một HTTP GET với Upgrade/Connection; server đáp 101 Switching Protocols kèm Sec-WebSocket-Accept tính từ key + GUID; sau đó là frame hai chiều, frame từ client bắt buộc mask.

Sec-WebSocket-Accept: bằng chứng server thực sự hiểu WebSocket

Vì sao cần Sec-WebSocket-Key và Sec-WebSocket-Accept? Để đảm bảo phía server thật sự là một server WebSocket, chứ không phải một HTTP server hay proxy vô tình trả 101. Server phải tính:

Sec-WebSocket-Accept = base64( sha1( Sec-WebSocket-Key + GUID ) )
GUID cố định = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"

GUID này là hằng số ghi thẳng trong RFC 6455. Một server không hiểu WebSocket sẽ không biết ghép key với GUID rồi băm, nên không tạo ra đúng giá trị — client sẽ từ chối. Mình dựng cả hai đầu bằng Python socket thuần và đo thật:

Ảnh chụp bảng kết quả chạy thật python socket tự dựng cả hai đầu output thật server cộng client WebSocket viết bằng socket thuần không thư viện, bắt tay 101 Switching Protocols cộng Accept khớp CLIENT gửi Sec-WebSocket-Key iZLhoB cộng yAh4zI cộng K5yMULnw SERVER tính Accept N4wshnMpptzJ742eQx07xK cộng fWts CLIENT nhận HTTP 1.1 101 Switching Protocols Sec-WebSocket-Accept N4wshnMpptzJ742eQx07xK cộng fWts CLIENT tự tính accept khớp True, trao đổi frame hai chiều trên cùng kết nối SERVER nhận frame FIN 1 opcode 0x1 MASK 1 payload xin chao WebSocket frame từ client bắt buộc mask MASK 1 CLIENT nhận frame trả về MASK 0 Chao lai xin chao WebSocket frame từ server không mask MASK 0, ý nghĩa Accept tính từ key cộng GUID server thường không hiểu WS sẽ không tạo đúng giá trị client từ chối đây là cái bắt tay đúng nghĩa sau 101 kết nối TCP giữ mở server đẩy dữ liệu mà client không hỏi

Hình 2: Chạy thật — client gửi key iZLhoB+yAh4zI+K5yMULnw==, server tính Accept N4wshnMpptzJ742eQx07xK+fWts=, trả 101, client tự tính lại khớp (True). Sau đó frame từ client có MASK=1, frame từ server MASK=0.

Client sinh key ngẫu nhiên, server tính Accept, và client tự tính lại để kiểm chứng — kết quả True. Đây là "cái bắt tay" đúng nghĩa: cả hai bên chứng minh mình nói cùng một giao thức.

Sau 101: không còn request–response, chỉ còn frame

Đây là chỗ WebSocket khác HTTP về bản chất. Sau khi nâng cấp, kết nối TCP giữ mở và cả hai bên trao đổi dữ liệu dạng frame — bất cứ lúc nào, theo cả hai chiều, không cần "hỏi rồi mới trả". Mỗi frame có bit FIN (frame cuối của thông điệp) và opcode (0x1 text, 0x2 binary, 0x8 close, 0x9 ping, 0xA pong).

Một chi tiết bảo mật quan trọng, đo thật: frame từ client BẮT BUỘC được mask (XOR với 4 byte ngẫu nhiên), còn frame từ server thì không. Trong output, frame client có MASK=1, frame server MASK=0. Quy tắc này (RFC 6455) nhằm chống một kiểu tấn công cache poisoning khi WebSocket đi qua proxy cũ hiểu nhầm dữ liệu là HTTP.

# Ghép byte đầu frame: FIN=1, opcode=0x1 (text)
b = bytearray([0x81])
if from_client:
    b.append(0x80 | len(data))   # bit MASK = 1, bắt buộc với client
    mk = os.urandom(4); b += mk
    b += bytes(c ^ mk[i % 4] for i, c in enumerate(data))
else:
    b.append(len(data)); b += data   # server không mask

Trong demo, client gửi "xin chao WebSocket" (đã mask), server giải mask, đọc được, rồi đẩy lại "Chao lai: xin chao WebSocket" (không mask) — một vòng trao đổi hai chiều hoàn chỉnh trên cùng một kết nối.

Đánh đổi cần cân nhắc

WebSocket không thay thế HTTP — nó cho bài toán khác. Với request–response thông thường (tải trang, gọi API lấy dữ liệu một lần), HTTP đơn giản và tận dụng được cache, CDN, mã trạng thái. WebSocket chỉ đáng dùng khi cần server đẩy hoặc trao đổi hai chiều liên tục (chat, game, cộng tác). Đừng dùng WebSocket cho mọi thứ chỉ vì nó "hiện đại".

So với polling: WebSocket tiết kiệm nhưng phức tạp hơn về vận hành. Long-polling (client hỏi lại liên tục) tốn round-trip và header lặp; WebSocket giữ một kết nối nên nhẹ hơn nhiều khi có nhiều thông điệp nhỏ. Đổi lại, kết nối thường trực khó cân bằng tải hơn, cần xử lý reconnect, heartbeat (ping/pong) để phát hiện đứt, và tiêu tốn tài nguyên server cho mỗi kết nối mở.

Có lựa chọn nhẹ hơn: Server-Sent Events (SSE). Nếu chỉ cần server đẩy một chiều (thông báo, cập nhật trạng thái) mà không cần client gửi ngược qua cùng kênh, SSE (text/event-stream) đơn giản hơn, chạy trên HTTP thường, tự động reconnect. Chọn WebSocket khi thực sự cần hai chiều.

Ba ý mang về

  1. WebSocket mọc lên từ HTTP bằng cái bắt tay Upgrade: một request GET với Upgrade: websocket, đáp lại là 101 Switching Protocols (không phải 200). Đo thật cả hai đầu bằng socket thuần cho thấy đúng luồng này.
  2. Sec-WebSocket-Accept = base64(sha1(key + GUID)) là bằng chứng server thật sự hiểu giao thức: đo thật client tự tính lại và khớp (True) — server không hiểu WS sẽ không tạo đúng giá trị và bị từ chối.
  3. Sau 101 là kênh hai chiều bằng frame, frame từ client bắt buộc mask: đo thật MASK=1 cho client, MASK=0 cho server; kết nối giữ mở để server đẩy dữ liệu — nhưng chỉ nên dùng khi thật sự cần hai chiều, còn lại HTTP hoặc SSE gọn hơn.

Nguồn

Phần sau ta khép lại loạt bài với các header bảo mật: Content-Security-Policy, Strict-Transport-Security, X-Content-Type-Options — những header mà chính blog này đang gửi, và mỗi cái chặn kiểu tấn công nào.