背景:用做了个小项目,对接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() 方法中读取到,即一次读了“事件”的一半,导致数据不完整。
猜想:
- 当有一个事件到达时,reader.read() 会被调用,拿到的是字节数组,再转成的字符串
text
去处理,这个过程是没问题的。 - 当事件扎堆到来的时候,reader.read() 会被调用后,它可能得到了
一堆事件
。- 如果有事件在 reader.read() 被调用时,没有完全到位,那么就导致这个
事件
在text
中只有一部分。 - 由于一个
事件
包含的是一个JSON字符串,所以缺失字符会导致没办法被JSON解析(会抛错)。
- 如果有事件在 reader.read() 被调用时,没有完全到位,那么就导致这个
小结
- 对线上问题,要全链路分析,验证。
- 前端知识的学习是很有必要的。
注意:本文归作者所有,未经作者允许,不得转载