springmvc前后端(详解SpringMVC 流式输出(流式响应))

springmvc前后端(详解SpringMVC 流式输出(流式响应))
详解SpringMVC 流式输出(流式响应)

一、什么是流式输出?解决什么问题?

✅ 1. 流式输出

SpringMVC中的流式输出(Streaming Response),也叫分块输出/渐进式响应,核心语义:

服务端不是一次性将所有响应数据组装完成后整体返回给前端,而是将数据拆分成多个数据块,分批、持续地写入HTTP响应体,边写边返回;前端则可以实时接收每一个数据块,即时处理展示

✅ 2. 流式输出的核心底层支撑:HTTP 分块传输编码

所有SpringMVC的流式输出能力,底层都是基于 HTTP/1.1 标准的分块传输编码(Chunked Transfer Encoding)实现

  • 当服务端需要流式输出时,会在HTTP响应头中设置:Transfer-Encoding: chunked
  • 该响应头的含义:响应体的数据是分块传输的,每一块都有自己的长度标识,最后以一个「长度为0的块」标识传输结束
  • 特性:无需设置响应头 Content-Length(因为总数据量未知),服务端可以边生成数据边传输,前端实时解析。

✅ 3. 流式输出的核心应用场景

✅ 大文件下载(Excel导出、视频/音频文件、压缩包):一次性加载大文件到内存会直接导致 OOM内存溢出,流式输出边读边写,内存占用极低;

✅ 大数据量接口返回(百万级列表、海量报表数据):避免一次性组装大数据JSON导致内存溢出,同时前端可以实时渲染;

✅ 实时数据推送(服务端主动推送):比如实时日志打印、任务进度推送、系统监控指标、AI流式问答(ChatGPT对话效果)

✅ 耗时任务的渐进式反馈:比如文件上传进度、数据同步进度,前端实时看到进度条更新;

✅ 长文本内容输出:比如动态生成的日志文件、数据库导出的SQL脚本。

springmvc前后端(详解SpringMVC 流式输出(流式响应))

✅ 4. 优势总结

  1. 极致节省内存:服务端无需将所有数据加载到内存,内存占用始终保持在极低水平,彻底解决大数据量/OOM问题;
  2. 前端实时响应:用户体验极佳,无需等待所有数据加载完成,前端可以即时展示内容(如AI问答的打字机效果);
  3. 适配未知数据量:数据量不确定时(如实时日志),流式输出是唯一可行的方案;
  4. 无性能瓶颈:分块传输的效率极高,不会因为数据量大导致请求超时。


二、SpringMVC流式输出的本质

所有SpringMVC的流式输出实现方案,无论高级还是低级,最终的底层操作都是同一个核心

操作 HttpServletResponse 对象提供的原生输出流,将数据分批写入流中,完成分块传输

SpringMVC为我们封装了多种优雅的上层API(如StreamingResponseBody、SseEmitter),但这些API的底层,都是对 HttpServletResponse.getOutputStream() / HttpServletResponse.getWriter() 的封装。


三、SpringMVC 流式输出 4种实现方案

✅ 方案一:StreamingResponseBody 标准流式输出

说明

StreamingResponseBody 是 SpringMVC 专门为流式输出设计的标准接口(Spring 4.2+ 引入),也是企业开发的首选方案,无任何侵入性,不需要手动操作原生的HttpServletResponse。

特点

  1. 优雅无侵入:Controller方法直接返回该接口的实现类即可,SpringMVC自动处理流式响应;
  2. 内存友好:数据按需生成、按需写入,无内存溢出风险;
  3. 自动适配:SpringMVC会自动为响应头添加 Transfer-Encoding: chunked,无需手动设置;
  4. 通用性强:支持任意类型的流式输出(文本、文件、JSON等),是最通用的流式方案。

接口核心源码

