Fetch请求中处理Event-Stream数据,发生数据“丢失”的现象

ragnar 1年前 ⋅ 927 阅读

背景:用做了个小项目,对接OpenAI接口实现了Chat功能。
用到的技术:java、mongo、html、js、css等等。

1 现象

  • 上云之前:在原来的环境是没有问题的:
    • 环境条件:4核/4G内存;
    • 宽带:下载:200M,上传:30M;
    • 表现:逐个“吐字”,且“吐字”有序无异常。
  • 上云之后:(腾讯云)就来事了:
    • 环境条件:2核/2G;
    • 宽带:峰值为60M,稳定值不可知;
    • 表现:出现成批的“吐字”,而且往往吐了一两批就结束了。由于数据在服务器端有保存起来,所以手动刷新页面,来获得生成的完整消息。

2 分析

可能的节点

  • 服务端,采用 SSE 来传输数据,messageConver 是 StringHttpMessageConver。
  • 服务端物理上部署在了A国,会存在网络不稳定的情况,且后面加了HTTPS协议。
  • 客户端,采用 fetch 来发请求和处理响应。

2.1 是后端的锅?

SSE:Server-Send Events,即服务器发送事件,可以用来支持text/event-stream类型响应数据的传输。

大概的过程:

  • 客户端发起HTTP请求,服务端返回的响应头Content-Type:text/event-stream表示响应数据是事件流,客户端就不会断开连接;
  • 服务端发送数据是通过发送“事件”,每个事件是以data:开头,以\n\n结尾;
  • 服务端发送完毕的标记是发送这样一个事件:data:[DONE],表示完成;
  • 客户端与服务端断开HTTP连接,请求结束。

大概的实现:

@RestController
public class ChatController {
	// ...
    @PostMapping(value = "/sendChat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public SseEmitter sendChat(@RequestBody ChatDTO dto, HttpServletRequest request){
    	SseEmitter emitter = new SseEmitter(DEFAULT_TIME_OUT);
        // ...
        	emitter.send(data, MediaType.TEXT_PLAIN);
        // ...
        	emitter.complete();
    	return emitter;
    }
    // ...
}

**分析:**如果是服务端出问题,那么各客户端请求都能复现相同问题。

验证:

  • Postman --- 不能复现。
  • java httpClient --- 不能复现。

2.2 协议的锅?

HTTPS:是基于HTTP协议的,HTTPS只是对报文做了加密而已。
HTTP:是基于TCP协议的,是经过三次握手建立起的连接,如果丢包了,理论上服务端会重发。

2.3 是前端的锅?

前端采用fetch来发起请求,be like:

fetch(URL, {
	method: "POST",
	headers: {
	"Content-Type": "application/json",
	},
	body: JSON.stringify(data)
})
.then(function(response) {
	var reader = response.body.getReader();
    
    // 处理流的方法
    function processStream({ done, value }) {
    	if (done) {
      		return;
    	}
        var text = new TextDecoder("utf-8").decode(value);
        // ... process
		return reader.read().then(processStream);
  	}
	return reader.read().then(processStream);
})
.catch(function(error) {
	// ...
	// 处理请求错误
	console.log(error);
});  

WebAPI: https://developer.mozilla.org/zh-CN/docs/Web/API/Response/body

由于对前端的API不是太熟悉就查询了一下:

  • Response.body 返回 ReadableStream 对象。
  • ReadableStream.getReader() 会创建一个读取器(reader)并将流锁定于其上:
    • 只有当前 reader 将流释放后,其他 reader 才能使用;
    • reader 的类型:ReadableStreamDefaultReader (默认的)类型或 ReadableStreamBYOBReader (en-US)。
  • ReadableStreamDefaultReader 接口表示一个用于读取来自网络提供的流数据(例如 fetch 请求)的默认 reader。
    • .read() ---返回一个 promise,提供对流内部队列中下一个分块的访问权限。

**直觉:**可能问题就出在reader.read()这里。

结果:经过Debug,发现的确是前端 Reader 读取数据的问题:一个 "data:"(对应SSE中一个事件)可能在两次调用 read() 方法中读取到,即一次读了“事件”的一半,导致数据不完整。 GPT模块数据缺失问题定位.png

猜想:

  • 当有一个事件到达时,reader.read() 会被调用,拿到的是字节数组,再转成的字符串text去处理,这个过程是没问题的。
  • 当事件扎堆到来的时候,reader.read() 会被调用后,它可能得到了一堆事件
    • 如果有事件在 reader.read() 被调用时,没有完全到位,那么就导致这个事件text中只有一部分。
    • 由于一个事件包含的是一个JSON字符串,所以缺失字符会导致没办法被JSON解析(会抛错)。

小结

  • 对线上问题,要全链路分析,验证。
  • 前端知识的学习是很有必要的。

全部评论: 0

    我有话说:

    目录