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".

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:

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ề
- WebSocket mọc lên từ HTTP bằng cái bắt tay
Upgrade: một requestGETvớiUpgrade: websocket, đáp lại là101 Switching Protocols(không phải200). Đo thật cả hai đầu bằng socket thuần cho thấy đúng luồng này. 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.- Sau 101 là kênh hai chiều bằng frame, frame từ client bắt buộc mask: đo thật
MASK=1cho client,MASK=0cho 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
- MDN Web Docs — The WebSocket API / Writing WebSocket servers: https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API/Writing_WebSocket_servers
- RFC 6455 — The WebSocket Protocol: https://datatracker.ietf.org/doc/html/rfc6455
- MDN Web Docs — 101 Switching Protocols: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/101
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.