第一次把 WebSocket 跑通的那天,我在浏览器控制台盯着一行connected看了很久。在此之前,我做消息推送用的是轮询:前端setInterval每 3 秒发一次请求,后端告诉你有没有新消息。这套东西能用,但它的本质是寄信——你想知道对方有没有回信,就得一趟一趟往邮局跑。WebSocket 换了个思路,它把这个流程变成了打电话:一次拨号接通,之后双方随时可以开口,线路一直挂着,谁都不用再重新拨号。这篇 WebSocket 快速入门的分享,就是把我从"寄信"切到"打电话"这一路上踩过的坑、写过的配置、想明白的原理,按实操顺序摊开讲一遍。不管你是刚接触长连接的前端同学,还是要给后台管理系统加实时通知的后端同学,都能从里面找到能直接抄作业的段落。我会从通信模型为什么变、协议细节长什么样,一直讲到 Spring Boot、Gin、FastAPI 三条后端路线的具体落地,以及 Nginx 配置、鉴权方案、断线重连、打包成 App 之后连不上这类真刀真枪的问题。
1. 通信模型为什么要换:从"寄信"到"打电话"
1.1 HTTP 短连接的"寄信"困局
HTTP 的请求-响应模型是一个彻底的"寄信"模型。客户端写一封信(Request),贴上信封(Header、Cookie、认证信息),寄到服务端;服务端读完,回一封信(Response),这次通信就结束了。下一次想问点别的,从头再来一遍。它的好处是简单、无状态、任何中间节点都能理解;坏处是,服务端没有任何办法主动找到客户端。服务端手里没有客户端的"电话号码",只有客户端上一次寄信时留下的回信地址,而这个地址在 NAT 和防火墙普及之后,往往压根不可达。
为了绕开这个限制,历史上出现了几种补丁式的做法。第一种是短轮询,前端定时发请求问"有新消息吗",实现成本最低,但延迟等于轮询间隔,请求量等于客户端数乘以轮询频率。一万个在线用户、3 秒轮询一次,就是每秒三千多次请求,其中绝大多数返回的是"没有新消息"。这些请求每一个都要带上完整的 Header,几百字节的冗余信息被反复传输,带宽和数据库连接池都在为"空转"付费。
第二种是长轮询,服务端收到请求后先不返回,把它挂住,等到真有消息了再响应。实时性上去了,但每一次推送都要重新建立一次连接,服务端需要维护海量挂起的请求,连接数成为瓶颈。你可以把它理解成:邮局的工作人员站在你家门口,一直等到有你的信才敲门,敲完门他就走了,下次还得再派一个人来。
第三种是SSE(Server-Sent Events),基于 HTTP 的分块传输,服务端可以持续往客户端写数据,实现简单,浏览器原生支持自动重连。但它只能单向推,客户端想发消息还得另开一个请求通道;而且 HTTP/1.1 下同域名并发连接数有限,多个标签页开着会互相挤占。
这三种方案的共同问题是:它们都在用一个为"一次性请求"设计的协议,去模拟"持续对话"的场景。协议本身的语义不匹配,剩下的就全是补丁。
1.2 WebSocket 的"打电话"本质
WebSocket 的做法是:借着 HTTP 的门进去,进去之后换一套规则。客户端先发一个特殊的 HTTP 请求,说"我想升级协议";服务端如果同意,回一个101 Switching Protocols,从这一刻起,这条 TCP 连接上跑的东西就不再是 HTTP 了,而是 WebSocket 帧。它复用了 80 和 443 端口,所以中间的网络设备、防火墙、CDN 基本不会拦它——这是它能普及的关键。
对比一下开销就很直观。HTTP 每次请求都要带一套 Header,随便一个请求几百字节起步;WebSocket 建立连接之后,每一个数据帧的头部最小只有 2 个字节,发一条{"type":"ping"}这样的消息,真正的额外开销几乎可以忽略。这就是"打电话"和"寄信"在成本结构上的差别:打电话是按分钟计费的固定开销,寄信是按封计费,你说得越多越吃亏。
| 方案 | 通信方向 | 典型延迟 | 单条消息开销 | 实现复杂度 | 适合场景 |
|---|---|---|---|---|---|
| 短轮询 | 客户端拉 | 等于轮询间隔 | 高(完整 Header) | 极低 | 低频通知、状态刷新 |
| 长轮询 | 服务端推(伪) | 接近实时 | 高(每次重建) | 中 | 兼容性要求极高的老系统 |
| SSE | 服务端单推 | 实时 | 低 | 低 | 日志流、进度条、行情 |
| WebSocket | 双向全双工 | 实时 | 极低(2 字节起) | 中高 | IM、协同、游戏、语音 |
1.3 什么时候该上 WebSocket,什么时候别凑热闹
我见过不少项目,一个"每天推送一条站内信"的需求,硬上 WebSocket,结果为了维护连接状态、心跳、重连、多实例广播,多写了上千行代码,运维还要专门为长连接调负载均衡。这属于典型的用力过猛。判断标准很简单:如果消息的产生频率低到用户根本感知不到延迟差异,轮询就是更优解。一天推一次的公告,用轮询既省钱又省心。
真正值得上 WebSocket 的场景,通常满足下面几个特征里的至少两个:消息是双向的(客户端也要频繁发);消息频率高(聊天、弹幕、协同编辑的增量同步);对延迟敏感(实时对战、行情撮合、远程控制);连接需要保持上下文(语音长连接、白板协作里的会话状态)。
还有一类是"看起来该用、其实要谨慎"的:需要严格可靠投递的业务消息。WebSocket 本身只保证"帧按序到达",不保证业务层不丢、不重、不乱序。断线期间的消息谁补?客户端 ACK 怎么设计?消息 ID 怎么去重?这些都要自己搭。把它当成一条"更快的管道"而不是"一个消息中间件",心态就对了。
2. 协议核心细节:握手、帧和心跳
2.1 握手:一次 HTTP 升级请求到底发了什么
WebSocket 的连接建立过程完全兼容 HTTP,这也是它能穿过各种代理的原因。客户端发的请求长这样:
GET /ws/chat HTTP/1.1 Host: example.com Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== Sec-WebSocket-Version: 13 Origin: https://example.com服务端同意升级,回:
HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=这里有个很多人第一次看会愣住的点:Sec-WebSocket-Accept是怎么算出来的?规则是——把客户端给的Sec-WebSocket-Key字符串,拼接上一个固定的 GUID258EAFA5-E914-47DA-95CA-C5AB0DC85B11,做一次 SHA-1,再 Base64 编码。上面这个例子里,dGhlIHNhbXBsZSBub25jZQ==算出来恰好就是s3pPLMBiTxaQ9kYGzzhZRbK+xOo=。
我特意强调这个计算过程,是因为它经常被误解成一种安全机制。它不是。它的作用是防止那些不理解 WebSocket 的中间缓存代理,把一个普通的 HTTP 响应误当成对某个请求的缓存命中返回给客户端。这个 Key 是客户端随机生成的,没有任何保密价值。真正的身份校验,还得靠 Token 和 Origin 检查。校验逻辑写错(比如多拼了一个换行、用了错误的 GUID),表现就是握手永远 101 不了,客户端报一个很含糊的错误,排查起来很浪费时间。
另外一个实战技巧:浏览器的 WebSocket API不允许自定义请求头,这是很多人第一次做鉴权时最抓狂的地方——new WebSocket()没有 headers 参数。所以 Token 只能塞在 URL 查询串里,或者塞进Sec-WebSocket-Protocol子协议字段里。这两种做法在第 4 章会详细展开。
2.2 帧结构与掩码:为什么客户端必须加掩码
握手完成之后,数据以"帧"为单位传输。帧头的前两个字节包含了几乎所有的控制信息:
| 字段 | 位宽 | 说明 |
|---|---|---|
| FIN | 1 bit | 是否为消息的最后一帧 |
| RSV1-3 | 3 bit | 保留位,扩展协商后才用 |
| Opcode | 4 bit | 帧类型:0x1 文本、0x2 二进制、0x8 关闭、0x9 Ping、0xA Pong、0x0 延续帧 |
| MASK | 1 bit | 客户端发往服务端时必须为 1 |
| Payload len | 7 / 7+16 / 7+64 bit | 载荷长度,分三档 |
| Masking key | 0 或 4 字节 | 掩码密钥,仅当 MASK=1 时存在 |
这里有一条硬规则:客户端发往服务端的所有帧必须使用掩码,服务端发往客户端的帧绝对不能使用掩码。违反了,对端可以直接以 1002(协议错误)断开连接。掩码的算法很朴素,就是把载荷的每个字节和 4 字节密钥循环异或:
// 掩码与解掩码是同一个操作,异或两次就还原 for (let i = 0; i < payload.length; i++) { payload[i] ^= maskKey[i % 4]; }为什么要这么设计?用生活化的类比:早期有人担心中间代理会被"欺骗",把一段看起来像 HTTP 请求的 WebSocket 载荷缓存下来,然后当成真实请求转发出去,造成缓存投毒。强制客户端加掩码,等于让攻击者无法精确构造出可预测的字节流——因为密钥是随机的,每次掩码后的结果都不一样。这个设计在今天看来有点过度防御,但它是协议的一部分,任何自己手写解析器的同学都必须实现,否则连不上任何标准服务端。
2.3 心跳、超时与 1006:连接是死是活谁来管
TCP 连接有一个很尴尬的特性:如果中间链路悄悄断了(拔网线、NAT 表项过期、运营商回收空闲连接),通信双方可能都不知道。它们各自以为连接还在,往一个已经不通的管道里写数据,直到某个超时触发。这就是所谓"半开连接"。
解决方式是心跳。WebSocket 协议层面内置了 Ping(0x9)和 Pong(0xA)两种控制帧。收到 Ping 的一方必须尽快回一个 Pong,载荷内容保持一致。很多库(比如 gorilla/websocket)会帮你自动回 Pong,你只需要负责定时发 Ping 并设置读超时。
但在真实项目里,我更倾向于同时使用协议层心跳和应用层心跳。协议层心跳负责探测链路,应用层心跳(比如每 25 秒发一条{"type":"ping"}的 JSON)负责让中间的反向代理、负载均衡、WAF 认为这条连接"有流量",从而不主动回收它。原因在于 Nginx 的proxy_read_timeout默认是 60 秒,只要 60 秒内这条连接上没有产生任何数据,代理就会把连接掐掉。你的心跳间隔必须显著小于这个值,我一般取 25 秒,留出足够的抖动余量。如果两个心跳都用,记得把应用层心跳的间隔设得比协议层稍短一点。
然后是 1006。这个关闭码是排查长连接问题时出现频率最高的一个,也是最容易被误解的一个:
注意:1006 是一个"保留码",应用层不允许主动发送它。它只表示"连接异常关闭,没有收到对端的关闭帧",由客户端或服务端的底层栈自动生成。
换句话说,看到 1006,说明对面根本没来得及说再见,连接就没了。常见原因包括:反向代理超时回收、服务端进程被重启或 OOM 杀掉、TLS 证书校验失败、网络切换(4G 切 Wi-Fi)、服务端因为缓冲区超限直接断连。排查 1006 的第一件事不是看客户端代码,而是去看服务端日志和代理日志,看连接是在哪个环节消失的。
常用的关闭码对照如下:
| 关闭码 | 含义 | 典型诱因 |
|---|---|---|
| 1000 | 正常关闭 | 页面卸载、业务主动关闭 |
| 1001 | 对端离开 | 服务端重启、标签页关闭 |
| 1002 | 协议错误 | 掩码缺失、帧格式非法 |
| 1006 | 异常关闭 | 代理超时、进程崩溃、网络中断 |
| 1009 | 消息过大 | 超过缓冲区上限被断开 |
| 1011 | 服务端内部错误 | 业务代码抛异常 |
| 4000-4999 | 应用自定义 | 鉴权失败、被踢下线、Token 过期 |
我自己的习惯是:鉴权失败用 4001,被新登录踢下线用 4002,Token 过期用 4003。客户端收到 4001 就不再重连,直接跳登录页;收到 4002 弹一句提示;收到 4003 先去刷新 Token 再重连。这套约定能让重连逻辑大幅简化,比在客户端猜"为什么断了"靠谱得多。
3. 后端落地:四条技术路线怎么选、怎么写
3.1 选型:Netty、Spring、Gin、FastAPI 各自站在哪
选型的核心变量有三个:并发连接量级、团队已有技术栈、业务逻辑的复杂度。下面这张表是我自己在几个项目里踩过之后总结的,仅供参考:
| 方案 | 单机连接能力 | 开发效率 | 生态与配套 | 适合场景 |
|---|---|---|---|---|
| Netty | 极高(十万级) | 低 | 需要自研协议处理、心跳、广播 | 网关、IM 中台、需要极致性能 |
| Spring WebSocket / STOMP | 中等(万级) | 高 | 广播、群组、用户属性开箱即用 | 业务系统的实时通知、后台管理系统 |
| Gin + gorilla/websocket | 高 | 中 | 轻量、可控、go 并发模型顺手 | 中小型业务、语音/推流控制通道 |
| FastAPI + websockets | 中 | 高 | asyncio 写起来舒服,生态偏 AI | 快速验证、Python 技术栈的实时服务 |
一个很重要的经验:不要把耗时的业务逻辑直接写在 WebSocket 的读写回调里。这些回调运行在 IO 线程上,你在这里查一次数据库、调一次外部接口,就会阻塞这个线程上所有连接的读写。正确做法是把消息丢进业务线程池或消息队列,处理完再异步写回。
3.2 Spring Boot 集成:两种玩法,各有各的适用面
Spring 生态里集成 WebSocket 有两条主流路径。第一条是 JSR-356 标准的@ServerEndpoint注解方式,写法最直观:
@Component @ServerEndpoint("/ws/notice/{uid}") public class NoticeEndpoint { private static final Map<String, Session> ONLINE = new ConcurrentHashMap<>(); private Session session; private String uid; @OnOpen public void onOpen(Session session, @PathParam("uid") String uid) { this.session = session; this.uid = uid; ONLINE.put(uid, session); } @OnMessage public void onMessage(String message) { // 注意:这里不能做阻塞操作 ONLINE.forEach((key, s) -> { if (s.isOpen()) { s.getAsyncRemote().sendText(message); } }); } @OnClose public void onClose() { ONLINE.remove(uid); } @OnError public void onError(Session session, Throwable error) { ONLINE.remove(uid); } }有几个坑必须提前说清楚。第一,@ServerEndpoint标注的类,每个连接会创建一个新实例,所以成员变量是线程安全的,但静态的ONLINE这个 Map 是所有连接共享的,增删都要小心,尤其是onError里也要记得移除,否则内存会慢慢涨上去。第二,在内嵌 Tomcat 里运行时,必须额外注册一个ServerEndpointExporterBean,否则注解不生效:
@Configuration public class WebSocketConfig { @Bean public ServerEndpointExporter serverEndpointExporter() { return new ServerEndpointExporter(); } }但如果你打的是 war 包、部署到外部 Tomcat,这个 Bean千万不能加,否则会报容器冲突的错误。这个坑我见过至少三次,每次都是排查半天。
第三,也是使用@ServerEndpoint时最别扭的一点:它默认不走 Spring 的依赖注入。你没办法直接@Autowired一个 Service 进来。常见的解法是用一个静态持有ApplicationContext的工具类,在onOpen里通过getBean拿;或者用ServerEndpointConfig.Configurator在创建实例时手动注入。前者简单但不太优雅,后者规范但写起来麻烦。如果项目里逻辑比较复杂,我通常直接放弃注解方式,改走第二条路。
第二条路是 Spring 原生的WebSocketConfigurer+TextWebSocketHandler,加上 STOMP 之后,广播、群组、用户属性这些能力基本开箱即用——这也是很多后台管理系统(比如各类 Spring Boot + Vue3 的管理框架)集成实时通知的常规做法:
@Configuration @EnableWebSocketMessageBroker public class StompConfig implements WebSocketMessageBrokerConfigurer { @Override public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint("/ws") .setAllowedOriginPatterns("*") .withSockJS(); } @Override public void configureMessageBroker(MessageBrokerRegistry registry) { // 客户端订阅的前缀 registry.enableSimpleBroker("/topic", "/queue"); // 客户端发送消息的前缀 registry.setApplicationDestinationPrefixes("/app"); // 点对点消息前缀 registry.setUserDestinationPrefix("/user"); } }配好之后,服务端用SimpMessagingTemplate就能做三件事:convertAndSend("/topic/notice", msg)广播给所有订阅者;convertAndSend("/topic/group/{groupId}", msg)做群组;convertAndSendToUser(userId, "/queue/msg", msg)做点对点。用户身份可以通过SimpUserRegistry查询,配合握手拦截器把 Token 里的用户 ID 写进attributes,整条链路就通了。
// 握手拦截器:从 URL 参数里取 token,校验通过后把 uid 放进 attributes @Override public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) { String token = UriComponentsBuilder.fromUri(request.getURI()) .build().getQueryParams().getFirst("token"); if (!TokenUtil.verify(token)) { response.setStatusCode(HttpStatus.UNAUTHORIZED); return false; } attributes.put("uid", TokenUtil.getUid(token)); return true; }还有一个很容易被忽略的配置:消息缓冲区大小。Spring 的默认值是 8KB,超过就直接断连(1009)。前端传一张 Base64 的小图或者一段长文本,很容易就越界了。所以要么在前端做分片,要么在配置里调大:
@Bean public ServletServerContainerFactoryBean createWebSocketContainer() { ServletServerContainerFactoryBean container = new ServletServerContainerFactoryBean(); container.setMaxTextMessageBufferSize(64 * 1024); container.setMaxBinaryMessageBufferSize(64 * 1024); container.setMaxSessionIdleTimeout(300000L); return container; }3.3 Gin 和 FastAPI 的极简写法
Go 侧最常用的是 gorilla/websocket。核心结构是读协程加写协程,每个连接两个 goroutine,一个负责读、一个负责写,中间通过 channel 传递消息——这个模式在官方的 chat 示例里就有,非常经典:
var upgrader = websocket.Upgrader{ ReadBufferSize: 1024, WriteBufferSize: 1024, CheckOrigin: func(r *http.Request) bool { return r.Header.Get("Origin") == "https://your-domain.com" }, } func serveWs(hub *Hub, w http.ResponseWriter, r *http.Request) { conn, err := upgrader.Upgrade(w, r, nil) if err != nil { log.Println("upgrade failed:", err) return } client := &Client{hub: hub, conn: conn, send: make(chan []byte, 256)} client.hub.register <- client go client.writePump() go client.readPump() }CheckOrigin这一项一定要显式写。gorilla 的默认实现是"如果请求带了 Origin 头且和 Host 不一致就拒绝",看起来安全,但在前后端分离、域名不同的部署里会直接导致连不上,很多人第一次用会卡在这里。反过来,如果图省事写成return true,就等于放弃了跨站防护,生产环境不建议。
读协程里必须设置读超时和 Pong 处理器,不然连接"假死"时读操作会永远挂住:
func (c *Client) readPump() { defer func() { c.hub.unregister <- c; c.conn.Close() }() c.conn.SetReadLimit(64 * 1024) c.conn.SetReadDeadline(time.Now().Add(60 * time.Second)) c.conn.SetPongHandler(func(string) error { c.conn.SetReadDeadline(time.Now().Add(60 * time.Second)) return nil }) for { _, message, err := c.conn.ReadMessage() if err != nil { break } c.hub.broadcast <- message } }Python 侧如果用 FastAPI,写起来会非常短,异步模型天然适合长连接。做语音这类长连接的 demo 时,典型的函数签名就是async def voice_socket(websocket: WebSocket) -> None这种形式:
@app.websocket("/ws/voice/{client_id}") async def voice_socket(websocket: WebSocket, client_id: str): await websocket.accept() try: while True: data = await websocket.receive_bytes() await manager.broadcast_bytes(client_id, data) except WebSocketDisconnect: manager.disconnect(client_id)要注意的是,FastAPI 默认的receive_bytes有大小限制,而且单进程能扛的连接数受限于事件循环和文件描述符,生产部署时记得调ulimit -n,并且用 gunicorn 配 uvicorn worker 做多进程。
4. 典型场景落地:广播、群组、鉴权与语音
4.1 会话管理:单机 Map 到多实例广播的鸿沟
几乎所有 WebSocket 项目都会经历同一个阶段:开发环境单机跑,用ConcurrentHashMap<String, Session>存所有连接,广播遍历一遍 Map,一切正常。上线之后扩容成两台机器,用户 A 连在 1 号机,用户 B 连在 2 号机,A 发消息 B 收不到,问题暴露。
根因很简单:连接是"黏"在某一台机器上的,而消息需要跨机器传播。解决办法有两个方向。方向一是让消息跨节点流动,用 Redis 的 Pub/Sub、RocketMQ、Kafka 这类中间件做一层广播总线:任何一台机器产生的消息,先发到总线,所有机器都订阅,收到后检查"这条消息要发给的人是不是连在我这台机器上",是就推,不是就丢弃。这个方案实现清晰,缺点是每个节点都要处理全量消息,连接数很大时压力会集中到消息总线上。
方向二是在入口做黏性路由,让同一个用户的连接永远落到同一台机器上(按 uid 做一致性哈希),然后在网关层做消息转发。这个方案对网关的要求更高,一般自研 IM 才会这么做。
群组消息还要多考虑一层"写扩散"的成本。一个五千人的大群,一条消息要产生五千次推送,如果每个人都连着,那就是五千次网络写。我的建议是:小群(几十人)用实时写扩散,大群改成"推拉结合"——只推一个"有新消息"的轻量信号,客户端收到后再去拉取具体内容。这样能显著降低突发流量。
还有一种被搜得比较多的形态叫"反向 WebSocket",思路和常规相反:不是服务端等客户端连进来,而是让位于受限网络环境里的节点主动向外建立长连接,连到公网服务上注册自己。这种模式在设备侧、机器人框架、内网服务对接里很常见,好处是不需要外部能直接访问到它。它的实现要点是连接建立后必须立刻发一条"我是谁"的注册消息,否则服务端没法把这条连接和业务身份对应起来。
4.2 鉴权:三条路线和一个必须做的检查
浏览器端 WebSocket 不能自定义 Header,逼出了三种主流方案:
| 方案 | 做法 | 优点 | 风险 |
|---|---|---|---|
| URL 查询参数 | wss://host/ws?token=xxx | 实现最简单,握手阶段就能校验 | Token 可能进 Nginx access log、浏览器历史 |
| 子协议字段 | new WebSocket(url, ['bearer', token]) | 不进 URL,不进日志 | 需要服务端在握手时解析Sec-WebSocket-Protocol |
| 首帧鉴权 | 连上后第一条消息发 Token | 最灵活,可以带复杂参数 | 连接已建立,服务端要自己控制未鉴权连接的权限 |
我个人的偏好是子协议字段。它的写法是new WebSocket(url, ['access_token', token]),服务端从请求头Sec-WebSocket-Protocol里把第二段取出来校验,校验通过后在响应里原样回一个access_token,否则拒绝握手。这样 Token 既不出现在 URL 里,也不出现在日志里,是三者中最干净的。
如果只能走 URL 参数,那一定要做两件事:一是 Token 有效期设短(分钟级),配合刷新机制;二是在 Nginx 配置里关掉查询串的完整记录,或者对token参数做脱敏。
另外有一个必须显式做的检查:Origin 校验。WebSocket 不受同源策略保护,任何网站都能在用户浏览器里发起一个连接请求。如果不校验Origin,攻击者可以诱导已登录用户访问恶意页面,用用户的 Cookie 建立一条 WebSocket 连接,从而冒用身份。所以无论用哪个框架,一定要把允许的 Origin 显式写死成自己的域名,不要图省事放开成通配符。
4.3 语音与实时数据:长连接不等于万能管道
语音场景是很多人搜 WebSocket 时的起点。这里的第一个认知是:WebSocket 适合做信令通道,不适合搬运原始音频流。原因很直接,音频是高频、大量的数据,一秒钟 16kHz 采样、16 位深度的单声道 PCM,就是 32KB/s,双声道翻倍。这些数据如果走 WebSocket 转发到多个接收方,服务端的带宽和 CPU 都会被迅速吃满。
比较务实的做法是:WebSocket 只负责"谁要跟谁说话""什么时候开始""编码格式是什么"这些信令,真正的音频流走 RTC 类的专门通道;如果非要走 WebSocket,那就必须在客户端做编码压缩(Opus 之类),并且做分片发送,每片控制在几百字节到 1KB,服务端只做转发不做处理。
第二个认知是背压。一个客户端如果在弱网环境下接收速度跟不上,服务端的发送缓冲区会持续堆积,最后要么内存爆掉,要么连接被断开。写队列必须有上限,达到上限时的策略要提前定好:是丢弃旧消息、还是丢掉这个慢客户端?做实时语音和直播弹幕时,我一般选择"丢弃旧消息保新消息",因为这种场景下过期的数据没有价值。
第三个是permessage-deflate压缩扩展。它能显著减少文本类消息的传输量,但代价是每个连接都要消耗 CPU 做压缩和解压,而且会引入额外的内存占用。是不是开,取决于你的瓶颈在带宽还是在 CPU。文本为主、消息较大的场景值得开;纯二进制小包、连接数极高的场景,关掉更好。
5. 常见问题排查与压测实录
5.1 一套从握手到帧的排查路径
遇到连不上的问题,我一般按这个顺序走,效率最高。
第一步,先确认握手有没有成功。打开浏览器开发者工具的 Network 面板,筛选 WS,点开那条连接,能看到握手请求的完整请求头和响应头,以及之后收发的每一帧。如果连 WS 条目都没有,说明是前端代码根本没执行到,或者 URL 拼错了。如果状态码不是 101,看响应头里的信息,401 就是鉴权没过,403 多半是 Origin 被拒,404 是路径不对。
第二步,绕过前端,用工具手测。最原始也最有效的办法是用 curl 手动发一次升级请求:
curl -i -N \ -H "Connection: Upgrade" \ -H "Upgrade: websocket" \ -H "Sec-WebSocket-Version: 13" \ -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \ http://127.0.0.1:8080/ws/chat看到101 Switching Protocols就说明服务端本身没问题,问题在前端或中间的代理层。日常调试我更常用wscat -c ws://127.0.0.1:8080/ws/chat,能直接交互着发消息,比 Postman 之类的图形工具更轻快。
第三步,查 Nginx 或网关配置。这是线上环境出问题概率最高的地方。一个能用的配置至少要包含这么几行:
location /ws/ { proxy_pass http://backend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_read_timeout 300s; proxy_send_timeout 300s; proxy_buffering off; }最常见的三种错误:忘了写proxy_http_version 1.1,导致升级头被丢弃;Connection 头写成了固定的close;proxy_read_timeout用的默认 60 秒,而心跳间隔设成了 60 秒,正好卡在边界上随机断开。还有一种更隐蔽的,是负载均衡层开了 buffering,把本该立即转发的帧缓存起来了,表现就是消息延迟忽大忽小。
5.2 高频问题速查表
下面这张表里的每一条,都是我或者同事真实遇到过的:
| 现象 | 最可能的原因 | 排查入口 |
|---|---|---|
| H5 能连,打包成 App 连不上 | Android 9+ 默认禁止明文流量、iOS ATS 限制 | 检查是否用了 ws:// 而非 wss://,检查 App 的网络配置 |
| 小程序里连不上 | 平台只允许 wss 且域名需备案配置 | 检查后台的 socket 合法域名配置 |
| 一连上就断,报 1006 | 鉴权失败被服务端直接断开、缓冲区超限 | 看服务端日志,确认握手阶段是否抛异常 |
| 运行一段时间集体掉线 | 代理超时回收空闲连接 | 对比心跳间隔与 proxy_read_timeout |
| 多实例部署后部分用户收不到消息 | 缺少跨节点广播 | 检查 Redis 订阅是否生效 |
| 内存持续增长 | 会话 Map 未清理、事件监听未解绑 | 打印在线连接数与实际连接数对比 |
| 特定网络下频繁重连 | 移动网络切换、弱网抖动 | 加大重连退避、增加心跳容错 |
| 使用某些 AI 编程工具时出现流式中断 | 中间层把长连接掐了 | 检查代理超时与网络稳定性 |
最后一行那个"stream disconnected before completion",本质上就是长连接被中途切断的典型表现。很多流式响应的实现底层走的就是长连接,中间任何一层代理设置了过短的读超时、或者做了响应缓冲,都会让流在传一半的时候突然断掉。排查思路和普通的 WebSocket 断连完全一致:先看是不是固定时长后断,再看网络链路。
5.3 压测与容量评估:把 sampler 装好之后看什么
功能调通之后,一定要做一次压测,因为 WebSocket 的性能瓶颈往往和普通 HTTP 接口完全不在一个地方。工具上,JMeter 的 WebSocket Sampler 插件是最常用的选择,通过 Plugins Manager 搜索安装即可,装完之后你就能在线程组里配置"打开连接、发请求、读响应、关闭连接"这四个动作。压测时重点看三组数据:握手成功率(反映鉴权、限流、文件描述符是否够用)、连接建立耗时、消息往返延迟的 P95 和 P99。
系统层面的几个瓶颈点要提前调:
- 文件描述符。每个连接占一个 fd,默认的
ulimit -n是 1024,压测到一千就上不去了。调成 65535 起步。 - 内核参数。
net.core.somaxconn和net.ipv4.tcp_max_syn_backlog决定了等待队列长度,高并发建连时必须调大,否则会出现大量连接被拒。 - 单连接内存。粗略估算,Netty 这种精简实现每个连接几十 KB,Tomcat 这类偏重的实现可能到 100KB 以上。换算一下,一台 4GB 内存的机器,实际上能稳定承载的连接数远没有想象中多。容量规划时,内存比 CPU 更早成为瓶颈。
需要提醒的是,压测机的数量往往比被测服务先到瓶颈。Windows 上的 JMeter 单机跑几千个连接就很吃力了,最好用分布式压测或者切换到 Linux 上跑。
5.4 面试里被问得最多的几个点
如果你正在准备相关面试题,下面这几个问题出现的频率非常高,思路整理一下也就够了。
为什么握手要用 HTTP 而不是从头设计一个协议?因为要复用现有的网络基础设施:端口、代理、TLS、认证体系。如果重新设计一套,中间设备和防火墙很可能直接拦掉。
为什么客户端必须加掩码,服务端却不加?防止中间代理被诱导缓存伪造载荷,历史上这是一个真实存在的攻击面。服务端不加掩码是因为服务端到客户端的路径经过了更严格的信任假设,加掩码反而增加无谓开销。
Ping/Pong 和应用层心跳有什么区别?Ping/Pong 是协议层控制帧,开销更小,但很多反向代理不计入"流量活跃"判断;应用层心跳是普通数据帧,能有效避免代理判定为空闲连接。
WebSocket 和 HTTP/2 的关系?它们是并列的两种机制。HTTP/2 的多路复用能让服务端推送和长连接更高效,但 WebSocket 在 HTTP/2 上的标准化进展有限。目前最常见的做法仍然是在 TLS 之上跑 WebSocket。
怎么水平扩展?首选 Redis Pub/Sub 或消息队列做跨节点广播,配合无状态化的鉴权;其次是网关层做一致性哈希的黏性路由。
5.5 我踩过的三个坑和对应的解法
第一个坑是心跳间隔和代理超时贴得太近。当时把心跳设成了 30 秒,Nginx 的proxy_read_timeout用的是默认 60 秒,看起来留了一倍余量,但实际运行中因为在代理层还有别的超时配置,加上网络抖动,导致心跳偶尔晚到,连接就被回收了。后来我把心跳改成 25 秒,并且把代理超时显式调到 300 秒,线上再也没有出现过无缘无故的批量掉线。
第二个坑是重连没做退避。最初写的是固定 3 秒重连一次,本地测试完全没问题。上线之后遇到一次服务端重启,几万个客户端在同一秒集体请求重连,直接把服务打挂了——服务刚起来又被打下去,形成循环。改成指数退避加随机抖动之后,问题彻底消失:第一次 1 秒,第二次 2 秒,第三次 4 秒,上限 30 秒,并且每次都在这个基础上乘一个 0.8 到 1.2 的随机系数,让重连请求自然散开。
第三个坑是页面切换之后忘了清理定时器。在做单页应用的时候,组件销毁了但心跳定时器和事件监听还挂着,用户来回切几次页面,同一时间居然有七八个心跳在跑,服务端看到的连接数也对不上。这个问题的解法很简单但必须做:把所有定时器和监听器的引用存在组件实例上,在onUnmounted或beforeDestroy里统一清理,并且在建立新连接之前先检查有没有旧连接还在 OPEN 状态,有就先关掉。内存泄漏和连接数虚高,很多都是这么来的。
如果你现在正准备给自己的项目加第一个 WebSocket 功能,我的建议是先用最简单的方式跑通:一个@ServerEndpoint或者一段二十行的 gorilla 代码,配合 wscat 手动连一次,把"握手成功、能收发、能关闭"这三件事确认掉。等这条链路真的跑通了,再去加心跳、重连、鉴权、跨节点广播这些工程化的东西。顺序反了,你会同时面对五个未知数,排查起来非常痛苦。