public interface StreamingResponseBody {    // 核心方法:将数据写入输出流,SpringMVC会自动处理分块传输    void writeTo(OutputStream outputStream) throws IOException;}

✔ 实战案例1:基础流式文本输出(如AI流式问答、实时文本推送)

常用的场景,比如AI问答的打字机效果,后端每生成一段文本就立即返回,前端实时展示:

import org.springframework.http.MediaType;import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.RestController;import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;import javax.servlet.http.HttpServletResponse;import java.io.IOException;import java.io.OutputStream;import java.nio.charset.StandardCharsets;@RestControllerpublic class StreamController {    /**     * 流式文本输出 - AI流式问答/实时文本推送 典型场景     */    @GetMapping(value = "/stream/text", produces = MediaType.TEXT_PLAIN_VALUE)    public StreamingResponseBody streamText(HttpServletResponse response) {        // 可选:设置响应头,指定编码(防止中文乱码)        response.setCharacterEncoding(StandardCharsets.UTF_8.name());                // 返回流式响应体,Lambda实现核心方法        return outputStream -> {            // 模拟分批生成的文本数据(比如AI生成的逐段回答)            String[] contentList = {                "SpringMVC流式输出的核心是分块传输编码\n",                "使用StreamingResponseBody是官方推荐的方案\n",                "该方案无侵入,内存占用极低\n",                "支持任意类型的流式数据输出\n",                "流式输出可以完美解决大数据量OOM问题\n"            };            // 分批写入输出流,模拟「边生成边返回」            for (String content : contentList) {                outputStream.write(content.getBytes(StandardCharsets.UTF_8));                outputStream.flush(); // 核心:刷新缓冲区,立即将数据返回给前端                Thread.sleep(500); // 模拟数据生成耗时,前端会看到「打字机」效果            }        };    }}

✔ 实战案例2:流式大文件下载

这是流式输出最高频的生产场景,解决大文件下载时的内存溢出问题,原理:服务端从磁盘/网络读取文件的一小块,写入输出流,立即返回给前端,循环往复直到文件读取完毕,内存中永远只存在一小块文件数据,内存占用几乎可以忽略不计。

/** * 流式大文件下载 - 生产环境必用方案,彻底解决大文件OOM问题 */@GetMapping("/stream/download")public StreamingResponseBody streamFileDownload(HttpServletResponse response) {    // 1. 设置下载响应头,告诉浏览器是下载文件    response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE);    response.setHeader("Content-Disposition", "attachment; filename=bigfile.txt");    response.setCharacterEncoding(StandardCharsets.UTF_8.name());    // 2. 流式读取文件并写入响应流    return outputStream -> {        // 读取本地大文件(替换为你的文件路径)        try (FileInputStream fis = new FileInputStream(new File("D:/test/bigfile.txt"));             BufferedInputStream bis = new BufferedInputStream(fis)) {            byte[] buffer = new byte[1024 * 8]; // 8KB缓冲区,按需调整大小            int len;            // 循环读取文件,分批写入响应流            while ((len = bis.read(buffer)) != -1) {                outputStream.write(buffer, 0, len);                outputStream.flush(); // 立即返回给前端            }        } catch (Exception e) {            e.printStackTrace();        }    };}

该方案的优势

✅ 无需手动处理分块编码,Spring自动完成;

✅ 无需手动关闭流,Spring自动释放资源;


✅ 方案二:原生 Servlet API 流式输出

说明

这是最基础、最底层、万能的流式输出方案,直接在Controller中注入 HttpServletResponse 对象,通过其提供的原生输出流(OutputStream/Writer)写入数据,是所有流式方案的底层根基。

该方案的优势是:无任何版本依赖、无任何封装、万能适配,无论是SpringMVC、原生Servlet项目,还是老旧的Spring版本,都能使用;缺点是:需要手动操作原生对象,代码侵入性稍强,但胜在灵活。

核心原理

  1. 从HttpServletResponse中获取 字节输出流OutputStream(适用于二进制数据:文件、图片、视频) 或 字符输出流PrintWriter(适用于文本数据:字符串、JSON);
  2. 手动设置响应头 Transfer-Encoding: chunked(SpringMVC会自动设置,可省略);
  3. 分批将数据写入输出流,调用flush()刷新缓冲区,立即返回给前端;
  4. 循环直到数据写入完毕,无需关闭流(容器会自动关闭)。

✔ 实战案例1:原生流 流式文本输出

/** * 原生Servlet API 流式文本输出 - 万能方案 */@GetMapping("/stream/native/text")public void nativeStreamText(HttpServletResponse response) throws IOException {    // 1. 设置响应头    response.setContentType(MediaType.TEXT_PLAIN_VALUE);    response.setCharacterEncoding(StandardCharsets.UTF_8.name());    // 2. 获取字符输出流(文本数据首选)    PrintWriter writer = response.getWriter();    // 3. 分批写入数据    String[] contentList = {"你好,", "这是原生Servlet的", "流式输出示例\n", "每一行都会实时返回!"};    for (String content : contentList) {        writer.write(content);        writer.flush(); // 核心:刷新缓冲区,立即返回        Thread.sleep(600);    }    writer.close(); // 可选,容器会自动关闭}

✔ 实战案例2:原生流 大文件流式下载

/** * 原生Servlet API 大文件流式下载 - 生产环境常用 */@GetMapping("/stream/native/download")public void nativeStreamDownload(HttpServletResponse response) throws IOException {    // 1. 设置下载响应头    response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE);    response.setHeader("Content-Disposition", "attachment; filename=bigfile.zip");    // 2. 获取字节输出流(二进制数据首选)    OutputStream out = response.getOutputStream();    // 3. 流式读取文件并写入    try (FileInputStream fis = new FileInputStream(new File("D:/test/bigfile.zip"));         BufferedInputStream bis = new BufferedInputStream(fis)) {        byte[] buffer = new byte[1024 * 16];        int len;        while ((len = bis.read(buffer)) != -1) {            out.write(buffer, 0, len);            out.flush();        }    } catch (Exception e) {        e.printStackTrace();    }}

注意事项

  1. flush()方法是核心:如果不调用flush(),数据会被缓存到服务端的缓冲区,直到缓冲区满了才会返回给前端,就失去了流式输出的意义;
  2. 字符流 vs 字节流:文本数据用PrintWriter,二进制数据用OutputStream,不要混用;
  3. 中文乱码:必须设置response.setCharacterEncoding("UTF-8")和response.setContentType("text/plain;charset=UTF-8")。

✅ 方案三:SseEmitter 服务端事件推送

说明

SseEmitter 是 SpringMVC 提供的 服务端推送(Server-Sent Events,简称SSE) 专用流式方案,是流式输出的进阶形态。

✔ 什么是 SSE?

SSE 是 基于HTTP长连接的单向流式推送技术:服务端和前端建立一个持久的HTTP连接,服务端可以在任意时间、持续地向前端推送文本数据块,前端实时接收并处理,直到连接主动关闭。

✔ SSE 与普通流式输出的核心区别

  1. 普通流式输出(StreamingResponseBody)一次性分块传输,数据传输完成后,连接立即断开;适用于大文件下载、大数据量返回等一次性输出场景;
  2. SSE 流式推送(SseEmitter)长连接持续传输,连接会一直保持,服务端可以主动、多次推送数据,直到手动关闭连接;适用于实时日志、任务进度推送、系统监控、AI流式对话等实时推送场景

✔ SSE 的特性

✅ 基于HTTP协议,无需额外协议(如WebSocket),无需引入额外依赖;

✅ 单向推送:服务端 → 前端,无法双向通信(如果需要双向通信,用WebSocket);

✅ 自动重连:前端原生支持自动重连机制,断连后会自动重试;

✅ 文本数据:只支持文本流(JSON、字符串),不支持二进制数据(文件、图片);

✅ SpringMVC完美封装:SseEmitter 是Spring对原生SSE的封装,开箱即用。

✔ 实战案例:实时日志推送

后端持续生成日志,前端实时展示日志内容,典型的运维平台/监控平台场景,代码可直接运行:

后端代码(SpringMVC)

import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.RestController;import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;import java.io.IOException;import java.util.Random;@RestControllerpublic class SseStreamController {    private final Random random = new Random();    /**     * SSE流式推送 - 实时日志推送 典型场景     */    @GetMapping("/stream/sse/log")    public SseEmitter sseLogStream() {        // 设置超时时间:0表示永不超时,也可以设置毫秒值(如30*60*1000)        SseEmitter emitter = new SseEmitter(0L);        // 异步推送数据(必须异步,否则会阻塞主线程)        new Thread(() -> {            try {                for (int i = 1; i <= 100; i++) {                    // 模拟实时生成的日志内容                    String log = "【" + System.currentTimeMillis() + "】- 系统日志:" +                                  "用户操作-" + i + ",状态:" + (random.nextBoolean() ? "成功" : "失败") + "\n";                    // 核心:推送数据到前端                    emitter.send(log);                    Thread.sleep(1000); // 每秒推送一条日志                }                // 推送完成,关闭连接                emitter.complete();            } catch (IOException | InterruptedException e) {                // 推送失败,关闭连接并抛出异常                emitter.completeWithError(e);            }        }).start();        return emitter;    }}

前端代码(原生JS,无需任何依赖,实时接收)

SSE的前端接收非常简单,原生EventSource对象即可实现,无需引入axios/fetch等库,完美适配流式推送:

    SSE实时日志    

实时日志输出:

[xss_clean] // 建立SSE连接,监听后端推送 const eventSource = new EventSource('http://localhost:8080/stream/sse/log'); const logContainer = document.getElementById('log-container'); // 接收后端推送的消息 eventSource.onmessage = function (event) { logContainer[xss_clean] += event.data; // 滚动到最新日志 logContainer.scrollTop = logContainer.scrollHeight; }; // 连接关闭时触发 eventSource.onclose = function () { console.log('SSE连接已关闭'); eventSource.close(); }; // 连接异常时触发 eventSource.onerror = function (error) { console.error('SSE连接异常:', error); eventSource.close(); }; [xss_clean]

✔ SSE vs WebSocket 选型建议

✅ 用 SseEmitter:需要单向推送(服务端→前端)、文本数据、实时日志/进度推送、无需双向通信,追求简单无依赖;

✅ 用 WebSocket:需要双向通信(前端↔服务端)、二进制数据、高并发实时交互(如聊天室、在线游戏);


✅ 方案四:ResponseBodyEmitter 通用异步流式输出【✅ 灵活度最高、进阶方案】

核心说明

ResponseBodyEmitter 是 SseEmitter 的父类,是SpringMVC提供的通用异步流式输出方案,灵活度最高,也是最底层的异步流式API。

核心特点

  1. 是SseEmitter的超类,SseEmitter本质是ResponseBodyEmitter的子类,专门适配SSE场景;
  2. 支持任意类型的流式数据:文本、二进制、JSON等,无数据类型限制;
  3. 支持异步推送:可以在任意线程中推送数据,完美适配异步业务逻辑;
  4. 支持自定义响应头、响应状态码,灵活度拉满;
  5. 适用于复杂的异步流式场景(如:多线程生成数据、异步调用返回结果后分批推送)。

✔ 极简实战案例

@GetMapping("/stream/response-body")public ResponseBodyEmitter responseBodyStream() {    ResponseBodyEmitter emitter = new ResponseBodyEmitter();    // 异步推送数据    new Thread(() -> {        try {            emitter.send("第一块数据", MediaType.TEXT_PLAIN);            Thread.sleep(500);            emitter.send("第二块数据", MediaType.TEXT_PLAIN);            Thread.sleep(500);            emitter.send("第三块数据", MediaType.TEXT_PLAIN);            emitter.complete();        } catch (Exception e) {            emitter.completeWithError(e);        }    }).start();    return emitter;}

四、避坑指南

✅ 坑点1:流式输出没有生效,前端一次性收到所有数据

原因

  • 忘记调用 flush() 方法:数据被缓存到服务端缓冲区,直到缓冲区满才会返回;
  • 数据量太小:服务端缓冲区默认大小为8KB,数据量小于8KB时,会被缓存;
  • 前端使用了axios的默认配置:axios会缓存响应数据,直到连接关闭才会返回给业务代码。

解决方案

  1. 必须调用 flush():所有流式方案中,写入数据后一定要调用flush()刷新缓冲区,立即返回数据;
  2. 模拟大数据量:开发测试时,可通过循环生成大量数据,或设置Thread.sleep()模拟耗时;
  3. 前端适配:axios需要关闭缓存,或使用原生fetch/EventSource接收流式数据。

✅ 坑点2:中文乱码

原因

未设置响应的字符编码,或字符编码不一致(服务端UTF-8,前端GBK)。

解决方案

// 方式1:设置响应头response.setCharacterEncoding("UTF-8");response.setContentType("text/plain;charset=UTF-8");// 方式2:在@RequestMapping中指定produces@GetMapping(value = "/stream/text", produces = "text/plain;charset=UTF-8")

✅ 坑点3:大文件下载时内存溢出

原因

没有使用流式读取,而是一次性将文件加载到内存(如FileUtils.readFileToByteArray())。

解决方案

强制使用流式读取:通过BufferedInputStream+缓冲区读取文件,分批写入输出流,如方案一/二中的文件下载案例。

✅ 坑点4:SSE连接频繁断开/超时

原因

  • 设置了较短的超时时间,或未设置超时时间;
  • 服务端长时间未推送数据,被网关/防火墙断开连接。

解决方案

  1. 设置超时时间为0(永不超时):new SseEmitter(0L);
  2. 定时推送心跳包:如果长时间无数据,推送一个空数据块,保持连接活跃;
  3. 前端开启自动重连:原生EventSource支持自动重连,无需额外配置。

✅ 坑点5:流式输出时接口阻塞

原因

同步执行流式输出逻辑,阻塞了SpringMVC的核心线程池,导致其他接口无法访问。

解决方案

异步执行流式逻辑:使用@Async注解,或手动创建线程执行流式写入,如SSE案例中的异步线程。


五、总结

✅ 流式输出底层原理

SpringMVC流式输出的核心是 HTTP/1.1 分块传输编码(Transfer-Encoding: chunked),所有方案最终都是操作HttpServletResponse的原生输出流,分批写入数据并刷新缓冲区。

✅ 方案选型

  1. 标准流式选 StreamingResponseBody;
  2. 实时推送选 SseEmitter;
  3. 万能兜底选 原生Servlet流;
  4. 复杂异步选 ResponseBodyEmitter。

✅ 避坑点

  1. flush()是流式输出的核心,必须调用;
  2. 中文乱码要设置统一的UTF-8编码;
  3. 大文件必须流式读取,避免OOM;
  4. 实时推送要异步执行,避免阻塞线程池。

文章版权声明:除非注明,否则均为边学边练网络文章,版权归原作者所有