SpringBoot4系列13 - 使用WebSocket实时通信

运营后台的「新订单监控」页面是我先用轮询撑起来的:前端每 3 秒发一次 GET /api/orders?since=…,几百个页面同时开着,一秒钟就是上百个请求,其中九成以上查回来的是空数组。高峰期轮询请求和正常下单请求挤在同一条队列里,接口响应从 40 毫秒涨到 1 秒开外,运营跑来找我反馈「页面刷新慢」,我打开数据库慢查询日志,里面躺着一排一模一样的查询,那一刻我就知道该换方案了。

https://static.xiongneng.me/ws-polling-vs-push-20260922022750.png

空轮询的病根在于「有事件」这个信息握在服务端手里,客户端却只能反复来问。WebSocket 把方向反过来:连接建立之后一直挂着,服务端有新订单就主动推一帧过去,没订单就什么都不发。这篇文章是我拿 Spring Boot 4.1.1 加 Framework 7.0.9 做的一套订单事件推送实测:运营后台的新订单事件要实时到达每一个在线客户端,客户端掉线了服务端还要能感知到。广播与定向推送、心跳、断线感知我都跑了实测,关键数字全留了原始记录,下文逐段对应。

编程模型与自动配置的事实

写代码前我习惯先把依赖组成与类的位置摸清楚。第一件事实来自 starter 制品解包(4.1.1 实测):

1
2
3
4
5
6
7
spring-boot-starter-websocket
  ├─ spring-boot-starter
  ├─ spring-boot-starter-webmvc
  │    └─ starter-tomcat → tomcat-embed-core / el / websocket
  └─ spring-boot-websocket
       ├─ spring-messaging   7.0.9
       └─ spring-websocket   7.0.9

spring-boot-websocket 是 Boot 4 拆模块后的专属模块,但它薄得出奇:整个模块只有一个自动配置 WebSocketMessagingAutoConfiguration,包名 org.springframework.boot.websocket.autoconfigure.servlet,我从字节码确认过它的条件是 @ConditionalOnClass(WebSocketMessageBrokerConfigurer),只服务 STOMP 子协议。普通 WebSocketHandler 路线没有任何 Boot 自动配置,@EnableWebSocket 加 WebSocketConfigurer 由你自己在配置类上声明。

这个自动配置装配的是 STOMP 场景的接线:内部类 SpringBootWebSocketMessageBrokerConfigurer 收集容器里所有用户的 WebSocketMessageBrokerConfigurer bean 聚合成一份注册;另一组内部类按 spring.websocket.messaging.preferred-json-mapper 的取值挑消息转换器,默认 jackson 走 Jackson 3 的 tools.jackson JsonMapper,jackson2 已在元数据里标注废弃。本篇的业务事件是裸文本帧加自定义 JSON,不经过这套子协议栈,我用 handleTextMessage 直接收发。

属性前缀 spring.websocket.* 存在,但我翻遍元数据只找到一条属性:

1
spring.websocket.messaging.preferred-json-mapper   默认 jackson

没有 WebSocketProperties 类,连接超时、缓冲区大小、会话上限这类属性统统不存在,这些都要在代码层自己管。tomcat-embed-websocket 随 webmvc starter 传递进来,jakarta.websocket 的 API 与容器实现都在里面,两条路线共用这一个 starter,依赖清单没有差别。

核心类的包位置清单(spring-websocket 7.0.9 解包实证),写 import 的时候照它核对:

1
2
3
4
5
6
7
8
9
org.springframework.web.socket                  WebSocketHandler / WebSocketSession
                                                TextMessage / BinaryMessage
                                                PingMessage / PongMessage / CloseStatus
org.springframework.web.socket.handler          TextWebSocketHandler / AbstractWebSocketHandler
org.springframework.web.socket.config.annotation  @EnableWebSocket / WebSocketConfigurer
                                                WebSocketHandlerRegistry
