分类:后端开发 · 作者:管理员 · 发布于 2026-10-10 · 阅读 0 · 标签:WebSocket / 实时推送 / 心跳保活 / Redis / 集群广播

一、场景痛点:为什么轮询不够用

电商小程序里,用户下单后希望立刻看到「支付成功」「发货通知」;客服 IM 需要实时收发消息;后台操作完商品上下架,运营端要马上刷新。最朴素的做法是 HTTP 轮询:客户端每隔几秒拉一次接口。它的三个硬伤很现实:

  • 延迟不可控:轮询间隔 3 秒,消息最快也要等 3 秒才被感知,体验就是「卡」;
  • 无效请求浪费:90% 的轮询没有任何新数据,请求打到网关、服务端、数据库,纯属空转;
  • 服务端压力随连接数线性上涨:一万个在线用户就是每秒上万次请求,削峰还没开始就被轮询把资源吃掉了。

WebSocket 用一条 TCP 长连接替代反复握手,服务端可以随时主动推送。下面以一个典型的电商中台实时消息服务为例,从握手鉴权、Session 管理、心跳保活写到集群广播,都是生产里真正会踩的坑。

二、最小可用:握手鉴权 + 消息处理器

Spring Boot 集成 WebSocket 不需要额外依赖,spring-boot-starter-websocket 即可。核心是两步:握手阶段校验身份,连接建立后用 Handler 收发消息。

public class ChatWebSocketHandler extends TextWebSocketHandler {

    // userId -> session,全局维护在线连接
    private static final ConcurrentMap<String, WebSocketSession> SESSIONS = new ConcurrentHashMap<>();

    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        String userId = (String) session.getAttributes().get("userId");
        SESSIONS.put(userId, session);
        // 广播上线事件,让其他端(如客服工作台)感知
        MessageCenter.broadcast(userId, Message.of("user_online", userId));
    }

    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) {
        // 业务消息:JSON 反序列化后按 type 分发
        ClientMessage msg = JsonUtil.parse(message.getPayload(), ClientMessage.class);
        MessageRouter.route(session, msg);
    }

    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
        String userId = (String) session.getAttributes().get("userId");
        SESSIONS.remove(userId);
        MessageCenter.broadcast(userId, Message.of("user_offline", userId));
    }
}

鉴权必须在握手阶段完成,不能在 message 里再做校验——否则未登录用户也能建立连接、白白占用资源。用 HandshakeInterceptor 拦截握手,从 query 参数或 Sec-WebSocket-Protocol 里取 JWT 校验,通过后把 userId 放进 attributes:

public class AuthHandshakeInterceptor implements HandshakeInterceptor {

    @Override
    public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response,
                                   WebSocketHandler wsHandler, Map<String, Object> attributes) {
        String token = UriComponentsBuilder.fromUri(request.getURI())
                .build().getQueryParams().getFirst("token");
        String userId = JwtUtil.verifyAndGetUserId(token); // 校验失败抛异常
        attributes.put("userId", userId);
        return true;
    }
}

三、Session 并发写:最容易被忽略的坑

很多教程到这里就结束了,但生产环境第一个崩溃点往往是 session 并发写。多个业务线程同时向同一个 session 调 sendMessage,会抛出 IllegalStateException: The remote endpoint was in state [TEXT_PARTIAL_WRITING]——底层 Tomcat 的 WebSocket 连接不允许并发写。

解法是给 session 包一层并发装饰器,把写操作串行化:

WebSocketSession safeSession = new ConcurrentWebSocketSessionDecorator(session, 1000, 8 * 1024);
// 之后所有 sendMessage 都走 safeSession,超过缓冲或发送超时会自动关闭

另外 session 必须随连接关闭及时从 Map 移除,否则就是典型的内存泄漏:用户断开一万次,Map 里留一万个僵尸 session,GC 都救不回来。

四、心跳保活:和代理层超时打架

TCP 长连接不会自己断,但链路上任何一环(客户端省电策略、网关、反向代理)都可能掐掉静默连接。经验值:代理层默认空闲超时约 60 秒,所以心跳间隔必须小于它,一般取 25~30 秒。

  • 服务端:配置 setMaxSessionIdleTimeout(60_000),并用定时任务对超过 30 秒无消息的连接主动 ping;
  • 客户端:收到 pong 视为存活,超过两个周期没收到任何帧就主动重连,重连用指数退避(1s、2s、4s……封顶 30s),避免断连风暴打垮服务端。

踩坑提醒:如果心跳间隔设成 50 秒而代理空闲超时是 60 秒,看起来没问题,但网络抖动一次、某个帧晚到几秒,连接就被代理静默断开,客户端还以为是「正常的」。把心跳压到代理超时的一半以下才安全。

五、离线补偿:推送失败怎么办

推送时 session 可能已经关闭(用户切后台、断网)。直接丢弃消息,用户回来就漏消息了。可靠做法:

  1. 推送前检查 session 是否 open,推送失败捕获异常;
  2. 失败则把消息写入离线表(offline_message:userId、content、create_time);
  3. 用户重新上线时,afterConnectionEstablished 里拉取未读消息补推,并标记已读。

这样「在线实时推、离线落库补」,消息一条不丢,代价只是一张表和一次查询,比引入重型 MQ 划算得多。

六、集群广播:多实例下的 WebSocket

单实例部署直接遍历本地 SESSIONS 推送即可。但中台服务都是多实例,用户的 WebSocket 连接只落在其中一台,另一台实例收到业务事件后找不到用户的 session,消息就丢了。常见解法是 Redis Pub/Sub 广播:

// 推送消息给 userId:先发本地,再广播给其他实例
public void push(String userId, Message message) {
    WebSocketSession session = ChatWebSocketHandler.getSession(userId);
    if (session != null && session.isOpen()) {
        safeSend(session, message);
    }
    redisTemplate.convertAndSend("ws:notify", userId + ":" + JsonUtil.toJson(message));
}

// 订阅端:收到广播后,如果目标用户连在本实例,就本地推送
@Bean
RedisMessageListenerContainer redisContainer(RedisConnectionFactory factory,
                                             MessageListenerAdapter adapter) {
    RedisMessageListenerContainer container = new RedisMessageListenerContainer();
    container.setConnectionFactory(factory);
    container.addMessageListener(adapter, new ChannelTopic("ws:notify"));
    return container;
}

这样每个实例都订阅同一个 channel,谁持有目标 session 谁负责推送,天然去重。连接数大了之后,也可以把 channel 按业务拆细(订单、客服、运营各一个 topic),减少无效广播量。

七、踩坑记录汇总

  • 代理层升级头:WebSocket 握手依赖 HTTP Upgrade 头,反向代理必须显式放行 Upgrade/Connection 头并配置读写超时,否则握手 101 失败或长连接被掐;
  • 握手失败要关连接:beforeHandshake 返回 false 后,确保底层连接被正确关闭,否则客户端会一直卡在握手状态;
  • 消息体要限长:单帧默认 64KB,业务消息(尤其是图片 base64)超长会直接报错,推送图片走对象存储、只推 URL;
  • 广播别用数据库轮询:有人用「每 500ms 扫一次消息表」模拟推送,连接一多就变成自造轮询,直接用 Pub/Sub 或 MQ。

八、最佳实践清单

  • 鉴权放握手阶段,别放 message 里;
  • session 写操作统一走 ConcurrentWebSocketSessionDecorator;
  • 心跳间隔小于链路代理空闲超时的一半;
  • 推送失败落库、上线补推,保证不丢消息;
  • 多实例广播用 Redis Pub/Sub,按业务拆 topic;
  • 定期监控在线连接数与广播 QPS,连接数异常上涨往往是客户端没做退避重连。