You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
functionparserOnBody(b,start,len){conststream=this.incoming;// If the stream has already been removed, then drop it.if(stream===null)return;// Pretend this was the result of a stream._read call.if(len>0&&!stream._dumped){constslice=b.slice(start,start+len);constret=stream.push(slice);if(!ret)readStop(this.socket);}}
// If the user did not listen for the 'response' event, then they// can't possibly read the data, so we ._dump() it into the void// so that the socket doesn't hang there in a paused state.if(req.aborted||!req.emit('response',res))res._dump();
最近,在维护 KProxy(一个代理服务,Web 版的 charles) 时,遇到一个故障问题:一个请求的响应早已结束,但是在一段时间后却触发了 request timeout。
1. 问题描述
KProxy server 作为代理服务,收到用户请求时会向真实服务端(real server)发出的一个 HTTP 请求,实现代理。但是,在真实服务端响应结束之后(过了较长一段时间),这个 KProxy 中的该请求的 timeout 事件却被触发了。由于真实服务端的响应早已结束,预期是不会触发 KProxy 请求侧的超时的。结合 KProxy 这边的一些超时保障逻辑,就出现了误“封禁”的问题。
核心情况,简单来说就是:一个请求的响应早已结束,但是在一段时间后却触发了 request timeout。
2. 代码复现
KProxy 相关的完整代码比较多,下面重写了一份简版,用以复现问题。
server.js
是一个简单的服务端,监听 8088 端口,对于请求会响应一串"1",2 秒后完成响应:client.js
则会请求该服务:启动
server.js
,然后执行client.js
,会看到控制台输出以下内容:可以看到,当时间来到第 5 秒时,
timeout
事件触发了。而实际情况是,服务端在 2 秒的时候就已经调用.end
方法结束响应了。查看 TCP 连接情况,看到连接也一致没有销毁。
3. 如何修复
修复方式很简单,可以在回调里添加
res.on('data', () => {})
这样一行代码:const http = require('http'); // 发送请求 const req = http.request({ port: 8088, timeout: 5000, }, res => { // 一些省略的逻辑 + res.on('data', () => {}); }); req.end(); req.on('timeout', () => console.log('request timeout!')); // 每秒输出一下时间 let i = 0; setInterval(() => console.log(1e3 * ++i, 'ms passed'), 1e3);
看下输出结果:
不会再误触发
timeout
事件。连接也正常销毁了。但这一段看似无意义的代码(监听了
data
事件但其实什么事情也没有做),是如何”修复“这个问题的呢?4. 背后的原因
下面结合 Node.js v16.10.0 的源码,来解释一下这个问题产生的原因。
4.1.
http.request
的大致实现为了更好理解这个问题,先简单介绍一些和
http.request
相关的知识。调用
http.request
实际会创建一个 httpClientRequest
对象。而ClientRequest
类是继承自OutgoingMessage
类的,它是一个 Stream。所以我们可以通过 Stream 的 API 向请求体中不断写入信息,并且通过调用.end
方法表示请求写入完成,例如:请求体发送完毕后,下一阶段就是等待处理响应数据。在响应头处理完毕后,Node.js 会创建
IncomingMessage
对象,这个对象也是一个 Stream(准确来说是继承自 Readable Stream )。然后调用parser.onIncoming
方法:对于 ClientRequest 来说,
parser.onIncoming
实际就是parserOnIncomingClient
方法,它会触发response
事件:由于 ClientRequest 在创建时就会监听
response
事件来触发http.request
的回调,因此在http.request
的回调中或者response
事件中都可以拿到 response 对象(IncomingMessage)。也就是说,回调触发的时候,响应头已经解析完毕,开始解析响应体。涉及到的主要的类的大致关系如下:
所以如果想拿到响应体的信息,可以在回调里监听对应
data
事件。4.2. Request Timeout 的实现
http.request
在超时的实现上,并没有什么非常特别之外,就是通过 JS Timer 来设置一个定时器:如果到期未被清理,则会触发timeout
事件。设置定时器最后会调用到stream_base_common.js
中的setStreamTimeout
方法:this._onTimeout
则会触发timeout
事件。而我们更关注的是定期器的清除。因为请求超时的误触发,很可能会和没有成功清理定时器有关。那么,定时器何时会被清除呢?
当 socket 销毁时,定时器就会被清除:
对此,我们可以添加
NODE_DEBUG
环境变量来查看调试信息,使用下面命令启动我们修复后的代码(添加data
空监听函数的):可以看到,正常调用了
Socket.prototype._destroy
方法。去掉
res.on('data', () => {})
这行修复代码再试一下:可以看到,一直都不会触发 destroy。
4.3. IncomingMessage 与 Socket
定时器清除的逻辑和 Socket 对象相关,但我们的修复代码操作的是 IncomingMessage 对象,那么他们之间有什么关系呢?
Socket 类继承自 Duplex(读写流),在流结束后会调用上面的
Socket.prototype._destroy
方法。在实际接收响应时,C++ 层的 node_http_parser
会把解析的响应体内容传到 JS 层的方法里,最终调用
parserOnBody
方法:上面的
this.incoming
就是我们的IncomingMessage
对象。其中主要的一段逻辑是它会向
IncomingMessage
中添加新的数据,如果返回值为false
,则会调用readStop
方法。该方法会将 socket 流暂停:两者在这块会有一个联系了。
4.4. Stream 的机制
根据上面的实现,可以知道:向
IncomingMessage
流添加数据时如果返回false
,则会暂停Socket
流。下面结合 Stream 本身的机制,来看下是如何影响到超时定时器的清除的。首先,是关于
.push
的返回值。Stream 会有水位控制(highWaterMark),如果一个可读流被不断 push 进数据,但是没有被消费,这时候 Stream 会把这些数据“暂存”在内部,等待消费。显然,如果无限制存储数据,就可能出现内存问题,所以 Node.js 通过返回
false
,来建议用户不要再继续写入了。其次,是关于暂停流。
可读流如果被暂停,除了内部的暂停状态被置为
true
之外,还有一个改变是它的消费模式(reading mode)将会从 flowing mode 变为 pause mode:它们的一大区别就是暂停模式下,'data' 监听不会收到事件,需要手动调用 stream 的
.read
方法来读取数据。所以,大致原因就是,在我们没有消费
IncomingMessage
的情况下,由于响应体过大,超过最高水位线后,Node.js 就把 Socket 流暂停了,导致无法触发 Socket 的结束销毁,间接导致控制超时的定时器没有被销毁。5. 再谈如何修复
5.1. 方法一
本文最开始,我们通过添加一段看似毫无意义的代码:
解决了这个问题。到这里我们应该理解为什么这段修复代码有效。当然,还有其他两个情况下,该问题也不会发生。
5.2. 方法二
第二个是不给
http.request
添加 callback。例如把client.js
代码变为:可以看到,这种情况下并未触发 timeout。
这是因为我们如果不添加回调(同时也不监听
response
事件),IncomingMessage
就会进入 dumped 状态:这个状态下,便不会再向
IncomingMessage
push 数据,也就没有了后面的事儿。5.3. 方法三
第三个方式是让服务端不要返回超过 16KB 的响应体。当然这从实际需求来说不太可行,但从原理上来说可以规避这个问题。
因为 highWaterMark 的默认值就是 16KB:
我们可以尝试把
server.js
中的返回值长度减小一些:这样也不再会误触超时问题。
6. 详细的故障场景
KProxy 是怎么触发这个问题的呢?
作为代理服务器,KProxy 大致工作方式如下:
它在收到用户的请求时(请求 A),会通过
http.request
创建一个到真实服务端的连接(请求 B),将请求 A 的 OngoingMessage pipe 给请求 B,然后将请求 B 的 IncomingMessage pipe 给请求 A。这个方式本身没有问题,但在一个特殊的功能下,会出现问题。
KProxy 可以支持完全覆盖响应体,在这种情况下,KProxy 会将请求 B 的响应头和用户设置的响应体直接拼接,形成完整的响应内容 pipe 给请求 A。可以看到,这里主要的区别就是,不会消费响应体(
IncomingMessage
)。但它仍然监听了http.request
的回调。同时,由于该代理请求的响应体正好超过了 16KB,就导致了「请求理论上应该结束,但却“误报”超时」的问题。7. 最后
由于本文结合源码阐述了该问题的原因,并且介绍了一些相关的基础知识,所以篇幅较长。精简来说:
IncomingMessage
的写入状态会影响 Socket 流的启停状态,从而可能导致 Socket 流无法如期调用其destroy
方法。此外,再补充一点与该故障无关也相关的点,Readable Stream 如果没有被消费,不会触发
end
事件。所以下面的代码也是不会打印 'finish' 的。除非也给它加上
res.on('data', () => {})
这段代码。The text was updated successfully, but these errors were encountered: