实时数据大屏的 4 种推送方案:短轮询 → 长轮询 → SSE → WebSocket

实时数据大屏的 4 种推送方案:短轮询 → 长轮询 → SSE → WebSocket

引言

上周有个朋友在面试时被问到:你上家公司的可视化是怎么做的,如果让你做一个实时数据大屏,你会怎么做?

他说:听起来很简单对吧?前端搞个 setInterval 每秒调一次接口不就行了?

直到面试官问道:没有用过WebSocket吗?

今天我们就系统性地把实时数据推送的方案都调研一遍,从最简陋的短轮询到全双工的 WebSocket,把每种方案的代码、原理都跑一遍。

这篇文章解决什么问题? 当你需要一个"后端数据变化→前端实时展示"的场景,该选哪种推送方案,以及每种方案的 Spring Boot 代码怎么写。

环境信息:Spring Boot 3.2.7 | JDK 21 | 前端原生 HTML + ECharts(CDN)


方案全景图

四种方案的本质区别在于------数据主动权在谁手里

sql 复制代码
短轮询:  客户端主动拉 ──→ "有新数据吗?""没有""有新数据吗?""没有"...
长轮询:  客户端主动拉 ──→ 服务端先hold住,有数据再响应
SSE:     服务端主动推 ──→ 单向(服务端→客户端)
WebSocket:双向通信    ──→ 全双工(客户端↔服务端)
维度 短轮询 长轮询 SSE WebSocket
实时性 秒级 亚秒级 实时 实时
方向 单向(C→S) 单向(C→S→C) 单向(S→C) 双向
连接模型 短连接 长连接(按需) 长连接(单向) 长连接(双向)
浏览器兼容 100% 100% 除 IE 外全支持 全支持
服务端复杂度 ⭐⭐ ⭐⭐ ⭐⭐⭐
HTTP/2 多路复用 ❌(独占连接) N/A(独立协议)
天然支持重连 ✅(EventSource API) 需手动实现

方案一:短轮询 --- 最简单也最"假实时"

原理

前端定时发起 HTTP 请求,后端直接返回最新数据。本质上是拉模式(Pull),实时性取决于轮询间隔。

复制代码
客户端:    ══req══▶  ══req══▶  ══req══▶
服务端:    ◀══res══  ◀══res══  ◀══res══
时间轴:    0s         2s         4s

后端代码

java 复制代码
// ShortPollingController.java
@RestController
public class ShortPollingController {

    private final MetricSimulator simulator;

    public ShortPollingController(MetricSimulator simulator) {
        this.simulator = simulator;
    }

    @GetMapping("/api/poll/short")
    public ResponseEntity<Map<String, Object>> shortPoll() {
        return ResponseEntity.ok(Map.of(
            "cpu", simulator.getCpuUsage(),
            "qps", simulator.getQps(),
            "orders", simulator.getOrderCount(),
            "timestamp", System.currentTimeMillis()
        ));
    }
}

前端代码

javascript 复制代码
// 每 2 秒拉一次数据
setInterval(async () => {
    const res = await fetch('/api/poll/short');
    const data = await res.json();
    updateChart(data);  // 更新 ECharts 图表
}, 2000);

核心问题

这个方案最大的问题不是你写了什么代码,而是你没写的那些细节

  1. 服务端压力:N 个客户端 × 每秒 0.5 次请求 = 随时间线性增长。100 个客户端意味着每秒 50 个请求------虽然不大,但大部分请求返回的数据根本没变。
  2. 实时性天花板:轮询间隔 = 最大延迟。设 1 秒间隔,最多晚 1 秒才看到新数据。设 100ms,请求量翻 10 倍。
  3. 资源浪费:99% 的请求可能什么都没拉到------数据根本没变。

适用场景

  • 数据变化频率低(几分钟才变一次)
  • 实时性要求不高(秒级延迟可接受)
  • 客户端数量可控(内部系统几十个人用)
  • 快速上线是第一优先级

方案二:长轮询 --- 服务端帮你"排队"

原理

客户端发起请求,服务端不是立刻返回,而是先 hold 住,等到有新数据再返回。客户端拿到响应后,立刻发起下一个请求。这个模式也叫 "Comet"。

scss 复制代码
客户端:    ════════req════════▶
服务端:    (hold... 等新数据...) ◀═══════res══════
客户端:                            ════════req════════▶

Spring Boot 实现:DeferredResult

java 复制代码
// LongPollingController.java
@RestController
public class LongPollingController {

    // 存放所有等待中的客户端
    private final CopyOnWriteArrayList<DeferredResult<Map<String, Object>>> waiters
            = new CopyOnWriteArrayList<>();

