Featured image of post 服务端到客户端的进度推送:SSE 与 WebSocket 选型

服务端到客户端的进度推送:SSE 与 WebSocket 选型

服务端不知道前端下载到哪一步?本文用 流式响应 / SSE / WebSocket 三种姿势实现下载进度的实时反馈

一个看似简单的需求

用户在管理后台点"导出报表"——服务端要生成一个 200MB 的 Excel,前端等 30 秒。最差的体验是这样

点了按钮,前端转圈圈。30 秒过去——用户以为卡住了,开始连点。后端被点出三次重复任务,最后崩了。

合格的体验应该是:

点了按钮,立刻看到"正在生成 Excel…12%",每秒进度条往上跳,用户安心等待。

实现这种"实时进度反馈"看起来不复杂——但坑很多:

  • HTTP 是请求-响应模型,服务端没法主动告诉前端"现在 20% 了"
  • 进度怎么从生成 Excel 的线程"流"到 HTTP 响应里
  • 浏览器有 Range 请求、有 chunked 编码、有 SSE、有 WebSocket——该选哪个

本文把三种主流方案讲清楚——HTTP 流式响应SSEWebSocket。各自的优劣、代码、踩坑都覆盖。


一、需求拆解:什么叫"下载进度"

要讲清进度反馈,先把"下载"分两类:

类型 A:纯文件传输

文件已存在,浏览器直接下载——进度信息浏览器自己能算(Content-Length / 已接收字节)。前端不需要服务端告诉,可以自己监听 progress 事件。这种场景根本不需要后端反馈

1
2
3
4
5
6
7
8
const xhr = new XMLHttpRequest();
xhr.responseType = 'blob';
xhr.onprogress = e => {
    const percent = (e.loaded / e.total * 100).toFixed(1);
    setProgress(percent);   // 浏览器自己算
};
xhr.open('GET', '/file/download/1');
xhr.send();

这种是大部分场景——多数情况下不需要后端介入,避免加不必要的复杂度。

类型 B:服务端要先"做事"才有文件

真正复杂的——服务端先生成 Excel(30 秒)、再让前端下载。生成过程中浏览器看到的是 0 字节、Content-Length 还没确定。进度只有服务端知道

类型 B 才是本文的主场。


二、方案 1:HTTP 流式响应(chunked)

最简单的姿势——把"任务进度"和"最终文件"塞到一个 HTTP 响应里。

服务端用 StreamingResponseBody,每行输出一个 JSON(NDJSON / JSON-Lines):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
@GetMapping("/export")
public ResponseEntity<StreamingResponseBody> export() {
    StreamingResponseBody stream = output -> {
        for (int i = 0; i <= 100; i += 10) {
            // 输出一行 JSON + \n
            String line = String.format("{\"progress\":%d}\n", i);
            output.write(line.getBytes(StandardCharsets.UTF_8));
            output.flush();
            Thread.sleep(500);   // 模拟工作
        }
        output.write("{\"done\":true,\"url\":\"/file/123\"}\n".getBytes(StandardCharsets.UTF_8));
    };
    return ResponseEntity.ok()
            .contentType(MediaType.parseMediaType("application/x-ndjson"))
            .body(stream);
}

这里故意不用 SSE 的 data: …\n\n 协议格式——避免和方案 2 混淆。要 SSE 就直接用 SseEmitter(见方案 2),这里就是普通的"按行 JSON 流"。

前端用 fetch + ReadableStream 读:

1
2
3
4
5
6
7
8
const resp = await fetch('/export');
const reader = resp.body.getReader();
while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    const text = new TextDecoder().decode(value);
    // 解析进度
}

优点

  • 实现简单,一个接口搞定
  • 浏览器原生支持

缺点

  • 进度信息和文件内容混在一个流里,前端要自己解析协议
  • 不是标准协议,每个项目都可能不一样

适合简单场景——不推荐做复杂业务。


三、方案 2:SSE(Server-Sent Events)

SSE 是 HTML5 的标准——专门为"服务端推送给客户端"设计,比 WebSocket 简单一半。

服务端

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@GetMapping(value = "/export/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter exportStream() {
    SseEmitter emitter = new SseEmitter(60_000L);   // 60s 超时

    executor.execute(() -> {
        try {
            for (int i = 0; i <= 100; i += 5) {
                emitter.send(SseEmitter.event()
                        .name("progress")
                        .data(Map.of("percent", i, "msg", "处理中...")));
                Thread.sleep(500);
            }
            // 最终输出文件下载链接
            emitter.send(SseEmitter.event()
                    .name("done")
                    .data(Map.of("url", "/file/12345")));
            emitter.complete();
        } catch (Exception e) {
            emitter.completeWithError(e);
        }
    });
    return emitter;
}

前端

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
const evt = new EventSource('/export/stream');

evt.addEventListener('progress', e => {
    const data = JSON.parse(e.data);
    setProgress(data.percent);
});

evt.addEventListener('done', e => {
    const data = JSON.parse(e.data);
    window.location.href = data.url;
    evt.close();
});

evt.onerror = err => {
    console.error('SSE error', err);
    evt.close();
};

SSE 的特点

  • 服务端 → 客户端单向——客户端不能用同一通道发消息
  • 基于 HTTP——不需要握手协议升级,CDN 能转发
  • 自动重连——网络断了浏览器自动重连
  • 协议极简——event 名 + data 行
  • 比 WebSocket 轻量——但功能少一半

