一、场景痛点:为什么轮询不够用
电商小程序里,用户下单后希望立刻看到「支付成功」「发货通知」;客服 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 可能已经关闭(用户切后台、断网)。直接丢弃消息,用户回来就漏消息了。可靠做法:
- 推送前检查 session 是否 open,推送失败捕获异常;
- 失败则把消息写入离线表(
offline_message:userId、content、create_time); - 用户重新上线时,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,连接数异常上涨往往是客户端没做退避重连。