WEB通信机制——轮询、SSE和WebSocket

HTTP的不足------半双工

  1. HTTP 三大核心基础特性

    (1)无状态

    HTTP 协议本身不保存客户端与服务端的通信记录,每次请求都是独立、隔离的。服务端无法识别两次请求是否来自同一个用户,不会自动留存会话数据;若要实现登录、用户身份识别,必须借助 Cookie、Session、Token 等额外机制手动维护状态。

    (2)短连接(默认短连接,HTTP/1.1 默认长连接仅复用 TCP 通道)

    一次完整流程:客户端发起请求 → 服务端处理并返回完整响应 → TCP 连接立即断开。

    即便 HTTP/1.1 通过 Connection: keep-alive 支持连接复用,也只是短暂保持通道,闲置一段时间后仍会主动断开;不存在永久持续的通信链路,每次数据交互都需要重新建立 / 复用连接,开销较高。

    (3)半双工通信

    半双工指通信双方拥有收发能力,但同一时间仅允许一方发送数据,另一方只能接收,无法同时双向传输。

    HTTP 采用「请求 - 响应」固定模型,强制单向交互:

    只能由客户端主动发起请求,服务端收到请求后才能返回响应;在无客户端请求的前提下,服务端没有任何渠道主动向客户端下发数据。

  2. HTTP 面向实时业务的核心痛点

  • 无法主动推送:服务端产生新消息、实时数据时,不能主动下发给前端,只能等待客户端来拉取;
  • 实时性差:消息存在延迟,数据更新完全依赖客户端请求频率;
  • 资源浪费:若要降低延迟,只能提高请求频次,产生大量无效空请求,消耗带宽与服务端性能;
  • 不支持双向交互:聊天、协同编辑、实时对战等双方频繁互发消息的场景完全无法原生实现。
  1. 前端实时通信技术迭代演进路线
    为弥补 HTTP 无法实时推送的短板,行业基于原有协议逐步迭代出四类方案,复杂度、实时性依次提升:
    • 短轮询:最简单的兼容方案,前端定时循环发起 HTTP 请求,主动拉取最新数据;
    • 长轮询:优化短轮询无效请求问题,前端发起请求后服务端阻塞挂起连接,有新数据 / 超时才返回,收到响应后前端立刻重连;
    • SSE(Server-Sent Events):基于 HTTP 流式长连接,专门实现服务端单向持续推送,浏览器原生支持自动重连,仅支持后端发、前端收;
    • WebSocket:全新独立通信协议,通过一次 HTTP 握手升级为 TCP 持久长连接,实现全双工双向通信,客户端与服务端可随时主动发送数据,是实时交互场景最优解

一、轮询

1.1 轮询

客户端定时向后端发送 HTTP 请求,后端无论有无新数据,立刻返回响应,客户端拿到结果后再次定时请求。

这种方式实现简单,但效率较低,客户端未更新时产生的是无效请求。

1.2 长轮询

客户端发起请求,后端不立即响应,而是hang住挂起连接,直到后端有新数据、或超时,才返回结果,客户端收到响应后,立刻再次发起新请求,保持持续连接。

解决短轮询频繁无效请求的问题,同时可支持弹性扩展,但保持链接需消耗更多资源。

1.3 实现方法

实现方法为前端递归轮询,有消息则获取并直接进行下一次监听,后端消息发生变化时返回推送,否则到达超时时间则返回空消息。

Vue前端轮询请求:

javascript 复制代码
<template>
  <div>
    <h3>长轮询最新消息:{{ longMsg }}</h3>
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import axios from 'axios'

const longMsg = ref('')
let pollingFlag = true

// 递归持续长轮询
function startLongPoll() {
  if (!pollingFlag) return
  axios.get('http://localhost:8080/poll/long').then(res => {
    // 收到新消息更新页面
    if (res.data.newMsg) {
      longMsg.value = res.data.data
    }
    // 立刻再次发起请求,保持长连接循环
    startLongPoll()
  }).catch(err => {
    console.error('长轮询异常,3s后重连', err)
    setTimeout(startLongPoll, 3000)
  })
}

