一个看似简单的需求
用户在管理后台点"导出报表"——服务端要生成一个 200MB 的 Excel,前端等 30 秒。最差的体验是这样:
点了按钮,前端转圈圈。30 秒过去——用户以为卡住了,开始连点。后端被点出三次重复任务,最后崩了。
合格的体验应该是:
点了按钮,立刻看到"正在生成 Excel…12%",每秒进度条往上跳,用户安心等待。
实现这种"实时进度反馈"看起来不复杂——但坑很多:
- HTTP 是请求-响应模型,服务端没法主动告诉前端"现在 20% 了"
- 进度怎么从生成 Excel 的线程"流"到 HTTP 响应里
- 浏览器有 Range 请求、有 chunked 编码、有 SSE、有 WebSocket——该选哪个
本文把三种主流方案讲清楚——HTTP 流式响应、SSE、WebSocket。各自的优劣、代码、踩坑都覆盖。
一、需求拆解:什么叫"下载进度"
要讲清进度反馈,先把"下载"分两类:
类型 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 已经够了。
五、三方案对比
| StreamingResponseBody | SSE | WebSocket |
|---|
| 实现复杂度 | 极简 | 简单 | 中 |
| 协议 | HTTP | HTTP(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 是要释放的——onCompletion、onTimeout、onError 三个回调要注册:
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:
flowchart LR
Front --> SSE[SSE Endpoint]
SSE -.poll.-> Redis[(Redis)]
Worker[(异步 Worker)] -.write.-> RedisSSE 通道每秒查一次 Redis 拿进度推给前端,Worker 进度写 Redis。进度生产和消费解耦。
小结
把全文压一句:
HTTP 是请求响应模型,要做"实时进度反馈",就要打开一个『可以持续推送』的通道——单向选 SSE,双向选 WebSocket,文件本体永远走标准 HTTP 下载。
工程要点:
- 浏览器原生
progress 能算的进度,别让后端反馈 - 后端进度反馈优先 SSE
- 真实进度比假进度重要
- Nginx
proxy_buffering off 是 SSE 必配 - 进度和文件本体分离
把这套方案吃透,“导出报表"这种烦人功能能瞬间从 1 星体验变成 5 星。