
1. 从一个实时刷新的需求说起HTTP轮询为什么不够用如果你做过带实时功能的Web项目——在线客服、消息通知、股票行情、协作编辑、游戏对战——大概率经历过类似的尴尬服务器明明有新数据了页面却迟迟不更新为了让页面“活”起来前端用setInterval每几秒钟拉一次接口结果数据没多及时服务器倒是被无效请求打得够呛。我最初做实时功能时也踩过这个坑。当时接手一个在线文档协作模块用户A编辑了内容用户B的页面要尽快看到变化。第一版方案很简单B每3秒请求一次文档最新内容。上线后倒也能用但几个问题非常明显——每次轮询都带上完整的HTTP请求头一个简单的状态同步请求真正有效的payload可能就几十字节传输开销却接近1KB。3秒的轮询间隔数据新鲜度上限就是3秒把间隔压到1秒服务器压力直接翻三倍高峰期CPU持续走高。最难受的是绝大多数轮询请求的结果是“没有新数据”纯属浪费。后来在技术群里聊起这事有人问为什么不直接用WebSocket对当时我对WebSocket的理解停留在“一种全双工通信协议”真正落到项目里怎么做、心跳怎么保活、断线怎么重连、和HTTP是什么关系其实并没有系统验证过。这篇文章就基于我后来又做过的几个真实项目把WebSocket从原理到落地完整梳理一遍重点聊那些文档里写得不明显、但实战中肯定会碰到的问题。如果你正准备给自己的系统加实时能力或者已经在用WebSocket但总感觉哪里不太稳定这篇应该能给你省不少时间。2. WebSocket到底是什么它不是轮询的升级版而是一套独立的通信机制很多人对WebSocket有个误解觉得它是一个“优化过的长轮询”或者“HTTP/2的变种”其实都不对。WebSocket是建立在TCP之上的一套独立协议它有自己独立的握手方式、帧格式、地址协议头ws://和wss://。2.1 和HTTP的关系一次握手升级然后脱离HTTPWebSocket最巧妙的地方在于它的启动方式它借助HTTP协议完成第一次握手但握手完成后连接就从HTTP协议“升级”成了WebSocket协议后续数据交换不再走HTTP的请求-响应模型。握手请求的样子大概是这样的GET /chat HTTP/1.1 Host: example.com 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: s3pPLMBiTxaQ9kYGzzhZRbKxOo注意两个细节Sec-WebSocket-Key是一个随机Base64编码的16字节值服务端拿到后拼上一个固定的GUID258EAFA5-E914-47DA-95CA-C5AB0DC85B11做SHA-1哈希后再Base64编码得到Sec-WebSocket-Accept返回客户端。这个过程本质上是一个简单的校验防止非WebSocket客户端比如普通HTTP请求意外建立连接。101 Switching Protocols这个状态码非常关键它意味着HTTP对话到此结束接下来的数据帧不再按HTTP规则解析。一旦握手完成客户端和服务端之间就是一个全双工的TCP通道两边都可以随时给对方发数据不再有“谁先请求谁才能响应”的限制。2.2 和轮询、长轮询的本质差异我把三种方式的差异整理成一张表方便理解维度传统HTTP轮询HTTP长轮询WebSocket通信方向只能客户端主动请求客户端请求服务端挂起延迟返回双方随时互发实时性取决于轮询间隔数据到达后尽快返回毫秒级数据一到就推资源开销高大量无效请求中每个请求占用一个连接低一个连接复用服务端推送能力无有限原生支持跨域处理普通CORS普通CORS握手时校验Origin长轮询我之前也试过客户端发一个请求服务端没有新数据就先hold住不返回等有新数据了再响应客户端收到响应后马上发出下一次请求。这种方式确实比普通轮询实时性好但服务端每个挂起的请求都要占用一个连接在线用户多的时候连接数压力很大而且中间如果发生代理超时、断线还需要额外的恢复逻辑。而WebSocket只需要一次握手之后一直复用同一个TCP连接。我做过压测对比同样的在线人数长轮询方案维护的连接数是WebSocket方案的几倍到几十倍内存和文件描述符的消耗差距非常明显。2.3 为什么说WebSocket是“有状态的”还有一个容易被忽略的特性WebSocket连接是有状态的。HTTP请求是无状态的服务端不需要记住某个客户端之前干了什么但WebSocket连接建立后服务端必须维护每个连接的状态——它属于哪个用户、在哪个房间、有没有认证过。这意味着两件事连接的管理注册、映射、销毁是你自己要在应用层做的协议本身不管这些。一旦连接断开状态就丢了需要重新走业务逻辑恢复。很多人在WebSocket上踩坑根源就是没意识到“你可以把WebSocket当作一个有状态的TCP长连接来管理”而不是简单的一套消息收发API。3. 从零跑通一个WebSocket项目选型、环境、第一个能跑起来的Demo理论说再多不如先把一个可运行的Demo跑起来。下面这节我会用一个实际的项目骨架来演示。3.1 选型服务端用什么客户端用什么我见过不少团队在选型上纠结很久。真实情况是几乎所有主流后端技术栈都有成熟的WebSocket支持关键看你团队熟悉什么以及你现有的基础设施是什么。我自己的偏好是服务端Java生态首选Spring Boot的spring-boot-starter-websocket配置简单注解风格清晰Node.js生态可选socket.io或原生ws库前者自带降级方案和房间管理适合快速出活。客户端浏览器原生WebSocket API就够用不需要额外框架复杂的业务场景自动重连、消息队列积压再考虑封装库。网络层前置Nginx需要配置Upgrade头转发云厂商的负载均衡器通常都支持WebSocket但要注意空闲超时时间的设置。3.2 一个最简的Spring Boot WebSocket服务端服务端我用Spring Boot实现一个极简的消息转发服务。依赖只需要一个dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-websocket/artifactId /dependency核心配置类Configuration EnableWebSocketMessageBroker public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { Override public void configureMessageBroker(MessageBrokerRegistry config) { // 客户端订阅消息的前缀比如 /topic/notifications config.enableSimpleBroker(/topic); // 客户端发送消息到服务端的前缀比如 /app/chat config.setApplicationDestinationPrefixes(/app); } Override public void registerStompEndpoints(StompEndpointRegistry registry) { // 握手端点前端连接的就是这个地址 registry.addEndpoint(/ws) .setAllowedOriginPatterns(*) .withSockJS(); } }再写一个简单的广播控制器Controller public class NoticeController { private final SimpMessagingTemplate messagingTemplate; public NoticeController(SimpMessagingTemplate messagingTemplate) { this.messagingTemplate messagingTemplate; } MessageMapping(/notice) public void handleNotice(String message) { // 收到客户端消息后转发给所有订阅了 /topic/notifications 的客户端 messagingTemplate.convertAndSend(/topic/notifications, 服务端收到: message); } }3.3 浏览器端的完整逻辑前端用原生WebSocket写不依赖任何框架// 注意如果配置了SockJS前端也要引入sockjs-client和stomp.js // 这里先演示原生WebSocket的写法方便理解协议层行为 const ws new WebSocket(ws://localhost:8080/ws); ws.onopen function() { console.log(连接已建立); // 发一条测试消息 ws.send(JSON.stringify({ type: NOTICE, content: hello from browser })); }; ws.onmessage function(event) { const data event.data; console.log(收到服务端消息:, data); }; ws.onerror function(error) { console.error(WebSocket发生错误:, error); }; ws.onclose function(event) { console.log(连接关闭:, event.code, event.reason); };跑起来之后打开浏览器控制台你会看到连接建立、消息发送、消息回显的完整日志。这就是WebSocket最基础的用法——握手、双向通信。3.4 一个容易被卡住的点Stomp协议要不要用上面的Spring配置里我用了WebSocketMessageBroker这意味着默认走的是STOMP子协议。STOMPSimple Text Oriented Messaging Protocol是一个基于文本的消息协议它定义了CONNECT、SUBSCRIBE、SEND等命令帧相当于在WebSocket之上又加了一层“消息路由规则”。很多初学者第一次接触Spring WebSocket时会分不清自己到底该用原生WebSocketHandler还是STOMP。我的建议是如果只是简单收发文本用原生注解ServerEndpoint或WebSocketHandler就够了少一层概念。如果需要按主题订阅/发布、需要推送给特定用户或组用STOMP的消息代理模式会省很多事convertAndSendToUser这类封装直接帮你解决了单点推送问题。我自己多数项目都用STOMP因为业务上总绕不开“按用户推送”“按群组推送”这样的需求。4. 心跳机制的完整实现为什么它直接决定长连接能活多久这个主题的热度一直很高因为凡是做过WebSocket生产环境的人几乎都被断连问题折磨过。明明前后端代码都没有bug连接就是会莫名其妙断开——大概率就是心跳机制没做好。4.1 先说清楚心跳要解决的三个问题第一个是代理超时。客户端和服务端之间的网络路径上可能有Nginx、云负载均衡、运营商网关。这些中间设备为了回收闲置资源通常会定时清理空闲连接。比如Nginx默认的proxy_read_timeout是60秒如果一个连接60秒内没有任何数据传输Nginx就可能把它断开。你明明在线却被网关“误杀”了。第二个是死连接检测。手机断网、WiFi切换、电脑休眠、客户端进程被杀这些情况TCP层往往感知不到——对端已经不在线了连接在操作系统里还挂着。如果不做心跳检测服务端永远不会知道这个用户已经“失踪”继续往死连接上推消息消息直接丢失用户侧毫无感知。第三个是网络质量监控。心跳的往返时间RTT本身就是一种网络质量指标如果心跳连续超时说明链路已经不稳定这时候主动重连比被动等待更合理。4.2 两种主流心跳实现方案对比心跳实现大致有两类思路一类是协议层自带的心跳。WebSocket协议本身定义了Ping/Pong控制帧一端发送Ping帧对端必须回Pong帧。浏览器原生WebSocket API不支持主动发送Ping但服务端可以向客户端发送Ping帧浏览器会自动回Pong。这个方案的好处是干净不会污染业务消息缺点是浏览器端想主动控制不太方便。另一类是应用层的心跳。客户端定期发送一条业务消息比如{type: heartbeat, timestamp: ...}服务端收到后知道这个连接还活着也可以再回复一条确认消息。这类消息和普通业务消息走同一个通道实现简单逻辑可控。我在实战中的做法是两者结合服务端通过Spring的Scheduler给每个连接周期性发送Ping帧频率设35秒一次原因后面说。客户端额外维护一个看门狗定时器每30秒发送一条应用层心跳同时记录最后一次收到任何消息的时间如果60秒没有收到任何服务端数据包括Pong和业务推送判定链路异常主动重连。4.3 服务端心跳的完整代码如果用Spring的TextWebSocketHandler维护一套连接池并定时发Ping核心代码如下Component public class HeartbeatWebSocketHandler extends TextWebSocketHandler { // 管理所有在线连接 private static final MapString, WebSocketSession SESSIONS new ConcurrentHashMap(); Scheduled(fixedRate 35000) public void sendHeartbeat() { long now System.currentTimeMillis(); for (Map.EntryString, WebSocketSession entry : SESSIONS.entrySet()) { WebSocketSession session entry.getValue(); if (session.isOpen()) { try { // 发送Ping帧协议层心跳 session.sendMessage(new PingMessage(ByteBuffer.wrap(new byte[0]))); } catch (IOException e) { // 发送失败说明连接已不可用 handleDisconnect(entry.getKey()); } } } } Override public void afterConnectionEstablished(WebSocketSession session) { SESSIONS.put(session.getId(), session); } Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) { SESSIONS.remove(session.getId()); } }4.4 客户端心跳的完整代码前端这边我习惯用一个独立的HeartbeatManager类来管理class HeartbeatManager { constructor(ws, options {}) { this.ws ws; this.heartbeatInterval options.heartbeatInterval || 30000; // 30秒发一次心跳 this.maxIdleTime options.maxIdleTime || 60000; // 60秒无任何消息则判死 this.timer null; this.lastReceivedTime Date.now(); this.onTimeout options.onTimeout || null; } start() { // 监听所有消息刷新最后接收时间 this.ws.addEventListener(message, () { this.lastReceivedTime Date.now(); }); this.timer setInterval(() { const now Date.now(); if (now - this.lastReceivedTime this.maxIdleTime) { // 超过时间没有收到任何服务端消息判定链路异常 console.warn([心跳] 链路长时间无数据触发重连); if (this.onTimeout) this.onTimeout(); this.stop(); return; } // 发送应用层心跳 if (this.ws.readyState WebSocket.OPEN) { this.ws.send(JSON.stringify({ type: HEARTBEAT, ts: now })); } }, this.heartbeatInterval); } stop() { if (this.timer) { clearInterval(this.timer); this.timer null; } } }4.5 心跳参数为什么是35秒和60秒这里的关键是和你所处的代理环境联动。如果前置Nginx配了proxy_read_timeout 60s你的心跳间隔至少要比60秒小否则代理会在下次心跳到来之前先把连接关了。我习惯留出25%以上的余量所以35秒的心跳对应60秒的代理超时。但有一种情况要注意心跳越频繁消耗的资源越多。对几千个在线连接来说每多一轮心跳就是几千条消息。如果你确认链路路径上没有严格的空闲超时可以把间隔放宽到50~60秒。核心原则是心跳间隔 (链路上最小的空闲超时时间) × 0.6~0.7。提示如果你发现连接总是在固定时间点断开先别在代码里找原因。去查一下你前面所有代理的read timeout和idle timeout配置这个概率远大于代码bug。4.6 心跳失败后的恢复策略心跳检测出来连接死了接下来就是重连。重连不是简单地重新new WebSocket(url)就完事几个细节值得注意指数退避重连第一次重连失败后等2秒再试第二次等4秒第三次等8秒……最多不超过30秒。防止服务端还没恢复时你几万个客户端同时疯狂重连把服务端砸垮。重连后重新订阅如果是STOMP协议重连后要重新SUBSCRIBE原来的主题如果是原生WebSocket业务上往往也需要重新发送一次“上线通知”。业务消息补偿重连成功后客户端需要请求一次“离线期间遗漏的数据”。我通常是在重连握手时带上lastMessageId或lastTimestamp服务端把该时间点之后的消息补发。5. 消息格式设计不做好后面全在还债WebSocket本身对消息内容没有格式限制你可以发纯文本、发二进制流、发JSON、发自定义协议。但正因为太自由很多人一开始不重视消息结构的规范等项目大了简直是一场灾难。5.1 我踩过的“口口相传”格式坑有个项目一开始前后端约定传纯字符串比如hello、online。后来需求增加要区分消息类型、要带发送人ID、要加时间戳结果字符串越拼越长解析逻辑全用split一团糟。后来重构就把所有消息统一成JSON结构{ type: MESSAGE, payload: { fromUserId: u001, toUserId: u002, content: 你好, timestamp: 1690000000000 }, traceId: a1b2c3d4 }这里type是消息类型payload是业务数据traceId用于全链路追踪。三类字段各司其职路由靠type业务靠payload排障靠traceId。5.2 枚举消息类型不要魔法字符串消息类型一定要集中枚举管理前后端各自维护一份对照表。我常用的一套类型包括type含义方向HEARTBEAT应用层心跳双方向JOIN_ROOM加入房间/群组客户端→服务端LEAVE_ROOM离开房间/群组客户端→服务端CHAT_MSG聊天消息双方向SYSTEM_NOTICE系统通知服务端→客户端ERROR错误信息服务端→客户端ACK消息确认双方向5.3 可靠投递WebSocket没有“消息已读”功能还有一个容易产生的误解WebSocket建起来之后发一条消息就一定能收到吗不一定。WebSocket协议本身只在TCP层面保证“数据是否被对端内核协议栈接收”不保证“业务层是否成功处理”。如果网络抖动导致部分帧丢失尤其TCP连接半开时业务上可能悄悄丢消息。所以对重要消息我习惯外加一层确认机制客户端收到消息后回一条{type: ACK, messageId: xxx}。服务端发送消息时带上messageId如果在规定时间内没收到ACK就走补偿逻辑重推或者标记未达。客户端发送消息时同样带上messageId服务端处理完成后回ACK客户端据此知道发送成功。这层机制的代码量不大但对订单通知、支付结果这类不能丢的业务场景是必须的。5.4 二进制消息和文本消息的选择如果只是传JSON、XML这些可读文本文本帧足够了。但如果要传图片、音视频流、压缩数据用二进制帧更高效。二进制的优势是不用经过字符串编码转换长度更短CPU开销更低。浏览器端用Blob或ArrayBuffer处理服务端Java里用ByteBuffer。如果业务有大量高频点位数据比如实时位置上报我会在应用层做一层简单压缩比如用MessagePack或者直接拼二进制结构体带宽消耗能降一个数量级。6. 连接管理实战在线状态、分组广播、单点推送的实现套路一旦在线用户多起来就绕不开“怎么管理连接”这个问题。Java里最经典的做法是维护一个连接池用ConcurrentHashMap存用户标识和Session的映射。但真正难的不是Map本身而是Map之外的一系列事情。6.1 连接注册与用户身份绑定WebSocket握手阶段可以通过URL参数、Header或者Subprotocol传递身份信息。最常用的做法Override public void afterConnectionEstablished(WebSocketSession session) { // 从握手阶段解析用户ID String userId (String) session.getAttributes().get(userId); // 注意一个用户可能多端登录一个userId对应多个session SESSIONS.computeIfAbsent(userId, k - new CopyOnWriteArrayList()) .add(session); // 更新在线状态 userStatusService.markOnline(userId); }这里我故意用了ListWebSocketSession而不是单个Session存user→session的映射——因为一个用户可能在浏览器、手机App、桌面端同时在线。如果你只存一个其他端会被错误顶下线。6.2 分组广播维护一套自己的Room体系很多业务消息有范围属性只发给某个聊天室的人、只发给某个项目组的人。我不建议靠遍历所有连接来过滤而应该建立房间表结构public class RoomManager { // roomId - 房间内的用户ID集合 private final ConcurrentHashMapString, SetString roomUsers new ConcurrentHashMap(); // roomId - 房间内的session集合从用户ID再映射到连接 private final ConcurrentHashMapString, SetWebSocketSession roomSessions new ConcurrentHashMap(); public void joinRoom(String roomId, String userId, WebSocketSession session) { roomUsers.computeIfAbsent(roomId, k - ConcurrentHashMap.newKeySet()).add(userId); roomSessions.computeIfAbsent(roomId, k - ConcurrentHashMap.newKeySet()).add(session); } public void broadcastToRoom(String roomId, Object message) { SetWebSocketSession sessions roomSessions.getOrDefault(roomId, Set.of()); for (WebSocketSession session : sessions) { if (session.isOpen()) { // 发送 } } } }房间的进出管理有个坑用户断开连接时要记得把他从所有房间里摘出去否则房间的session集合会不断膨胀逐渐变成内存泄漏。所以断开处理逻辑里除了移除连接池还要遍历一遍他所在的所有房间。6.3 单点推送用STOMP的convertAndSendToUser如果是Spring Boot的STOMP模式单点推送有更优雅的方式messagingTemplate.convertAndSendToUser( userId, /queue/private, payload );这个方法的底层逻辑是给用户名加上simpUser前缀订阅地址变成/user/{userId}/queue/private只有认证过的对应用户才能收到。省去了你自己维护user→session映射的功夫。6.4 断线时的清理最容易被忽视的一步我在代码评审时看过很多次afterConnectionClosed里只简单执行了session.close()没有做任何清理。这样做短时间看不出来问题几天之后隐患就爆发了连接池里有大量僵尸连接内存和句柄只增不减。在线人数统计越来越虚高。房间广播时遍历到一堆已关闭的Session打印大量WARN日志拖慢系统。正确做法是在连接关闭时把下面这几件事都做了Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) { String userId (String) session.getAttributes().get(userId); // 1. 从用户连接的session列表移除 // 2. 从所有房间的session集合移除 // 3. 更新在线状态为离线 // 4. 清理内存里为该连接缓存的数据(未发送消息缓冲区等) }7. 断线重连与异常恢复把不可靠的网络变成可靠的体验WebSocket连接本质是长TCP连接这决定了它一定会遇到断线。与其祈祷网络稳定不如把断线恢复设计成系统的一个标准能力。7.1 前端的自动重连封装下面这段代码我改造过很多次最终沉淀为一个比较稳定的版本class ReconnectingWebSocket { constructor(url, options {}) { this.url url; this.options options; this.maxRetries options.maxRetries || 10; this.baseDelay options.baseDelay || 2000; this.ws null; this.retryCount 0; this.connect(); } connect() { this.ws new WebSocket(this.url); this.ws.onopen () { this.retryCount 0; this.options.onOpen this.options.onOpen(this.ws); }; this.ws.onclose () { this.options.onClose this.options.onClose(); this.scheduleReconnect(); }; this.ws.onerror () { // 先关闭由onclose统一触发重连 this.ws.close(); }; this.ws.onmessage (event) { this.options.onMessage this.options.onMessage(event); }; } scheduleReconnect() { if (this.retryCount this.maxRetries) { console.error([重连] 超过最大重试次数停止); if (this.options.onMaxRetries) this.options.onMaxRetries(); return; } // 指数退避 const delay Math.min( this.baseDelay * Math.pow(2, this.retryCount), 30000 ); this.retryCount; console.log([重连] ${delay}ms后进行第${this.retryCount}次尝试); setTimeout(() this.connect(), delay); } send(data) { if (this.ws this.ws.readyState WebSocket.OPEN) { this.ws.send(data); } else { // 连接没准备好时要缓存或丢弃由业务决定 console.warn([发送] 连接尚未就绪消息未发送); } } }7.2 服务端的状态恢复session丢了怎么办连接断开时服务端的Session对象已经没了。用户重连成功后服务端需要明确知道“这个连接是新的”而不是继续复用旧状态的残留数据。我实践中常做的几件事生成新的sessionId旧session对应的未确认消息队列转移到新session上。重新加载用户在线的业务上下文比如他订阅了哪些分组、待推送的消息有哪些。推送一个SESSION_RECOVERED事件让前端知道自己已经连上并恢复了哪些状态。7.3 客户端如何补拉离线消息重连成功不等于数据全补回来了。断线期间产生的新消息需要一次定向补偿。我常用的协议如下// 重连成功后发一条补偿请求 // lastMsgId在本地存储中维护每次收到服务端消息都更新 const snapshot localStorage.getItem(ws_last_msg_id); ws.send(JSON.stringify({ type: REPLAY_REQ, payload: { lastMsgId: snapshot || null, limit: 100 } }));服务端收到后从消息库里查出这个id之后的消息批量推送客户端把消息按时间戳顺序合并进本地状态再更新lastMsgId。7.4 重连为什么会把服务端搞挂重连风暴几百上千个客户端同时断线再同时重连服务端瞬间会涌进大量握手请求。这种“重连风暴”我见过一次当时线上服务直接雪崩。预防措施就是前面提的指数退避多端重连的时间错开不要齐刷刷地发起。另外一个是服务端的握手限流。用令牌桶或信号量限制单位时间内的握手数量超出部分的连接请求直接拒绝让客户端等一会儿再试。这个在小系统上不是必需品但一旦用户量上万这就是保命手段。8. 流量控制和粘包问题高并发下必须提前设计的机制WebSocket消息不是“发一次就完整收到一次”的天然保证吗其实不然。高并发下有几个隐藏问题值得提前设计。8.1 粘包与拆包流式传输中多个消息可能在底层被合并成一个数据块发送或者一个消息被拆分成多个数据块。WebSocket协议本身有帧的概念协议栈会帮你把一帧一帧的数据还原到消息边界所以应用层通常不会遇到HTTP那种完全需要自己拆包的粘包问题。但有一种情况例外如果消息太大几MB的大文件虽然协议支持分片但浏览器和很多服务端的默认缓冲区有限。我在传输大对象时习惯主动分片const CHUNK_SIZE 16 * 1024; // 16KB一片 const fileBuffer new Uint8Array(fileArrayBuffer); for (let offset 0; offset fileBuffer.length; offset CHUNK_SIZE) { const chunk fileBuffer.slice(offset, offset CHUNK_SIZE); ws.send(chunk); } // 发送结束标记 ws.send(JSON.stringify({ type: FILE_END }));服务端按流式方式接收重组不做整体缓冲内存占用可控很多。8.2 下游消费能力跟不上时的背压策略一个比较经典的场景服务端从消息队列里拿消息推给WebSocket客户端如果客户端处理速度慢或者网络差消息会在服务端的发送缓冲区里越积越多。这时候靠TCP的滑动窗口会兜底一部分但业务层要有兜底策略。我常用的三种策略丢弃非关键消息实时行情类的点位数据跳几个旧点位问题不大优先推最新的。断开最慢的客户端如果发送缓冲区持续超过阈值这个客户端大概率已经“假死”了不如断掉让它重连拉状态。按条数限制堆积每个session的未确认消息超过一定数量比如200条就强制断开重连。8.3 连接数的上限怎么评估理论上一个TCP服务端能承载的连接数受文件描述符上限影响ulimit -n可以调高到十万甚至百万级别。但每个连接的内存开销不是0Java里一个WebSocketSession大约几KB到几十KB取决于缓冲区配置一万个连接就可能是几百MB内存。做容量规划时我一般这么估算单节点WebSocket连接数上限 可用内存 / (单连接内存开销 平均发送缓冲占用)比如8GB内存分配给WebSocket服务单连接按20KB估算理论上限是8G/20KB约40万连接。但还要留足JVM堆外内存和业务内存的余量实际压测下来单节点稳定跑2~5万连接是合理的区间。过了这个量级就该上集群了。9. 集群环境下的方案选型用户连在不同节点怎么办单机版的连接池方案在集群下会失效因为用户A连的是节点1用户B连的是节点2A要发消息给B消息路由不过去。9.1 三种经典集群方案对比方案实现思路优点缺点Sticky Session 本地路由负载均衡固定某个用户到某个节点节点内存管理自己用户的连接实现简单无额外依赖节点宕机时该节点的连接全断扩展需要重建连接Redis Pub/Sub广播节点收到消息后发布到Redis频道所有订阅节点收到后查本地是否有目标连接代码可控Redis现成所有消息广播到所有节点流量浪费离线消息还要另做消息队列定向投递消息先进MQ路由到目标节点削峰、可靠链路变长延迟增加运维组件多9.2 我用的方案Redis Channel做路由我个人最常用的是Redis Pub/Sub因为大部分项目已经依赖Redis了不需要额外引入组件。核心思路public class ClusterMessageRouter { private final RedisTemplateString, String redisTemplate; private final MapString, WebSocketSession localSessions; // 发送消息给指定用户 public void sendToUser(String targetUserId, String message) { // 先检查本地有没有 if (localSessions.containsKey(targetUserId)) { localSessions.get(targetUserId).sendMessage(message); return; } // 不在本地发布到Redis频道 redisTemplate.convertAndSend(ws.route, JSON.toJsonString(new RouteMessage(targetUserId, message))); } // 每个节点启动时订阅同一个频道 public void listenChannel() { redisTemplate.getConnectionFactory().getConnection().subscribe( (message, pattern) - { RouteMessage route JSON.parseObject(message, RouteMessage.class); WebSocketSession session localSessions.get(route.getUserId()); if (session ! null session.isOpen()) { session.sendMessage(route.getMessage()); } }, ws.route.getBytes() ); } }这种方案有个需要容忍的点不是目标用户的节点也会收到这条Redis消息只是发现本地没有该用户就丢弃。在用户总量不大、路由消息量可控时问题不大如果消息量巨大建议改成定向MQ投递或者用Redis Cluster的channel粒度做更精细的路由。9.3 跨节点的在线状态维护集群环境下“用户是否在线”不能再靠节点的本地Map得放到共享存储里。Redis的Hash结构比较合适hash: user_online field: userId value: { nodeId: node-1, sessionId: s-123, onlineTime: 1690000000000 }在线状态带上节点信息后面做广播、推送、离线补偿都能直接用。下线时清除hash里的字段同时发一个离线事件。10. 安全加固认证、鉴权、防滥用一个都不能少WebSocket连接不是HTTP那样“一次请求一次鉴权”的轻量操作它建立后就是一个常驻通道安全设计的缺失会带来持续风险。10.1 握手阶段的认证最常见的做法是让客户端在URL里带token不过token放URL容易被日志记录更稳妥的是放在Sec-WebSocket-Protocol头里自定义子协议或者放在握手Cookie里。服务端在握手流量进入业务代码之前完成校验public class AuthHandshakeHandler implements HandshakeInterceptor { Override public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, MapString, Object attributes) { String token extractToken(request); if (token null || !authService.validate(token)) { response.setStatusCode(HttpStatus.UNAUTHORIZED); return false; } // 解析出用户ID放入attributes后续Handler可直接用 attributes.put(userId, authService.getUserId(token)); return true; } }这里有个容易被忽视的细节握手阶段的鉴权只是“入场券”连接建立后的每条业务消息也要按类型做权限校验不能默认“连接过了就全信任”。10.2 常见WebSocket攻击面我在安全评审中会重点查这么几个点跨站WebSocket劫持攻击者在别的网站上构造WebSocket连接如果服务端不校验Origin头攻击者的网站就能向你的服务端发消息。握手阶段必须校验Origin是否在白名单内。消息注入/超大消息比如有人故意发几十MB的文本把内存打爆。服务端要限制单条消息大小。消息风暴客户端以极快频率发消息服务端要有限流。可以用令牌桶算法每秒最多允许N条消息超出直接断开或丢弃。心跳伪装如果只靠心跳保活攻击者用程序定时发包就可以一直挂着连接不做事。配合业务侧的活跃度检测更稳。10.3 生产环境的WSS部署要点生产环境一定要用wss://TLS加密的WebSocket否则所有消息以明文在网络上传输安全性等于裸奔。WSS的部署通常有两种前置Nginx配置TLS再反向代理到后端的ws://端口。负载均衡器如云SLB/ALB直接支持WSS证书挂在LB上。Nginx关键配置片段map $http_upgrade $connection_upgrade { default upgrade; close; } upstream websocket_backend { server 127.0.0.1:8080; keepalive 64; } server { listen 443 ssl; server_name example.com; ssl_certificate /path/to/cert.pem; ssl_certificate_key /path/to/key.pem; location /ws { proxy_pass http://websocket_backend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_set_header Host $host; # 心跳间隔建议保持在60秒以下 proxy_read_timeout 60s; proxy_send_timeout 60s; } }提示很多踩坑案例里用户发现“明明加了proxy_read_timeout 60s还是断连”是因为HTTP/1.1的Upgrade机制没配对Connection头没有正确传递。上面用map按$http_upgrade动态设置Connection值是标准做法照抄即可。11. WebSocket与HTTP/2、Socket.IO的对比什么时候用哪个WebSocket不是唯一的实时通信方案。我在技术选型时都会做一轮对比防止“手里有锤子看什么都像钉子”。11.1 与HTTP/2 Server Push对比HTTP/2引入了Server Push和Stream机制确实可以在一个TCP连接上并行传输多个资源但它的设计目标是优化资源加载不是双向实时消息。Server Push只能由服务端主动推资源客户端不能自由上行流式数据。所以对“客户端持续发送、服务端持续下发”的即时通讯场景HTTP/2替代不了WebSocket。11.2 与SSEServer-Sent Events对比SSE是服务端单向推送客户端用EventSource接口接收。它的优势是基于HTTP没有跨域复杂度自动重连机制内建文本消息友好。但它是半双工的客户端不能通过SSE给服务端发数据还得再发HTTP请求。如果你只需要服务端单向推送比如行情、新闻、公告SSE可能是更省事的选择。如果双向交互频繁聊天室、协同编辑、多人游戏操作选WebSocket没有悬念。11.3 Socket.IO要不要用Socket.IO在WebSocket之上做了很多便利封装自动降级WebSocket不行就切XHR轮询、命名空间、房间、ACK、断线自动重连。另一方面它也带来了额外的包体开销和抽象层排障链路变长。我的看法是中小型项目、团队前端资源紧、需要快速逼功能选Socket.IO很香。大型项目要求精细把控网络行为和性能或者团队对底层行为有要求直接上原生WebSocket 自己封装运营基础设施。11.4 一张选型决策表需求特征推荐方案服务端单向下发消息客户端无上行需求SSE双向高频互动低延迟要求高WebSocket快速开发容忍额外依赖Socket.IO只需要HTTP短请求能解决的保持HTTP别上WebSocket这里想提醒一点不要为了“用新技术的快感”而上WebSocket。如果需求只是每分钟刷一次数据HTTP轮询或者SSE更简单运维成本、调试成本都低得多。架构上最贵的开销不是CPU和内存是多引入一个组件后带来的心智负担和故障面。12. 一些最后的经验碎碎念文章写到这里已经很长了最后分享几条我个人做WebSocket项目沉淀下来的体会希望能帮你少走一些弯路。一先做端到端连通性测试再做功能开发。我习惯拿到WebSocket需求时第一件事不是写业务代码而是先搭一个最简单的连接、发送、回显链路把中间的网络路径跑通。因为WebSocket部署涉及代理层、负载均衡、DNS等一堆环节网络链路不通时业务代码写得再漂亮也白搭。二把连接生命周期管理当作一个独立模块来设计。别把连接建立、心跳、重连、退出这些逻辑散落在业务代码里。我用这段时间反复迭代后意识到断线重连、心跳保活、消息补偿这些能力本质上是通用的把它们封成独立的服务前端有ReconnectingWebSocket后端有统一的SessionManager业务开发只需要关注消息内容本身。三监控体系一定要有连接层指标。我通常会监控这么几项当前在线连接数、每秒握手成功率、每秒消息收发量、平均心跳RTT、连接断开原因分布超时断开/主动断开/异常断开、被代理层断开的连接占比。没有这些指标排查线上问题时你就像在黑屋子里找开关。四不要在生产环境用SockJS的降级能力“隐形”。SockJS的降级模式在本地测试时几乎不会被触发但如果线上用户网络环境复杂你会看到一部分用户莫名走的是XHR轮询而不是WebSocket实时性下降还不容易察觉。所以我有段时间直接把SockJS禁用强制走原生WebSocket行为可预期排障也直接。五WebSocket上线前把压测做了。用WebSocket压测工具比如免费的websocat、开源的Autobahn测试套件、商用压测平台里的WebSocket协议支持模拟几千个并发连接把服务端的连接数上限、消息吞吐量、内存水位摸一遍再上线。我见过不止一次“代码看着没问题一上线几千人同时用直接OOM”的事故都是因为没做压测。WebSocket看似简单但真正落地到稳定状态需要处理的事情比协议本身的知识多得多。希望这篇围绕原理、心跳、重连、集群、安全的完整梳理能让你在自己的实时通信项目上少一些摸索的时间。