构建高可用 WebSocket 的稳定性基石 —— 心跳机制与性能权衡

websocket

引言

初次接触 WebSocket 的时候,我对它的理解是持久连接。理论上,一次握手之后,客户端和服务端之间会建立起一条全双工的通信管道,可以持续不断的双向传递数据。

但实际使用时,我发现一个看似持久的 WebSocket 连接如果没有任何数据活动,往往会在一分钟甚至更短的时间内断开。我一开始以为是相关代码配置的问题,但修改相关配置似乎并没有什么作用。搜索了一下告诉我必须要实现“心跳机制”。

但我还是觉得有疑问:

  • 若连接本身实际并不持久,那么“持久连接”的核心优势要从何谈起?
  • 文章都说 WebSocket 避免了 HTTP 频繁建连的性能损耗,但为了维持连接引入了心跳机制和服务器内存占用,如何权衡这些性能上的损耗?

1. “持久连接”的真相

确实从协议设计的角度看 WebSocket 的持久连接并非谎言。WebSocket 协议本身确实允许连接在建立后保持开放,直到客户端或服务器任何一方明确的发送一个“关闭”的消息来终止它。那连接为什么会自动断开?

罪魁祸首其实并非协议,而是当前所处的由无数中间设备构成的复杂网络环境。

在 WebSocket 协议进行通信时,会至少经过以下关卡:

  1. NAT 网关

    NAT 扮演着地址转换的角色,为了高效管理有限的公网 IP 资源,NAT 设备会维护一张动态的会话表,如果它检测到某条 TCP 连接在一段时间内没有任何数据包通过,它会武断地认为该连接已失效,并从表中清除该会话。也就是说,这条链路事实性地中断了。

  2. 防火墙

    出于安全和资源管理的考虑,企业级防火墙同样会监控TCP会话的状态,并清除长时间不活动的“僵尸连接”。

  3. 负载均衡器/反向代理

    像 Nginx,通常会配置一个空闲超时时间(Idle Timeout),当一个连接(包括 WebSocket 连接)的空闲时间超过这个阈值,代理服务器会主动关闭它,以释放资源,服务于其他请求。

  4. 移动网络运营商

    为了优化无线信道资源和节省移动设备的电量,4G/5G网络对空闲连接的管理策略通常更为激进,超时时间可能更短。

所以实际上持久连接是一个 WebSocket 协议理论上的美好愿景,要让这个愿景在现实的网络中落地,是需要采取一些主动的工程手段去维护的。

2. 心跳机制,构建可靠性的生命线

心跳机制是维持 WebSocket 持久连接的核心武器。它的原理非常简单:定期通过 WebSocket 连接发送一个极小的数据包,以模拟“业务活动”,重置所有中间节点的空闲计时器。这就像在一段漫长的对话中,一方时不时地问一句“喂,还在听吗?”来确认对方没有掉线。在生产环境中,心跳不是锦上添花的功能,而是保证 WebSocket 可靠性的生命线。

直接上代码,展示如何在 SpringBoot 环境中构建 WebSocket 的心跳机制。

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;

import javax.websocket.*;
import java.nio.ByteBuffer;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;

@ClientEndpoint
@Component
public class HeartbeatWebSocketClient {

    private static final Logger logger = LoggerFactory.getLogger(HeartbeatWebSocketClient.class);
    private Session session;
    private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
    private ScheduledFuture<?> heartbeatTask;

    @OnOpen
    public void onOpen(Session session) {
        this.session = session;
        logger.info("WebSocket连接成功建立,Session ID: {}", session.getId());
        // 连接成功后,启动心跳任务
        startHeartbeat();
    }

    @OnMessage
    public void onMessage(String message) {
        logger.info("收到消息: {}", message);
        // 这里可以添加一个逻辑:任何消息的到来都可以被视为一次“心跳”
    }
    
    // 专门处理Pong消息的回调
    @OnMessage
    public void onPong(PongMessage pongMessage) {
        logger.debug("收到来自服务器的Pong心跳响应");
    }

    @OnClose
    public void onClose(CloseReason closeReason) {
        logger.warn("WebSocket连接关闭,原因: {}", closeReason);
        // 连接关闭后,停止心跳任务
        stopHeartbeat();
    }

    @OnError
    public void onError(Throwable throwable) {
        logger.error("WebSocket发生错误", throwable);
        stopHeartbeat();
    }

    /**
     * 启动心跳任务
     * 每隔25秒向服务器发送一个Ping帧
     */
    private void startHeartbeat() {
        // 使用lambda表达式定义心跳任务
        heartbeatTask = scheduler.scheduleAtFixedRate(() -> {
            try {
                if (this.session != null && this.session.isOpen()) {
                    // Ping帧的负载可以是任意数据,这里我们发送一个空Buffer
                    this.session.getAsyncRemote().sendPing(ByteBuffer.allocate(0));
                    logger.debug("发送Ping心跳...");
                }
            } catch (Exception e) {
                logger.error("发送Ping心跳失败", e);
            }
        }, 25, 25, TimeUnit.SECONDS);
    }

