Starkwang.log
首发于Starkwang.log
如何解决 Keep-Alive 导致 ECONNRESET 的问题

如何解决 Keep-Alive 导致 ECONNRESET 的问题

使用 Node.js 搭建的服务中,如果存在 HTTP 的 RPC 调用,并且使用了 keep-alive 来保持 TCP 长连接, 那么一定会有一个牛皮糖般的问题困扰着你,那就是 ECONNRESET 或者 socket hang up 这种错误。

一段简单的复现代码:

const http = require("http");
const agent = new http.Agent({ keepAlive: true });

// 从 Node.js 8 开始,服务器的 keep-alive 默认 5 秒超时
http
  .createServer((req, res) => {
    res.write("hello world");
    res.end();
  })
  .listen(8080);

// 每 5 秒发起一次请求
setInterval(() => {
  http.get("http://127.0.0.1:8080", { agent }, res => {
    res.on("data", () => {})
    res.on("end", () => {
      console.log("success");
    });
  })
}, 5000);

等 3-4 次请求之后,会出现报错:

Error: read ECONNRESET
    at TCP.onStreamRead (internal/stream_base_commons.js:111:27)
Emitted 'error' event at:
    at Socket.socketErrorListener (_http_client.js:392:9)
    at Socket.emit (events.js:189:13)
    at emitErrorNT (internal/streams/destroy.js:82:8)
    at emitErrorAndCloseNT (internal/streams/destroy.js:50:3)
    at process._tickCallback (internal/process/next_tick.js:63:19)

这个问题是如何产生的

其实这就是状态机里一个简单的竞争情形:

  1. 客户端与服务端成功建立了长连接
  2. 连接静默一段时间(无 HTTP 请求)
  3. 服务端因为在一段时间内没有收到任何数据,主动关闭了 TCP 连接
  4. 客户端在收到 TCP 关闭的信息前,发送了一个新的 HTTP 请求
  5. 服务端收到请求后拒绝,客户端报错 ECONNRESET

总结一下就是:服务端先于客户端关闭了 TCP,而客户端此时还未同步状态,所以存在一个错误的暂态(客户端认为 TCP 连接依然在,但实际已经销毁了)

这个问题如何解决

有两种方法可选:

1、保证客户端永远先于服务端关闭 TCP 连接

这种方法就是把客户端的 keep-alive 超时时间设置得短一些(短于服务端即可)。这样就可以保证永远是客户端这边超时关闭的 TCP 连接,消除了错误的暂态。

但这样在实际生产环境中是没法 100% 解决问题的,因为无论把客户端超时时间如何设置到多少,因为网络延迟的存在,始终无法保证所有的服务端的 keep-alive 超时时间都长于客户端的值;如果把客户端超时时间设置得太小(比如 1 秒),又失去了意义。

可以参考:zhuanlan.zhihu.com/p/34

2、错误重试

最佳的解决方法还是,如果出现了这种暂态导致的错误,那么重试一次请求就好,但是只识别 ECONNRESET 这个错误码是不够的,因为服务端可能因为某些原因真的关闭了 TCP 端口。

所以最佳的做法是,使用一个标记表示当前的请求是否复用了 TCP,如果错误码为 ECONNRESET 且存在标记(复用了 TCP),那么就重试一次。但目前 Node.js 的 HTTP Agent 里还无法识别一个请求是否复用了 TCP 连接。

例如 request 的做法,就是使用 forever-agent 标记是否复用了 TCP,然后再识别错误码。然而,forever-agent 只会在 0.10 版本生效,现在早就不能用了。

所以近期在 Node.js 合入了一个 PR,加入了一个 req.reusedSocket,将会在下一个 minor 版本中发布。我们可以通过 req.reusedSocket 是否为 true 来表示当前 HTTP 请求是否复用 TCP。

对于 Node.js 之前的版本,我们可以改造 HTTP Agent,使其在旧版本中也会有这个标记,例如 agentkeepalive 的改动:github.com/node-modules

于是我们可以像下面这样写代码:

const http = require("http");
const request = require("request");
const Agent = require("agentkeepalive");

const agent = new Agent();

http
  .createServer((req, res) => {
    res.write("hello world");
    res.end();
  })
  .listen(8080);

setInterval(() => {
  const reqInfo = request.get("http://127.0.0.1:8080", { agent }, (err) => {
    if (!err) {
      console.log("success");
    } else if (err.code === 'ECONNRESET' && reqInfo.req.reusedSocket) {
      // 如果错误码为ECONNRESET,且复用了TCP连接,那么重试一次
      return request.get("http://127.0.0.1:8080", (err) => {
        if (err) {
          throw err;
        } else {
          console.log("success with retry");
        }
      });
    } else {
      throw err;
    }
  });
}, 5000);

输出如下,可以看到之前存在的偶现错误,都会自动重试:

success
success
success with retry
success
success with retry
success
success

部分考古研究

上文提到的这种竞态,在浏览器端也经常出现,根据 RFC 规定,此时浏览器应该返回 HTTP 408 状态码。

Chrome 在 2014 年之前一直是根据 RFC 标准实现的,返回 408 状态码。然而有人给 Chromium 提过一个 issue,希望 Chromium 在这种状态下能够自动重试,随后 Chromium 实现了这个重试的特性,你可以在这里看到具体的代码变更。

相关资料:

  1. stackoverflow.com/quest
  2. github.com/nodejs/node/
发布于 2019-10-16