ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Java WebSocket从零到生产:实战心跳机制与高并发连接管理

2026/10/2 4:52:08 拓冰建站 浏览量
Java WebSocket从零到生产:实战心跳机制与高并发连接管理 1. 为什么是WebSocket当你需要服务器主动找上门时在Java Web开发里摸爬滚打几年的人大概率都经历过这样一个阶段接到一个实时推送需求第一反应是轮询第二反应是长轮询第三反应才是WebSocket。我刚开始接触这块时也是这样直到在一个在线客服系统里被HTTP轮询折磨得够呛——客户端每3秒发一次请求服务器明明没数据也得回一个空响应数据库连接被白白消耗网络流量也翻了好几倍。1.1 从轮询到长连接HTTP的心累WebSocket的轻松先看看最原始的短轮询是怎么工作的浏览器定时器每隔几秒发一个Ajax请求问服务器有新消息吗没有就返回空。这种方式逻辑简单但问题非常明显——大部分请求都是无效的、浪费的。即便优化成连接合并、批量查询本质上还是客户端反复敲服务器门只为了看看有没有信。长轮询稍微聪明一点客户端发起请求后服务器先hold住这个连接等到真有数据了再响应。这样请求次数少了但服务器端需要维护大量挂起的HTTP请求一旦超时又要重连连接对象堆积得多了内存和线程开销非常可观。WebSocket的思路完全不同。它把问一句答一句改成了打通一个双向管道。握手只发生一次之后服务器和客户端可以随时互相发数据数据帧只有几字节的开销没有HTTP头部的重复传输。用打电话类比轮询是每隔几分钟给对方发一条你说话了吗的短信而WebSocket是直接打通电话两边随时讲话随时听。从Java的角度看JDK标准里没有内置WebSocket客户端/服务端实现但Java EE的JSR 356规范javax.websocket已经定义得明明白白Tomcat、Jetty、Undertow这些主流容器都实现了它。Spring Boot也在此基础上提供了很好的封装。这就是我这个Java程序员今天能在这里写一篇零基础到精通长文的地基。1.2 协议核心一次握手一个帧WebSocket协议的原理其实只看两个关键动作就够了握手和数据帧。握手这一步基于HTTP升级机制客户端发起一个普通GET请求头上带着Upgrade: websocket、Connection: Upgrade、Sec-WebSocket-Key等字段。服务器验证通过后返回101 Switching Protocols响应连接协议就正式切换成WebSocket。这里有个细节值得注意——Sec-WebSocket-Key不是用来鉴权的它只是一串随机数服务器按约定拼上固定GUID做SHA1并Base64返回就是为了证明对面真的是WebSocket服务器。真正的身份校验得靠URL里的token或握手阶段的自定义Header。数据帧的结构也不复杂一个FIN标记位表示这是不是最后一帧opcode表示帧类型1是文本2是二进制8是关闭连接9是Ping10是Pong然后是长度字段和掩码标记。有个容易踩坑的点——浏览器发到服务器的帧必须做掩码处理服务器发到浏览器的帧不需要掩码。这部分协议底层由容器实现了但对理解为什么有的抓包工具看不到消息内容很有帮助。零基础的人不用纠结帧的二进制细节但必须理解一个关键推论WebSocket的连接是长久的、有状态的所以服务器端必须自己维护每个连接会话并且处理连接失效的情况。这也是后面心跳机制和排错章节的铺垫。2. 从零搭建Java WebSocket服务端原生API跑通全过程我见过很多新手学WebSocket上来就配Spring的STOMP配RabbitMQ搞得非常复杂。其实入门阶段完全不需要这些用Java原生注解API就能在半小时内跑通一个实时聊天Demo。等你理解了连接生命周期再往Spring生态、集群方向扩展会顺畅得多。2.1 依赖引入与项目结构如果你的项目是Spring Boot环境开发期最省事的做法是引入spring-boot-starter-websocket它会带上Tomcat的WebSocket实现。如果是不依赖Spring的纯Servlet项目直接在pom里加上Tomcat的websocket依赖即可。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-websocket/artifactId /dependency项目结构上我建议按端点类配置类工具类来拆。端点类负责业务回调配置类负责注册工具类负责管理Session。千万不要把几百行逻辑全塞进一个类里后面维护会很难受。2.2 ServerEndpoint注解一小时上手的核心用法在Java原生API里定义一个WebSocket服务端端点只需要在上面的注解里声明路径和回调方法。下面的例子是单个连接的收发回声import javax.websocket.*; import javax.websocket.server.ServerEndpoint; import java.io.IOException; ServerEndpoint(/echo) public class EchoEndpoint { OnOpen public void onOpen(Session session) { System.out.println(连接打开session id session.getId()); } OnMessage public void onMessage(String message, Session session) throws IOException { System.out.println(收到消息 message); session.getBasicRemote().sendText(回声 message); } OnError public void onError(Session session, Throwable error) { error.printStackTrace(); } OnClose public void onClose(Session session, CloseReason reason) { System.out.println(连接关闭原因 reason.getReasonPhrase()); } }在Spring Boot中使用时有个我见过无数人踩过的坑直接写ServerEndpoint的类默认不会被注入Spring容器管理。如果你在端点类里想注入Service会得到一个空指针。必须在配置类里注册一个ServerEndpointExporter的Bean让它自动发现并注册所有ServerEndpoint注解的类并且在该类上加上Component这样Spring才能帮你完成依赖注入。import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.socket.server.standard.ServerEndpointExporter; Configuration public class WebSocketConfig { Bean public ServerEndpointExporter serverEndpointExporter() { return new ServerEndpointExporter(); } }注意如果项目是打War包部署到外部Tomcat的ServerEndpointExporter反而可能会冲突。此时不要加它直接依赖Tomcat的类扫描。这个差异官方文档提过但很容易被忽略我当年在这个问题上卡了将近一下午。2.3 连接生命周期打开、消息、错误、关闭上面四个注解对应WebSocket连接的四个阶段理解它们比记住代码重要。OnOpen是握手成功后服务器执行的回调。这个阶段最适合做两件事保存Session到全局Map、给客户端发一条欢迎消息。Session是服务器和这个客户端的唯一通道它的生命周期等于连接生命周期所以必须妥善保存。OnMessage是业务逻辑的核心入口。注意这里的方法参数可以是String、byte[]、Reader、InputStream甚至自定义对象通过decoder处理。参数里可以注入Session也可以单独注入。如果消息处理抛异常会走OnError而不是直接断开这是我实际调试中发现的一个细节——很多人以为错误会导致立即关闭其实容器会先回调onError再根据情况决定是否关闭连接。OnClose负责清理资源。这里最常见的错误是忘了从Map里移除Session导致僵尸连接越积越多内存泄漏。OnError要特别注意一个点——出错之后最好主动调用session.close()否则连接状态不明确客户端那边可能一直以为还连着。2.4 多人在线广播SessionMap的设计单回声Demo没多大实际意义真实项目里更常见的是一个用户上线所有在线用户都能收到通知。这需要用静态Map维护全部活跃Session再遍历发送。import javax.websocket.*; import javax.websocket.server.PathParam; import javax.websocket.server.ServerEndpoint; import java.io.IOException; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; ServerEndpoint(/chat/{username}) public class ChatEndpoint { private static final MapString, Session ONLINE_USERS new ConcurrentHashMap(); OnOpen public void onOpen(Session session, PathParam(username) String username) { ONLINE_USERS.put(username, session); broadcast(用户 username 上线了当前在线 ONLINE_USERS.size()); } OnMessage public void onMessage(String message, Session session, PathParam(username) String username) { broadcast(username message); } OnClose public void onClose(Session session, PathParam(username) String username) { ONLINE_USERS.remove(username); broadcast(用户 username 下线了当前在线 ONLINE_USERS.size()); } OnError public void onError(Session session, Throwable error) { error.printStackTrace(); } private void broadcast(String message) { for (Map.EntryString, Session entry : ONLINE_USERS.entrySet()) { try { entry.getValue().getBasicRemote().sendText(message); } catch (IOException e) { e.printStackTrace(); } } } }这里有个并发安全细节消息发送应该用session.getBasicRemote()还是session.getAsyncRemote()单条小消息用Basic没问题但它是同步阻塞的如果一条消息发送缓慢会阻塞当前线程。高并发场景建议用AsyncRemote异步发送每条消息几十毫秒内就返回发送结果真正的I/O由容器线程池处理。如果是大量广播还可以配合BatchedMessage批量发送减少系统调用次数。3. 心跳机制不写三天后连接就断超时检测的设计思路热词里websocket心跳机制实现排得很靠前说明这个问题困扰了很多人。我敢说大多数Java开发者写过WebSocket Demo但很少有人在项目上线前认真设计心跳机制直到生产环境频繁掉线才回头补课。3.1 为什么必须有心跳网络设备不认静默连接WebSocket连接虽然建立后是长活着的但它本质上是一条TCP连接。TCP连接有超时机制更重要的是中间经过的负载均衡器、防火墙、运营商网络设备都会清理空闲太久的连接。Nginx的proxy_read_timeout默认60秒也就是说如果60秒内后端或客户端没有任何数据传输Nginx可能主动切断连接。在实际项目里用户可能只是把页面挂着不动几分钟甚至半小时都不操作。这时服务器和浏览器之间的数据通路早就被中间设备悄悄掐断了但双方都没有立刻感知到。等服务器想推送消息时才发现连接已经死了或者客户端想发消息已经发不出去了。这就是所谓连接但不接收信息的一大元凶。心跳机制就是为了对抗这种问题每隔一段时间主动发一个我还活着的信号让连接保持活跃同时探测连接是否真的可用。3.2 协议级Ping/Pong vs 业务级心跳WebSocket协议本身定义了Ping帧和Pong帧。浏览器端的WebSocket API没法直接发协议级Ping帧但服务器端可以通过容器的底层API发。比如Tomcat的WsSession暴露了sendPingMessage方法Jetty也有对应API。不过在实际Java项目里我见过更多的方案是业务级心跳约定一个特殊的消息格式比如客户端每隔30秒发一个文本帧{type:ping}服务器收到后回复{type:pong}。这种方案的好处是跟语言无关、协议无关前端JS处理后端Java解析都简单而且可以在心跳包里携带额外信息比如客户端当前在线状态、唯一标识。还有一种更省事的方案只依赖服务器端的空闲超时检测不给客户端发送任何东西。也就是定期扫描那些超过N秒没有任何消息的连接把它们强制关闭。这样服务器端能清理僵尸连接但解决不了中间设备已经切断连接而服务器不知情的问题——因为服务器根本不会主动发数据。我的建议是两层一起做客户端每隔30秒发送一次业务心跳消息。服务器端维护每个Session的最后心跳时间超过60秒未收到任何消息就判定超时主动关闭连接。服务器端如果有自己的空闲连接清理线程比如容器级别的maxIdleTimeout也一起配置兜底。3.3 服务端超时检测的具体实现Java的javax.websocket API里Session有个setMaxIdleTimeout(long)方法单位毫秒设置为0表示永不超时。这个方法属于容器级别的空闲超时只要有数据帧经过就会刷新计时。用它可以做一个基础防线但它不区分消息类型业务上还需要更精细的控制。下面是一个简单的服务端心跳扫描实现import javax.websocket.Session; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; public class HeartbeatManager { private static final MapString, Session SESSION_MAP new ConcurrentHashMap(); private static final MapString, Long LAST_HEARTBEAT new ConcurrentHashMap(); private static final ScheduledExecutorService SCHEDULER Executors.newSingleThreadScheduledExecutor(); public static void start() { SCHEDULER.scheduleAtFixedRate(() - { long now System.currentTimeMillis(); for (Map.EntryString, Long entry : LAST_HEARTBEAT.entrySet()) { if (now - entry.getValue() 60_000) { Session session SESSION_MAP.get(entry.getKey()); if (session ! null session.isOpen()) { try { session.close(); System.out.println(心跳超时关闭连接 entry.getKey()); } catch (Exception e) { e.printStackTrace(); } SESSION_MAP.remove(entry.getKey()); LAST_HEARTBEAT.remove(entry.getKey()); } } } }, 10, 10, TimeUnit.SECONDS); } public static void register(Session session, String userId) { SESSION_MAP.put(userId, session); LAST_HEARTBEAT.put(userId, System.currentTimeMillis()); } public static void heartbeat(String userId) { LAST_HEARTBEAT.put(userId, System.currentTimeMillis()); } }这里的细节在于扫描间隔和超时阈值的关系。如果客户端30秒发一次心跳服务端60秒判定超时扫描线程10秒跑一次就能容忍一次心跳丢失但要连续丢两次才判死容错比较合理。如果你把超时时间设成和心跳间隔一样短网络稍微抖动一下就会误杀连接体验会非常差。4. 连接但不接收信息我排查过的三个真实案例热词里有个websocket连接但不接受信息更精确的说法是连接建立了但收不到消息这是我在社区被问得最多的一类问题。整理一下我自己遇到的和帮别人解决的三个典型场景每个都展现了完全不同的根因。4.1 案例一消息推给了旧Session现象是用户A刷新了页面重新建立了WebSocket连接但服务器往旧连接上推送消息前端当然收不到。排查过程先看服务端日志确认消息确实发出了且没有抛异常。再在Chrome DevTools的Network面板里找到WebSocket连接看它的Message状态。如果连接显示已关闭说明推送目标是历史连接。查看SessionMap的注册和清理逻辑发现用户A重连时新的Session覆盖了Map里的旧值但旧连接对象还留在别的地方或者广播时遍历的是另一份Map两边数据不一致。根因其实是Session生命周期管理不严格。解决方案也简单Map的key不要用容易重复的用户ID而是用Session.getId()或者在OnOpen里先做一次removePrevious(userId)把同一个用户旧的Session主动关闭再注册新的。更彻底的做法是用SessionListener监听容器内Session的销毁事件统一清理。4.2 案例二服务端抛异常客户端却毫无感知另一个典型场景是前端连上了也显示WebSocket已连接但消息就是不来。服务端日志里也没有明显的业务错误。排查过程拿起Chrome DevTools的Console和Network翻了一遍发现WebSocket的帧里什么也没有。改用抓包工具wscat直接连服务器发同样的消息居然能收到回复。这说明问题出在前端和服务端的某个中间环节。翻服务端日志发现一条异常栈JSON序列化报错OnMessage方法抛了JsonProcessingException。这里要说明一下javax.websocket的OnMessage方法如果抛异常容器会调用OnError回调但不会中断其他连接。问题在于如果OnError里只是打日志没关闭连接客户端那边的连接看起来还是好的但它永远等不到任何数据。而且如果异常发生在消息解码阶段decoder客户端可能连错误提示都没有直接表现为静默丢失。以后遇到连接正常但收不到消息的情况第一件事永远是在服务端打开日志看有没有异常。尤其是检查decoder/encoder类看看是不是消息对象转JSON时遇到了字段序列化问题或者编码器没有在ServerEndpoint注解里声明。4.3 案例三负载均衡器掐断了空闲连接这个案例很有代表性。某个生产环境的WebSocket服务挂在Nginx后面没配心跳用户挂机超过60秒就收不到推送了。从浏览器看连接状态仍然是OPEN但服务器发消息前端收不到直到下次刷新页面才恢复正常。排查过程先看Nginx的配置proxy_read_timeout是默认的60sproxy_send_timeout也是60s。再看TCP连接状态发现服务端和Nginx之间的连接变成CLOSE_WAIT而Nginx到浏览器的连接还保持说明Nginx已经主动断了后端连接。复现步骤很清晰连上WebSocket什么都不做等60秒尝试服务器推送失败。这种问题的正确解法是双向的Nginx配置里把proxy_read_timeout和proxy_send_timeout调大比如3600s。同时客户端加心跳保证每30秒有一次数据传输让Nginx的计时器不断刷新。另外proxy_http_version要改成1.1并且设置Upgrade相关的Header否则WebSocket的升级请求会被Nginx拒绝。我自己在写配置时踩过一个坑光调了proxy_read_timeout忘记了proxy_send_timeout从服务器往浏览器推数据超过该时间没响应一样被断。两个参数必须同时调整。5. 生产环境进阶并发推送、集群部署和前端配合跑通了本地Demo不等于能上线。生产环境要考虑并发量、多实例部署、前端断线重连每一块都是单独的深水区。5.1 会话管理别再单机Map一把梭单机部署时一个静态ConcurrentHashMap确实够用。但并发量上来以后有几个隐形问题。第一Session对象是不是线程安全的javax.websocket的Session不是严格线程安全的不能多个线程同时对同一个Session调用sendText否则可能出现数据交错或IllegalStateException。解决方式是每个Session配一个单线程执行器把发给同一条连接的N条消息串行化。Spring的ConcurrentWebSocketSessionDecorator做了类似的事可以借鉴。第二Map的value要小心空引用和腐坏连接。我见过有人在遍历Map发送时不检查session.isOpen()结果消息发到已关闭的连接上抛IOException后还把整个循环中断了。正确写法是发送前检查isOpen发送失败后从Map移除。第三如果是高并发推送可以考虑批量发送模式。Tomcat的WsSession支持通过BatchMode批量缓冲帧减少I/O次数对大量小消息很有效果。但使用批量模式时要记得flushBatch()否则消息可能一直积压到连接关闭才发出去。5.2 集群部署消息怎么跨节点路由当服务从1台变成N台时问题就来了用户A连的是节点1用户B连的是节点2节点1收到A的消息怎么推给B答案是把广播从进程内调用改成消息中间件广播。常见的方案Redis的Pub/Sub。实现简单基于发布订阅模型消息不落库适合WebSocket实时广播这种发了就不管的场景。RabbitMQ/Kafka这类MQ。功能更强可以做持久化、延时队列但架构更重。如果不想引入外部组件还可以用多个节点之间的内部HTTP通道互相转发但复杂度和可用性都不如消息中间件。用Redis Pub/Sub的核心代码思路是每个节点的WebSocket服务收到业务消息后第一件事不是直接推给本机Session而是先发布到Redis频道同时每个节点订阅同一个频道收到别的节点发布的消息后从本机SessionMap里找到目标用户推送。// 伪代码节点收到消息后的处理 OnMessage public void onMessage(String message, Session session, PathParam(userId) String userId) { // 1. 本地推送 sendToLocalUser(userId, message); // 2. 广播到Redis让其他节点也推送 redisTemplate.convertAndSend(websocket:broadcast, userId | message); } // Redis订阅回调 public void onRedisMessage(String payload) { String[] parts payload.split(\\|); sendToLocalUser(parts[0], parts[1]); }这里有个必须警惕的坑如果消息只是给单个用户推送就不应该用上面的广播所有节点方案否则每个节点都会收到并尝试推送很容易造成重复推送。正确的做法是带上目标节点标识或者用Redis的channel命名做路由比如websocket:node1这样的频道每个节点只订阅自己的频道。或者更简单用Redis的List/Stream先定位用户Session落在哪个节点再定向推送。5.3 前端JS怎么配合重连策略与心跳定时器热词里websocket js和react sse/websocket 轮询文件变化搜索量不低说明很多问题发生在前后端配合上。Java后端写得再稳前端如果不考虑断线重连体验也会稀碎。前端核心代码其实很短但有几个容易忽略的细节。function connectWebSocket() { const ws new WebSocket(ws://localhost:8080/ws?tokenxxx); ws.onopen () { console.log(WebSocket已连接); // 开启心跳 heartbeatTimer setInterval(() { if (ws.readyState WebSocket.OPEN) { ws.send(JSON.stringify({ type: ping })); } }, 30000); }; ws.onmessage (event) { // 收到消息重置重连次数 reconnectAttempts 0; console.log(event.data); }; ws.onclose () { clearInterval(heartbeatTimer); // 指数退避重连 const delay Math.min(1000 * Math.pow(2, reconnectAttempts), 30000); setTimeout(connectWebSocket, delay); reconnectAttempts; }; ws.onerror () { // onerror之后通常跟着onclose所以重连逻辑放onclose里 ws.close(); }; }两个注意点一是onerror里不要重复调用connectWebSocket因为onclose几乎一定会触发双重重连会让连接数翻倍。二是重连次数一定要重置否则退避时间会一直指数增长最后卡在30秒上限不动。前端还可以在URL参数里带上当前用户ID和token服务端在OnOpen里解析并发给客户端一个握手确认消息这样能减少重连时的身份校验逻辑。5.4 面试高频题WebSocket与HTTP的区别既然热词里有java面试题和java开发工程师面试题顺便把几个常考点梳理一遍面试时不会慌。WebSocket和HTTP是什么关系WebSocket握手阶段完全走HTTP协议升级后变成独立的TCP长连接协议。连接建立后谁可以主动发消息双方都可以。HTTP只有客户端能主动请求。为什么WebSocket省流量数据帧头只有几个字节而HTTP请求头动辄几百字节。什么时候用SSE、什么时候用WebSocket单工推送、服务器到浏览器方向为主的场景SSE更简单双向交互实时性要求高选WebSocket。如何保证消息顺序WebSocket本身是有序的基于TCP但多线程并发发送时同一个Session可能乱序需要配合单线程执行器。WebSocket和TCP的关系WebSocket是应用层协议TCP是传输层协议WebSocket帧的可靠性由TCP保证。6. 记住这些就够了会话清理、日志埋点和验收清单最后聊几个我实际操作中的习惯这部分不算高深理论但能省掉很多线上事故。6.1 会话清理的兜底方案SessionMap的清理不能只靠OnClose。原因很简单客户端断网、断电、进程被杀的时候服务器可能根本收不到关闭帧TCP半开连接也不会主动通知应用层。兜底方案就是在之前的HeartbeatManager里做定期扫描凡是超过阈值未通信的Session一律关闭删除别指望客户端自觉。另外提醒一句传统的ConcurrentHashMap是好的但如果你自己写了一个带过期时间的Map记得考虑弱引用和防内存泄漏。我见过有人用普通的HashMap在并发环境下扩容导致死循环直接CPU飙到100%。6.2 日志埋点和问题定位WebSocket的问题是出了名的难抓现场因为连接一旦断开信息就丢了。我的做法是每个Session建立时打一条带Session ID、用户ID、来源IP的日志。每次收发消息打一条带方向和消息长度的日志内容级别按需生产环境不建议打全量消息体。连接关闭时打一条带关闭原因码的日志。全局再挂一个SessionListener把所有打开/关闭事件汇总到一个监控指标里。有了这些日志再遇到收到消息但前端没反应直接按Session ID查链路很快就能定位是服务端没发出还是发出但前端没收到还是中间网络断了。6.3 上线前自检清单我每次给新项目接WebSocket前都会过一遍下面的清单握手URL有没有带鉴权参数服务端是否校验了tokenSessionMap有没有清理逻辑OnClose里是否移除了对应条目心跳机制是否已实现客户端和服务端的超时时间是否匹配Nginx或网关的read/send超时和升级Header是否配置正确是否限制了单用户的并发连接数防止同一个账号被重复登录刷爆SessionMap是否配置了最大消息大小Tomcat默认单帧不能超过8KBmaxTextMessageBufferSize要传大文件或长文本得显式调大。跨域问题前端域名和后端不一致时allowed-origins要显式配置否则浏览器会拦截握手响应。压测过没有至少用几万条消息跑一遍看内存和GC有没有明显异常。这些检查项看着零碎但每一条背后都有过真实的事故案例。WebSocket作为一种长连接协议它的坑不在建立连接而在连接的生命周期管理——什么时候该关、什么时候该踢、中间设备会不会悄悄掐断。把这些想清楚了你的Java WebSocket技能就从能跑Demo升级成能抗生产了。