Phần 1 đo WebSocket từ bên ngoài: 0,3 ms một sự kiện, 1,3 KB một phút. Phần này mở nắp. WebSocket bắt đầu bằng một request HTTP bình thường — một GET với năm header — và kết thúc bằng một phản hồi 101 mà sau đó kết nối TCP không còn nói HTTP nữa. Mọi thứ hay gãy trong vận hành WebSocket (proxy, load balancer, CORS, phiên bản) đều gãy ở đúng mấy chục byte này, nên đáng để đọc chúng từng dòng, tự tính lại Sec-WebSocket-Accept bằng tay, đo bắt tay tốn bao nhiêu so với một request HTTP, và cố tình làm nó hỏng theo bốn cách để biết mỗi cách trông thế nào.
Một request HTTP, rồi đổi giao thức

Hình 1: Năm header đi, ba header về, một phép SHA-1 ở giữa. Mọi thứ gãy trong WebSocket thường gãy ở đây.
Bắt tay là một GET HTTP/1.1 với năm header bắt buộc. Upgrade: websocket xin đổi giao thức; Connection: Upgrade là header hop-by-hop — nó nói với proxy kế tiếp rằng request này cần được chuyển nguyên vẹn, và một proxy không hiểu sẽ bỏ nó đi theo đúng quy tắc HTTP. Sec-WebSocket-Key là 16 byte ngẫu nhiên mã hoá base64 — không phải mật mã, không bí mật. Sec-WebSocket-Version: 13 là phiên bản duy nhất còn dùng.
Server trả 101 Switching Protocols với Sec-WebSocket-Accept = base64(SHA-1(key + 258EAFA5-E914-47DA-95CA-C5AB0DC85B11)). Chuỗi GUID đó cố định trong RFC; mục đích của Accept không phải bảo mật mà là chứng minh server hiểu WebSocket: một HTTP server ngu ngơ trả 200 OK cho mọi GET sẽ không bao giờ sinh ra đúng chuỗi này, và client sẽ từ chối. Sau 101, cả hai đầu ngừng nói HTTP; từ byte kế tiếp trở đi là khung WebSocket (phần 3).
Công cụ đo là một chương trình Go tự chứa: server echo dùng gorilla/websocket trên :8090 với hai upgrader — một CheckOrigin mở, một mặc định (Origin phải khớp Host); một proxy "ngây thơ" trên :8091 chuyển tiếp HTTP nhưng xoá Upgrade/Connection như một proxy cũ; một httputil.ReverseProxy chuẩn trên :8092. Client thô mở TCP bằng net.Dial, viết từng dòng header, và in nguyên văn những gì server trả.
Đo thật: byte, micro giây, và bốn kiểu hỏng

