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
公众号:服务端技术精选
    评论
    0 评论
avatar

取消