org.springframework.web.socket.server           HandshakeInterceptor / DefaultHandshakeHandler
org.springframework.web.socket.server.standard  SpringConfigurator / ServerEndpointExporter
jakarta.websocket(tomcat-embed-core)           @ServerEndpoint / Session / ContainerProvider

https://static.xiongneng.me/ws-two-routes-20260922022754.png

依赖和配置

工程依赖我压到最省:starter 加 JDBC,订单落 MySQL,事件推送不依赖任何消息队列:

1
2
3
4
5
6
7
8
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>

配置文件里没有 websocket 专属配置段,端口、数据源与建表脚本就位即可:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
server:
  port: ${SERVER_PORT:18130}

spring:
  datasource:
    url: jdbc:mysql://${DB_HOST:192.168.1.97}:3306/${DB_NAME:springboot4_websocket}?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true&createDatabaseIfNotExist=true
    username: ${DB_USER:root}
    password: ${DB_PASSWORD:root123456}
  sql:
    init:
      mode: always
      schema-locations: classpath:schema.sql

事件帧的 JSON 序列化用 Jackson 3,包名 tools.jackson,com.fasterxml.jackson 不在 Boot 4 的 classpath 上。我的事件结构只有五种字段:type、seq、orderNo、amount、ts,seq 是服务端维护的事件序号,客户端拿它断言消息顺序。

事件帧的生成集中在 NotifyService 里,落库完成后按一个固定结构组装:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
public String orderEvent(Order order) {
    Map<String, Object> event = new LinkedHashMap<>();
    event.put("type", "order");
    event.put("seq", seq.incrementAndGet());
    event.put("orderNo", order.orderNo());
    event.put("amount", order.amount());
    event.put("status", order.status());
    event.put("ts", Instant.now().toEpochMilli());
    return mapper.writeValueAsString(event);
}

seq 是服务端单调递增的事件序号,客户端收不到、收重复、收乱序,比对 seq 就能发现。帧里的字段全部是原始类型与字符串,两端都不需要约定复杂结构,JSON 解析在 handler 侧用 readTree 转 JsonNode,取值走 asString(""),字段缺失时落到空串而不是抛异常,客户端发的报文质量参差时不至于把连接打崩。

握手与会话登记

握手阶段把身份定下来。我的连接身份来自 URL 上的 query 参数 user,拦截器缺参就拒:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
@Component
public class HandshakeAuthInterceptor implements HandshakeInterceptor {

    @Override
    public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response,
                                   WebSocketHandler wsHandler, Map<String, Object> attributes) {
        String user = parseQueryParam(request.getURI(), "user");
        if (user == null || user.isBlank()) {
            response.setStatusCode(HttpStatus.UNAUTHORIZED);
            return false;
        }
        attributes.put("user", user);
        return true;
    }
}

返回 false 握手即终止,客户端收到 HTTP 401;通过后 user 写进 attributes,后续 handler 从 session.getAttributes().get("user") 取。我实测缺 user 参数的连接被拒,客户端抛 WebSocketBadStatusException,带身份的 3 个客户端(operator-a、operator-b、operator-c)全部握手成功,connected 计数到 3。

handler 用 TextWebSocketHandler,只处理文本帧:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
@Component
public class NotifyWebSocketHandler extends TextWebSocketHandler {

    @Override
    public void afterConnectionEstablished(WebSocketSession session) {
        String user = (String) session.getAttributes().get("user");
        registry.register(user == null ? "anonymous" : user, session);
    }

    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
        String user = (String) session.getAttributes().get("user");
        registry.unregister(user == null ? "anonymous" : user, session);
    }
}

afterConnectionEstablished 与 afterConnectionClosed 是会话生命周期的两端,中间的登记与移除都落到会话登记器上:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
@Component
public class SessionRegistry {

    private final Map<String, List<WebSocketSession>> sessionsByUser = new ConcurrentHashMap<>();

    public void register(String user, WebSocketSession session) {
        sessionsByUser.computeIfAbsent(user, k -> new CopyOnWriteArrayList<>()).add(session);
        connected.incrementAndGet();
    }