onMounted(() => {
  startLongPoll()
})

// 组件卸载停止轮询
onUnmounted(() => {
  pollingFlag = false
})
</script>

Java后端

java 复制代码
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;

@RestController
@RequestMapping("/poll")
public class LongPollController {
    // 存储消息
    private String message = "默认消息";
    // 标记是否有新消息
    private volatile boolean hasNew = false;

    // 模拟后台推送新消息
    @GetMapping("/setMsg")
    public String setMsg(String content) {
        this.message = content;
        this.hasNew = true;
        return "已推送新消息";
    }

    @GetMapping("/long")
    public Map<String, Object> longPoll() throws InterruptedException {
        // 阻塞等待,最多等待5秒超时
        long waitMax = 5000;
        long start = System.currentTimeMillis();

        // 无新消息则循环等待
        while (!hasNew) {
            TimeUnit.MILLISECONDS.sleep(200);
            // 超时直接返回空标记
            if (System.currentTimeMillis() - start > waitMax) {
                return Map.of("code", 200, "data", "", "newMsg", false);
            }
        }
        // 有新消息,重置标记并返回
        hasNew = false;
        return Map.of(
                "code", 200,
                "data", message,
                "newMsg", true
        );
    }
}

1.4 评价与适用场景

短轮询

优点:实现简单、兼容性好、服务端逻辑简单

缺点:请求量大、无效请求多、占用带宽、延迟高

场景:简单心跳、低实时性场景(简单消息刷新、简单状态同步)

长轮询

优点:减少无效请求、实时性优于短轮询、基于 HTTP 无兼容性问题

缺点:服务端连接挂起占用资源、超时重连、并发压力大

场景:早期 IM 聊天、简单消息通知、银行通知、小程序实时状态

二、SSE

2.1 Server-Sent Events

SSE 全称 Server-Sent Events,是一套完全基于标准 HTTP 协议的浏览器实时推送规范,不需要像 WebSocket 那样进行协议升级握手,仅依靠普通 GET 请求即可建立长连接。

核心通信方向为单向通信:只允许服务端主动向客户端持续下发数据流,客户端没有任何接口、机制向服务端发送消息,天生只适用于服务端下发数据的场景。

SSE 能够持续分段推送数据,依靠固定 MIME 类型标识流传输,后端必须在响应头设置:

复制代码
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive

text/event-stream:告诉浏览器当前响应是持续文本流,不要一次性缓存完整响应再返回,收到一段就立刻交给前端解析;

Cache-Control: no-cache:关闭浏览器缓存,避免推送数据被缓存拦截;

Connection: keep-alive:维持 TCP 长连接,持续传输消息。

同时 SSE 规定了固定消息分段格式,每条消息由多行标识组成,以空行 \n\n 作为一条消息结束分隔符,支持三类关键字段:

  • data::承载实际传输的数据(字符串 / JSON),最核心字段;
  • event::自定义事件名称,前端可单独监听不同类型业务消息;
  • id:事件唯一 ID,用于断点续传;
  • retry::单位毫秒,指定断线后浏览器自动重连的间隔时间。

标准消息示例:

复制代码
event: notice
id: 1001
data: {"title":"系统公告","content":"服务器已完成升级"}

2.2 实现方法

后端:设置响应头 Content-Type: text/event-stream,使用SseEmitter持续推送流数据(该对象会根据请求自动对应地址,Tomcat自动封装上下文),使用SSE推送的Java代码如下:

java 复制代码
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

import java.io.IOException;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;

@RestController
@RequestMapping("/api/sse")
public class SseController {

    // 定时线程模拟业务实时数据
    private final ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor();

