
第一次被逼着做在线客服系统的时候我还在用 HTTP 定时轮询硬扛。前端每隔 3 秒发一次请求问后端“有新消息吗”后端每次都查一遍数据库回一句“没有”。结果就是服务器 CPU 飙高、用户消息延迟到骂人而真正的问题在于——HTTP 根本不是为这种场景设计的。后来把整个推送链路换成 WebSocket同样一台机器几百个客户端同时在线都稳得一批。这篇文章我从零开始讲讲 WebSocket 的原理再用 JavaSpring Boot带你从依赖配置到完整服务端实现一步步写出来同时把我在生产环境踩过的坑一并交了底。适合刚接触 WebSocket 的后端新手也适合那些已经写了几个 Demo 但总觉得差点意思的人。1. 为什么 HTTP 做不到实时通信先理解 WebSocket 解决的是什么1.1 轮询不是不能做是不划算很多初学者会觉得实时通知这种东西用 HTTP 轮询Polling不也能做吗前端定时发请求后端检查有没有新消息有就返回没有就返回空。确实能跑但一旦并线上来了问题就非常明显。我用一个实际数字来说明。假设一个在线协作白板有 500 个用户同时打开。每个用户每 3 秒轮询一次那么 1 分钟内你的后端要处理 10000 次请求其中绝大多数是无效请求——没有新数据。这些请求每 1 次要走一次完整的 HTTP 建连/断连流程每 1 次都要经过网络栈、容器线程池、业务代码最终大部分只是查了数据库然后返回空数组。随着用户数增长服务器压力是线性甚至超线性上涨的但实际吞吐的“有效数据”占比低得可怜。轮询还有一个致命问题延迟不可控。消息在第 1 秒到达服务端客户端可能在第 3 秒的轮询里拿到也可能要等到第 6 秒那次这中间用户就一直盯着旧数据。你要想让延迟降低只能缩短轮询间隔间隔越短服务器压力越大这就是轮询方案的死结。1.2 WebSocket 是一条打通的双向隧道WebSocket 跟 HTTP 不是替代关系而是互补关系。它的核心思路是客户端先通过 HTTP 发送一个特殊的握手请求服务端确认后双方之间建立一条长期存在的双向通信通道。之后服务端可以随时主动往客户端推数据客户端也可以随时发数据给服务端不再有“一问一答”的约束。我常用的一个类比是HTTP 就像打电话每次通话前要拨号、接通、说完就挂断下次再说还得重新拨号WebSocket 更像是你和大楼保安之间拉了一条专用对讲线说了一次“开始吧”这条线就一直通着两边随时可以开口说话。这个特性解决了实时场景里的几大痛点服务端可以主动推送股票行情、在线人数、聊天消息、双向交互白板协同、远程操控、长连接免去重复握手开销。理解了这一点你就明白了市面上五花八门的 WebSocket 教程最终都是在围绕“建立通道、收发消息、维护通道”这三件事做文章。2. 一次 WebSocket 连接的一生握手、帧格式与关闭2.1 握手一次伪装成 HTTP 的“敲门”WebSocket 连接开始之前客户端会先发一个普通的 HTTP 请求这个请求里带着几个特殊头。服务端看到后知道“这家伙想升级成 WebSocket 连接”于是返回 101 状态码。这个过程叫握手也是整个 WebSocket 协议里最容易被当作黑盒的部分。一个典型的握手请求长这样GET /ws/chat?tokenabc HTTP/1.1 Host: localhost:8080 Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ Sec-WebSocket-Version: 13关键在于Sec-WebSocket-Key这个字段。它是客户端随机生成的一个 Base64 字符串服务端收到后会拿它拼上一个固定的 GUID 字符串258EAFA5-E914-47DA-95CA-C5AB0DC85B11然后做一次 SHA-1 哈希再把结果 Base64 编码作为Sec-WebSocket-Accept返回给客户端。这个计算过程不同客户端和服务端库都已经封装好了但你最好亲手写一遍因为面试和排查问题时经常要用。我贴一段 Java 实现import java.security.MessageDigest; import java.util.Base64; public class WebSocketHandshakeUtil { // 协议规定固定 GUID private static final String WEBSOCKET_GUID 258EAFA5-E914-47DA-95CA-C5AB0DC85B11; public static String computeAccept(String secWebSocketKey) throws Exception { MessageDigest digest MessageDigest.getInstance(SHA-1); byte[] hash digest.digest((secWebSocketKey WEBSOCKET_GUID).getBytes(UTF-8)); return Base64.getEncoder().encodeToString(hash); } }服务端把这个值放在响应头的Sec-WebSocket-Accept里返回客户端一校验通过连接就算建立成功了。之后两边发送的数据都不再走 HTTP 报文格式而是走 WebSocket 自定义的帧格式。2.2 帧格式连接里跑的数据到底长什么样WebSocket 没有像 HTTP 那样的请求行和 Header它用的是紧凑的二进制帧。你不用把每一位都背下来但至少要知道几个关键部分。帧里有三个最重要的控制信息FIN表示这一帧是不是消息的最后一帧。大消息会被拆成多帧发送比如一个 10MB 的文件会切成多个帧最后一个帧的 FIN 才置 1。Opcode指明帧类型。常见的有1文本、2二进制、8关闭帧、9Ping、10Pong。Mask掩码位。协议强制规定客户端发给服务端的帧必须置 1服务端发给客户端的帧必须为 0。客户端发的数据会被一个 4 字节的 masking key 做异或掩码这样做的目的是防止早期代理缓存污染属于协议设计的历史原因了解即可。Payload 长度的表示是分段式的如果数据长度小于 126那 7 位直接装下如果 126 到 65535 之间第一个字节填 126后面跟 16 位长度如果更大第一个字节填 127后面跟 64 位长度。理解帧格式不是让你自己造轮子Java 里的 WebSocket 实现早就帮你封装好了。甚至 Sprint Boot 的TextWebSocketHandler直接给你handleTextMessage方法里面拿到的就是解析好的字符串。但面试被问到协议原理的时候能说出来这几位含义和只会说“WebSocket 是全双工长连接”给面试官的印象完全不一样。2.3 连接的关闭只用一条关闭帧WebSocket 连接关闭时不是直接断掉 TCP 那么简单。主动关闭的一方先发一个 Opcode 为 8 的关闭帧可以带一段状态码和原因文本比如 1000 表示正常关闭1005 表示没有收到状态码。对端收到后可以选择也回一个关闭帧然后双方各自关闭底层 TCP 连接。这个流程里有个容易踩坑的地方如果你的代码里直接调用session.close()而不等对端响应某些浏览器端可能会出现“连接异常断开”而不是“正常关闭”。实践里服务端主动踢人时最好先发一条通知消息比如“你被管理员移出房间”等个几百毫秒再发关闭帧让客户端有充足时间展示原因而不是一脸茫然地看着连接断掉。3. Java 服务端实战Spring Boot 集成 WebSocket 的两种姿势3.1 姿势一Spring 封装的 WebSocketHandler推荐Spring Boot 集成 WebSocket 最主流、也最适合做二次扩展的方式是使用spring-boot-starter-websocket依赖加上TextWebSocketHandler。优点是所有 Bean 都归 Spring 容器管理依赖注入没有坑拦截器机制也比较完善适合对接鉴权、业务逻辑分发。先把依赖加上dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-websocket/artifactId /dependency这个依赖本身已经带了内嵌 Tomcat 对 WebSocket 的支持不需要额外引入其他东西。然后写一个核心的业务处理器继承TextWebSocketHandler重写三个方法连接建立、收到消息、连接关闭。这是整个 WebSocket 服务的灵魂Component public class ChatWebSocketHandler extends TextWebSocketHandler { // 用哪个线程安全的 Map 保存会话后面会细说 private static final MapString, WebSocketSession SESSIONS new ConcurrentHashMap(); Override public void afterConnectionEstablished(WebSocketSession session) { SESSIONS.put(session.getId(), session); System.out.println([连接] session.getId()); } Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { String payload message.getPayload(); System.out.println([收到] session.getId() : payload); // 广播给所有连接 for (WebSocketSession target : SESSIONS.values()) { if (target.isOpen()) { target.sendMessage(new TextMessage(payload)); } } } Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) { SESSIONS.remove(session.getId()); System.out.println([关闭] session.getId()); } }再写一个配置类把处理器注册到指定路径上Configuration EnableWebSocket public class WebSocketConfig implements WebSocketConfigurer { Autowired private ChatWebSocketHandler chatWebSocketHandler; Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(chatWebSocketHandler, /ws/chat) .addInterceptors(new ChatHandshakeInterceptor()) .setAllowedOrigins(*); } }这里的setAllowedOrigins(*)是允许跨域访问开发阶段先放开生产环境务必改成白名单。3.2 姿势二ServerEndpoint 注解式写起来快但坑不少如果只是写个 DemoServerEndpoint方式确实更省事。只需要一个类加上注解写几个OnOpen、OnMessage、OnClose方法就完事了。但坑也随之而来。一个典型的实现长这样ServerEndpoint(/ws/chat) Component public class ChatEndpoint { private static final MapString, Session SESSIONS new ConcurrentHashMap(); OnOpen public void onOpen(Session session) { SESSIONS.put(session.getId(), session); System.out.println([连接] session.getId()); } OnMessage public void onMessage(Session session, String message) throws Exception { for (Session target : SESSIONS.values()) { target.getBasicRemote().sendText(message); } } OnClose public void onClose(Session session) { SESSIONS.remove(session.getId()); System.out.println([关闭] session.getId()); } OnError public void onError(Session session, Throwable error) { error.printStackTrace(); } }但这里有个非常经典的坑ServerEndpoint的类在 Spring Boot 里虽然加了Component但每个 WebSocket 连接创建时都会重新 new 一个实例而不是从 Spring 容器里取。所以你在这个类里直接Autowired注入一个业务 Service运行时会发现它是 null——因为你注入的那个 Bean 和每个连接的新实例根本不是同一个对象。如果你硬要用注解式通常的解法是造一个静态工具类或者把要注入的 Bean 设为静态字段通过ApplicationContext去取。说白了就是绕开 Spring 的注入机制很别扭。因此我的建议非常明确如果项目有正经业务逻辑不要用注解式老老实实用TextWebSocketHandler哪怕多写一个配置类长期维护会舒服得多。两种方式的对比我在实践里的体感也整理成了一张表对比维度TextWebSocketHandlerServerEndpointSpring 管理方式一个 Bean所有连接共用每连接一个实例注入需绕路依赖注入正常注入需要静态工具类兜底握手阶段定制支持 HandlerInterceptor不支持代码量略多少生产环境推荐度高低4. 把消息发出去会话管理、广播和定向推送4.1 用 ConcurrentHashMap 管理会话还不够得设计好 Key第 3 章里的代码用session.getId()作为 Key 保存所有会话这在广播场景下没问题但真实业务里几乎一定会遇到“给某个用户发消息”的需求。比如用户 A 发给用户 B服务端必须知道 B 当前在哪个 WebSocket 会话上。一个更实用的设计是用用户 ID 作为 Key或者用“用户 ID 连接序号”作为 Key。因为同一用户可能同时开着两个页面也就是两个连接。如果你直接用用户 ID 做 Key新连接会覆盖旧连接旧页面发消息就没人处理了。通常的做法是这样// Key: userId_连接序号Value: WebSocketSession private static final MapString, WebSocketSession SESSIONS new ConcurrentHashMap(); private static final ConcurrentLinkedQueueString TOKEN_QUEUE new ConcurrentLinkedQueue(); private static String buildToken(String userId) { TOKEN_QUEUE.offer(userId _ System.nanoTime()); return TOKEN_QUEUE.poll(); }当然也可以用MapString, ListWebSocketSession一个用户对应多个会话在定向推送时遍历列表。这个设计取决于业务复杂程度但有一个原则是确定的不要把多端连接的场景给设计死否则后面加“多设备同步”功能时得重构。4.2 三种常见推送场景广播、定向推送、给指定组推送广播就是往所有会话发一份数据在handleTextMessage里遍历SESSIONS.values()。这种适合系统公告、在线人数变化这类场景。定向推送是给指定用户发需要根据业务 ID 从 Map 里找到会话再调用sendMessage。比如聊天私信、订单状态通知public void sendToUser(String userId, String message) { // 遍历找到该用户的所有会话 for (Map.EntryString, WebSocketSession entry : SESSIONS.entrySet()) { if (entry.getKey().startsWith(userId _)) { WebSocketSession session entry.getValue(); if (session.isOpen()) { session.sendMessage(new TextMessage(message)); } } } }组播可以理解为“给一组满足条件的用户推送”。最简单粗暴的方式就是每次推送时遍历全部会话用业务条件判断数据量大了以后再引入 Redis 的 pub/sub 或者维护一个组 ID 到会话列表的内存映射。4.3 一个绕过 IDE 的单元测试小技巧很多人写 WebSocket 服务端时懒得写单元测试因为 WebSocketSession 不太好 mock。实际上 Spring 对这种情况有WebSocketStompClient但如果只是快速验证一个 Handler 最基础的逻辑可以用一个简单方式写一个本地 main 方法用 Java 原生客户端连上去发消息。我用过的最大教训是别一上来就搞全套自动化测试框架。先保证能连上、能发消息、能收到消息后面再补测试反倒顺手很多。5. 客户端联调与测试浏览器、Postman 和命令行5.1 浏览器就是最好的调试台WebSocket 客户端调试不用装任何工具现代浏览器都在开发者工具里原生支持 WebSocket 查看。你可以在 Console 里直接写const ws new WebSocket(ws://localhost:8080/ws/chat); ws.onopen () { console.log(连接已建立); ws.send(大家好我是测试消息); }; ws.onmessage (event) { console.log(收到:, event.data); }; ws.onclose () { console.log(连接已关闭); }; ws.onerror (error) { console.error(出错:, error); };把这段代码贴到页面控制台跑一下如果服务端配置没问题你会看到控制台打印“连接已建立”服务端日志也打印出[连接] xxx。用浏览器调试有个小技巧在 Network 面板里找到 WebSocket 连接那一项点开切换到 Messages 标签页你能看到客户端和服务端双向往来的每一条帧消息包括 ping/pong 这种控制帧。断线排查时这里的信息浓度比 Console 日志高得多。5.2 Postman 支持 WebSocket 连接调试如果你不喜欢用浏览器写代码Postman 从很早就支持了 WebSocket。左上角新建请求时请求类型选WebSocket输入ws://localhost:8080/ws/chat点击 Connect然后在下面的 Message 输入框里直接发消息右边面板就能实时看到服务端推回的内容。这里一个容易忽略的点是HTTP 要写http://WebSocket 要写ws://线上如果做了 TLS则要写wss://。写错协议前缀是新手最常见的连接失败原因而且报错信息往往不那么友好。5.3 用 Java 写一个极简客户端做回归测试如果你要在集成测试或者 CI 流程里验证 WebSocket 服务写一个 Java 客户端非常有用。JDK 从 11 开始自带java.net.http.WebSocket客户端不需要额外依赖import java.net.URI; import java.net.http.HttpClient; import java.net.http.WebSocket; import java.time.Duration; public class WebSocketClientTest { public static void main(String[] args) throws Exception { HttpClient client HttpClient.newHttpClient(); WebSocket ws client.newWebSocketBuilder() .connectTimeout(Duration.ofSeconds(10)) .buildAsync(URI.create(ws://localhost:8080/ws/chat), new WebSocket.Listener() { }) .join(); // 发送消息 ws.sendText(Hello from Java client, true); System.out.println(消息已发送); Thread.sleep(5000); ws.sendClose(WebSocket.NORMAL_CLOSURE, bye); } }这个客户端类我在本地回归测试时经常用尤其是验证服务端重启后的断线重连逻辑时比反复手动打开浏览器省事得多。6. 生产环境避坑指南断线重连、心跳保活和线程安全6.1 断线重连服务端和客户端的共同责任WebSocket 连接虽然长但网络环境并不会保证它一直存活。中间路由器超时、服务器重启、移动网络切换信号都可能导致连接在双方都不知情的情况下“假死”。TCP 层可能还挂着但应用层已经收不到数据了。服务端这边的责任是在连接关闭时一定要清理会话释放资源。如果忘了从SESSIONS里删除那么这次连接占用的会话永远不会释放随着一天里无数用户的不断进出内存泄漏就是这么攒出来的。我见过一个同事的线上服务跑了一周后响应越来越慢一查内存Map 里躺着上万条已经失效的连接。所以afterConnectionClosed里的SESSIONS.remove(session.getId())一行都不能省。客户端这边的责任是断线后要自动重连。而且不能对着断开瞬间的那个 WebSocket 对象重连必须重新new WebSocket()创建一个新连接。最简单的策略是在onclose事件里加一个几秒后的定时器重新连接function connect() { const ws new WebSocket(ws://localhost:8080/ws/chat); ws.onclose () { console.log(连接断开3秒后重连...); setTimeout(connect, 3000); }; } connect();更稳一点的做法是使用指数退避第一次重连等 1 秒第二次 2 秒第三次 4 秒最多到 30 秒封顶避免服务端刚挂还没恢复时所有客户端同时疯狂重连打满端口。6.2 心跳机制让连接保持“活跃”而不是“假死”WebSocket 协议本身提供了 Ping/Pong 控制帧但很多网络中间层比如 Nginx 的proxy_read_timeout、云厂商的负载均衡会主动掐掉长期没有数据的空闲连接。解决办法就是心跳——每隔一段时间就发一个 Ping 帧让连接保持活跃。Spring 的WebSocketSession提供了setMaxIdleTimeout方法来控制空闲超时时间但这只是服务端自己认定连接过期的阈值。真正防中间层超时需要客户端定期打心跳。最简单的方式是在客户端加个定时器每 30 秒发一个心跳文本setInterval(() { if (ws.readyState WebSocket.OPEN) { ws.send(__ping__); } }, 30000);服务端收到__ping__这类心跳消息时直接丢弃即可不用回任何东西。或者你也可以用更标准的ping帧但文本心跳更方便观察和调试。心跳其实是在“多花一点流量”和“避免连接被掐断”之间做取舍30 秒空心跳几乎可以忽略不计但能把大量连接从“假活”变成“真活”。6.3 并发发送消息时报错TEXT_PARTIAL_WRITING 的解法这是我在生产环境踩过最深的一个坑也是很多人写 WebSocket 服务端时根本不会注意到的。当一个会话同时有多条消息要通过sendMessage发送时比如一个线程在推送订单状态另一个线程在推送客服回复同一条 WebSocket 连接上就会出现发送竞争。如果不对 Session 的发送加锁很可能会抛出类似下面这样的异常java.lang.IllegalStateException: The remote endpoint was in state [TEXT_PARTIAL_WRITING]这个报错的意思是说你上一个消息还没写完下一条消息又想往里写了把整个会话的发送状态搞乱了。解决方式有两种。第一种是手动给每个 Session 加锁用一个 Channel 级的锁对象来保证同一时刻只有一个线程调用sendMessageprivate final MapString, Object LOCK_MAP new ConcurrentHashMap(); public void sendToSession(WebSocketSession session, String message) throws IOException { Object lock LOCK_MAP.computeIfAbsent(session.getId(), k - new Object()); synchronized (lock) { if (session.isOpen()) { session.sendMessage(new TextMessage(message)); } } }第二种是使用 Spring 提供的ConcurrentWebSocketSessionDecorator这个装饰器内部已经实现了发送加锁和缓冲WebSocketSession session new ConcurrentWebSocketSessionDecorator( originalSession, 5000, // 发送超时毫秒 8192 // 缓冲大小 );把底层 Session 包装成装饰器对象后再去调用sendMessage大部分并发发送问题都能被挡在外面。我个人在项目里两种方案结合使用业务层用装饰器兜底个别对顺序敏感的消息在业务代码里再加锁双保险。6.4 Nginx 反代别忘了 Upgrade 头WebSocket 线上部署基本都会在 Nginx 后面。如果你发现本地直连服务端没问题但只要走 Nginx 代理就始终握手失败十有八九是 Nginx 配置里少了 WebSocket 的升级头。在 Nginx 的 location 配置里必须显式加上这两行location /ws/ { proxy_pass http://backend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_read_timeout 60s; }其中Connection upgrade是固定写法不能照抄默认的proxy_set_header Connection $http_connection。proxy_read_timeout也建议调大到大于心跳间隔否则即使你的应用层频繁发心跳Nginx 在它自己的读超时时间到了之后照样会切断连接。7. 写在最后的几点经验WebSocket 这个东西原理层面并不复杂但工程化落地的时候坑真的不少。根据我个人的项目经验有几个习惯建议你从一开始就养成。一是日志一定要全。连接建立、消息收发、连接关闭、异常捕获这四个环节每个都要打印关键信息尤其是会话 ID 和用户 ID。否则线上出问题的时候你对着一个黑盒子根本没法定位是客户端断了还是服务端挂了。二是做好异常兜底。handleTextMessage里的业务逻辑如果可能抛出异常一定要加 try-catch否则异常会直接穿透到容器层可能导致连接被异常关闭。我见过一个项目因为里面某个消息处理时调了个空指针结果那 krz 用户一发言连接就断客户端不断重连又被踢下来体验极其糟糕。三是消息边界要设计清楚。WebSocket 是长连接服务端要跟客户端约定好消息的格式和分帧规则。最简单的做法是定义统一的 JSON 格式带上消息类型字段比如{type:chat,data:{...}}和{type:heartbeat,data:{} }服务端根据 type 字段分发到不同业务处理器。这个看似简单的设计越到后面越能看出它的价值。四是无论你用了多完整的框架都要亲自动手抓包看一次真实的 WebSocket 帧是什么样的。我之前对帧格式一直停留在“概念上懂了”直到有一次排查一个大数据量发送延迟问题用抓包工具看到消息被分成了多帧传输才真正理解了 FIN 位和 opcode 在实践里的作用。那之后再看 Spring 的源码理解速度完全是另一个级别。