尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

WebSocket后端实战:从协议原理到线上排障的完整指南

WebSocket后端实战:从协议原理到线上排障的完整指南 聊到后端实时通信WebSocket 是绕不开的一个坎。很多后端同学对它的认知停留在“用来做聊天室”“比轮询好用”这种层面但真到了线上连接闪断、消息丢失、集群广播失效、鉴权怎么做、nginx 要不要配 Upgrade 头这些问题一个接一个冒出来才会发现自己对它的理解其实很浅。这篇笔记不是把官方文档抄一遍而是把我从协议细节到代码落地、再到线上排障的完整经验梳理出来适合正在学后端、准备面试、或者已经在项目里被 WebSocket 折磨过的朋友。1. 为什么后端要单独研究 WebSocket1.1 HTTP 的请求-响应模型在实时场景下的尴尬HTTP 协议是典型的“一问一答”模式客户端发请求服务端给响应一次请求对应一次响应连接用完就断。这种模型在普通的 CRUD 接口里没有任何问题但放到实时性要求高的场景里就非常别扭。举个最常见的例子网页上的未读消息提醒。如果只用 HTTP前端只能靠轮询Polling来解决——每隔几秒发一个请求问后端“有没有新消息”。时间间隔短了服务端压力巨大明明没有新消息也要硬扛一堆无效请求时间间隔长了消息延迟高用户那边体验很差。我在早期项目里就做过 3 秒轮询的推送方案一个 500 人在线的后台系统光轮询请求就能把 Tomcat 的线程池打到快满而真正有意义的业务请求反而被挤掉了。SSEServer-Sent Events能解决一部分问题它允许服务端单向推送数据给客户端基于 HTTP 协议实现不需要额外协议。但 SSE 是单向的客户端没法通过同一条连接向服务端持续发送数据而且浏览器对 HTTP/1.1 下 SSE 连接数也有限制。如果业务需要客户端和服务端频繁双向交互比如实时协作编辑、股票行情双向订阅、在线客服SSE 的局限性就暴露了。1.2 WebSocket 到底改了什么WebSocket 本质上是在 TCP 之上建立了一条全双工的持久通道。握手阶段借用 HTTP 的 101 Switching Protocols 完成协议升级之后双方就在同一条 TCP 连接上双向收发数据不再有请求-响应的一一对应关系。这意味着三件事第一实时性从“秒级延迟”降低到“消息一到立刻推送”不需要客户端反复轮询。第二连接复用服务端可以主动向客户端推送数据服务器资源消耗大幅下降。第三协议本身的数据帧开销很小一个文本帧的控制头只有几十比特相比 HTTP 每次请求都要带一堆 Header省了太多带宽。对后端工程师来说WebSocket 不是一个“库”或者“框架”层面的东西而是一个需要从协议层理解的通信机制。因为线上问题往往不在业务代码里而在于连接管理、心跳保活、代理层透传、集群消息路由这些基础设施层面的细节。把这些搞明白了你写出来的推送服务才是真正能上线的而不是 demo 级别。2. 协议级的关键点后端必须吃透2.1 握手不是魔法从 HTTP Upgrade 说起很多人第一次接触 WebSocket 都会困惑为什么浏览器的 WebSocket API 直接填一个ws://地址就能连上感觉跟 HTTP 没关系。实际上WebSocket 连接的第一步就是一次普通的 HTTP 请求只是带上了特殊的 Upgrade 头。我抓包看过一次完整的握手过程客户端发出去的请求长这样GET /chat HTTP/1.1 Host: example.com Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ Sec-WebSocket-Version: 13 Origin: http://example.com服务端返回HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbKxOo这里的Sec-WebSocket-Key是客户端生成的一个随机 Base64 字符串服务端拿到后拼上一个固定的 GUID258EAFA5-E914-47DA-95CA-C5AB0DC85B11做 SHA-1 哈希再 Base64 编码得到Sec-WebSocket-Accept返回给客户端。客户端会校验这个值确认服务端真的懂 WebSocket 协议。这个机制不是摆样子它的目的是防止一些缓存代理服务器误把 WebSocket 请求当成普通 HTTP 请求缓存下来导致连接异常。后端开发如果自己实现协议解析这段逻辑必须完全按规范来不能偷懒。2.2 数据帧格式和掩码规则握手完成后数据就开始以帧Frame的形式在连接上传输了。WebSocket 的帧格式要比 HTTP 报文简单得多但有一个容易踩坑的细节——掩码。数据帧的关键字段分布如下FIN1 bit标记这是不是消息的最后一帧。消息可以分多个帧发送最后一片帧上 FIN 才置 1。opcode4 bits表示帧类型。0x1文本帧0x2二进制帧0x8关闭帧0x9Ping0xAPong。MASK1 bit掩码标志位。Payload length7 bits 或扩展表示数据长度小数据用 7 位不够就扩展到 716 位或 764 位。Masking-key4 bytes仅在 MASK 为 1 时存在。Payload data实际业务数据。协议里有一个硬性规定客户端发送给服务端的帧必须带掩码MASK1服务端发送给客户端的帧必须不带掩码MASK0。这个设计的初衷是防止早期浏览器被恶意脚本用 WebSocket 连接攻击内网服务。服务端在实现时如果收到客户端未掩码的帧应该按协议错误处理。对于后端开发来说如果是用成熟的 WebSocket 库比如 Spring 的 WebSocket 模块、Netty、Node.js 的 ws 库掩码处理已经被底层封装好了不手写解析的话通常碰不到。但一旦用原生 Socket 或者做网关转发就必须自己处理这一步。2.3 帧类型文本、二进制、Ping/Pong 与关闭帧实际开发里业务数据主要用文本帧或二进制帧。文本帧要求数据必须是合法的 UTF-8发二进制数据比如图片上传、文件流就要用二进制帧。选择哪种取决于业务场景聊天消息用文本帧就够了要传序列化对象或者文件分片就用二进制帧省掉 Base64 编码带来的 33% 体积膨胀。Ping 和 Pong 帧的作用是保活。WebSocket 连接底层是 TCPTCP 本身虽然有心跳机制但默认关闭且探测周期很长。如果客户端和服务端之间长时间没有数据传输中间经过的 NAT 网关、负载均衡、防火墙设备可能会把空闲连接回收掉连接就悄悄断了而双方都不知道。这时候就需要应用层心跳一端发 Ping 帧另一端收到后必须回 Pong 帧。很多库也支持直接发 Pong 帧作为对 Ping 的响应。如果发了 Ping 之后在超时时间内没收到 Pong就可以判定连接已经死了主动关闭然后触发重连逻辑。关闭帧则用于正常关闭连接。关闭时可以带一个状态码和原因说明。常见状态码比如 1000 表示正常关闭1001 表示服务端即将关停比如应用重启1008 表示策略违规比如鉴权失败。提示如果服务端在收到关闭帧之后继续往这条连接上写数据很可能会触发异常。正确的流程是收到关闭帧 - 回复关闭帧 - 双方关闭 TCP 连接 - 释放相关资源。3. 服务端实现与代码落地3.1 在 Spring Boot 里写一个 WebSocket 服务端我平时主要用 Java 技术栈Spring Boot 项目里集成 WebSocket 非常顺手。Spring 提供了两套方式一套是基于ServerEndpoint的 Java WebSocket 标准JSR-356实现另一套是 Spring 自家的WebSocketHandlerWebSocketConfigurer。两者对比下来我的习惯是用ServerEndpoint做业务接入因为它在处理连接生命周期、消息接收、异常处理时的代码更直观配合ConcurrentHashMap管理会话非常清晰。先加依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-websocket/artifactId /dependency然后有一个配置类注入ServerEndpointExporterConfiguration public class WebSocketConfig { Bean public ServerEndpointExporter serverEndpointExporter() { return new ServerEndpointExporter(); } }接下来是具体的 Endpoint 类我用一个通知推送服务来做示例Component ServerEndpoint(/notice/{userId}) Slf4j public class NoticeWebSocketServer { // 会话集合key 是用户标识value 是 Spring 封装的 Session private static final MapString, Session SESSION_MAP new ConcurrentHashMap(); OnOpen public void onOpen(Session session, PathParam(userId) String userId) { SESSION_MAP.put(userId, session); log.info(用户 {} 建立连接当前在线数{}, userId, SESSION_MAP.size()); } OnClose public void onClose(PathParam(userId) String userId) { SESSION_MAP.remove(userId); log.info(用户 {} 断开连接当前在线数{}, userId, SESSION_MAP.size()); } OnMessage public void onMessage(String message, Session session) { // 根据业务处理客户端发来的消息比如回复确认、上行指令 log.info(收到来自客户端消息{}, message); } OnError public void onError(Session session, Throwable error) { log.error(连接异常: , error); } public static void sendToUser(String userId, String message) { Session session SESSION_MAP.get(userId); if (session ! null session.isOpen()) { try { session.getBasicRemote().sendText(message); } catch (IOException e) { log.error(推送消息失败userId: {}, userId, e); } } } }注意SESSION_MAP要支持多线程并发访问所以用ConcurrentHashMap。因为 WebSocket 的onOpen、onMessage、onClose是在不同线程里被调用的普通的HashMap在高并发下扩容时可能出现死循环或者数据错乱。3.2 把连接对象管起来会话管理Session对象就是服务端和某个客户端之间的一条持久通道。管理好这些 Session是 WebSocket 后端最核心的日常操作。会话管理的核心要点有三个第一选择合适的 Key。上面示例用的是userId适用于一个用户同时只维持一个连接的场景。如果是同一个用户在多端登录手机、浏览器、桌面应用一个userId对应多个 Session就要用userId 端类型做 Key或者改成userId - SetSession的结构推送时遍历这个集合。第二及时清理失效连接。客户端直接断网、断电是不会有正常关闭帧发过来的服务端的onClose不一定被触发。如果不做心跳检测SESSION_MAP里会堆积大量僵尸 Session内存和文件描述符都会泄漏。第三推送时要检查session.isOpen()。连接可能已经关闭了但对象还在 Map 里直接sendText会抛IllegalStateException。先判空再检查 isOpen这是最基本的防御。3.3 服务端主动推送消息的正确姿势很多项目会通过业务线程主动推送比如订单支付成功给用户发一个站内信通知、告警系统推送异常信息。这里有一个很容易犯的错误直接在业务线程里调用session.getBasicRemote().sendText()。getBasicRemote()是同步发送如果网络慢或者客户端处理慢会阻塞当前业务线程。在秒杀、告警这种高并发推送场景这个操作会直接影响业务接口的响应时间。我一般用getAsyncRemote()改异步发送或者干脆封装一层推送服务把“发送消息”和“业务处理”解耦。另外一个实战技巧是不要用单条 session 的锁来同步批量推送。最典型的问题出现在群发场景——如果按用户维度加锁一个用户的连接卡顿会导致整个循环推送阻塞。我在上一个项目里就是把“往单个客户端发消息”和“遍历所有用户群发”拆成两个方法单用户发送失败直接捕获异常跳过绝对不让一个坏连接拖垮整个群发。4. 线上一定会遇到的四个问题4.1 连接不稳定心跳与重连机制WebSocket 连接在公网环境下很难保持长期稳定。运营商 NAT 映射有超时时间公司防火墙会回收闲置连接服务端应用重启也会断掉所有连接。设计一个健壮的 WebSocket 服务心跳和重连是标配。心跳机制通常是这样设计的服务端维护一个定时任务每隔一段时间比如 30 秒主动向客户端发 Ping 帧同时记录每个 Session 最近一次收到消息的时间。如果连续 N 次 Ping 都没有响应就把这个 Session 标记为超时并关闭。还有一种常见做法是客户端主动心跳由前端每隔一段时间发一个业务心跳包服务端收到后重置空闲计时。这两种方案可以组合服务端 Ping 客户端业务心跳双保险。无论哪种方案关键点是服务端必须兜底清理超时连接不能依赖客户端主动断。我在第一次做的时候就把这件事想简单了只做了服务端 Ping没做超时清理。结果线上运行三天后堆内存里躺着几万个已经不存在的 Session 对象连接数飙升到服务端文件描述符上限整个应用直接拒连接。从那以后我所有的 WebSocket 服务都强制加超时清理逻辑。前端那边的重连也很有讲究。最简单的方案是断线后固定延迟重连但服务端发布重启时成百上千的客户端会同时发起重连造成“惊群效应”——服务端刚启动就被一轮重连请求打挂。我建议前端做指数退避重连从 1 秒开始每次失败翻倍最长不超过 30 秒并且加上随机抖动jitter避免客户端的重连请求在同一时刻爆发。4.2 鉴权怎么做token 带在哪WebSocket 握手走的是 HTTP所以鉴权理论上可以在握手阶段完成。但 WebSocket API 在浏览器里有一个限制无法自定义 Header。new WebSocket(url)只能设置 URL 和协议列表不能像fetch那样手动带Authorization头。因此常用方案有三种方案一token 放进 URL Query 参数。比如ws://localhost:8080/notice/1001?tokenabc123。实现简单但 token 会出现在日志、浏览器历史、反向代理的 access log 里有泄露风险务必做好日志脱敏。方案二token 放到子协议Subprotocol里。在握手时用Sec-WebSocket-Protocol字段携带 token服务端从 header 里取出来校验。这种方式相对隐蔽但协议本意是标记应用层协议拿它传 token 属于“擦边球”需要团队统一约定。方案三先通过 HTTP 接口换取一次性票据再在握手时通过 Query 参数提交服务端校验通过后立即失效。适合对安全要求高的场景。我个人最常用的组合是token 放 Query 参数 服务端拦截器校验 对日志做脱敏处理。校验放在握手阶段有一个巨大的好处——失败的连接根本不会被OnOpen接收避免非法连接占用资源。4.3 多实例部署消息怎么广播WebSocket 的连接是有状态的Session 绑定在某个实例上。一旦服务做水平扩容部署了多个实例用户 A 连接在实例 1用户 B 连接在实例 2A 要给 B 发消息直接遍历本地 SESSION_MAP 根本找不到 B。解决思路不是让每个实例共享 Session而是引入一个消息路由层。最经典的做法是基于 Redis 的发布订阅Pub/Sub实例 1 收到 A 的消息后把消息发到 Redis 的指定 Channel所有订阅了这个 Channel 的实例都能收到再由持有目标 Session 的实例完成推送。核心逻辑大致如下// 推送入口 public void pushMessage(String targetUserId, String message) { // 1. 先查本地 Session 是否存在 if (SESSION_MAP.containsKey(targetUserId)) { sendToLocalUser(targetUserId, message); return; } // 2. 本地不存在通过 Redis Pub/Sub 广播出去 redisTemplate.convertAndSend(ws:notice, targetUserId : message); } // Redis 消息监听器里接收并处理 public void onMessage(String payload) { String[] parts payload.split(:, 2); String targetUserId parts[0]; String message parts[1]; // 消息可能是发给本实例的也可能是发给其他实例的统一走本地发送逻辑 if (SESSION_MAP.containsKey(targetUserId)) { sendToLocalUser(targetUserId, message); } }这样每个实例只需要维护自己的本地 SESSION_MAP跨实例的消息通过 Redis 中转。要注意的是Redis Pub/Sub 的消息是即发即弃的如果某个实例当时不在线消息就丢了。对推送可靠性要求更高的场景可以考虑用 Redis Stream、RocketMQ 这类带持久化的消息队列从“订阅推送”演进为“先存储后投递”。还有一种方案是引入专门的网关层所有 WebSocket 连接都挂在网关层业务后端不直接持有连接网关负责把消息路由到目标客户端。这种架构更彻底但实现成本也更高适合体量更大、微服务划分更细的团队。4.4 网关/代理层nginx 配置与连接超时WebSocket 服务前面几乎都会加一层 nginx 做负载均衡和域名转发。nginx 默认配置对 WebSocket 并不友好因为它需要支持 HTTP 升级。在我的服务器上一个可用的 location 配置是这样的map $http_upgrade $connection_upgrade { default upgrade; close; } upstream ws_backend { server 127.0.0.1:8080; server 127.0.0.1:8081; # 开启长连接复用避免每次握手都新建后端连接 keepalive 32; } server { listen 80; server_name ws.example.com; location /notice { proxy_pass http://ws_backend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $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; # 关键如果 60 秒内没有数据交互nginx 会主动断开连接 proxy_read_timeout 3600s; proxy_send_timeout 3600s; } }上面配置里有几个坑都是我实际踩过的proxy_set_header Connection $connection_upgrade是必须的如果写成固定的upgrade普通 HTTP 请求也会被当作升级请求处理导致静态资源接口异常。用map做动态映射是最稳妥的方式。proxy_read_timeout默认是 60 秒如果不调大当客户端和服务端之间刚好没有数据交换超过 60 秒nginx 就会先断开连接。客户端和应用层心跳的数据能覆盖这个超时时间但保险起见我会把超时时间设置为心跳间隔的好几倍。另外负载均衡算法默认是轮询但 WebSocket 连接一旦建立后续帧都走同一条 TCP 连接所以 nginx 层不需要做会话保持sticky session只要保证握手中的 Upgrade 请求被转发到正确后端即可之后的流量都跟着连接走。5. 常见问题与排查技巧实录5.1 连接一打开就断浏览器报 10061006 是 WebSocket 里最让人头疼的错误码因为它是“连接非正常关闭”的统称浏览器不会给任何具体原因。服务端日志和浏览器 Network 面板往往都没有有效信息。我遇到这个问题的排查顺序一般是这样的第一步确认握手是否成功。在浏览器 Network 面板看 WebSocket 那条请求的 Status Code 是不是 101。如果不是 101说明代理层或后端没有正确返回升级响应大概率是 nginx 配置少了 Upgrade 头。第二步确认服务端有没有在握手之后立刻关闭连接。常见原因是鉴权失败、Session 为空返回了异常。在OnOpen里处理完逻辑后打一条日志确认连接是否真的注册成功。第三步排查是不是代理层提前断开了。如果前面有 nginx 或者云负载均衡先跳过它直连后端测试。直连没问题就把焦点放到代理层配置上。第四步看服务端进程是不是有 GC 停顿或者线程阻塞。Full GC 如果造成长时间 Stop The World也会触发底层 TCP 超时表现出来就是 1006。5.2 服务端消息推送不出去代码里明明调用了sendText没有抛异常但客户端就是收不到。这种“静默失败”比报错更烦人。根据我的经验先查session.isOpen()。如果连接已经断了但状态没来得及更新发送接口可能不报错消息却发不出去。其次是检查是否用了同一个BasicRemote实例在多个线程里同时发送。WebSocket 协议要求同一个连接的数据帧必须串行发送多个线程同时写会导致数据帧交错发送接口会抛异常或者消息被丢弃。正确的做法是把发送操作统一收口到一个方法里加锁保证同一时刻只有一个线程在写。在 Netty 或者 Spring 底层同一条连接本身是不允许并发写的框架会在高并发下触发异常。5.3 连接数持续增长内存和句柄被吃光这个问题几乎每个做 WebSocket 的人都会遇到。现象是线上连接数一直涨不下降最后服务端报 “Too many open files” 或者堆内存耗尽。原因大多是两类。一类是客户端异常断开拔网线、App 闪退没有发关闭帧服务端的onClose没有被调用Session 一直留在 Map 里。另一类是服务端业务代码持有 Session 引用即使底层连接已经关闭对象仍然无法被 GC。方案只有一个也是最有效的一个定时清理。写一个定时任务每隔一段时间遍历SESSION_MAP检查每个 Session 的最后活动时间。如果超过设定阈值比如 90 秒没有收到任何帧就主动调用session.close()并从 Map 中移除。需要注意的是清理任务本身就是对宿主机资源的消耗几千个连接还好几万个连接的时候ConcurrentHashMap的遍历也会带来线程竞争。我现在的做法是把 Session 按时间分桶存放清理时只扫最近超时的桶而不是全量扫描。5.4 谷歌浏览器高版本无法启用 WebSocket有不少人遇到这类问题高版本浏览器里 WebSocket 连接始终建立不起来控制台报错信息又很模糊。首先要澄清一点现代主流浏览器对 WebSocket 的支持是非常成熟的不需要“启用”什么开关。所谓“无法启用”绝大多数情况下是页面代码把ws://和wss://写错了。如果页面是 HTTPS 环境浏览器会强制要求 WebSocket 使用 TLS 加密也就是wss://协议。如果用ws://浏览器会直接拒绝连接报错内容可能只是WebSocket connection failed。开发环境用 HTTP 就配ws://生产环境只要上了 HTTPS就必须对应改成wss://并且 nginx 层要配置 SSL 证书和代理。另外浏览器对 WebSocket 连接数也有限制。HTTP/1.1 下单个域名最多 6 条并发连接如果页面里同时开了多个 WebSocket超出的连接会排队甚至失败。高版本浏览器在 HTTP/2 下的连接限制略有放宽但多路复用对 WebSocket 的支持并不完美仍然建议控制同页面的连接数量。5.5 常见问题速查表现象常见原因处理优先级连接一直无法建立协议前缀错误、nginx 没配 Upgrade、端口不通先查 nginx 配置和浏览器 Network连接建立后秒断报 1006鉴权失败、代理层超时、后端异常关闭服务端日志 直连测试客户端收不到推送Session 已失效、多线程并发写、代理层缓冲检查 isOpen、统一收口发送连接数只增不减没有心跳清理、onClose 未触发增加定时清理任务后端多实例时消息不互通缺少消息路由层引入 Redis Pub/Sub 或消息队列服务端 CPU 飙升心跳频繁、全量扫描 Session、同步阻塞发送优化心跳策略、异步发送把这些场景都过一遍之后再回头看 WebSocket会发现它其实就是一个“长连接 帧协议 连接管理”的组合体。协议本身不复杂复杂的是把它放进真实系统里的各种约束。我在最初学习的时候总想着把协议背熟、代码写好就万事大吉后来发现真正拉开差距的是连接断没断、消息丢没丢、服务挂没挂这几个在生产环境逃不掉的工程问题。现在手边每一个 WebSocket 项目我上手第一件事就是先问清楚心跳怎么做、断线重连怎么设计、集群节点挂了消息怎么兜底这比磨任何框架 API 都重要。
返回列表