    public int onlineCount() {
        return sessionsByUser.values().stream().mapToInt(List::size).sum();
    }
}

按用户分组的值我存的是 List:同一个账号开多个标签页就有多个会话,每个都要单独登记、单独推送。日志里一行 WS_OPEN user=operator-a sessionId=… online=1 把用户、会话 ID、在线数同时打出来,运行期的对账全靠这三样。

广播与定向推送

事件推送有两个方向:新订单事件广播给全部在线客户端,复核类消息只发给指定用户。两个方法都挂在 SessionRegistry 上:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public int broadcast(String text) {
    int delivered = 0;
    for (List<WebSocketSession> sessions : sessionsByUser.values()) {
        for (WebSocketSession session : sessions) {
            sendSafe(session, text);
            delivered++;
        }
    }
    broadcastSent.addAndGet(delivered);
    return delivered;
}

private void sendSafe(WebSocketSession session, String text) {
    try {
        synchronized (session) {
            if (session.isOpen()) {
                session.sendMessage(new TextMessage(text));
            }
        }
    } catch (IOException e) {
        log.warn("WS_SEND_FAIL sessionId={} error={}", session.getId(), e.toString());
    }
}

synchronized (session) 这行不是多余动作:多个线程同时向同一个 session 写,Tomcat 会抛 IllegalStateException: The remote endpoint was in state [TEXT_FULL_WRITING],发送必须按 session 串行化。我实测 10 个线程并发触发广播打向同一个会话,串行化之后服务端日志没有一条发送失败,客户端收齐了事件。

HTTP 侧的触发入口只有三个,运营后台的广播接口专门为演示与截图保留:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
@PostMapping("/notify/broadcast")
public Map<String, Object> broadcast(@RequestParam(defaultValue = "WS80001") String orderNo,
                                     @RequestParam(defaultValue = "88.50") String amount) {
    NotifyService.Order order = new NotifyService.Order(orderNo, amount, "CREATED", null);
    int delivered = notifyService.broadcastNewOrder(order);
    ...
}

@PostMapping("/notify/direct")
public Map<String, Object> direct(@RequestParam String user,
                                  @RequestParam(defaultValue = "请复核新订单") String message) {
    return notifyService.direct(user, message);
}

下单接口走同一条广播路径,区别只是先落库再推事件:

1
curl -s -X POST "http://localhost:18130/api/notify/broadcast?orderNo=WS80001&amount=88.50"

https://static.xiongneng.me/ws-broadcast-flow-20260922022758.png

这一轮跑下来,我把关键数字留了一份原始记录,逐行对着服务端日志核过:

1
2
3
4
5
6
7
8
9
握手        3 客户端全成功,connected=3;缺 user 参数 → 401 拒绝
广播        POST /api/notify/broadcast → HTTP 返回 13 ms,delivered=3
            事件帧 {"type":"order","seq":1,"orderNo":"WS80001",
                    "amount":"88.50","status":"CREATED","ts":...}
定向        POST /api/notify/direct?user=operator-b → 3 ms,delivered=1
            operator-a 与 operator-c 的直推消息 0 条
心跳        ping 5 次 → pong 收到 5 次
断线        operator-c 主动 close → closed +1,online 3 → 2
下单        POST /api/orders → 落库加推送 75 ms,broadcast=2,事件帧 seq=2

广播与定向的延迟都压在 15 毫秒以内,比 3 秒轮询的感知速度低两个数量级,这就是轮询换推送最直接的收益。delivered 由服务端逐个会话发送后累加,与在线数对得上:3 个客户端在线时广播返回 3,1 个断开后下单事件返回 2。

心跳与断线感知

断线感知我拆成两种情况,责任方不同。客户端主动 close 或 TCP 正常断开,容器能立刻发现,回调 afterConnectionClosed,CloseStatus 带上原因码,我在这里递减在线数、递增 closed 计数。实测客户端调用 close() 后,stats 接口的 closed 从 0 变 1,online 从 3 变 2,全程不到半秒。

