bigywater.tech
Spring · 49

SSE 与流式响应

用 SseEmitter 推送要注意什么?为什么我在 StreamingResponseBody 里 flush 了客户端还是收不到?客户端断开后服务端什么时候知道?超时多准?压缩会不会让流式推送变成最后一起到?这篇在 Spring Boot 4.0.8 上用逐行计时的客户端实测,并读了 spring-web 7.0.9 的源码确认 flush 为什么无效。

925 ms
StreamingResponseBody 里每块 flush,小块数据的第一块还是到了这么晚才到客户端(直接写 servlet 流是 36 ms)
2 个
客户端断开之后,服务端多发出的事件个数(tick-3 成功、tick-4 才失败)
1975 ms
设定 1000 ms 的 SseEmitter 超时,onTimeout 实际触发的时间

SseEmitter

实测 · 本机执行输出
  客户端看到的(行 = 一行文本,空行是事件分隔):
       响应头到达  49 200 {'Content-Type': 'text/event-stream', 'Transfer-Encoding': 'chunked'}
       49 ms  id:1
       49 ms  event:greet
       49 ms  data:hello
       49 ms  data:world
       49 ms  retry:3000
       49 ms
      353 ms  :heartbeat
      353 ms
      701 ms  id:2
      701 ms  data:{"k":"v"}
      701 ms
     1022 ms  (流结束)
  服务端事件:
       40 ms  控制器方法在 http-nio-9601-exec-2 返回 SseEmitter;Last-Event-ID=41
       43 ms  已发送事件 1(线程 producer)
      353 ms  已发送注释
      701 ms  已发送事件 2(对象 → JSON)
     1012 ms  complete()

线格式很简单:id、event、data、retry 和以冒号开头的注释;事件逐个到达。

StreamingResponseBody

实测 · 本机执行输出
  对照:Controller 里直接写 HttpServletResponse 并 flush:
       响应头到达  36 200 {'Content-Type': 'text/plain', 'Transfer-Encoding': 'chunked'}
       36 ms  chunk-1
      339 ms  chunk-2
      650 ms  chunk-3
      954 ms  (流结束)
    (服务端)    36 ms  raw 写出 chunk-1 并 flush
    (服务端)   339 ms  raw 写出 chunk-2 并 flush
    (服务端)   649 ms  raw 写出 chunk-3 并 flush
  直接返回 StreamingResponseBody(不包 ResponseEntity),每块 flush:
       响应头到达  928 200 {'Transfer-Encoding': 'chunked'}
      928 ms  chunk-1
      928 ms  chunk-2
      928 ms  chunk-3
      932 ms  (流结束)
    (服务端)     6 ms  direct 写出 chunk-1 并 flush
    (服务端)   316 ms  direct 写出 chunk-2 并 flush
    (服务端)   627 ms  direct 写出 chunk-3 并 flush
  ResponseEntity<StreamingResponseBody>,每块 20 KB 并 flush:
       响应头到达  6 200 {'Content-Type': 'text/plain', 'Transfer-Encoding': 'chunked'}
        6 ms  chunk-1
      310 ms  xxxxxxxxxxxxxxxxxxxx…(共 20000 个字符)
      311 ms  chunk-2
      618 ms  xxxxxxxxxxxxxxxxxxxx…(共 20000 个字符)
      618 ms  chunk-3
      927 ms  xxxxxxxxxxxxxxxxxxxx…(共 20000 个字符)
      931 ms  (流结束)
    (服务端)     6 ms  big 写出 chunk-1(20 KB)并 flush
    (服务端)   310 ms  big 写出 chunk-2(20 KB)并 flush
    (服务端)   618 ms  big 写出 chunk-3(20 KB)并 flush
  ResponseEntity<StreamingResponseBody>,每块 flush:
       响应头到达  924 200 {'Content-Type': 'text/plain', 'Transfer-Encoding': 'chunked'}
      925 ms  chunk-1
      925 ms  chunk-2
      925 ms  chunk-3
      927 ms  (流结束)
    (服务端)     5 ms  控制器方法在 http-nio-9602-exec-10
    (服务端)     7 ms  StreamingResponseBody 在 task-3 开始写
    (服务端)     7 ms  写出 chunk-1 并 flush
    (服务端)   310 ms  写出 chunk-2 并 flush
    (服务端)   612 ms  写出 chunk-3 并 flush
  StreamingResponseBody,不 flush:
       响应头到达  931 200 {'Content-Type': 'text/plain', 'Transfer-Encoding': 'chunked'}
      931 ms  chunk-1
      931 ms  chunk-2
      931 ms  chunk-3
      931 ms  (流结束)
    (服务端)     7 ms  写出 chunk-1(没有 flush)
    (服务端)   317 ms  写出 chunk-2(没有 flush)
    (服务端)   626 ms  写出 chunk-3(没有 flush)
  对照:普通 String 返回(处理 900ms 后一次性返回):
       响应头到达  927 200 {'Content-Type': 'text/plain;charset=UTF-8', 'Content-Length': '24'}
      927 ms  chunk-1
      927 ms  chunk-2
      927 ms  chunk-3
      927 ms  (流结束)

