WebSocket 快速入门:从轮询到长连接的全链路实战
发布时间:2026/9/16 5:28:00来源:尧图网络
第一次把 WebSocket 跑通的那天我在浏览器控制台盯着一行connected看了很久。在此之前我做消息推送用的是轮询前端setInterval每 3 秒发一次请求后端告诉你有没有新消息。这套东西能用但它的本质是寄信——你想知道对方有没有回信就得一趟一趟往邮局跑。WebSocket 换了个思路它把这个流程变成了打电话一次拨号接通之后双方随时可以开口线路一直挂着谁都不用再重新拨号。这篇 WebSocket 快速入门的分享就是把我从寄信切到打电话这一路上踩过的坑、写过的配置、想明白的原理按实操顺序摊开讲一遍。不管你是刚接触长连接的前端同学还是要给后台管理系统加实时通知的后端同学都能从里面找到能直接抄作业的段落。我会从通信模型为什么变、协议细节长什么样一直讲到 Spring Boot、Gin、FastAPI 三条后端路线的具体落地以及 Nginx 配置、鉴权方案、断线重连、打包成 App 之后连不上这类真刀真枪的问题。1. 通信模型为什么要换从寄信到打电话1.1 HTTP 短连接的寄信困局HTTP 的请求-响应模型是一个彻底的寄信模型。客户端写一封信Request贴上信封Header、Cookie、认证信息寄到服务端服务端读完回一封信Response这次通信就结束了。下一次想问点别的从头再来一遍。它的好处是简单、无状态、任何中间节点都能理解坏处是服务端没有任何办法主动找到客户端。服务端手里没有客户端的电话号码只有客户端上一次寄信时留下的回信地址而这个地址在 NAT 和防火墙普及之后往往压根不可达。为了绕开这个限制历史上出现了几种补丁式的做法。第一种是短轮询前端定时发请求问有新消息吗实现成本最低但延迟等于轮询间隔请求量等于客户端数乘以轮询频率。一万个在线用户、3 秒轮询一次就是每秒三千多次请求其中绝大多数返回的是没有新消息。这些请求每一个都要带上完整的 Header几百字节的冗余信息被反复传输带宽和数据库连接池都在为空转付费。第二种是长轮询服务端收到请求后先不返回把它挂住等到真有消息了再响应。实时性上去了但每一次推送都要重新建立一次连接服务端需要维护海量挂起的请求连接数成为瓶颈。你可以把它理解成邮局的工作人员站在你家门口一直等到有你的信才敲门敲完门他就走了下次还得再派一个人来。第三种是SSEServer-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: s3pPLMBiTxaQ9kYGzzhZRbKxOo这里有个很多人第一次看会愣住的点Sec-WebSocket-Accept是怎么算出来的规则是——把客户端给的Sec-WebSocket-Key字符串拼接上一个固定的 GUID258EAFA5-E914-47DA-95CA-C5AB0DC85B11做一次 SHA-1再 Base64 编码。上面这个例子里dGhlIHNhbXBsZSBub25jZQ算出来恰好就是s3pPLMBiTxaQ9kYGzzhZRbKxOo。我特意强调这个计算过程是因为它经常被误解成一种安全机制。它不是。它的作用是防止那些不理解 WebSocket 的中间缓存代理把一个普通的 HTTP 响应误当成对某个请求的缓存命中返回给客户端。这个 Key 是客户端随机生成的没有任何保密价值。真正的身份校验还得靠 Token 和 Origin 检查。校验逻辑写错比如多拼了一个换行、用了错误的 GUID表现就是握手永远 101 不了客户端报一个很含糊的错误排查起来很浪费时间。另外一个实战技巧浏览器的 WebSocket API不允许自定义请求头这是很多人第一次做鉴权时最抓狂的地方——new WebSocket()没有 headers 参数。所以 Token 只能塞在 URL 查询串里或者塞进Sec-WebSocket-Protocol子协议字段里。这两种做法在第 4 章会详细展开。2.2 帧结构与掩码为什么客户端必须加掩码握手完成之后数据以帧为单位传输。帧头的前两个字节包含了几乎所有的控制信息字段位宽说明FIN1 bit是否为消息的最后一帧RSV1-33 bit保留位扩展协商后才用Opcode4 bit帧类型0x1 文本、0x2 二进制、0x8 关闭、0x9 Ping、0xA Pong、0x0 延续帧MASK1 bit客户端发往服务端时必须为 1Payload len7 / 716 / 764 bit载荷长度分三档Masking key0 或 4 字节掩码密钥仅当 MASK1 时存在这里有一条硬规则客户端发往服务端的所有帧必须使用掩码服务端发往客户端的帧绝对不能使用掩码。违反了对端可以直接以 1002协议错误断开连接。掩码的算法很朴素就是把载荷的每个字节和 4 字节密钥循环异或// 掩码与解掩码是同一个操作异或两次就还原 for (let i 0; i payload.length; i) { payload[i] ^ maskKey[i % 4]; }为什么要这么设计用生活化的类比早期有人担心中间代理会被欺骗把一段看起来像 HTTP 请求的 WebSocket 载荷缓存下来然后当成真实请求转发出去造成缓存投毒。强制客户端加掩码等于让攻击者无法精确构造出可预测的字节流——因为密钥是随机的每次掩码后的结果都不一样。这个设计在今天看来有点过度防御但它是协议的一部分任何自己手写解析器的同学都必须实现否则连不上任何标准服务端。2.3 心跳、超时与 1006连接是死是活谁来管TCP 连接有一个很尴尬的特性如果中间链路悄悄断了拔网线、NAT 表项过期、运营商回收空闲连接通信双方可能都不知道。它们各自以为连接还在往一个已经不通的管道里写数据直到某个超时触发。这就是所谓半开连接。解决方式是心跳。WebSocket 协议层面内置了 Ping0x9和 Pong0xA两种控制帧。收到 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被新登录踢下线用 4002Token 过期用 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 MapString, 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 原生的WebSocketConfigurerTextWebSocketHandler加上 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, MapString, 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 项目都会经历同一个阶段开发环境单机跑用ConcurrentHashMapString, Session存所有连接广播遍历一遍 Map一切正常。上线之后扩容成两台机器用户 A 连在 1 号机用户 B 连在 2 号机A 发消息 B 收不到问题暴露。根因很简单连接是黏在某一台机器上的而消息需要跨机器传播。解决办法有两个方向。方向一是让消息跨节点流动用 Redis 的 Pub/Sub、RocketMQ、Kafka 这类中间件做一层广播总线任何一台机器产生的消息先发到总线所有机器都订阅收到后检查这条消息要发给的人是不是连在我这台机器上是就推不是就丢弃。这个方案实现清晰缺点是每个节点都要处理全量消息连接数很大时压力会集中到消息总线上。方向二是在入口做黏性路由让同一个用户的连接永远落到同一台机器上按 uid 做一致性哈希然后在网关层做消息转发。这个方案对网关的要求更高一般自研 IM 才会这么做。群组消息还要多考虑一层写扩散的成本。一个五千人的大群一条消息要产生五千次推送如果每个人都连着那就是五千次网络写。我的建议是小群几十人用实时写扩散大群改成推拉结合——只推一个有新消息的轻量信号客户端收到后再去拉取具体内容。这样能显著降低突发流量。还有一种被搜得比较多的形态叫反向 WebSocket思路和常规相反不是服务端等客户端连进来而是让位于受限网络环境里的节点主动向外建立长连接连到公网服务上注册自己。这种模式在设备侧、机器人框架、内网服务对接里很常见好处是不需要外部能直接访问到它。它的实现要点是连接建立后必须立刻发一条我是谁的注册消息否则服务端没法把这条连接和业务身份对应起来。4.2 鉴权三条路线和一个必须做的检查浏览器端 WebSocket 不能自定义 Header逼出了三种主流方案方案做法优点风险URL 查询参数wss://host/ws?tokenxxx实现最简单握手阶段就能校验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 头写成了固定的closeproxy_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 这种精简实现每个连接几十 KBTomcat 这类偏重的实现可能到 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 手动连一次把握手成功、能收发、能关闭这三件事确认掉。等这条链路真的跑通了再去加心跳、重连、鉴权、跨节点广播这些工程化的东西。顺序反了你会同时面对五个未知数排查起来非常痛苦。
网站建设高端定制企业官网