背景痛点:为什么浏览器突然翻脸
初学 WebSocket 时,我刷新页面看到控制台飘红Error: can upgrade only to websocket
第一反应是“我明明写了ws://呀?”
其实这句话不是浏览器傲娇,而是服务器在握手阶段拒绝升级。
触发场景通常只有三类:
- 少了
Connection: Upgrade或Upgrade: websocket头 - 服务端返回的状态码不是 101 Switching Protocols
- 反向代理(Nginx、Kong 等)把 Upgrade 头吃掉
只要任意一项不满足,浏览器就会抛出这条错误,后续帧直接罢工。
理解 HTTP 升级机制后,你会发现 WebSocket 并不是“另一端口”,而是“同端口、不同语法”——先假装自己是 HTTP,拿到 101 车票后,再“变脸”成二进制帧协议。
技术对比:长轮询、SSE 还是 WebSocket?
| 维度 | HTTP 长轮询 | Server-Sent Events | WebSocket |
|---|---|---|---|
| 协议 | HTTP/1.1 | HTTP/1.1 | HTTP→Upgrade→WS |
| 方向 | 双向模拟 | 仅服务端推送 | 真正双向 |
| 延迟 | 1~3 s(轮询间隔) | <1 s | <100 ms |
| 开销 | 每次带 HTTP 头 | 带少量头 | 帧头仅 2~14 B |
| 适用场景 | 低版本 IE 兼容 | 新闻/股价推送 | 实时游戏、通话、协同编辑 |
一句话总结:
“能 Upgrade 就别轮询,SSE 是单向备胎,WebSocket 才是双向正主。”
核心实现:让服务器说“101”
1. 标准握手流程(必须字段)
客户端请求头:
GET /chat HTTP/1.1 Host: example.com:8080 Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== Sec-WebSocket-Version: 13服务端校验后返回:
HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=Key→Accept 的算法固定:base64(sha1(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"))
任何偏差都会触发can upgrade only to websocket。
2. Node.js 示例(ws 库)
安装:
npm i wsserver.js:
const WebSocket = require('ws'); const http = require('http'); // 先跑一个普通 HTTP 服务器 const server = http.createServer((req, res) => { res.writeHead(200); res.end('WebSocket upgrade server'); }); const wss = new WebSocket.Server({ noServer: true }); // 监听协议升级事件 server.on('upgrade', (request, socket, head) => { // 手动校验必要头 if ( request.headers.upgrade !== 'websocket' || !request.headers.connection.includes('Upgrade') ) { socket.write('HTTP/1.1 400 Bad Request\r\n\r\n'); socket.destroy(); return; } wss.handleUpgrade(request, socket, head, (ws) => { wss.emit('connection', ws, request); }); }); wss.on('connection', (ws) => { console.log('[WS] 客户端已连接'); ws.on('message', (data) => { console.log('[WS] 收到:%s', data); ws.send(`服务端回声:${data}`); }); ws.on('error', (err) => console.error('[WS] 异常:', err.message)); }); server.listen(8080, () => console.log('HTTP+WS 服务已启动于 8080'));关键注释已写中文,异常直接try/catch会漏掉帧级错误,因此统一在error事件里处理。
3. Python 示例(websockets 库)
安装:
pip install websocketsserver.py:
import asyncio import websockets import logging logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(message)s") async def echo(websocket, path): remote = websocket.remote_address logging.info(f"[WS] 客户端 {remote} 连接成功") try: async for msg in websocket: logging.info(f"[WS] 收到:{msg}") await websocket.send(f"服务端回声:{msg}") except websockets.exceptions.ConnectionClosed: logging.info(f"[WS] 客户端 {remote} 已断开") except Exception as e: logging.error(f"[WS] 异常:{e}") # 官方库已封装好 101 校验,直接启动即可 start_server = websockets.serve(echo, "0.0.0.0", 8081) asyncio.get_event_loop().run_until_complete(start_server) asyncio.get_event_loop().run_forever()如果想手动校验 Upgrade 头,可在process_request钩子内拦截,返回426 Upgrade Required。
生产考量:别让握手成功倒在最后一公里
连接超时与重连
- 建议客户端 55 秒无响应就主动重连,避开默认 60 秒 NAT 超时
- 服务端启用
ping/pong心跳,Node 版ws自动回pong,Python 需await websocket.pong()
WSS 加密配置
- 证书放在反向代理层最省事,Nginx 监听 443,后端 8080 走裸 WS 即可
- 若直连,需要
wss://+ssl.create_default_context(),别忘了把中间证书链补全
负载均衡器特殊配置
Nginx 示例:
map $http_upgrade $connection_upgrade { default upgrade; '' close; } server { listen 443 ssl; location /ws { proxy_pass http://upstream_ws; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_read_timeout 300s; # 给心跳留时间 } }漏掉任何一行,都会把 Upgrade 头吞掉,浏览器继续报错。
避坑指南:426/400/Origin 一个都别放过
- 426 Upgrade Required
服务端显式告诉客户端“我拒绝非 WebSocket 流量”,检查是否误把/ws当普通 HTTP 路由 - 400 Bad Request
常见把Sec-WebSocket-Key大小写写错,或Accept计算错误 - Origin 校验陷阱
浏览器一定会带 Origin 头,但 Node 默认不检查。生产环境务必做白名单,防止被恶意网页跨域连进来 - 心跳间隔
内网 30 s,公网 45~60 s 足够,太频繁浪费流量,太长容易被防火墙 RST
延伸思考:QUIC 时代的 WebTransport
WebSocket 建立在 TCP 之上,队头阻塞依旧存在。
Chrome 最新版已开放 WebTransport API,走 HTTP/3 over QUIC,天然多路复用、0-RTT 握手。
思路相同:先fetch()协商,再createBidirectionalStream()拿到读写流。
如果你已经把 Upgrade 玩熟,不妨把实验目标换成 WebTransport,提前踩坑,等标准落地就能无缝迁移。
写在最后:把“升级”真正跑通
从 HTTP 到 WebSocket,其实就是一次“换票上车”的小动作,但票根(101 状态)、身份证(Upgrade 头)、座位号(Sec-WebSocket-Key)一个都不能少。
我在本地调试通过后,第一时间把代码丢到线上,结果还是被 Nginx 打脸——忘了加proxy_set_header,浏览器继续can upgrade only to websocket。
踩完坑最大的感受是:协议文档写得很细,但魔鬼藏在“反向代理默认不转发”这种小字里。
如果你想亲手把“耳朵、大脑、嘴巴”串成一条完整的实时通话链路,又懒得自己搭 ASR、LLM、TTS,可以试试这个动手实验:
从0打造个人豆包实时通话AI
实验把火山引擎的豆包系列模型都封装好了,WebSocket 升级部分也给了现成模板,小白直接跑脚本就能在浏览器里跟 AI 语音聊天。
我跟着做完,把聊天角色改成“猫娘”音色,30 分钟搞定,比自己东拼西凑省了不少时间。
升级协议只是第一步,真正的乐趣是让 AI 开口说话那一刻——祝你也能一次 101,永不 426。