WebSocket 优雅停机与连接迁移:服务发版用户频繁掉线?平滑过渡 + 状态保持方案!
公司的在线客服系统用的是 WebSocket。每次发版滚动更新,旧 Pod 一停,挂在上面的几千个 WebSocket 连接全断。前端虽然做了自动重连,但重连期间用户发了一条消息,没收到回复,以为客服不理他,直接给了差评。更糟糕的是,客服正在输入的内容也丢了——WebSocket 断了,会话状态没了。
WebSocket 跟 HTTP 不一样。HTTP 是无状态的,请求断了重试一次就好。WebSocket 是长连接,一旦断了,连接上的状态全丢。发版又是必然事件——你不能为了 WebSocket 永远不发版。
今天聊聊怎么让 WebSocket 在服务发版时平滑过渡,不让用户感知到断线。
WebSocket 发版的三个痛点
滚动更新时,K8s 或者运维平台会给旧 Pod 发 SIGTERM 信号,然后等一段时间(默认 30 秒)后 SIGKILL。WebSocket 没有 HTTP 那样的负载均衡重试机制,Pod 一死连接直接断。
三个痛点:
连接断开——旧 Pod 停了,上面的 WebSocket 连接瞬间全断。用户端要么看到连接断开提示,要么消息发不出去。
状态丢失——客服正在输入的内容、用户当前浏览的页面、会话上下文,全在内存里。Pod 一停,内存就没了。
重连风暴——几千个连接同时断开、同时重连。如果新 Pod 刚启动还没预热完,瞬间涌入的几千个 WebSocket 握手请求可能直接把它打崩。
优雅停机:先通知客户端"我要下班了"
Spring Boot 从 2.3 开始支持优雅停机。配置很简单:
server:
shutdown: graceful
spring:
lifecycle:
timeout-per-shutdown-phase: 30s
但这对 WebSocket 不够。HTTP 请求可以等当前请求处理完再关闭,WebSocket 连接是长时间的,等到自然断开可能要几个小时。
正确的做法是:收到停机信号后,主动给所有 WebSocket 客户端发一条"服务器即将重启"的消息,让客户端主动断开并重连。
@Component
public class WebSocketGracefulShutdown {
// 记录所有活跃连接
private final Set<WebSocketSession> sessions =
ConcurrentHashMap.newKeySet();
@EventListener
public void onShutdown(ContextClosedEvent event) {
log.info("收到停机信号,通知 {} 个客户端准备迁移", sessions.size());
// 1. 给所有客户端发送"即将重启"的通知
CloseMessage msg = new CloseMessage(
CloseStatus.SERVICE_RESTARTED.getCode(),
"server restarting, please reconnect"
);
for (WebSocketSession session : sessions) {
try {
session.close(msg);
} catch (Exception e) {
log.warn("通知客户端失败", e);
}
}
// 2. 等待一段时间让客户端完成重连
// (Spring 的优雅停机超时时间内)
}
}
这样客户端收到的不是"连接被重置"的粗暴断开,而是一条有原因的正常关闭消息。客户端可以根据 CloseStatus.SERVICE_RESTARTED 这个状态码判断——这是服务端主动要求重连,不是网络故障,不需要弹错误提示。
状态保持:让重连后的客户端无缝衔接
通知客户端重连只是第一步。重连之后,如果会话状态全丢,用户的体验还是断层的——客服不知道客户刚才说了什么,客户要重新描述一遍问题。
解法是把 WebSocket 的会话状态外移到 Redis。
连接时:
客户端连接 WebSocket → 服务端生成 sessionId
→ Redis SET session:{sessionId} {userId, page, context}
断开时:
服务端不立即删除 Redis 里的 session
→ 设一个过期时间(比如 30 秒)
→ 如果 30 秒内客户端重连成功,session 还在
重连时:
客户端带上旧的 sessionId 连接 WebSocket
→ 服务端从 Redis 查出原有状态
→ 恢复上下文,无缝衔接
关键的细节:不要在前端生成 sessionId,要在服务端生成。 前端生成的 ID 可能重复或者被伪造。服务端用一个随机 UUID,然后通过 WebSocket 握手后的第一条消息发给客户端,客户端存到 localStorage 里供重连时使用。
连接迁移:配合网关做流量切换
单靠服务端的优雅停机和状态保持还不够。从旧 Pod 停到新 Pod 起,中间有个窗口——旧 Pod 不接新连接了,新 Pod 还没完全就绪。
这个窗口可以用网关(Nginx、Spring Cloud Gateway)来填:
滚动更新流程:
1. 运维平台发 SIGTERM 给 Pod-A
2. Pod-A 通知所有客户端"我要重启了"
3. 网关把 Pod-A 从 upstream 摘除(不再分配 HTTP 请求)
4. 客户端收到通知后主动断开 WebSocket
5. 客户端重连 → 网关把 WebSocket 路由到 Pod-B
6. Pod-A 等待优雅停机超时 → 退出
网关在这里做了两件事:一是新请求不发给旧 Pod,二是 WebSocket 重连时自动路由到健康的新 Pod。
需要在网关层配置 WebSocket 的健康检查——不能只看 /actuator/health 返回 200 就认为 Pod 健康,还得确认 WebSocket 端点可连接。
几个常见的坑
不要把 WebSocket session 存成 Spring Bean
WebSocket 的 Session 对象是每个连接独立的。有人图省事把它 @Autowired 注入了——但这玩意不是单例,注入会失败。正确的做法是用一个 ConcurrentHashMap 或者 CopyOnWriteArraySet 管理。
客户端重连策略要加退避
不要一断开就立刻重连。几千个客户端同时重连等于一次 DDoS。加一个指数退避:
第 1 次重连:等 1 秒
第 2 次重连:等 2 秒
第 3 次重连:等 4 秒
第 4 次及以后:等 8 秒(上限)
加上随机抖动。同时连接别蜂拥而至。
WebSocket 和 HTTP 的健康检查要分开
有人的健康检查是这样写的:/actuator/health 返回 UP 就认为服务正常。但对 WebSocket 服务来说,即使 HTTP 层正常,WebSocket 端点也可能因为端口冲突或线程池耗尽而不可用。
加一个专门的 WebSocket 健康检查:
@EventListener
public void onServerReady(ApplicationReadyEvent event) {
// 注册 WebSocket 端点可用标记
websocketReady = true;
}
网关探测到这个标记变了,才知道该不该把 WebSocket 流量切过来。
总结
WebSocket 的优雅停机,一句话就是别让用户发现你重启了。
三个环节:
通知迁移——停机前发 CloseMessage,让客户端主动断开而不是被动掉线。带上原因码,客户端可以区分"服务器重启"和"网络故障"。
状态外移——会话状态存 Redis,重连后恢复。前端存 sessionId 到 localStorage,重连时带上。
网关配合——摘除旧 Pod 的流量入口,WebSocket 重连自动路由到新 Pod。健康检查要覆盖 WebSocket 端点。
三件事做完,用户只会感觉"好像卡了一下",而不是"客服又掉线了"。
有用的话转给还在发版时手动通知用户"系统维护中"的同事。
标题:WebSocket 优雅停机与连接迁移:服务发版用户频繁掉线?平滑过渡 + 状态保持方案!
作者:jiangyi
地址:http://jiangyi.space/articles/2026/06/10/1780756606634.html
公众号:服务端技术精选
评论