    @GetMapping("/connect")
    public SseEmitter connect() {
        // 设置连接超时 60s,0L=永不超时,emitter 对象是一对一绑定单个浏览器连接的,前端发起请求自动封装上下文
        SseEmitter emitter = new SseEmitter(60000L);

        // 连接关闭/异常回调
        emitter.onCompletion(() -> System.out.println("SSE 连接关闭"));
        emitter.onError((e) -> {
            System.err.println("SSE异常:" + e.getMessage());
            emitter.complete();
        });
        emitter.onTimeout(() -> System.out.println("SSE连接超时"));

        // 每秒推送一条实时消息
        executor.scheduleAtFixedRate(() -> {
            try {
                String data = "当前服务时间:" + System.currentTimeMillis();
				// 1. 创建一条标准SSE消息构建器
				SseEmitter.SseEventBuilder event = SseEmitter.event()
				       .name("message") // 自定义事件类型标识
				       .data(data);     // 要推送给前端的业务数据
				// 2. 把这条消息通过当前客户端长连接发送出去
				emitter.send(event);
            } catch (IOException e) {
                emitter.complete();
            }
        }, 0, 1, TimeUnit.SECONDS);

        return emitter;
    }
}

前端:使用浏览器原生API EventSource(url) 对接 SSE 服务,发起一条GET 长连接 HTTP 请求,持续接收后端 text/event-stream 流式数据,可指定具体事件与后端匹配,vue前端接收代码如下:

javascript 复制代码
<template>
  <div class="sse-box">
    <h3>SSE实时消息推送</h3>
    <div v-for="item in msgList" :key="item">{{ item }}</div>
    <button @click="closeSse">手动断开连接</button>
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted } from 'vue'

const msgList = ref([])
let eventSource = null

// 初始化SSE连接
const openSse = () => {
  const url = 'http://localhost:8080/api/sse/connect'
  eventSource = new EventSource(url)

  // 监听后端name="message"事件
  eventSource.addEventListener('message', e => {
    msgList.value.push(e.data)
  })

  // 连接出错,浏览器会自动重试
  eventSource.onerror = err => {
    console.log('SSE连接异常', err)
  }
}

// 手动关闭
const closeSse = () => {
  if (eventSource) {
    eventSource.close()
    console.log("已关闭SSE")
  }
}

onMounted(() => openSse())
// 页面销毁关闭连接,防止内存泄漏
onUnmounted(() => closeSse())
</script>

出于安全性,浏览器限制脚本内发起的跨源 HTTP 请求,强制实施同源策略,只有满足下面三点全部相同,才属于同源,接口请求不受限制:

  1. 协议相同:http /https
  2. 域名 / IP 相同:localhost / 127.0.0.1 / 业务域名
  3. 端口相同:8080、5173、3000
    只要任意一项不同,就是跨域请求,浏览器会拦截后端返回的数据,控制台报 CORS 跨域错误。

前后端项目通常配置为跨域场景

Vue 前端地址:http://localhost:5173

SpringBoot 后端地址:http://localhost:8080

二者端口不一致,属于典型跨域,不配置 CORS 前端拿不到后端数据。

CORS = Cross-Origin Resource Sharing 跨域资源共享 ,是一套后端配置规则,让后端主动告诉浏览器:允许某个前端域名跨域访问我的接口。

CORS 由后端响应头实现,WebMvcConfigurer 是 Spring 提供的简化配置方式,不用手动写响应头

后端配置如下:

java 复制代码
@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {

    private final JwtInterceptor jwtInterceptor;

    /**
     * CORS 配置:允许前端开发服务器(localhost:5173)跨域访问所有 /api/** 接口。
     * allowCredentials=true 支持携带 Cookie;生产环境应限制 allowedOrigins。
     */
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")
                .allowedOrigins("http://localhost:5173")
                .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
                .allowedHeaders("*")
                .allowCredentials(true)
                .maxAge(3600);
    }

    /**
     * 注册 JWT 拦截器:
     * - 拦截生成与查询接口(需要登录)
     * - 排除认证接口(注册/登录本身不需要 Token)
     */
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(jwtInterceptor)
                .addPathPatterns("/api/generate", "/api/task/**", "/api/user/**")
                .excludePathPatterns("/api/auth/**");
    }
}