    private final MetricSimulator simulator;

    public LongPollingController(MetricSimulator simulator) {
        this.simulator = simulator;
        // 模拟:每 1 秒有新数据时,通知所有等待的客户端
        Executors.newSingleThreadScheduledExecutor()
            .scheduleAtFixedRate(this::notifyAllWaiters, 0, 1, TimeUnit.SECONDS);
    }

    @GetMapping("/api/poll/long")
    public DeferredResult<Map<String, Object>> longPoll() {
        // 30 秒超时,超时后返回空(客户端会重连)
        DeferredResult<Map<String, Object>> result =
                new DeferredResult<>(30_000L, Map.of("timeout", true));

        waiters.add(result);

        result.onCompletion(() -> waiters.remove(result));
        result.onTimeout(() -> waiters.remove(result));

        return result;
    }

    private void notifyAllWaiters() {
        Map<String, Object> data = Map.of(
            "cpu", simulator.getCpuUsage(),
            "qps", simulator.getQps(),
            "timestamp", System.currentTimeMillis()
        );
        // 通知所有等待者,并清空列表
        List<DeferredResult<Map<String, Object>>> snapshot;
        snapshot = new ArrayList<>(waiters);
        waiters.clear();
        for (DeferredResult<Map<String, Object>> waiter : snapshot) {
            waiter.setResult(data);
        }
    }
}

前端代码

javascript 复制代码
async function longPoll() {
    try {
        const res = await fetch('/api/poll/long');
        const data = await res.json();
        if (!data.timeout) {
            updateChart(data);
        }
    } finally {
        longPoll();  // 拿到结果后立刻发起下一次请求
    }
}
longPoll();  // 启动

要点

  • DeferredResult 是 Spring MVC 提供的异步返回机制,它不会阻塞 Tomcat 的工作线程(这是关键!如果用 Thread.sleep() hold 住请求,线程池很快就耗尽了)
  • CopyOnWriteArrayList 适合读多写少的场景,这里 waiters 列表的 notify 操作需要遍历,但添加/移除操作相对少
  • 超时时间要合理设置------太短增加请求次数,太长可能被网关/代理断开(Nginx 默认 proxy_read_timeout 是 60s)

适用场景

  • 对实时性要求比短轮询高
  • 需要兼容老旧浏览器或网络环境(HTTP 协议,没有额外握手)
  • 微信小程序(小程序对 WebSocket 有限制时可用长轮询兜底)

方案三:SSE(Server-Sent Events)--- HTTP 原生推送

原理

SSE 是 HTML5 标准的一部分。客户端通过 EventSource API 建立一个 HTTP 长连接,服务端以 text/event-stream 格式单向推送数据。客户端自动处理重连,不需要手动写重试逻辑。

csharp 复制代码
客户端:    ═══req══▶
服务端:    ◀══event════◀══event════◀══event════ (持续推送...)

Spring Boot 实现:SseEmitter

java 复制代码
// SseController.java
@RestController
public class SseController {

    private final MetricSimulator simulator;

    public SseController(MetricSimulator simulator) {
        this.simulator = simulator;
    }

    @GetMapping(value = "/api/sse/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public SseEmitter stream() {
        // 超时设为 0 表示永不超时(实际由底层连接决定)
        SseEmitter emitter = new SseEmitter(0L);

        ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor();
        ScheduledFuture<?> future = executor.scheduleAtFixedRate(() -> {
            try {
                Map<String, Object> data = Map.of(
                    "cpu", simulator.getCpuUsage(),
                    "qps", simulator.getQps(),
                    "timestamp", System.currentTimeMillis()
                );
                emitter.send(SseEmitter.event()
                    .name("metric-update")  // 命名事件,前端可以按事件名监听
                    .data(data));
            } catch (IOException e) {
                // 客户端断开连接
                emitter.completeWithError(e);
            }
        }, 0, 1, TimeUnit.SECONDS);

        // 连接关闭时清理定时任务
        emitter.onCompletion(() -> future.cancel(true));
        emitter.onTimeout(() -> future.cancel(true));
        emitter.onError(e -> future.cancel(true));

        return emitter;
    }
}

前端代码 --- EventSource API,三行搞定

javascript 复制代码
const es = new EventSource('/api/sse/stream');

// 监听命名事件(对应后端 .name("metric-update"))
es.addEventListener('metric-update', (e) => {
    const data = JSON.parse(e.data);
    updateChart(data);
});