一个常被忽视的细节

很多 PaaS / Nginx 默认 buffer 响应,SSE 推送的消息要等 buffer 满了才到前端。要在 nginx 里关闭:

1
2
3
4
5
6
location /export/ {
    proxy_pass http://backend;
    proxy_buffering off;        # ★ 关键
    proxy_read_timeout 3600s;
    chunked_transfer_encoding on;
}

不开就是"明明代码 emit 了但前端 30 秒后才一次性收到所有进度"。


四、方案 3:WebSocket

最重量级的——双向通信,服务端推前端、前端发命令到服务端都行。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Component
public class ExportWebSocketHandler extends TextWebSocketHandler {

    @Override
    public void handleTextMessage(WebSocketSession session, TextMessage msg) throws Exception {
        // 收到前端"开始导出"指令后启动任务
        ExportRequest req = JSON.parseObject(msg.getPayload(), ExportRequest.class);
        executor.execute(() -> doExport(session, req));
    }

    private void doExport(WebSocketSession session, ExportRequest req) {
        try {
            for (int i = 0; i <= 100; i += 5) {
                session.sendMessage(new TextMessage(JSON.toJSONString(
                        Map.of("type", "progress", "percent", i))));
                Thread.sleep(500);
            }
            session.sendMessage(new TextMessage(JSON.toJSONString(
                    Map.of("type", "done", "url", "/file/123"))));
        } catch (Exception e) {
            log.error("export error", e);
        }
    }
}

适用场景:

  • 同一连接里要前端 → 后端发指令(如取消任务、调整参数)
  • 已经有 WebSocket 长连接复用
  • 复杂双向交互(如客服聊天 + 文件传输)

但单纯做"进度反馈"上 WebSocket 是杀鸡用牛刀——SSE 已经够了。


五、三方案对比

StreamingResponseBodySSEWebSocket
实现复杂度极简简单
协议HTTPHTTP(standard)HTTP Upgrade
双向✗ 单向
自动重连✗(要自己写)
Nginx 兼容△(要关 buffer)△(要 upgrade 配置)
浏览器支持✓ 全部✓ 全部✓ 全部
适用场景进度+文件混合单向进度反馈双向交互

给一个直接的建议:

类型 A(纯文件下载):浏览器原生 progress;类型 B(服务端做事 + 进度):99% 用 SSE 就够。WebSocket 留给真正的双向场景。


六、生产细节

1. 进度的"真实性"

1
2
3
4
for (int i = 0; i <= 100; i += 5) {
    emit(i);
    Thread.sleep(500);
}

写成这样的进度是假进度——看着舒服但和真实工作进度无关。生产代码进度要从真实业务里取:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
public void doExport(SseEmitter emitter, List<User> users) {
    int total = users.size();
    for (int i = 0; i < total; i++) {
        process(users.get(i));
        if (i % 100 == 0 || i == total - 1) {     // 每 100 条推一次
            emitter.send(SseEmitter.event()
                    .name("progress")
                    .data(Map.of("percent", i * 100 / total)));
        }
    }
}

2. 异常处理

1
emitter.send(...);   // 客户端断开会抛 IllegalStateException

客户端关页面、断网、超时——服务端继续 send 会异常。要 catch 后调 emitter.completeWithError(e),并停止剩余工作(节省服务器资源)。

3. 资源清理

SseEmitter 是要释放的——onCompletiononTimeoutonError 三个回调要注册:

1
2
3
emitter.onCompletion(() -> log.info("client disconnected"));
emitter.onTimeout(() -> log.warn("session timeout"));
emitter.onError(err -> log.error("session error", err));

4. 不要在 SSE 通道传文件本体

进度通道传进度元数据,文件本体走单独下载链接:

1
2
SSE 推送 → 任务完成时给一个临时下载 URL
前端拿到 URL → 触发标准下载

不要硬塞 base64 文件到 SSE,体积和性能都差。

5. 限流

SseEmitter 是长连接——大流量场景要限连接数(防止有人开 1 万个连接):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
@RestController
public class ExportController {
    private final Semaphore connections = new Semaphore(100);   // 最多 100 并发

    @GetMapping("/export/stream")
    public SseEmitter export() {
        if (!connections.tryAcquire()) {
            throw new BusinessException("服务繁忙");
        }
        SseEmitter emitter = new SseEmitter();
        emitter.onCompletion(connections::release);
        emitter.onTimeout(connections::release);
        emitter.onError(e -> connections.release());
        // ...
        return emitter;
    }
}

6. 跨进程任务进度

如果导出任务是异步发到 MQ 或别的进程执行——进度数据要走 Redis

SSE 通道每秒查一次 Redis 拿进度推给前端,Worker 进度写 Redis。进度生产和消费解耦


小结

把全文压一句:

HTTP 是请求响应模型,要做"实时进度反馈",就要打开一个『可以持续推送』的通道——单向选 SSE,双向选 WebSocket,文件本体永远走标准 HTTP 下载。

工程要点:

  • 浏览器原生 progress 能算的进度,别让后端反馈
  • 后端进度反馈优先 SSE
  • 真实进度比假进度重要
  • Nginx proxy_buffering off 是 SSE 必配
  • 进度和文件本体分离

把这套方案吃透,“导出报表"这种烦人功能能瞬间从 1 星体验变成 5 星。

使用 Hugo 构建
主题 StackJimmy 设计