另一种麻烦得多,是半开连接:客户端拔网线、进程被 kill、移动网络切换,TCP 层没有任何通知,容器侧的 session 看上去还是 open 的。这种情况容器无能为力,只有应用层心跳能发现。本工程的约定是客户端定时发一帧 {"type":"ping"},服务端回 pong:

1
2
3
4
5
6
7
8
9
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws IOException {
    JsonNode node = mapper.readTree(message.getPayload());
    String type = node.path("type").asString("");
    if ("ping".equals(type)) {
        registry.onPing(session);
        return;
    }
}

registry.onPing 收到 ping 计数加一,回一帧 {"type":"pong","t":…}。服务端同时记录每个会话的上次 pong 时间,超过阈值没有心跳就把会话移除,触发与主动断开相同的 afterConnectionClosed 路径。我实测客户端连发 5 次 ping,收到 5 次 pong,pingReceived 与 pongSent 计数都是 5,两个口径对得上。

这里有个容易想岔的点:spring-websocket 顶层的 PingMessage 走的是 RFC 6455 控制帧,session.sendMessage(new PingMessage()) 发出去之后由容器自动回 pong,应用层收不到回调。做应用级存活判断要用文本帧自己约定报文,控制帧那套只适合探测协议层连通性。我第一版就拿 PingMessage 当心跳用,测到半开连接才发现它帮不上忙。

https://static.xiongneng.me/ws-heartbeat-awareness-20260922022906.png

完整案例,跑起来看

把前面的部件按运营后台的真实顺序串起来。三个客户端带着身份连上:

1
2
# operator-a / operator-b / operator-c 各连一条
ws://localhost:18130/ws?user=operator-a

服务端日志给出会话证据,online 逐次累加:

1
2
3
WS_OPEN user=operator-a sessionId=e932f9ca-… online=1
WS_OPEN user=operator-b sessionId=23e957f6-… online=2
WS_OPEN user=operator-c sessionId=9f825394-… online=3

运营触发一次广播,HTTP 响应在 13 毫秒内返回,delivered 直接写明送达数:

1
2
curl -s -X POST "http://localhost:18130/api/notify/broadcast?orderNo=WS80001&amount=88.50"
# {"type":"order","seq":1,"orderNo":"WS80001","amount":"88.50","delivered":3}

https://static.xiongneng.me/ws-panels-20260920013246.png

面板拼板和上面那份原始记录是分开跑的两次,毫秒数有抖动,计数值我逐项核过全都一致:广播面板 delivered 是 3,定向面板只有 operator-b 收到、delivered 是 1,operator-a 的会话帧面板里能看到广播帧与 pong 帧原文,stats 面板在 operator-c 断开后 closed 等于 1、online 等于 2、pingReceived 与 pongSent 都是 3。

最后一个视角是下单到推送的端到端耗时。POST /api/orders 先写 MySQL 再广播,我实测一轮 75 毫秒,其中落库占大头;事件帧的 seq 从广播轮的 1 走到 2,客户端按序号就能确认没有漏帧。

JSR-356 对照:@ServerEndpoint 路线

jakarta.websocket 的注解路线是另一套编程模型,端点类长这样:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
@ServerEndpoint(value = "/ws/jsr356/{user}")
public class NotifyJsr356Endpoint {

    @Autowired
    private SessionRegistry registry;

    @OnOpen
    public void onOpen(Session session, @PathParam("user") String user) { ... }

    @OnMessage
    public String onMessage(String message, Session session) {
        String user = (String) session.getUserProperties().getOrDefault("user", "anonymous");
        return "echo:" + user + ":" + message;
    }
}