// 连接断开自动重连,无需写任何代码!
// 如果想在重连时做点什么:
es.addEventListener('error', (e) => {
    console.log('连接断开,EventSource 正在自动重连...');
});

SSE 的收与放

优势

  • 自动重连:EventSource API 内置指数退避重连策略,不需要你写一行重连代码
  • 轻量:基于 HTTP 协议,不需要像 WebSocket 那样升级连接
  • 穿透代理:纯 HTTP,不会被不理解 WebSocket 的代理拦截

局限

  • 单向推送:只能服务端→客户端,客户端发数据需要另行 POST
  • 连接数限制 :浏览器对同域名的 HTTP 连接数有限制(HTTP/1.1 下约 6 个),SSE 会占用一个。用 HTTP/2 可以解决这个问题
  • 不支持二进制text/event-stream 只能传文本,传二进制需要 base64 编码

方案四:WebSocket --- 全双工双向通信

原理

WebSocket 通过一次 HTTP Upgrade 握手,将 HTTP 连接升级为 TCP 长连接,之后双方可以随时互发消息。它有自己的帧协议,头部只有 2-14 字节,比 HTTP 头轻得多。

复制代码
客户端:    ═══Upgrade══▶
服务端:    ◀══101 Switching══
之后:      ◀══════▶ 全双工帧通信 ◀══════▶

Spring Boot 实现:STOMP over WebSocket

STOMP(Simple Text Oriented Messaging Protocol)是 WebSocket 之上的一个子协议,提供了"主题订阅"的语义,用起来像消息队列。

Step 1:配置

java 复制代码
// WebSocketConfig.java
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        // 客户端订阅的前缀(服务端→客户端)
        registry.enableSimpleBroker("/topic");
        // 客户端发送消息的前缀(客户端→服务端)
        registry.setApplicationDestinationPrefixes("/app");
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        // SockJS 是 WebSocket 的降级方案(不支持 WebSocket 时自动降级到长轮询)
        registry.addEndpoint("/ws")
                .setAllowedOriginPatterns("*")
                .withSockJS();
    }
}

Step 2:服务端定时推送

java 复制代码
// WebSocketPushService.java
@Service
public class WebSocketPushService {

    private final SimpMessagingTemplate messagingTemplate;
    private final MetricSimulator simulator;

    public WebSocketPushService(SimpMessagingTemplate messagingTemplate,
                                 MetricSimulator simulator) {
        this.messagingTemplate = messagingTemplate;
        this.simulator = simulator;
    }

    @Scheduled(fixedRate = 1000)
    public void pushMetrics() {
        Map<String, Object> data = Map.of(
            "cpu", simulator.getCpuUsage(),
            "qps", simulator.getQps(),
            "timestamp", System.currentTimeMillis()
        );
        // 向所有订阅 /topic/metrics 的客户端广播
        messagingTemplate.convertAndSend("/topic/metrics", data);
    }
}

Step 3:前端代码

html 复制代码
<script src="https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/stompjs@2/lib/stomp.min.js"></script>
<script>
const socket = new SockJS('/ws');
const stomp = Stomp.over(socket);

stomp.connect({}, () => {
    stomp.subscribe('/topic/metrics', (msg) => {
        const data = JSON.parse(msg.body);
        updateChart(data);
    });
});
</script>

WebSocket 的生产级考量

WebSocket 功能最强,但在生产环境中要考虑的事情也最多:

  • 连接状态管理 :客户端断线后需要手动重连(stompClientonclose 回调可以处理)
  • 心跳保活:STOMP 有内置 heartbeat 配置,但代理/负载均衡器也可能有独立的超时
  • 认证 :WebSocket 握手是 HTTP 请求,可以在 URL 上带 token,也可以在第一帧 CONNECT 时传
  • 水平扩展:多实例部署时,需要 STOMP broker(RabbitMQ/ActiveMQ)做消息中继,或者用 Redis pub/sub 跨实例广播
  • 内存:每个 WebSocket 连接大约占用几 KB 到几十 KB 内存,1 万个连接可能吃掉几百 MB

方案五 & 六:MQTT 与 RSocket --- 进阶选项

这两个方案不在 Demo 中演示,但作为高级工程师你应该知道它们的存在:

MQTT:物联网场景的首选。超轻量(最小头部仅 2 字节),支持 QoS(消息质量保证),客户端断线后 broker 可以缓存消息。如果你的"实时大屏"面向的是嵌入式设备或移动端弱网环境,MQTT 比 WebSocket 更合适。Java 实现可以用 Eclipse Mosquitto + Spring Integration MQTT。