2.3 评价与适用场景

优点:

  1. 轻量、带宽开销极低;
  2. 浏览器原生EventSourceAPI,无需第三方库;
  3. 底层内置断线自动重连、支持断点续传(Last-Event-ID);
  4. 基于标准 HTTP,网关、防火墙、Nginx 友好,跨域配置和普通接口一致,无 WebSocket 特殊代理配置。

缺点:

  1. 严格单向通信:只能服务端→客户端推送,前端无法发送数据;
  2. 老旧 IE 等浏览器不兼容;
  3. 单条连接仅支持下行,无法双向交互;
  4. 不支持二进制消息,只能传utf-8文本。

适用场景:

监控大屏、实时日志、股票行情、系统公告、文件导出 / 任务进度推送等仅后端主动下发数据的业务

SseEmitter 的长连接是基于异步非阻塞 Web 模型,不占用 Tomcat 核心工作线程,同时一条 TCP 连接复用全程,不反复新建请求,所以服务端承压能力远高于长轮询。

三、WebSocket

3.1 全双工通信WebSocket

WebSocket 是一套独立的双向通信协议,不基于普通 HTTP 做持续流,依靠一次 HTTP 握手完成协议升级,切换为专用 ws/wss 长连接通道,通信开销小,但开发复杂度较高。

3.2 实现方法

前端:原生 WebSocket 对象

后端通过ServerEndpoint注解绑定url,建立连接、接受消息、连接关闭等事件都通过注解绑定进行操作,语法为

复制代码
@ServerEndpoint("/ws/chat") // 修饰类,绑定url,前端使用new WebSocket("ws://ip:端口/ws/chat")自动匹配
@OnOpen    // 客户端成功建立连接时触发
@OnMessage // 收到前端发送的文本/二进制消息触发
@OnClose   // 连接正常关闭触发
@OnError   // 连接发生异常、断线报错触发

具体使用方法如下:

引入依赖

xml 复制代码
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-websocket</artifactId>
</dependency>

实现代码

java 复制代码
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;

// websocket管理类
@Configuration
public class WebSocketConfig {
    // 自动扫描@ServerEndpoint注解的websocket端点
    @Bean
    public ServerEndpointExporter serverEndpointExporter() {
        return new ServerEndpointExporter();
    }
}

import jakarta.websocket.*;
import jakarta.websocket.server.ServerEndpoint;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.util.concurrent.CopyOnWriteArraySet;

@Component
@ServerEndpoint("/ws/chat") // 前端连接地址 ws://localhost:8080/ws/chat
public class WebSocketEndpoint {
    // 存储所有在线客户端session
    private static final CopyOnWriteArraySet<Session> SESSION_SET = new CopyOnWriteArraySet<>();
    private Session session;

    // 连接建立成功
    @OnOpen
    public void onOpen(Session session) {
        this.session = session;
        SESSION_SET.add(session);
        sendMsgAll("有新用户上线,当前在线人数:" + SESSION_SET.size());
    }

    // 接收前端发送的消息
    @OnMessage
    public void onMessage(String message, Session session) {
        System.out.println("收到前端消息:" + message);
        // 广播给所有客户端
        sendMsgAll("服务端收到消息:" + message);
    }

    // 连接关闭
    @OnClose
    public void onClose() {
        SESSION_SET.remove(this.session);
        sendMsgAll("用户下线,当前在线人数:" + SESSION_SET.size());
    }

    // 连接异常
    @OnError
    public void onError(Session session, Throwable error) {
        error.printStackTrace();
    }

