SpringAI前置基础学习记录(一)|SSE协议及具体实现(SseEmitter)

前言

在 SpringAI、大模型对话、智能问答项目中,我们看到的 AI 逐字输出、打字机流式效果 ,底层全部依赖 SSE(Server-Sent Events) 长连接流式推送。

本文一方面是为了记录自己学习相关知识以及学习过程中,遇见的各种坑。另一方面是为了如果有朋友遇见同样的问题或是有同样的疑惑,可以作为一个参考。

SSE协议

SSE(Server-Sent Events)是 基于 HTTP 的单向长连接协议, 仅支持服务器向客户端推送数据,客户端无法通过同一连接向服务器发送消息,如需上传数据需要额外发起HTTP请求。

SSE消息的格式

  1. 消息头的内容类型(content-type)是text/event-stream
  2. 消息体主要是一个或多个键值对应,每个字段以换行符结束,消息以双换行符结束。
    • [field]:value\n
  3. 消息体field 的字段可选:
    • data(必须):定义消息的实际内容。可以包含多条数据。
    • even(可选):表示消息的类型。默认为message。
    • id(可选):设置消息ID。用于重连时恢复。
    • retry(可选):指浏览器重新发起连接的时间间隔。单位是毫秒。
  4. 消息还可以有冒号开头的行,表示注释。

SSE的实现-SseEmitter

我这里学习用的是3.3.5 版的SpringBoot 对应的是 6.1.14 版的SpringFramework 下面与SseEmitter类的相关截图等都是来自这个版本。

根据官网文档可以知道,它是自Spring Framework(Spring MVC) 的4.2.x 版本引入的。ResponseBodyEmitter 一个专门用途,用于发送SSE的类。

SseEmitter的方法

在下面的图,我们可以很清晰的看到SseEmitter 只有6个方法。其中,框出来的都是发送SSE消息的重载方法。那么,我们只要会用这三个方法就可以了。

接下来,我们去看一下我们会用到的方法。

send(Object object)

从下图的官网对方法的解释来看。这里是默认走了SseEmitter 的静态event()方法。

那么先搞清楚,这个静态方法是做什么用的。找一下源码,如下图。

通过源码可以看到,这里只是创建了一个SseEventBuilderImpl 类。这个东西看起来就很熟悉了。因为在官网看到的SseEmitter 重载的三个方法当中,最后一个send方法传入的参数就是SseEventBuilder 。由此可以推测,第一个send方法 最后调用的就是 第三个send方法 。找一下源码看看,如下图。

发现这里调用了第二个send方法 。再跟进去看。

发现这里是先调用了静态的event() 方法创建了一个新的SseEventBuilderImpl 类,再调用data方法。跟官网第一个send方法 的解释一样。也就是说,创建了一个SseEventBuilderImpl 类,然后调用了SseEventBuilderImpl.data 方法。再跟进send方法 去看看。

发现跟我之前猜的一样,最后都是走到了第三个send方法 。也就是说,第一和第二个send方法,是系统自己创建了SseEventBuilderImpl ,而第三个send方法是我们手动创建SseEventBuilderImpl 类。那么关键就是SseEventBuilderImpl 的自己创建和系统帮忙创建有什么区别了。去看看这个类。

发现他是SseEmitter 的内部实现类,实现的也是内部的接口(SseEventBuilder )也是第三个send方法 传入的参数。接下来,看看里面的方法和参数。

发现这里四个方法中拼接的东西,正好就是上面SSE消息格式 提到的,需要传入的参数格式。

再看看刚刚调用的data(Object object, @Nullable MediaType mediaType)

可以看到,这里只拼接了data: 。也就是说,调用send(SseEventBuilder builder) 方法之前,SSE消息格式就已经完成。内容只包含data 也就是消息的内容。像idretryevent ,这三个都没有。

send(Object object, @Nullable MediaType mediaType)

可以从上面找到过程。