RSocket :反应式网络协议,支持四种交互模式(请求-响应、流、通道、fire-and-forget)。如果你的微服务体系已经是 WebFlux + Reactor 技术栈,RSocket 的 requestStream 模式天然适合服务端推送。但目前浏览器不支持 RSocket,需要经过 WebSocket 桥接。


选型决策树

markdown 复制代码
需要双向通信?(客户端也要给服务端发消息)
   ├── 是 ──▶ WebSocket
   └── 否 ──▶ 实时性要求高?
                 ├── 是 ──▶ 需要兼容老旧浏览器?
                 │            ├── 是 ──▶ 长轮询
                 │            └── 否 ──▶ SSE(推荐)
                 └── 否 ──▶ 短轮询

一句话建议

内部数据大屏,单向推送,用 SSE。代码量少、自动重连、HTTP 协议无额外部署成本。

需要双向交互(如聊天室、协同编辑),用 WebSocket

需求不确定或快速原型,先用短轮询跑起来再说。


生产环境注意要点清单 ⚠️

写到这里,Demo 代码跑起来一切正常。但当你准备部署到生产环境时,下面这几个坑大概率会遇到

要点1:Nginx 反向代理把 SSE "吞了"

最常见的翻车现场:本地调试 SSE 一切正常,部署到服务器后前端收不到流式数据,或者等了 30 秒一次性收到一大坨。

原因 :Nginx 默认开启 proxy_buffering on,会把后端响应先缓冲到内存/磁盘,攒够了再发给客户端。这对普通 HTTP 响应是优化,对 SSE 是灾难。

解决:在 Nginx 的 location 配置中关掉缓冲:

nginx 复制代码
location /api/sse/ {
    proxy_buffering off;
    proxy_cache off;
    proxy_read_timeout 86400s;  # SSE 是长连接,超时设大
    proxy_set_header Connection '';
    proxy_http_version 1.1;     # 长连接需要 HTTP/1.1
}

另外,响应头中加 X-Accel-Buffering: no (Spring 端 response.setHeader("X-Accel-Buffering", "no")),这会让 Nginx 知道自己不该缓冲这个响应。

要点 2:Spring MVC 的异步超时比你想象得短

我在 SseEmitter 构造里传了 0L(永不超时),但 Spring MVC 还有一个全局配置 spring.mvc.async.request-timeout默认值只有 30 秒。如果不改这个,SseEmitter 30 秒后就会被强制超时。

yaml 复制代码
# application.yml
spring:
  mvc:
    async:
      request-timeout: -1   # -1 表示永不超时

同样,长轮询里的 DeferredResult 超时时间(30s)也要和这个配置搭配好。

要点 3:scheduleAtFixedRate 可能导致任务堆积

在 SseController 中,我用了 scheduleAtFixedRate。这个方法的语义是"以上一次任务开始时间为基准 ,每隔 N 秒启动一次"。如果 emitter.send() 因为网络原因耗时超过 1 秒,下一个任务会立刻启动------两个线程同时往同一个 SseEmitter 写数据,导致数据乱序甚至并发异常。

更安全的写法是用 scheduleWithFixedDelay

java 复制代码
// scheduleWithFixedDelay:以上一次任务**结束**时间为基准,隔 N 秒再启动
executor.scheduleWithFixedDelay(() -> {
    try {
        emitter.send(SseEmitter.event()
            .name("metric-update")
            .data(data));
    } catch (IOException e) {
        emitter.completeWithError(e);
    }
}, 0, 1, TimeUnit.SECONDS);

要点 4:WebSocket 多实例部署------消息发给了空气

假设你部署了 2 个服务实例 A 和 B,客户端和实例 A 建立了 WebSocket 连接。当 @Scheduled 在实例 B 上触发推送时,B 调用 convertAndSend------消息只发给了连到 B 的客户端,连到 A 的客户端收不到

有几种解法:

  1. Sticky Session(简单但不优雅):负载均衡器根据 WebSocket 握手请求做会话保持,同一客户端始终路由到同一实例。

  2. External Broker(推荐) :用 RabbitMQ 或 ActiveMQ 做 STOMP broker,所有实例的消息都通过 broker 中转:

    java 复制代码
    registry.enableStompBrokerRelay("/topic")
        .setRelayHost("rabbitmq-host")
        .setRelayPort(61613);
  3. Redis Pub/Sub(轻量级):不用 STOMP broker,用 Redis 做跨实例消息广播,自己管理 WebSocket session。

要点 5:连接泄漏------无声的内存炸弹

每种长连接方案都可能泄漏,只是泄漏方式不同:

