做实时通信系统这几年,WebSocket 是我最常用的长连接方案。最近在给一个在线客服系统做实时工单推送,服务端要把新的会话状态主动推到前端,前端也要把自己的操作行为实时上报给后端,一开始图省事想用轮询,但连接一多、消息一密,性能和资源占用就非常难看,只好老老实实上 WebSocket。等真正把它跑起来之后才发现,最让人头疼的其实不是消息收发,而是两个看起来非常基础的问题:一是长连接怎么能一直稳稳地保持住,二是服务端到底该通过什么姿势拿到客户端真实 IP。这篇文章不聊虚的,就把整个“WebSocket 实现长连接 + 通过 WebSocket 获取客户端 IP”的过程拆开讲清楚,包括协议原理、代码实现、Nginx 代理、跨域、心跳、重连、压测会遇到的坑,适合正在做消息推送、在线客服、实时协同、硬件接入这些实时场景的兄弟参考。
1. 为什么你的业务需要 WebSocket 长连接,而不是继续轮询
很多团队在决定“要不要用 WebSocket”之前,其实先要想清楚一个更根本的问题:你需要的到底是一个“状态同步通道”还是一个“请求响应接口”。如果只是隔几秒拉一次数据,HTTP 轮询完全够用,引入 WebSocket 反而会让系统复杂度上升。但一旦你的业务对实时性要求比较高,或者服务端要主动给客户端发数据,那 HTTP 轮询就会变得非常别扭,WebSocket 长连接几乎是唯一合理的选择。
1.1 长连接和短连接的本质区别
HTTP 短连接很好理解,浏览器每次发请求都要重新走一遍 TCP 三次握手,服务器响应之后,连接要么立刻关闭,要么通过 Keep-Alive 复用一阵子。即便使用了 HTTP Keep-Alive,也依然是“请求-响应”这种一问一答的模式,服务端没有请求就不能主动把数据塞给客户端。WebSocket 则是在 HTTP 基础上通过 Upgrade 升级出来的长连接,一次握手成功之后,客户端和服务端之间就是一个双向、全双工的管道,两边随时可以主动发数据,不用再重复握手。
可以用一个生活化的类比。HTTP 短连接像是你去银行柜台办一笔业务,办完就走,下次来还要重新取号排队。HTTP 长连接像是你拿了一个贵宾号,可以在大厅坐着等,但每次还是得叫到你才办事,柜员不会主动喊你说“顺便帮你把下个月的转账也做了”。WebSocket 更像是你和柜员之间拉了一根专线,你随时可以说话,柜员有新的理财消息也能第一时间告诉你,不需要你反复问“有没有新业务”。
1.2 轮询、长轮询与 WebSocket 的对比
老项目里最常见的替代方案是轮询。4 秒一次还是 10 秒一次,取决于业务容忍的延迟。一旦消息到达频率不固定,轮询的空转浪费非常明显,每次请求都要带一堆 HTTP 头,服务端还要处理大量无效请求。长轮询稍微聪明一点,客户端发一次请求后服务端挂着,等有数据才返回,但连接保持期间服务端线程资源被占用,还要处理超时和异常,复杂度并不低。
下面简单对比一下三种方式的实时性和开销:
| 方案 | 实时性 | 额外开销 | 服务端主动推送 | 实现复杂度 |
|---|---|---|---|---|
| HTTP 短轮询 | 取决于轮询间隔 | 高,大量无效请求 | 不支持 | 最低 |
| HTTP 长轮询 | 延迟较低 | 中,长时间占用连接 | 模拟支持 | 中 |
| WebSocket 长连接 | 毫秒级 | 低,建立后无头部重复开销 | 原生支持 | 中高 |
实际项目里选 WebSocket 通常不是因为“听起来酷”,而是因为并发一高,轮询的无效请求会直接打满应用服务器,而 WebSocket 建立连接之后,大部分请求都被省掉了,服务端可以把计算资源集中到业务处理上。
1.3 哪些场景非 WebSocket 不可
从标题关联到的技术栈就能看出来,WebSocket 的适用面非常广。网页版在线客服要处理工单状态实时流转,协同编辑要把光标和内容变更实时同步,行情软件要把价格变动推给所有订阅者,物联网场景里 ESP32 设备要上报传感器数据和服务端下发控制指令,这些都适合用 WebSocket。甚至在很多 Java、Vue3、Node.js 技术栈里,WebSocket 都是集成实时通信的默认方案。
我在实际项目里还有一个判断标准:如果业务里出现了“服务端要主动给指定客户端发消息”的需求,那么优先考虑 WebSocket。反过来说,如果你的业务只是客户端主动拉取数据,接口请求就够了,千万不要为了用 WebSocket 而用。
2. WebSocket 建连流程、帧结构、心跳机制详解
搞懂 WebSocket 的握手过程,遇到连接异常、代理配置、鉴权问题的时候才不至于两眼一抹黑。WebSocket 长连接表面上是一套独立的协议,但它脱胎于 HTTP,握手阶段就是一个标准的 HTTP Upgrade 请求。理解了这一点,就理解了很多框架为什么要配置允许跨域、为什么要处理请求头。
2.1 从 HTTP 到 WebSocket 的 Upgrade 握手
WebSocket 连接的建立始于一次 HTTP GET 请求。这个请求里必须带上特定的 Header,服务端识别之后返回101 Switching Protocols,从这一刻开始,连接就从 HTTP 协议切换成了 WebSocket 协议。
一个典型的请求头大概是这个样子的:
GET /ws/chat HTTP/1.1 Host: example.com Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== Sec-WebSocket-Version: 13 Origin: http://localhost:5173这里有几个关键点。Upgrade: websocket和Connection: Upgrade是显式告诉服务端“我要升级协议”。Sec-WebSocket-Version用 13 表示使用的协议版本。Sec-WebSocket-Key是一个客户端随机生成的 Base64 编码字符串,它不是用来做鉴权的,而是用来校验服务端确实支持 WebSocket 协议。
服务端收到 Key 之后,会把它和固定 GUID 拼接,然后做一次 SHA-1 哈希,再对结果做 Base64 编码,作为Sec-WebSocket-Accept返回给客户端。这个机制看起来简单,但它的意义是不让普通的 HTTP 请求缓存代理服务器误把 WebSocket 握手请求当成普通 HTTP 请求缓存下来,从而保证连接能顺利升级。
2.2 帧结构:长连接里消息是怎么传输的
一旦完成握手,后续数据就不是 HTTP 报文了,而是 WebSocket 帧。WebSocket 协议定义了多种帧类型,包括文本帧、二进制帧、Ping 帧、Pong 帧、关闭帧等。对大多数业务来说,只需要关心文本帧和 Ping/Pong 帧就够了。
帧结构中最关键的几个字段是 FIN、Opcode 和 Mask。FIN 标识这一帧是否为消息的最后一帧。Opcode 标识帧类型,比如 0x1 表示文本帧,0x2 表示二进制帧,0x9 是 Ping,0xA 是 Pong。Mask 位表示客户端发送给服务端的帧是否需要进行掩码处理,浏览器原生 WebSocket 强制要求客户端发出的帧必须带掩码,而服务端发送帧则不需要。
这个设计对普通开发者来说是黑盒,做全栈开发时不需要手动去拼帧,但当你遇到某些单条消息过大、或者消息被分包传输的情况时,理解帧结构能帮你判断是否需要在应用层做消息聚合。
2.3 心跳保活:长连接稳定不断的命门
很多人以为长连接建起来之后就能一帆风顺地躺着,实际上 WebSocket 长连接很容易被网络中间设备静默断掉。NAT 网关为了收敛资源,通常会在一定时间内没有数据传输的情况下回收端口映射。运营商侧的防火墙也可能主动断开空闲连接。这些中断对客户端和服务端来说都是“突然死亡”,如果没有心跳机制,连接两端根本感知不到,过了很久才发现数据发不出去了。
解决这个问题的标准做法是心跳,也就是定期在客户端和服务端之间发送 Ping/Pong 帧。实现方式有两种。一种是浏览器端定时发送一个 Ping 帧,服务端收到后自动回复 Pong 帧;另一种是服务端主动发送 Ping,客户端回复 Pong。考虑到浏览器原生 WebSocket 的 ping/pong 控制帧并不完全暴露给开发者,前端经常使用在普通文本帧里塞一条 JSON 心跳消息的方式来实现应用层心跳。
心跳间隔怎么定很讲究。太短会产生大量无用流量,太长又起不到保活作用。我一般设置在 30 秒到 60 秒之间,具体数值要看中间网络设备的限制。如果是内网环境,可以设置到 60 秒;如果客户端要跨越复杂网络访问,30 秒更稳妥。更重要的是,心跳消息不能只发一次,要配合定时器周期发送,并且要在一段时间没收到任何响应时触发重连逻辑。
3. 获取客户端 IP 的几种方案与隐藏的深坑
获取客户端 IP 这件事,在普通 HTTP 请求里都有不少讲究,放到 WebSocket 里因为多了一层握手和代理转发,坑只会更多。很多人在本地测试时拿到的 IP 都是127.0.0.1,一部署到服务器又发现拿到的是 Nginx 的内网地址,并不是真实客户端 IP。下面把常见方案和注意事项一次说清楚。
3.1 从 HTTP 请求头和 Socket 地址获取 IP
WebSocket 握手阶段本质上还是一次 HTTP 请求,所以所有能拿 HTTP 客户端 IP 的手段,在这里全都适用。最底层的方式是直接从 TCP 层取 Socket 的远端地址,在 Java 服务端里可以通过session.getRemoteAddress()拿到InetSocketAddress,再调用getAddress().getHostAddress()得到 IP。
但这里有个非常容易踩的坑:如果客户端经过了 Nginx 反向代理或 CDN,那么服务端 TCP 层的远端地址其实是 Nginx 或 CDN 节点的地址,并不是客户端的真实 IP。因此,想要拿真实 IP,通常要依赖代理服务器在转发请求时往 Header 里插入原始 IP 信息,常见的 Header 有X-Forwarded-For、X-Real-IP、X-Forwarded-Proto等。
X-Forwarded-For的格式是一个逗号分隔的 IP 列表,最右边是直连服务端的地址,最左边是最初发起请求的客户端 IP。比如X-Forwarded-For: 1.2.3.4, 10.0.0.1,表示真实客户端 IP 是1.2.3.4,下一跳是10.0.0.1。
3.2 Nginx 反向代理下的 WebSocket 真实 IP 获取
在实际生产环境里,WebSocket 很少直接暴露给公网,通常前端通过 Nginx 反向代理访问后端服务。这时候 Nginx 配置如果没做对,WebSocket 升级会失败,或者升级成功但真实 IP 传不过来。
Nginx 里做 WebSocket 反向代理有几个关键配置项,一个都不能少。proxy_set_header Upgrade $http_upgrade和proxy_set_header Connection "upgrade"负责保留 WebSocket 的升级头,否则 Nginx 默认会把 Connection 头清理掉,导致握手失败。转发 IP 则需要配置:
location /ws/ { proxy_pass http://backend-server:8080/ws/; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; }这里最重要的就是$proxy_add_x_forwarded_for,它会在 Nginx 收到的原始X-Forwarded-For基础上,追加上当前 Nginx 和客户端直连的$remote_addr,确保后面每一级代理都能保留来源信息。
服务端在解析的时候,不要直接拿第一个 IP 当作真实客户端,因为客户端自己也可以伪造X-Forwarded-For头。稳妥的做法是:先从直连 IP 开始,向左遍历,跳过所有内网网段,找到第一个公网地址,这个地址才算真正的客户端 IP。如果整个链路都在内网环境,那直连 IP 就是真实地址。
3.3 WebSocket 场景中 IP 获取的细节处理
WebSocket 握手完成后,很多框架在onOpen回调里还能拿到握手请求的信息,但如果直接把session.getRemoteAddress()存进会话里,后续 Nginx 再想追加 IP 信息就没有机会了。所以必须在握手阶段,也就是 WebSocket 拦截器(HandshakeInterceptor)里完成 IP 的解析和保存,再把解析结果通过 attributes 传给 WebSocket Handler。
还有一点容易被忽略:getRemoteAddress()拿到的不只是 IP,有时还带端口和字母前缀。比如InetSocketAddress.toString()输出可能是/192.168.1.100:5678,需要先去掉前导斜杠再按冒号分割。对于 IPv6 地址,情况更复杂,需要区分形如0:0:0:0:0:0:0:1的回环地址,也要注意方括号的格式。建议把 IP 解析单独封装成一个工具类,统一处理后返回干净的 IP 字符串。
如果是移动端或硬件的客户端,公网 IP 可能是变化的,获取到的 IP 可能只是接入网的出口 IP,并不一定能精确到某个用户。这一点需要提前和产品对齐,不要指望用 IP 做用户唯一标识,它只能作为辅助定位信息。
4. 从零实现:Spring Boot 服务端 + Vue3 客户端完整示例
理论说了一堆,最后还是得落到代码上。这套示例我建议按“后端核心 Handler + 前端封装 + 停连重连”三个部分去看,每一部分都有对应的坑。我这里以 Java 技术栈为主,因为热搜词里 Spring Boot 和 Vue3 的呼声最高,其他语言在后面给个思路。
4.1 后端依赖与核心配置
新建一个 Spring Boot 工程,只需要引入一个依赖:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> </dependency>然后注册 WebSocket 处理器。关键是有两步:第一,把自定义 Handler 注册到指定路径;第二,添加一个自定义的握手拦截器,用来在握手阶段获取请求头、解析客户端 IP,并放到 attributes 里。
@Configuration public class WebSocketConfig implements WebSocketConfigurer { @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(new ChatWebSocketHandler(), "/ws/chat") .addInterceptors(new IpHandshakeInterceptor()) .setAllowedOriginPatterns("*"); } }setAllowedOriginPatterns("*")这一步不能漏,否则浏览器跨域请求会被拒掉。生产环境建议换成具体的前端域名,不要直接通配。
4.2 握手拦截器:在连接建立前拿到客户端 IP
握手拦截器是实现“获取客户端 IP”的核心位置。在beforeHandshake方法里,请求还是标准的ServerHttpRequest,可以直接读 Header:
public class IpHandshakeInterceptor implements HandshakeInterceptor { @Override public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception { String ip = resolveClientIp(request); attributes.put("CLIENT_IP", ip); return true; } private String resolveClientIp(ServerHttpRequest request) { String xff = request.getHeaders().getFirst("X-Forwarded-For"); if (xff != null && !xff.isBlank()) { String firstIp = xff.split(",")[0].trim(); if (isValidIp(firstIp)) { return firstIp; } } String realIp = request.getHeaders().getFirst("X-Real-IP"); if (realIp != null && !realIp.isBlank()) { return realIp.trim(); } if (request.getRemoteAddress() != null) { return request.getRemoteAddress().getAddress().getHostAddress(); } return "UNKNOWN"; } private boolean isValidIp(String ip) { try { InetAddress.getByName(ip); return true; } catch (UnknownHostException e) { return false; } } }attributes就是连接建立之后WebSocketSession.getAttributes()能拿到的数据,这一行属性传递是整个链路的关键。注意这里只是在 Header 里读第一个 IP,生产环境如果要防伪造,需要从右侧开始查找第一个公网地址,代码会更长一些。
4.3 核心 WebSocketHandler:会话管理与消息推送
接下来是消息处理器。我用一个静态的ConcurrentHashMap保存所有在线会话,同时把客户端 IP 和连接建立时间一起存进去。这样后续做消息推送、统计在线数、排查故障都很方便。
public class ChatWebSocketHandler implements WebSocketHandler { private static final Map<String, WebSocketSession> SESSION_MAP = new ConcurrentHashMap<>(); private static final Map<String, String> SESSION_IP_MAP = new ConcurrentHashMap<>(); @Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { String sessionId = session.getId(); String ip = (String) session.getAttributes().get("CLIENT_IP"); SESSION_MAP.put(sessionId, session); SESSION_IP_MAP.put(sessionId, ip); System.out.println("连接建立,session=" + sessionId + ", ip=" + ip); } @Override public void handleMessage(WebSocketSession session, WebSocketMessage<?> message) throws Exception { String payload = (String) message.getPayload(); System.out.println("收到消息:" + payload); // 处理业务逻辑,然后可以回推消息 session.sendMessage(new TextMessage("服务端已收到:" + payload)); } @Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception { SESSION_MAP.remove(session.getId()); SESSION_IP_MAP.remove(session.getId()); System.out.println("连接关闭,session=" + session.getId()); } @Override public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception { exception.printStackTrace(); session.close(CloseStatus.SERVER_ERROR); } public static void sendToAll(String message) { TextMessage textMessage = new TextMessage(message); for (WebSocketSession session : SESSION_MAP.values()) { synchronized (session) { try { if (session.isOpen()) { session.sendMessage(textMessage); } } catch (Exception e) { e.printStackTrace(); } } } } }实际生产里不要把业务逻辑全写在 Handler 里,建议把消息体设计成 JSON 格式,例如{"type":"heartbeat","data":{}}和{"type":"chat","data":{}},然后在handleMessage里做类型分发。上面的示例只是为了方便展示原理。
4.4 前端 Vue3 封装 WebSocket 长连接
Vue3 的组件里可以直接用原生WebSocket,但为了方便复用,我会封装成一个 composable。核心要解决的问题有三个:连接建立、心跳保活、自动重连。
// useWebSocket.js export function useWebSocket(url, options = {}) { const { heartbeatInterval = 30000, reconnectDelay = 3000 } = options let ws = null let heartbeatTimer = null let reconnectTimer = null let manualClose = false function connect() { manualClose = false ws = new WebSocket(url) ws.onopen = () => { console.log('WebSocket 连接建立') startHeartbeat() } ws.onmessage = (event) => { const data = JSON.parse(event.data) if (data.type === 'pong') { // 收到 pong,说明服务端存活 return } // 分发其他消息 options.onMessage && options.onMessage(data) } ws.onclose = () => { clearInterval(heartbeatTimer) if (!manualClose) { reconnectTimer = setTimeout(connect, reconnectDelay) } } ws.onerror = (error) => { console.error('WebSocket 错误', error) } } function startHeartbeat() { clearInterval(heartbeatTimer) heartbeatTimer = setInterval(() => { if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'ping', ts: Date.now() })) } }, heartbeatInterval) } function send(data) { if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify(data)) } } function close() { manualClose = true clearInterval(heartbeatTimer) clearTimeout(reconnectTimer) if (ws) { ws.close() } } return { connect, send, close } }心跳里的ping/pong采用了应用层 JSON 协议,因为浏览器原生 WebSocket 对控制帧的暴露有限,单纯依赖原生 Ping 帧不能很好地跨端生效。另外要注意,断线重连不能无限快速重试,指数退避更合理,比如第一次 1 秒、第二次 2 秒、第三次 4 秒,上限 30 秒。上面的示例用了固定延迟,实际生产可以改成动态退避。
4.5 通过 IP 反查和定位问题
当一个用户连接异常,我经常用保存的 IP 去排查是哪个客户端在捣乱。这里给出一个简单的接口,用来查看当前在线会话和 IP,方便联调:
@RestController @RequestMapping("/api/debug") public class WebSocketDebugController { @GetMapping("/sessions") public Map<String, String> sessionInfo() { return ChatWebSocketHandler.getSessionIpMap(); } }联调时打开网页,然后访问这个接口,就能看到所有在线连接对应的 IP。这一步在排查代理配置是否生效时非常有帮助:如果看到一堆127.0.0.1,那说明 Nginx 转发头没配置好或者你本地直连了后端。
4.6 其他技术栈的实现思路
标题相关热搜词里还出现了 Python、Node.js、ESP32 等场景,这里简单补一句。Python 的 FastAPI 可以使用websockets库,写一个async def voice_socket(websocket: WebSocket)这样的 handler,同样能从websocket.client拿到对端地址,再结合headers里的x-forwarded-for获取真实 IP。Node.js 服务端使用ws库,在request事件里能读到握手请求和socket.remoteAddress。ESP32 通过 Arduino 环境里的WebServer.h库也能建立WebSocket连接,但因为没有浏览器,IP 获取通常就是 TCP 层的client.remoteIP()。
5. 长连接常见问题与排查技巧实录
这一部分全是实际踩坑总结。很多问题看起来玄乎,实际上就是配置或心跳设置的问题。下面按“连接断开”“IP 不对”“浏览器兼容”“压测”四类展开。
5.1 连接稳定性和频繁断开问题
最常见的现象是:连接建立后一小段时间内是好的,过了几分钟就自动断线,然后前端自动重连,重连之后又断了。这种基本是网关或代理空闲超时导致的。Nginx 的proxy_read_timeout默认是 60 秒,如果业务数据不频繁,WebSocket 连接在 60 秒内没有消息,Nginx 就会主动断开。解决办法有两个:一是在 Nginx 配置里把proxy_read_timeout调大,比如 3600 秒;二是实现心跳,让连接在超时周期内至少有一次数据帧,我通常两种都做。
另外,Linux 系统的文件句柄限制也会导致连接数上来之后疯狂断开,需要把/etc/security/limits.conf里的软硬 nofile 调大,并确认应用进程实际生效。线上的连接数监控不能少,否则文件句柄打满之后,新的客户端根本连不上。
心跳包的格式也要统一。前端发{"type":"ping"},服务端就要返回{"type":"pong"}。如果服务端只收包但不回包,前端就无法判断连接是否可用,此时应该设置“连续 3 次未收到 pong 就主动断开重连”。
5.2 获取到的 IP 不对,排查思路
获取到的 IP 全是127.0.0.1,多半是本地开发时前端直连了后端的localhost,服务端拿到的自然就是回环地址。这时候可以检查浏览器访问的 URL:如果前端页面是http://localhost:5173,WebSocket 连接也指向ws://localhost:8080/ws,那 IP 就是127.0.0.1,这不算 bug,属于联调环境特征。
但部署到服务器之后,如果拿到的 IP 是 Nginx 内网地址,比如127.0.0.1或者172.17.0.1(Docker 网关),那就说明 Nginx 的X-Forwarded-For没有配好,或者服务端没有解析这个 Header。排查思路很直接:先去 Nginx 的 access log 里看$remote_addr是什么,如果是用户真实 IP,说明 Nginx 没问题,问题出在后端解析逻辑;如果 log 里已经是内网 IP,说明前一层还有代理,需要追到最接近用户的代理层,确保每一层都追加转发头。
5.3 浏览器兼容性和安全限制
WebSocket 的浏览器兼容性整体很好,但还是有几个边界情况要注意。比如在 HTTPS 页面里连接ws://会被浏览器拦截,必须用wss://。Chrome 109 之后其实并没有禁用 WebSocket,但很多旧版扩展、混合内容策略会导致连接失败。遇到这种情况,优先检查页面协议和 WebSocket URL 协议是否匹配,再看浏览器控制台有没有 mixed content 报错。另外,部分公司网络环境会拦截非 80/443 端口的 WebSocket 连接,生产环境一定要走 80/443,使用 Nginx 做反代,不要直接在公网暴露后端端口。
5.4 压测 WebSocket 长连接的工具配置
压测 WebSocket 不能只用普通 HTTP 压测工具,需要给 JMeter 安装 WebSocket Sampler 插件。安装办法是在 JMeter 的 Plugins Manager 里搜索WebSocket Sampler by Peter Doornbosch,安装后重启。之后可以新建WebSocket Open Connection、WebSocket Request Response Sampler、WebSocket Close三个组件来模拟一个完整的连接生命周期。
压测时要注意,长连接场景的并发模型和 HTTP 不同。HTTP 是“多个线程不断建连、请求、断开”,WebSocket 是“一批线程建立连接后保持住,再周期性地发送消息”。所以在 JMeter 里,线程数代表连接数,持续时间代表连接保持时间,消息发送频率则通过定时器控制。我压测时习惯把连接数先从 100 开始,观察服务端 CPU、内存和句柄数,再逐步加到 500、1000,记录失败率和响应时间。
5.5 常见问题速查表
| 问题现象 | 可能原因 | 解决方向 |
|---|---|---|
| 连接一直建立不起来 | 网络中间设备限制、协议格式错误 | 检查浏览器 Network;确认 80/443 端口;检查 Nginx Upgrade 配置 |
| 建立后几分钟自动断开 | Nginx 或网关空闲超时 | 调大proxy_read_timeout,加心跳 |
| 发送消息后偶尔收不到 | 网络抖动、服务端线程阻塞 | 前端实现重试,服务端发送加锁,异步线程池 |
| 获取到的 IP 是 127.0.0.1 | 本地联调或未配置代理转发 | 配置 NginxX-Real-IP、X-Forwarded-For |
| 获取到的 IP 是内网地址 | 多层代理,只取到了中间节点 | 从左往右找第一个公网 IP,或配置可信代理列表 |
| 上线后有跨域报错 | Origin 被服务端拒绝 | 在setAllowedOriginPatterns中配置可信域名 |
| 服务端主动推送失败 | 连接已失效但未感知 | 发送前检查isOpen(),失败时清理会话 |
写在最后的实操心得
折腾完这套之后,我的体感是:WebSocket 长连接本身并不难,难的是把“建连、保活、代理转发、IP 溯源”这条链路完整串联起来。尤其是 IP 获取和心跳这两块,看着不起眼,线上出问题的时候最能折腾人。根据我个人的经验,有几个小建议供参考:第一,连接建立时就一定要把 IP 存进会话属性,不要等用的时候再想办法拿,因为后续根本没有机会再解析 Header;第二,前端心跳和服务端空闲检查要成对出现,最好做成可配置项,方便不同网络环境调参数;第三,生产环境的 Nginx 配置要额外注意Connection和Upgrade头,少一个配置,WebSocket 就退化成普通 HTTP 请求,问题会非常隐蔽。如果你正在做类似的实时系统,建议先把这几个环节跑通,再往里面填业务逻辑,后面会省很多事。