流的 flush() 被包装成什么都不做,小块数据攒到结束才发;要逐块实时发,用 SseEmitter。

断开

实测 · 本机执行输出
  客户端:
       响应头到达  55 200 {'Content-Type': 'text/event-stream', 'Transfer-Encoding': 'chunked'}
       55 ms  id:1
       55 ms  data:tick-1
       55 ms
      252 ms  id:2
      252 ms  data:tick-2
      252 ms  (客户端读到第 2 个数据后主动关闭连接)
  服务端(客户端关闭连接后继续观察):
       49 ms  发送 tick-1 成功
      253 ms  发送 tick-2 成功
      464 ms  发送 tick-3 成功
      674 ms  发送 tick-4 失败:AsyncRequestNotUsableException
      674 ms  onError 回调:AsyncRequestNotUsableException(线程 http-nio-9603-exec-3)
      680 ms  onCompletion 回调(线程 http-nio-9603-exec-3)

服务端靠写失败发现客户端断开,不写就不知道。

超时

实测 · 本机执行输出
  客户端:
       响应头到达  40 200 {'Content-Type': 'text/event-stream', 'Transfer-Encoding': 'chunked'}
       40 ms  data:only-one
       40 ms
     1986 ms  (流结束)
  服务端:
     1975 ms  onTimeout 回调(线程 http-nio-9604-exec-2)
     1986 ms  onCompletion 回调

亚秒级超时不精确。

压缩

实测 · 本机执行输出
  不带 Accept-Encoding:
      响应头到达 44 ms,Content-Encoding=None
      44 ms 收到 51 字节
      345 ms 收到 12 字节
      689 ms 收到 21 字节
      1011 ms 流结束
  Accept-Encoding: gzip:
      响应头到达 5 ms,Content-Encoding=gzip
      5 ms 收到 10 字节
      5 ms 收到 53 字节
      313 ms 收到 17 字节
      624 ms 收到 23 字节
      935 ms 收到 10 字节
      935 ms 流结束

开压缩后事件仍逐块到达,但要注意测量方式。

分步演示

SSE 与流式响应
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / OpenJDK 21.0.12(Homebrew) / Spring Boot 4.0.8(Spring Framework 7.0.9、Tomcat 11.0.24) / Spring Cloud 2025.1.3;用 javac 编译、java 直接运行,依赖由 Maven 从 Maven Central 解析;数字每次运行会有小幅抖动。用 Spring Boot 4.0.8 的 SpringApplication(spring-boot-starter-webmvc,内嵌 Tomcat 11.0.24);客户端是 Python http.client 逐行或逐数据块读取并记录到达时间,服务端事件时间由应用自己打印(两边是同一台机器的同一时钟);flush 无效的原因来自对 spring-web 7.0.9 源码 ServletServerHttpResponse.getBody() 的阅读
    • 没有验证:text/event-stream 在没有手动配置 mime-types 时会不会被压缩;反向代理(nginx 等)对 SSE 的缓冲(proxy_buffering);浏览器 EventSource 的自动重连行为;ResponseBodyEmitter 的非 SSE 用法;ServerSentEvent 与 Flux 的 WebFlux 写法(见第 55 篇起);HTTP/2 下的行为;大量并发 SSE 连接对线程和内存的占用;StreamingResponseBody 的 flush 无效是否在别的 Spring 版本中不同
    京ICP备2026067502号-1京公网安备11010502063487号