@OnMessage 的返回值就是回给客户端的帧,路径参数 {user} 从 @PathParam 取。这条路线有两个注册问题必须处理。注解本身不会生效:内嵌 Tomcat 的 WsSci 扫描机制在 Boot 里扫不到应用类路径,端点要编程式注册到 ServerContainer。注册的时机也有讲究,我踩过这个坑:直接放进 ServletContextInitializer 会拿到 null,因为 ServerContainer 属性由 WsSci 在 SCI 阶段写入,那一步还没执行;把注册挪进 ServletContextListener.contextInitialized 就能拿到:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
@Bean
public ServletContextInitializer jsr356EndpointRegistrar() {
    return servletContext -> servletContext.addListener(new ServletContextListener() {
        @Override
        public void contextInitialized(ServletContextEvent sce) {
            ServerContainer container = (ServerContainer)
                    sce.getServletContext().getAttribute(ServerContainer.class.getName());
            ServerEndpointConfig config = ServerEndpointConfig.Builder
                    .create(NotifyJsr356Endpoint.class, "/ws/jsr356/{user}")
                    .configurator(new BootSpringConfigurator())
                    .build();
            container.addEndpoint(config);
        }
    });
}

依赖注入由 configurator 完成。spring-websocket 自带的 SpringConfigurator 在 Boot 下不可用,它在握手期找 root WebApplicationContext,Boot 的单层上下文查不到,直接抛 IllegalStateException: Failed to find the root WebApplicationContext。我的做法是自写 BootSpringConfigurator,走 AutowireCapableBeanFactory.createBean:每个连接新建一个端点实例并完成 @Autowired 注入,语义与规范一致。

两条路线的行为我都验证过:客户端连 ws://localhost:18130/ws/jsr356/inspector-x 发送 hello,收到 echo:inspector-x:hello;两个客户端各自收到各自的回显,互不串线。选型上没有悬念:Spring 路线有拦截器、有会话管理、有事件,业务系统用它;@ServerEndpoint 路线胜在标准 API,适合对接只认 JSR-356 的客户端。

https://static.xiongneng.me/ws-jsr356-endpoint-20260922022805.png

测试怎么写

WS 测试的关键是「真连」。测试里我用 jakarta.websocket 的标准客户端 API,实现在 tomcat-embed-websocket 里,不需要额外依赖:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
@ClientEndpoint
public class WsTestClient implements AutoCloseable {

    private final List<String> messages = new CopyOnWriteArrayList<>();

    public static WsTestClient connect(int port, String path) throws Exception {
        WsTestClient client = new WsTestClient();
        WebSocketContainer container = ContainerProvider.getWebSocketContainer();
        container.connectToServer(client, URI.create("ws://localhost:" + port + path));
        ...
    }
}

客户端回调跑在容器线程上,与测试主线程不同步,断言用「等消息」而不是轮询计数:awaitMessage 在收到满足条件的帧之前阻塞,超时直接失败。广播用例连两个客户端、调一次广播接口、两边各等自己的帧:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
@Test
void broadcastReachesAllOnlineClients() throws Exception {
    WsTestClient a = connect("operator-a");
    WsTestClient b = connect("operator-b");

    mockMvc.perform(post("/api/notify/broadcast")
                    .param("orderNo", "WS80001").param("amount", "88.50"))
            .andReturn();

    String payload = a.awaitMessage(m -> m.contains("\"type\":\"order\""), 5);
    assertThat(payload).contains("WS80001").contains("\"amount\":\"88.50\"");
    assertThat(b.awaitMessage(m -> m.contains("WS80001"), 5)).isNotNull();
}

17 个用例全连真实 MySQL,分布与覆盖:配置事实 3 个(两条路线的 bean 在位、handler 是 TextWebSocketHandler),HTTP 接口 3 个(广播响应、下单落库、stats 结构),全链路 8 个(广播到达、定向隔离、ping/pong 计数、断线计数、下单事件、seq 递增、并发广播、无身份握手被拒),JSR-356 3 个(回显、双客户端隔离、依赖注入)。断线感知用 Awaitility 轮询 stats 接口,客户端 close 之后 closed 计数加一才算过。

两个边界用例我单独拎出来说。seq 递增断言把消息顺序钉死,两次广播之间序号必须变大:

1
2
3
4
5
String first = f.awaitMessage(m -> m.contains("WS80003"), 5);
String second = f.awaitMessage(m -> m.contains("WS80004"), 5);
long seq1 = Long.parseLong(first.replaceAll(".*\"seq\":(\\d+).*", "$1"));
long seq2 = Long.parseLong(second.replaceAll(".*\"seq\":(\\d+).*", "$1"));
assertThat(seq2).isGreaterThan(seq1);

无身份握手被拒的用例反过来验证拦截器:连接 ws://…/ws 不带 user 参数,connectToServer 必须抛异常才算过,因为服务端在握手期就返回了 401。定向隔离用例在目标客户端收到 direct 帧之后,还要断言另一个客户端的消息列表里 0 条 direct 帧,「只有目标收到」才是这个接口的完整语义。

避坑指南

坑一,并发写同一个 session 直接抛异常。 Tomcat 的写状态机是单向的,上一个 TEXT_FULL_WRITING 没写完又来一条就是 IllegalStateException。服务端任何一处可能并发触达同一会话的发送,都要按 session 串行化,广播与定向共用一个 synchronized (session) 的发送方法最省心。

坑二,SpringConfigurator 在 Boot 下不可用。 它在握手期查 root WebApplicationContext,Boot 单层上下文查不到,连接直接失败。替代方案是自写 Configurator,从 AutowireCapableBeanFactory.createBean 拿端点实例,每个连接一个新实例并完成注入。

坑三,@ServerEndpoint 注解写完不会生效。 内嵌 Tomcat 扫不到应用类路径里的注解端点,必须编程式 ServerContainer.addEndpoint。注册还要选对时机:ServletContextInitializer 阶段 ServerContainer 属性还是 null,放进 ServletContextListener.contextInitialized 才拿得到。

坑四,TextWebSocketHandler 收到二进制帧就断开。 二进制帧走父类默认实现,抛 CloseStatus.NOT_ACCEPTABLE,连接直接被服务端关掉。客户端约定只用文本帧,或者改继承 AbstractWebSocketHandler 自己处理二进制分支。

坑五,容器感知不了半开连接。 拔网线、进程被 kill 之后 session 看上去仍然 open,服务端推送会静默失败。存活判断只能靠应用层心跳:记录上次 pong 时间,超时主动关闭并移除会话。RFC 6455 的 ping 控制帧由容器代答,应用层收不到,不能当心跳用。

坑六,握手默认不设防。 没有拦截器时任何客户端都能连上来。身份校验放 HandshakeInterceptor,缺参返回 false 加 401;跨域同理,setAllowedOriginPatterns 不配就是裸奔。

坑七,同一账号会有多个会话。 一个用户开三个标签页就是三条连接,按用户推送时值要用 List 存会话,登记与移除都要按 session 粒度做。用单值存最后一个会话,其余标签页的推送就全丢了。

坑八,送达数要和在线数对账。 广播返回的 delivered 只统计发送成功的会话,isOpen 为 false 的会话跳过不计数。delivered 与 onlineCount 对不上,先查有没有把已断开的会话移除,再查发送失败日志 WS_SEND_FAIL。

小结

真要往生产里落,我的选型建议分三步走:业务系统的推送老老实实用 Spring 的 WebSocketHandler 路线,@EnableWebSocket 加握手拦截器加会话登记器这套组合装好就能用,广播与定向共用一个串行化的发送方法;要主题订阅、确认机制这类子协议语义再上 STOMP,那条唯一的自动配置就是为它准备的;只有对接方只认 JSR-356 标准时才走 @ServerEndpoint,注册时机与 Configurator 两处坑提前绕开。连接数一多、会话要跨实例共享时,先给 SessionRegistry 换上外置的 pub/sub 通道,再谈横向扩容。

没解决的事也直说:本文的广播只在单实例里成立,多副本部署时各实例的 SessionRegistry 互不相通,跨实例推送得引入 broker relay 或者外挂消息队列,这块我没搭环境测,不敢给数字;半开连接的超时阈值怎么定也没有公式,阈值短了误杀慢客户端,长了资源白白挂着,只能按你自己的网络场景压测着调。

参考链接