    /**
     * 停止心跳任务
     */
    private void stopHeartbeat() {
        if (heartbeatTask != null && !heartbeatTask.isDone()) {
            heartbeatTask.cancel(true);
        }
    }
}

注:WebSocket 协议内建了 Ping/Pong 控制帧,这是实现心跳的理想方式,因为它在协议层完成,开销极小。

从代码中可以看出:

  • @OnOpen连接成功后,通过ScheduledExecutorService启动一个定时任务。
  • 该任务每隔25秒(这个值应小于大部分网络设备的超时阈值)调用session.getAsyncRemote().sendPing()方法,异步地向服务器发送一个Ping控制帧。
  • 服务器的 WebSocket 实现会自动回复一个 Pong 帧。在客户端通过@OnMessage重载方法onPong(PongMessage)来接收它,一次完整的心跳检测就完成了。
  • @OnClose@OnError时,务必停止心跳任务,以释放资源。

3. 性能的权衡

说完引言中的第一个疑问,现在来说说第二个疑问

WebSocket 和 HTTP(轮询模式)代表了两种截然不同的损耗模式。

  • HTTP 轮询损耗
    • 核心成本:高频次的CPU和网络开销
    • 收益:服务器内存占用低
    • 具体
      • CPU开销:每一次轮询,即使服务器没有新数据,都需要完整的TCP/TLS握手(如果连接未复用)、HTTP头部解析等流程,这些都是密集的CPU操作
      • 网络开销:每次请求都携带数百字节的HTTP头部信息(Cookie、User-Agent等),其中大部分是重复的,浪费了带宽
      • 内存开销:由于连接是“用完即走”的,服务器只需为处理请求的瞬间分配资源,因此内存占用非常低。
  • WebSocket 损耗
    • 核心成本:持续性的服务器内存开销
    • 收益:极低的通信延迟、CPU 和网络开销
    • 具体
      • CPU开销:仅在初次握手时有一次性的较高开销,后续的数据帧头部很小(2-14字节),解析效率高
      • 网络开销:除了有效载荷,几乎没有额外的协议开销,网络利用率高
      • 内存开销:这是 WebSocket 的主要代价。服务器必须为每一个连接的客户端维持一个 TCP 套接字、一个 Session 对象、以及相应的读写缓冲区。10万个在线用户就意味着服务器上存在10万个持续占用内存的对象。

用表格进行一个对比:

损耗维度 HTTP 轮询 WebSocket
CPU开销 (重复握手/解析) (轻量级数据帧)
网络开销 (冗余的HTTP头) 极低 (协议开销小)
内存开销 (无状态,瞬时) (有状态,持久占用)
通信延迟 (取决于轮询间隔) 极低 (实时推送)

所以对于需要实时、双向、高频通信的应用,HTTP 轮询的 CPU 和网络成本是毁灭性的,它会迅速耗尽服务器资源并导致糟糕的用户体验。在这种场景下就要考虑使用 WebSocket,用可预期的内存增长来换取性能瓶颈的释放,是一笔很其划算的交易。

4. 如何选择?

虽然对于 HTTP 和 WebSocket 的异同对于开发人员来说如同常识一般,但还是在此进行一个整理。

选择 HTTP:

  • 通信频率低,主要由客户端发起(典型的请求-响应)
  • 对实时性要求不高,秒级的延迟可以接受
  • 无状态的 RESTful API
  • 服务器内存有限,需要服务于海量的、非持续性的并发请求

选择 WebSocket:

  • 需要双向通信,服务器须能随时主动向客户端推送数据
  • 对延迟敏感,要求毫秒级的实时性
  • 通信频率高,数据交换频繁
  • 能够承受为每个活跃连接保留一部分服务器内存的代价

总结

回到引言中最初的问题。

  1. WebSocket “持久连接”的核心优势从何谈起?

    持久连接是协议的真实意图,但需要心跳机制这种工程手段,在现实的网络环境中去努力维持与实现。

  2. 如何权衡 WebSocket 和 HTTP 上的性能上的损耗?

    需要充分了解 HTTP 和 WebSocket 之间的区别,在合适的场景选择合适的方式进行通信。

希望这篇文章能够为在面对复杂多变的业务需求时选择合适的方式提供一些思路,构建出稳定、高效、可靠的系统。

posted @ 2025-07-14 15:18  knqiufan  阅读(220)  评论(0)    收藏  举报