Hình 2: Nguyên văn 155 byte đi và 129 byte về; Accept tự tính khớp RFC; bốn kiểu hỏng với mã lỗi thật; bắt tay 80 µs so với GET mới 94 µs và GET keep-alive 10 µs.
| phép đo | kết quả |
|---|---|
| request bắt tay / phản hồi 101 | 155 byte / 129 byte, 333 µs |
| Accept tự tính từ key của RFC | s3pPLMBiTxaQ9kYGzzhZRbK+xOo=, khớp |
| thiếu Upgrade / Version 12 / Origin lạ (upgrader mặc định) | 400 / 400 / 403 |
| proxy bỏ Upgrade / ReverseProxy chuẩn của Go | bad handshake (HTTP 400) / 101 |
| dial WebSocket / GET kết nối mới / GET keep-alive, p50 | 80 / 94 / 10 µs |
| một thông điệp 10 byte sau bắt tay | 16 byte đi (2 + 4 mask + 10), 12 byte về |
| header tối thiểu một GET keep-alive của Go | 99 byte |
155 byte đi, 129 byte về, và Accept khớp RFC tới từng ký tự. Dùng đúng key ví dụ trong RFC 6455 (dGhlIHNhbXBsZSBub25jZQ==), server trả Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo= — và base64(sha1(key + GUID)) tính bằng tay cho ra cùng chuỗi, cũng là chuỗi RFC in trong ví dụ. Toàn bộ bắt tay là 284 byte và 333 µs trên cùng máy. Không có gì bí ẩn: đó là một request HTTP mà bất kỳ công cụ nào đọc HTTP đều đọc được, và một phép băm mà bạn kiểm tra được bằng echo -n | sha1sum | base64.
Bốn kiểu hỏng, ba mã lỗi — và một chỗ gorilla khác RFC. Thiếu Upgrade: 400 Bad Request — server thấy một GET thường vào một đường chỉ chấp nhận WebSocket. Sec-WebSocket-Version: 12: RFC gợi ý 426 Upgrade Required kèm phiên bản server hỗ trợ, nhưng gorilla trả 400 — bài này dự đoán 426 trước khi đo và sai; thư viện khác có thể khác, nên đừng viết logic client dựa vào mã đó. Origin: http://evil.example tới upgrader mặc định: 403 Forbidden — CheckOrigin mặc định của gorilla đòi Origin khớp Host, và vì trình duyệt luôn gửi Origin còn client ngoài trình duyệt thì không, đây là lớp chặn CSRF duy nhất WebSocket có sẵn; đổi Origin cho khớp thì 101. Kiểu thứ tư đau nhất trong thực tế: proxy "ngây thơ" xoá Upgrade/Connection (đúng như quy tắc hop-by-hop cho phép) → server thấy một GET thường, trả 400, client báo websocket: bad handshake. Cùng request qua httputil.ReverseProxy chuẩn của Go: 101. Khi WebSocket "chạy ở local mà không chạy trên production", 90% là một nginx thiếu proxy_set_header Upgrade $http_upgrade hoặc một load balancer L7 không bật hỗ trợ WebSocket.
Bắt tay tốn 80 µs — ngang một GET mở kết nối mới, gấp 8 lần một GET keep-alive. 2.000 lần dial WebSocket (TCP connect + 101): p50 80 µs, p99 256 µs, 187 byte gửi và 129 byte nhận mỗi lần. 2.000 GET mở kết nối mới mỗi lần: p50 94 µs. 2.000 GET trên kết nối keep-alive sẵn có: p50 10 µs. Con số đáng nhớ là cái thứ ba: WebSocket không cạnh tranh với "mở kết nối" — nó cạnh tranh với "gửi một request nữa trên kết nối đã mở", và ở đó bắt tay đắt gấp tám lần. Nghĩa là mở WebSocket để gửi một thông điệp rồi đóng là lỗ; lợi chỉ đến từ việc dùng lại kết nối đã bắt tay — như phần 1 đã thấy ở 1,3 KB/phút.
Sau bắt tay, một thông điệp 10 byte là 16 byte đi và 12 byte về. Khung client → server: 2 byte header, 4 byte mask, 10 byte payload; server → client: 2 + 10, không mask. So với một GET keep-alive tối thiểu của Go: 99 byte header trước khi có một byte body nào, và phản hồi thêm chừng ấy nữa. Bắt tay là khoản đầu tư 316 byte để mỗi thông điệp sau đó tốn 12–16 byte thay vì ~200 — hoà vốn sau hai thông điệp. Vì sao client phải mask còn server thì không, và mask có "bảo mật" gì không, là chuyện của phần 3.
Đánh đổi cần cân nhắc
Bắt tay dựa vào HTTP để đi qua mọi thứ — và chính điều đó làm nó dễ gãy ở hạ tầng. Lý do WebSocket bắt đầu bằng HTTP là để dùng cổng 80/443, đi qua proxy và cân bằng tải sẵn có. Nhưng Connection: Upgrade là hop-by-hop, nên mỗi tầng trung gian phải chủ động chuyển tiếp nó; mặc định của nhiều proxy là bỏ. Checklist triển khai: nginx proxy_http_version 1.1 + hai dòng proxy_set_header; AWS ALB bật WebSocket (mặc định có), Classic ELB thì không; CDN thường cần bật riêng. Lỗi hiện ra đúng một dạng: bad handshake kèm 400 hoặc 200.
Accept không phải bảo mật; Origin không đủ; TLS là bắt buộc. Sec-WebSocket-Accept chống nhầm server, không chống nghe lén hay giả mạo — đó là việc của wss://. CheckOrigin chặn trình duyệt lạ nhưng client không phải trình duyệt đặt Origin tuỳ ý; xác thực thật phải nằm ở cookie/token trong request bắt tay hoặc thông điệp đầu tiên. Và một upgrader CheckOrigin: return true như endpoint /ws ở đây là thứ không được mang lên production.
Phạm vi đo. Cùng máy, không TLS: 80 µs là chi phí CPU của bắt tay, không phải độ trễ người dùng thấy. Qua Internet, bắt tay là một RTT TCP cộng một RTT HTTP cộng một đến hai RTT TLS — như mọi kết nối mới, và đó là lý do kết nối phải được giữ chứ không mở lại mỗi lần. Chưa chạm: Sec-WebSocket-Protocol (subprotocol), permessage-deflate, và HTTP/2 (RFC 8441) nơi bắt tay là một CONNECT thay vì GET.
Ba ý mang về
- Bắt tay là một GET với năm header và một phép SHA-1 kiểm tra được bằng tay: 155 byte đi, 129 về, Accept khớp RFC;
Connection: Upgradelà hop-by-hop nên mọi proxy ở giữa phải chủ động chuyển, không thìbad handshakevới400. - Bắt tay tốn 80 µs — bằng mở một kết nối HTTP mới, gấp 8 lần một GET keep-alive: nó chỉ có lãi khi kết nối được dùng lại; sau đó mỗi thông điệp 10 byte là 12–16 byte thay vì ~200.
- Mã lỗi thật khác sách: version sai cho 400 chứ không 426 với gorilla; Origin lạ cho 403 chỉ khi bạn không tắt
CheckOrigin; và Accept không bảo vệ gì ngoài việc chống nhầm server — bảo mật làwss://cộng xác thực riêng.
Nguồn
- RFC 6455 — The WebSocket Protocol, mục 1.3 Opening Handshake và 4.2.2: https://www.rfc-editor.org/rfc/rfc6455#section-1.3
- RFC 9110 — HTTP Semantics, mục 7.6.1 Connection (hop-by-hop): https://www.rfc-editor.org/rfc/rfc9110#section-7.6.1
- gorilla/websocket —
Upgrader,CheckOrigin: https://pkg.go.dev/github.com/gorilla/websocket#Upgrader - nginx — WebSocket proxying: https://nginx.org/en/docs/http/websocket.html
- RFC 8441 — Bootstrapping WebSockets with HTTP/2: https://www.rfc-editor.org/rfc/rfc8441
Phần sau đi tiếp vào những byte sau 101: khung WebSocket là gì, vì sao client phải mask còn server thì không, phân mảnh ảnh hưởng độ trễ ra sao, và gửi 1 KB tới 1 MB tốn bao nhiêu so với HTTP/1.1 cùng payload.