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

Ảnh chụp đoạn mã nền tối bắt tay WebSocket một request HTTP rồi đổi giao thức với RFC 6455 Go 1.23 gorilla websocket 1.5.3 client thô viết từng dòng header hai proxy để thấy ai làm hỏng, bắt tay bằng GET với năm header trả lời 101 rồi TCP đó không còn là HTTP nữa gồm GET ws HTTP 1.1, Host localhost 8090, Upgrade websocket xin đổi giao thức, Connection Upgrade header hop-by-hop proxy phải hiểu không được bỏ, Sec-WebSocket-Key dGhlIHNhbXBsZSBub25jZQ 16 byte ngẫu nhiên base64 không phải mật mã, Sec-WebSocket-Version 13, HTTP 1.1 101 Switching Protocols, Upgrade websocket, Connection Upgrade, Sec-WebSocket-Accept bằng base64 sha1 key cộng 258EAFA5-E914-47DA-95CA-C5AB0DC85B11, Accept chứng minh server hiểu WebSocket không phải HTTP server trả 200 cho mọi thứ chỉ thế thôi, bốn cách bắt tay hỏng và lỗi trông thế nào gồm thiếu Upgrade hoặc Connection 400 Bad Request server chỉ thấy một GET thường, Sec-WebSocket-Version khác 13 RFC gợi ý 426 Upgrade Required gorilla trả 400, Origin không khớp Host 403 Forbidden CheckOrigin mặc định trình duyệt luôn gửi Origin, proxy bỏ header Upgrade client bad handshake server chưa từng thấy WebSocket, đo gì gồm A gửi request thô với key của RFC in từng byte server trả tự tính Accept bằng sha1 để so, B 2000 lần dial WebSocket TCP cộng 101 GET kết nối mới GET keep-alive p50 p99 micro giây byte mỗi bắt tay, C bốn cách hỏng ở trên cộng proxy ngây thơ bỏ Upgrade so với ReverseProxy chuẩn của Go, D sau bắt tay một thông điệp 10 byte đi về tốn bao nhiêu byte so với header một GET keep-alive

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

Ảnh chụp output thật nền tối 155 byte đi 129 byte về và Accept khớp RFC với Go 1.23 cộng gorilla websocket 1.5.3 trong go-lab client thô qua net.Dial server echo 8090 proxy 8091 8092, A bắt tay thô với key ví dụ của RFC 6455 gồm client gửi 155 byte GET ws HTTP 1.1 Host localhost 8090 Upgrade websocket Connection Upgrade Sec-WebSocket-Key dGhlIHNhbXBsZSBub25jZQ Sec-WebSocket-Version 13, server trả 129 byte sau 333 micro giây HTTP 1.1 101 Switching Protocols Upgrade websocket Connection Upgrade Sec-WebSocket-Accept s3pPLMBiTxaQ9kYGzzhZRbK cộng xOo bằng, tự tính base64 sha1 key cộng GUID bằng s3pPLMBiTxaQ9kYGzzhZRbK cộng xOo bằng khớp đúng như RFC in, C bốn cách làm hỏng cộng hai proxy gồm thiếu header Upgrade 400 Bad Request, Sec-WebSocket-Version 12 400 Bad Request RFC gợi ý 426 gorilla chọn 400, Origin http evil example upgrader mặc định 403 Forbidden, Origin http localhost 8090 khớp Host 101 Switching Protocols, qua proxy ngây thơ bỏ Upgrade Connection 8091 client websocket bad handshake HTTP 400, qua httputil ReverseProxy chuẩn của Go 8092 101 OK Go chuyển Upgrade đúng từ 1.12, B chi phí 2000 lần mỗi kiểu cùng máy gồm WebSocket dial bằng TCP connect cộng bắt tay 101 p50 80 micro giây p99 256 byte gửi 187 nhận 129, HTTP GET kết nối mới mỗi lần p50 94 micro giây p99 168 bắt tay WS không đắt hơn một GET mới, HTTP GET keep-alive đã có kết nối p50 10 micro giây p99 53 thứ bắt tay WS cạnh tranh là cái này, D sau bắt tay một thông điệp 10 byte gồm client sang server 16 byte 2 header cộng 4 mask cộng 10 payload server sang client 12 byte 2 cộng 10 không mask, một GET keep-alive tối thiểu của Go 99 byte header trước khi có 1 byte body và phản hồi còn khoảng 100 byte nữa, bắt tay trả 316 byte một lần để từ đó mỗi thông điệp tốn 12 tới 16 byte thay vì khoảng 200, nói thẳng 80 micro giây là cùng máy không TLS qua Internet bắt tay là 1 RTT TCP cộng 1 RTT HTTP cộng 1 tới 2 RTT TLS như mọi kết nối mới Accept không phải bảo mật nó chống nhầm server không chống kẻ nghe lén việc đó của wss

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ề

  1. 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: Upgrade là hop-by-hop nên mọi proxy ở giữa phải chủ động chuyển, không thì bad handshake với 400.
  2. 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.
  3. 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

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.