send(SseEventBuilder builder)

看看源码先,如下图。

这里会调用SseEventBuilderImpl.build() 返回一个set集合对象。跟进去看看。

这里出现了sbdataToSend 两个成员变量。我们先找一下,这两个成员变量是什么东西。

一个是可以为空的拼接字符串,另一个是有4个空位的LinkedHashSet。接下来,先看一下都是什么方法用到了拼接字符串sb

从这里,我们可以看到,主要就是两个重载的append 方法。在我们上面看SseEventBuilderImpl 方法的时候,有记录过,在SSE消息要设置idretryevent 的时候会用到append 方法。也就是说,sb 用来存储的都是消息的格式。

接下来,我们看一下dataToSend 是用在什么地方。

data 方法,我们之前在介绍第一个send方法 的时候,介绍过这里主要就是用于拼接消息内容。也就是说这里的集合,第一条数据存放的是data: ,第二条数据存放的是我们想要发送的消息内容 ,第三条数据存放的是/n 分隔符。在这里,用到LinkedHashSet ,首先是保证消息的有序性,其次是去除重复的数据。

接下来,回到build 方法。

消息内容完全添加之后,最后以/n 结束。完全符合SSE消息内容最后以/n/n 结束的格式。

回到SseEmitter.send(SseEventBuilder builder) 方法。如下图。

拼接完所有的消息之后,加了一个写入锁,在这里用的是ReentrantLock

我很好奇为什么不直接用synchronized 。于是去他们的github查了一下,发现他们之前的确用的是synchronized 。只是为了解决虚拟线程的pin问题,后续改用了ReentrantLock

随后,查询了虚拟线程的问题。发现这是JDK19 提出来的功能,在JDK21 已经成为一个核心功能。更能兼容高并发的场景,更灵活的加锁解锁机制也更适配调用AI大模型的长连接场景,不容易造成IO阻塞。感兴趣的朋友,可以在后面的相关资料查看,或是自己找资料。

回到代码本身。发现走的是super.send() 方法。说明调用的是父类或父类的父类方法。

点进去看一下。

发现的确是走了父类(ResponseBodyEmitter)的send方法。这里还是用synchronized 依旧会出现虚拟线程pin的问题。但是,这一块的代码问题,已经在2025.9.9日修改成了使用ReentrantLock ,并且合并于主线6.2.x主线。因为我这里用的是Spring-framework的6.1.14版本,所以代码还是显示使用synchronized

接着回到代码本身。

父类的send方法,主要走的是下面的sendInternal 方法。在这个方法里面,最重要的就是this.handler.send(items) 。用过SpringMVC的朋友们都知道handler 是一个非常重要的概念。这个才是springMVC当中,处理真正执行业务主体的部分。点进去看看,如下图。

其中,红框的是send方法走的步骤。而这个方法走的是ResponseBodyEmitterReturnValueHandler 的内部类HttpMessageConvertingHandlersendInternal 方法。实际上就是将推送的数据推送到客户端的过程。

我们看ResponseBodyEmitterReturnValueHandler 类的命名,可以知道,现在走到的是一个处理返回值的handler。在这样的handler里面,最重要的就是篮框里的handleReturnValue 方法。我们点进去看看