方案 泄漏点 后果
长轮询 DeferredResult 未超时、未清理的 CopyOnWriteArrayList 条目 线程占用 + 内存增长
SSE ScheduledExecutorService 未随 emitter 关闭而终止 后台线程持续运行,越来越多
WebSocket 客户端异常断开但服务端未感知(半开连接) 文件描述符耗尽

关键防护

  • SseEmitter:务必在 onCompletiononTimeoutonError 三个回调中都清理定时任务
  • WebSocket:配置 STOMP heartbeat(registry.setTaskScheduler()),让服务端能检测到死连接
  • 全局:暴露连接数 metrics,设告警阈值

要点 6:认证------Token 放哪儿?

SSE 的 EventSource API 不支持自定义请求头,所以 JWT token 不能放在 Authorization header 里。两种常见解法:

javascript 复制代码
// 方案 A:token 放 URL 参数(注意 HTTPS 防泄露)
const es = new EventSource(`/api/sse/stream?token=${jwt}`);

// 方案 B:先 POST 拿一个临时 ticket,再用 ticket 连 SSE
const ticket = await fetch('/api/sse/ticket', {
    method: 'POST',
    headers: { 'Authorization': `Bearer ${jwt}` }
}).then(r => r.text());
const es = new EventSource(`/api/sse/stream?ticket=${ticket}`);

WebSocket 的 STOMP 协议可以在 CONNECT 帧中传认证头,SockJS 也可以在 URL 上传 token。

要点 7:数据从哪来?------推送通道 ≠ 数据源

这是很多新人容易忽视的问题。本文 4 种方案解决的是"数据怎么到前端 ",但"数据怎么变"是另一个问题。Demo 中用了一个模拟器定时生成随机数据,生产中你面对的是真实数据库:

  • 最简单@Scheduled 每秒查一次数据库 → 数据库压力大,不推荐
  • 应用层事件 :数据写入时发 Spring Event → @EventListener 异步处理 → 推送到前端。耦合低,推荐
  • 数据库 CDC:用 Canal(MySQL)或 Debezium 监听 binlog/WAL 变更 → MQ → 推送服务。零侵入,适合已有系统
  • Redis Pub/Sub:写入方写完 DB 后 publish → 推送服务 subscribe → 推前端。轻量,适合中小规模

建议:先用应用层事件跑通流程,数据量大或解耦要求高时再上 CDC/MQ。


总结

  1. 短轮询不是原罪------对于内部工具、低频数据、快速上线场景,它是最务实的选择。别为了技术炫技把事情搞复杂。
  2. SSE 被严重低估------在"服务端主动推送"这个场景下,SSE 比 WebSocket 简单太多,而且 EventSource API 的自动重连帮你省了多少事。大部分"实时大屏"用 SSE 就够了。
  3. 选方案先看连接模型------单向还是双向,比你想象的更重要。用错了方向等于给自己挖坑。
  4. Demo 和生产的距离 = 以上 7 个要点------Nginx 配好了吗?异步超时改了吗?多实例消息怎么广播?把这些都考虑到了,才算真正做好实时推送。

完整源码

🔗 Gitee 仓库gitee.com/gcchech/art...

模块路径:realtime-data-push/

直接运行 RealtimeDataPushApplication,浏览器打开 http://localhost:8080 即可看到四个方案的可视化对比页面------通过 Tab 切换短轮询/长轮询/SSE/WebSocket,实时观察 CPU、QPS、订单量等指标变化。

相关推荐
刘沅1 小时前
LeetCode 93.复原IP地址
算法·面试
yinshaojun0011 小时前
Function Calling:从模型输出到安全的工具执行
后端
码事漫谈2 小时前
让 AI 直接操作数据库:电科金仓 KES MCP Server 实践
后端
用户47949283569152 小时前
专升本前端毕业 1 年,从初创到大厂,我的开源项目上了 github trending,顺便聊聊做开源的收获
前端·后端·github
小小放舟、2 小时前
PaiCLI-Demo:从零实现 ReAct Agent + Tool Call
java·后端·intellij-idea·springboot·agent·react·tool call
不简说3 小时前
JS 代码技巧 vol.2 — 20 个数组/对象/字符串/正则的实用技巧
前端·javascript·面试
用户6757049885023 小时前
别再乱用了!var arr []int 和 arr := []int{} 给整懵了?不就声明个空数组,项目居然崩了?
后端·go
马达拉3 小时前
为什么Bun查询数据库接口响应比Rust还快
后端
geovindu3 小时前
go: Enumeration Algorithm
开发语言·后端·算法·golang·枚举算法