    // 群发消息
    private void sendMsgAll(String msg) {
        for (Session s : SESSION_SET) {
            try {
                s.getBasicRemote().sendText(msg);
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

前端使用new WebSocket(url)创建websocket连接,使用后端类似方法处理事件,Vue代码实例如下:

javascript 复制代码
<template>
  <div class="ws-demo">
    <h3>WebSocket聊天室</h3>
    <!-- 消息列表 -->
    <div class="msg-box">
      <div v-for="(item, idx) in msgList" :key="idx">{{ item }}</div>
    </div>
    <!-- 发送消息 -->
    <input v-model="sendText" placeholder="输入消息" />
    <button @click="sendMsg">发送</button>
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted } from 'vue'

const msgList = ref([])
const sendText = ref('')
let ws = null

// 初始化WebSocket连接
const initWs = () => {
  // ws协议,后端地址
  const url = 'ws://localhost:8080/ws/chat'
  ws = new WebSocket(url)

  // 连接成功回调
  ws.onopen = () => {
    console.log('WebSocket连接成功')
  }

  // 接收后端推送消息
  ws.onmessage = (event) => {
    console.log('收到后端消息', event.data)
    msgList.value.push(event.data)
  }

  // 连接关闭
  ws.onclose = () => {
    console.log('连接关闭,可做重连逻辑')
  }

  // 连接报错
  ws.onerror = (err) => {
    console.error('WebSocket异常', err)
  }
}

// 向服务端发送消息
const sendMsg = () => {
  if (!ws || ws.readyState !== WebSocket.OPEN) {
    alert('连接未就绪')
    return
  }
  ws.send(sendText.value)
  sendText.value = ''
}

// 页面挂载建立连接
onMounted(() => {
  initWs()
})

// 页面销毁关闭连接,防止内存泄漏
onUnmounted(() => {
  if (ws) ws.close()
})
</script>

3.3 评价与适用场景

优点:真正实时、双向通信、极低延迟、无冗余请求

缺点:协议特殊、部分防火墙拦截、开发复杂度高 (每个客户端Session独立,若A=>socket1,B=>socket2,A的消息要给B展示,则需要在socket内部增加通信机制,所以开发复杂度高真不是说说而已)

场景:聊天室、直播弹幕、游戏联机、协同编辑、实时对战、智能设备实时通信

四、总结

对比维度 短轮询 长轮询 SSE WebSocket
通信模式 单向拉取(客户端主动) 单向拉取(客户端主动) 单向推送(服务端主动) 全双工双向通信
底层协议 HTTP 短连接 HTTP 挂起长连接 HTTP 流式长连接(text/event-stream) 独立 WS 协议(HTTP 握手升级)
服务端线程模型 同步、用完即放 同步阻塞(高并发耗线程) 异步非阻塞(不占业务线程) 异步非阻塞(高性能)
传参方式 GET/POST 任意、灵活 GET/POST 任意、灵活 仅支持 GET,不可传请求体 握手HTTP传参,连接后自由收发
数据格式支持 任意 HTTP 数据 任意 HTTP 数据 仅 UTF-8 文本/JSON,不支持二进制 文本 + 二进制(文件/流/图片)
带宽开销 极高(大量无效请求头) 中(减少无效请求) 极低(单长连接持续推送) 极低(自定义帧、无冗余头)
实时性 差(固定间隔延迟) 中(接近实时) 极高(毫秒级)
开发复杂度 极低 较高(需管理会话、广播、重连)
核心优点 最简单、兼容所有设备 解决无效请求、比短轮询实时 轻量、自动重连、服务端压力小 双向通信、低延迟、支持二进制
核心缺点 无效请求多、浪费带宽 阻塞线程、并发压力大 单向、仅GET、无二进制 连接独立、需要手动管理会话集群
最佳适用场景 低频刷新、简单状态、老旧设备 简单消息通知、老项目兼容 大屏数据、日志、进度条、公告推送 聊天、弹幕、协同编辑、实时交互

web通信的三种方式没有谁完全高于谁的说法,本质上都是trade-off。

实时性要求不高,要求兼容性好的场景可使用轮询,同时需要请求体的消息SSE也无法完成;

只有服务器向前端推送可使用SSE,真正需要双向通信的,才考虑WebSocket。