java 复制代码
@Override  
@SuppressWarnings("resource")  
public void handleReturnValue(@Nullable Object returnValue, MethodParameter returnType,  
       ModelAndViewContainer mavContainer, NativeWebRequest webRequest) throws Exception {  
  
    if (returnValue == null) {  
       mavContainer.setRequestHandled(true);  
       return;  
    }  
  
	//获取原始的response,并封装成outputMessage
    HttpServletResponse response = webRequest.getNativeResponse(HttpServletResponse.class);  
    Assert.state(response != null, "No HttpServletResponse");  
    ServerHttpResponse outputMessage = new ServletServerHttpResponse(response);  
    //如果返回值是responseEntity的处理步骤
    if (returnValue instanceof ResponseEntity<?> responseEntity) {  
       response.setStatus(responseEntity.getStatusCode().value());  
       outputMessage.getHeaders().putAll(responseEntity.getHeaders());  
       returnValue = responseEntity.getBody();  
       returnType = returnType.nested();  
       if (returnValue == null) {  
          mavContainer.setRequestHandled(true);  
          outputMessage.flush();  
          return;  
       }  
    }  
  
    ServletRequest request = webRequest.getNativeRequest(ServletRequest.class);  
    Assert.state(request != null, "No ServletRequest");  
  
    ResponseBodyEmitter emitter;  
    
    if (returnValue instanceof ResponseBodyEmitter responseBodyEmitter) {  
    //如果是 ResponseBodyEmitter 这个类或者其子类,说明还是传统的 servelet API 处理
       emitter = responseBodyEmitter;  
    }  
    else {  
	   // 如果不是 ResponseBodyEmitter 这个类或者其子类,那么就是响应式 API
       emitter = this.reactiveHandler.handleValue(returnValue, returnType, mavContainer, webRequest);  
       if (emitter == null) {  
          // Not streaming: write headers without committing response..  
          outputMessage.getHeaders().forEach((headerName, headerValues) -> {  
             for (String headerValue : headerValues) {  
                response.addHeader(headerName, headerValue);  
             }  
          });  
          return;  
       }  
    }  
    //如果是ResponseBodyEmitter这个类或者其子类,这里会将响应数据进行扩展
    //又因为ResponseBodyEmitter子类只有一个,就是SseEmitter,所以会去执行SseEmitter的
    //extendResponse方法,在里面设置Content-Type为text/event-stream
    emitter.extendResponse(outputMessage);  
  
    // At this point we know we're streaming..  
    ShallowEtagHeaderFilter.disableContentCaching(request);  
  
    // Wrap the response to ignore further header changes  
    // Headers will be flushed at the first write    outputMessage = new StreamingServletServerHttpResponse(outputMessage);  
  
    HttpMessageConvertingHandler handler;  
    try {  
       DeferredResult<?> deferredResult = new DeferredResult<>(emitter.getTimeout());  
       WebAsyncUtils.getAsyncManager(webRequest).startDeferredResultProcessing(deferredResult, mavContainer);  
       handler = new HttpMessageConvertingHandler(outputMessage, deferredResult);  
    }  
    catch (Throwable ex) {  
       emitter.initializeWithError(ex);  
       throw ex;  
    }  
  
    emitter.initialize(handler);  
}

这一块的原理想看的深入一些,推荐SSE 简单实践 - SpringMVC 异步消息处理 的后端部分。

代码实现

了解完执行流程之后,简单用send(myObject)来实现一下简单的Demo。后面的两个方法,你可以自己动手试试。先创建一个新的web项目,pom文件如下:

xml 复制代码
<?xml version="1.0" encoding="UTF-8"?>  
<project xmlns="http://maven.apache.org/POM/4.0.0"  
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"  
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">  
    <modelVersion>4.0.0</modelVersion>  
  
    <groupId>com.sse</groupId>  
    <artifactId>SseEmitterDemo</artifactId>  
    <version>1.0-SNAPSHOT</version>  
  
    <properties>        
	    <maven.compiler.source>17</maven.compiler.source>  
        <maven.compiler.target>17</maven.compiler.target>  
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>  
    </properties>  
    <!-- 继承 Spring Boot 父工程 -->  
    <parent>  
        <groupId>org.springframework.boot</groupId>  
        <artifactId>spring-boot-starter-parent</artifactId>  
        <version>3.3.5</version>  
    </parent>  
    <dependencies>        
	    <dependency>            
		    <groupId>org.springframework.boot</groupId>  
            <artifactId>spring-boot-starter-web</artifactId>  
        </dependency>  
        <!-- JMH 核心依赖 用于测试内存占比,可以不用写-->  
        <dependency>  
            <groupId>org.openjdk.jmh</groupId>  
            <artifactId>jmh-core</artifactId>  
            <version>1.37</version> <!-- 建议使用最新稳定版,如 1.37 或 1.45 -->  
        </dependency>  
  
        <!-- JMH 注解处理器依赖 用于测试内存占比,可以不用写-->  
        <dependency>  
            <groupId>org.openjdk.jmh</groupId>  
            <artifactId>jmh-generator-annprocess</artifactId>  
            <version>1.37</version> <!-- 版本需与 jmh-core 保持一致 -->  
        </dependency>  
        <dependency>            <groupId>io.projectreactor</groupId>  
            <artifactId>reactor-core</artifactId>  
        </dependency>    
    </dependencies>  
    <build>        
	    <plugins>            <!-- 确保编译器插件支持注解处理 -->  
            <plugin>  
                <groupId>org.apache.maven.plugins</groupId>  
                <artifactId>maven-compiler-plugin</artifactId>  
                <version>3.11.0</version>  
                <configuration>   
	                <!-- 根据你的 JDK 版本调整,如 8, 11, 17, 21 -->                
	                <source>17</source> 
                    <target>17</target>  
                </configuration>            
            </plugin>  
        </plugins>    
    </build>
</project>

配置文件application.yaml如下:

yaml 复制代码
spring:  
  application:  
    name: sseTest  
server:  
  port: 8080

服务启动类SseEmiterDemoApplication:

java 复制代码
package com.sse.demo;  
  
import org.springframework.boot.SpringApplication;  
import org.springframework.boot.autoconfigure.SpringBootApplication;  
  
/**  
 * @Author: whatever  
 * @Date: 2026/7/24 10:30  
 * @JDK: 17.0.19  
 * @Description: sse消息测试入口  
 */@SpringBootApplication  
public class SseEmiterDemoApplication {  
    public static void main(String[] args) {  
        SpringApplication.run(SseEmiterDemoApplication.class, args);  
    }  
}
SseEmitter的同步实现

代码部分:

java 复制代码
@GetMapping("test/sync")  //因为SseEmitter的扩展里已经设置Content-Type为text/event-stream,所以我这里不需要显式设置也可以,另外需要注意的是sse这里用的是get请求,用post的话会报Method Not Allowed的错误
public SseEmitter testSync() throws IOException {  
    SseEmitter emitter = new SseEmitter();  
    System.out.println("同步主线程名称:" + Thread.currentThread().getName());  
    for (int i = 0; i < 10; i++) {  
        System.out.println("同步循环里线程名称:" + Thread.currentThread().getName());  
        emitter.send("模拟AI输出" + i);  
    }  
    System.out.println("同步主线程名称2:" + Thread.currentThread().getName());  
    emitter.complete();  
  
    return emitter;  
}

接下来,用apifox来测试一下。

可以看到,数据几乎是同一时间一次性返回给数据。根本没有达到想要的流式输出的感觉。再来看看,响应的消息头是否像我们看的源码那样,自动设置成text/event-stream

这样看起来是没有任何问题的。接下来,看一下同步情况下,底层是否会自动开启线程发送数据。

发现这里不管是发送数据的线程,还是循环外的线程都是同一个。说明如果我们想要实现异步进行数据的反馈,还是需要自己手动开启线程池。也说明同步情况并不适合真实的生产环境,如果数据量巨大的话,主线程一直处于执行状态,后续的业务要等待线程一直处理完成数据,才能执行,容易造成业务超时,主要业务的线上bug。

SseEmitter的异步实现

现在用代码来进行异步实现:

java 复制代码
@GetMapping(value = "test/async") //因为SseEmitter的扩展里已经设置Content-Type为text/event-stream,所以我这里不需要显式设置也可以,另外需要注意的是sse这里用的是get请求,用post的话会报Method Not Allowed的错误 
public SseEmitter testAsync() {  
    SseEmitter emitter = new SseEmitter();  
    AtomicInteger staticCount = new AtomicInteger();  
    //如果是生产环境的话,不要使用Executors.newSingleThreadScheduledExecutor(),最好自己定义线程池,这里只是为了方便测试
    ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor();  
    System.out.println("异步主线程名称:" + Thread.currentThread().getName());  
    executor.scheduleWithFixedDelay(() -> {  
        try {  
            staticCount.getAndIncrement();  
            emitter.send("模拟AI输出:" + staticCount);  
            System.out.println("异步线程名称:" + Thread.currentThread().getName());  
            if (staticCount.get() >= 10) {  
                emitter.complete();  
                executor.shutdown();  
            }  
        } catch (IOException e) {  
            emitter.complete(); 
            executor.shutdown();  
        }  
    }, 0, 500, TimeUnit.MILLISECONDS);  
  
    System.out.println("异步主线程名称2:" + Thread.currentThread().getName());  
  
    // 连接关闭释放资源  
    emitter.onCompletion(executor::shutdown);  
    emitter.onError((e) -> executor.shutdown());  
    emitter.onTimeout(executor::shutdown);  
    return emitter;  
}

异步也用apifox测试一下:

这里可以看到,输出的效果有点像是AI输出的结果了。我们来看看消息头是否也自动设置了自动设置成text/event-stream

看起来也没有问题。再来看一下,开启线程之后,主线程业务是否会处于等待。

看起来主线程的业务不会受影响。

SseEmitter的同步/异步对比
对比维度 sse同步 sse异步
Tomcat 工作线程生命周期 长连接全程独占、阻塞,无法复用 接口毫秒级返回,线程立刻归还线程池复用
前端展示效果 接口结束一次性返回,无打字机流式效果 每隔固定时间推送一条,流式效果正常
SSE 超时故障率 极高,主线程业务耗时极易超过 Emitter 超时阈值 仅长期无数据闲置触发超时,故障率极低
生产可用度 仅本地玩玩,禁止上线 AI 流式对话、实时日志、进度推送、设备状态推送标准生产方案

前端实现sse

这个可以参考使用服务器发送事件,我就不写了。

最后

如果你想用apifox验证SSE末尾用于分隔符的\n\n 的话,建议抓包或者用后置脚本pm.response.text()检查。

查阅的相关资料

相关推荐
卷心菜的学习路10 小时前
基于多模态向量检索的数学相似题推荐系统:完整设计、算法与实验
java·python·算法·大模型·推荐算法·数学相似题
BIGmustang11 小时前
千帆模型微调及部署
人工智能
神奇霸王龙11 小时前
CC Switch 配置 Claude Desktop+ selltoken 中转 API 实测教程(2026 年 8 月更新)
人工智能·ai·aigc·agent·ai编程·claude
朱涛的自习室11 小时前
从 Prompt 到 Graph:AI 工程的进化史
android·前端·人工智能
程序员cxuan11 小时前
Pi + DeepSeek-v4-Flash,这用着也太爽了。
人工智能·后端·程序员
stormzhangV11 小时前
2026 年 8 月,我的最新 AI 装机单
人工智能·openai·ai编程
暴躁的小鸟11 小时前
附近口碑好的斜视配镜训练的眼视光中心
人工智能·python·深度学习
deepseek2311 小时前
OpenAI 首次为自家模型踩刹车:Astra「无法排除关键网络能力」,Preparedness Framework 第一次真的咬人
人工智能·网络安全·ai agent
zhangfeng113311 小时前
免费 GPU 资源清单(按用途选)包括国产显卡 和 amd显卡 英伟达v100等
人工智能·ai编程·显卡
今天的砖头有点烫手啊12 小时前
AI Agent 开发实战(十):Agent 设计模式(ReAct / Plan-Execute / Reflection)
人工智能·react.js·设计模式