JAVA应用使用SSE长连接进行即时通讯

1.背景

为什么需要使用SSE

对于体验过大语言模型的人(chatgpt,文心一言,通义千问...)都知道,大模型的回答是一边思考一边返回数据的,属于流式响应。要达到这种效果就需要实现前后端的即时通讯。

即时通讯的实现方式有4种:轮询、长轮询(comet)、长连接(SSE)、WebSocket,目前主流的技术方案都是使用SSE长连接,具体各个实现方式的描述及优缺点如下:

轮询(短轮询),长轮询(comet),长连接(SSE),WebSocket - 简书

2.服务端实现SSE

在Spring Boot中实现Server-Sent Events (SSE) 可以通过SseEmitter类来实现。

以下是一个使用SseEmitter在Spring Boot中创建SSE长连接的步骤:

  1. 添加依赖:确保在你的build.gradle或pom.xml中引入了Spring Web的依赖。
  2. 创建Controller: 创建一个Controller来处理SSE连接请求,并使用SseEmitter作为响应。
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    @RequestMapping(value = "/sub", method = RequestMethod.POST, produces = {MediaType.TEXT_EVENT_STREAM_VALUE})
    public SseEmitter subscribe(@RequestBody Body body) {
        System.out.println("accept_new_question,id=" + JSON.toJSONString(body));
        // 添加订阅,建立sse链接
        SseEmitter emitter = SSEUtils.addSub(body.getSubId());
        new Thread(() -> {
            try {
                for (int i = 0; i < 60; i++) {
                    // 发送消息
                    SSEUtils.pubMsg(body.getSubId(), "", String.valueOf(i), body.getSubId() + " - hmg come " + i);
                    Thread.sleep(60 * 1000);
                }
            } catch (Exception e) {
                e.printStackTrace();
            }finally {
                // 消息发送完关闭订阅
                SSEUtils.closeSub(body.getSubId());
            }
        }).start();
        return emitter;
    }
    import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
    
    import java.util.Map;
    import java.util.concurrent.ConcurrentHashMap;
    
    import static org.springframework.web.servlet.mvc.method.annotation.SseEmitter.event;
    
    public class SSEUtils {
        // timeout
        private static Long DEFAULT_TIME_OUT = 5*60*1000L;
    
        private static final Map<String, SseEmitter> subscribeMap = new ConcurrentHashMap<>();
    
        /** 添加订阅 */
        public static SseEmitter addSub(String subId) {
            if (null == subId || "".equals(subId)) {
                return null;
            }
    
            SseEmitter emitter = subscribeMap.get(subId);
            if (null == emitter) {
                emitter = new SseEmitter(DEFAULT_TIME_OUT);
                
                emitter.onTimeout(() -> {
                    // 注册超时回调,超时后触发
                    System.out.println("onTimeout,subId=" + subId);
                    closeSub(subId);
                });
                
                emitter.onCompletion(() -> {
                    // 注册完成回调,调用 emitter.complete() 触发
                    System.out.println("onCompletion,subId=" + subId);
                    closeSub(subId);
                });
                subscribeMap.put(subId, emitter);
            }
            return emitter;
        }
    
        public static void pubMsg(String subId, String name, String id, Object msg) {
            SseEmitter emitter = subscribeMap.get(subId);
            if (null != emitter) {
                try {
                    System.out.println(msg);
                    // 更规范的消息结构看源码
                    emitter.send(event().name(name).id(id).data(msg));
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        }
    
        // 关闭订阅
        public static void closeSub(String subId) {
            SseEmitter emitter = subscribeMap.get(subId);
            if (null != emitter) {
                try {
                    emitter.complete();
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }finally{
                subscribeMap.remove(subId);
            }
        }
    }

    3.使用SSE的踩坑

    3.1 Async support must be enabled on a servlet and for all filters involved in async request processing

    
    java.lang.IllegalStateException: Async support must be enabled on a servlet and for all filters involved in async request processing. This is done in Java code using the Servlet API or by adding "<async-supported>true</async-supported>" to servlet and filter declarations in web.xml.
    	at org.springframework.util.Assert.state(Assert.java:76) ~[spring-core-5.3.9.jar:5.3.9]
    	at org.springframework.web.context.request.async.StandardServletAsyncWebRequest.startAsync(StandardServletAsyncWebRequest.java:112) ~[spring-web-5.3.9.jar:5.3.9]
    	at org.springframework.web.context.request.async.WebAsyncManager.startAsyncProcessing(WebAsyncManager.java:483) ~[spring-web-5.3.9.jar:5.3.9]

    异常堆栈中已经说明:异步请求必须对servlet和所有涉及到的filter都需要开启异步支持

    adding "<async-supported>true</async-supported>" to servlet and filter declarations

    springboot项目中,filter一般通过@WebFilter注解引入,开启asyncSupported的方式如下:

    3.2流式输出未生效

    流式输出未生效的表现为:sse长连接已成功建立,但是并没有达到实时推送消息给客户端的效果,而是要等流式输出已完成时一次性将所有内容返回客户端。

    造成这个情况的原因是流式输出的内容被nginx代理或servlet缓存下来,需要设置关闭缓存。

    3.2.1 nginx配置

    nginx默认会开启缓存,当服务端响应消息时,不会直接返回客户端,而是放入缓冲区,待缓冲区放满了以后再返回客户端一次,重复这个步骤。

    表现为:客户端收到的消息是一段一段的

    解决方案:在nginx.conf文件中,对sse的location关闭缓存

    解决SSE流被Nginx缓存的问题_nginx sse-CSDN博客

    location ^~ /sse/ {
      proxy_pass   http://127.0.0.1:7001;
      add_header X-Location "sse";
      proxy_http_version 1.1;
      proxy_set_header Connection "";
      proxy_set_header X-Real-IP $remote_addr;
      proxy_set_header Host $host;
      proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
      # SSE 连接时的超时时间
      proxy_read_timeout 300s;
      # 取消缓冲
      proxy_buffering off;
      # 关闭代理缓存
      proxy_cache off;
      
      # 重要!因为项目实际不止有一个nginx代理,还有可能会有统一的上层nginx网关。
      # 只在第一个nginx种关闭缓存还不够,由于第二个nginx默认会缓存数据,所以sse流就再一次被缓存了。
      # 解决办法是在第一个nginx的配置里面,继续加上这个header,表示这个路径下的请求需要带上这个header,这样第二个nginx才能继续收到这个header并且不缓存数据。
      add_header X-Accel-Buffering "no";
    }

    4.关于各种超时时间

    SSE长连接有很多地方需要配置到超时时间,这些超时时间设置起到什么作用,如果超时会有产生什么样的情况,具体如下:

    4.1 new SseEmitter(DEFAULT_TIME_OUT);

    springboot实现SSE长连接是通过SseEmitter对象实现的,在SseEmitter的构造方法需要传入timeout,单位milliseconds。

    这里timeout的作用是长连接和前端保持的时间,到达超时时间之后,服务端就会与前端断开连接,即使后续还有推流内容前端也无法接收到。

    服务端在超时之后继续推流,代码会抛出ResponseBodyEmitter has already completed异常。

    所以SseEmitter的超时时间需要根据实际情况合理设置:

    如果设置的太短,消息无法完整给到前端;

    如果设置的太长又没有正确的主动关闭长链接,会造成服务端资源浪费。

    4.2.nginx proxy_read_timeout

    Nginx超时配置_nginx设置超时时间-CSDN博客

    proxy_read_timeout:设置从后端/上游服务器读取响应的超时时间,默认为60s,此超时时间指的是两次成功读操作间隔时间,而不是读取整个响应体的超时时间,如果在此超时时间内上游服务器没有发送任何响应,则Nginx关闭此连接。
    如果触发proxy_read_timeout

    客户端表现:

    服务端表现:

    如果继续往这个通道推送消息,代码会抛出ResponseBodyEmitter has already completed异常。

    proxy_read_timeout的超时时间可以设置大一些,让的SseEmitter来控制整个长连接的超时。

    4.3 OkHttpClient readTimeout

    上面讲的都是java应用作为服务端配置的超时时间。

    实际的情况中,java应用往往需要扮演两个角色,作为大语言模型与用户之间的中转

  • 作为服务端给用户流式推送数据
  • 作为客户端与大模型建立SSE连接,流式接受数据

        java应用与大模型建立SSE链接,一般都是用okhttp实现,创建OkHttpClient时,需要设置readTimeOut。

        readTimeOut的作用和上面提到过的nginx proxy_read_timeout作用类似,如果大模型两次推送的时间间隔超过readTimeOut,okhttpclient关闭链接,触发EventSourceListener的onFailure方法

readTimeOut的超时时间可以设置大一些,让的SseEmitter来控制整个长连接的超时。

评论 12
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值