聊聊服务间的网络通信 - TCP 与 HTTP

前言

阅读前你可能需要了解这些:

  • 了解 TCP/IP、OSI 模型
  • 了解 HTTP 协议
  • 了解 Node.js

从几个问题入手:

  • 服务间调用的长连接如何设置
  • 服务器上 TCP 连接数限制
  • 服务器上 TCP 连接数对业务的影响

服务间的长连接

假设我们的目标服务,存在服务的消费者和提供者,服务之间存在上下游依赖关系:

我们期望服务间的连接是长连接,即 TCP 连接只建立一次,无需每次请求调用都发起 3 次握手、4 次挥手,以提升网络 IO 吞吐量。但是事实跟期望可能有所出入。

假设微服务间通信使用的应用层协议是 HTTP 1.1,单个 TCP 连接同时只能发出单个 HTTP 请求。即当同一时间请求并发数为 n ,会存在 n 个 TCP 连接,并且会存在 3 * n + 4 * n 次握手挥手动作,甚至可能会触发 sockets 连接数用满。

长连接示例

我们通过一个示例,感受并发调用场景下,TCP 建连的过程。

以下为代码启动一个 HTTP server 作为上图中的 Provider Service:

  • 建立 TCP 连接时,打印 new connection 日志
  • 收到 HTTP 请求时,返回 ok 作为 response body,并打印 request 日志
const http = require('http');

var server = http.createServer(function (req, res) {
  res.end('ok');
  console.log('request');
});

server.on('connection', function (socket) {
  console.log('new connection');
});

server.listen(3000);

以下代码为客户端代码作为 Target Server(由于我们想要测试长连接,双方交互的服务一定是长期存活的,所以这里我们启动一个服务,而不是直接写个 client.js 做测试):

  • 服务端口监听在 3001
  • 当收到 /batch 请求时,并发调用 Provider Service 10 次
const http = require('http');

var server = http.createServer(async function (req, res) {
  if (req.url === '/batch') {
    await Promise.all(Array(10).fill(1).map(request));
  }
  res.end('ok');
});

server.listen(3001);

async function request() {
  return new Promise((resolve) => {
    http
      .request('http://127.0.0.1:3000', (res) => {
        res.on('data', resolve);
      })
      .end();
  });
}

以 curl 作为客户端(或者作为 Consumer Service)

$ curl http://127.0.0.1:3001/batch

Provider Service 输出如下日志:

整理下完整的调用链为:curl -> Target Service -> Provider Service。

可见 10 次 HTTP 并发调用产生了 10 次 TCP 连接,符合预期,因为 HTTP 1.1 并发调用一定会产生相对应数量的 TCP 连接。

再次 curl ,Target Service 与 Provider Service 之间继续新建 10 条 TCP 连接,原因也很简单,之前的 TCP 连接都是用完即销毁的。

假设我们想要第二次并发的 10 次请求,继续复用之前的 10 个 TCP 连接就需要做如下处理,代码变更如下:

连续手动操作进行 3 次 curl 调用:

对输出做一下分析:

  • 首次 curl 调用,建立 10 次 TCP 连接,符合预期
  • 二次 curl 调用,复用原有的 TCP 连接,符合预期
  • 三次 curl 调用,又建连了 10 次 TCP 连接,不符合预期

大家可能对第三次调用结果比较疑惑,这里直接放下结论:因为 TCP 连接只存活 5s ,超时后,自动断连了。

Wireshark 网络分析

为了对如上的调用做解释,我们需要一个工具去查看 TCP、HTTP 的完整过程,这里我们用到一个工具: Wireshark。

Wireshark 是一个强大的网络分析工具,它工作于 OSI 网络模型的 Data Link Layer 层,即数据链路层,所以可以分析 Data Link Layer以上的所有层数据,包括本次分析的 TCP、HTTP 过程。

Wireshark 相对于一些其他常用的网络分析工具,例如 Fiddler、Charles、Whistle 等工具,其有如下优势:

  • 实现机制更底层,所以能捕获 Data Link Layer上层的数据,而其他代理工具只能看应用层数据,顶多再看个传输层数据
  • 由于更底层,所以无需配置应用的代理配置(部分应用可能不走默认系统代理,需要手动配置,例如你启动的 Node.js 服务)

话不多数,关于 Wireshark 的使用,有兴趣直接看官网文档吧: https://www.wireshark.org/docs/wsug_html_chunked/

对单条请求做分析

为了减少干扰,我们仅发出一条请求 Target Server -> Provider Service。

关于图例说明下:

  • 绿底的输入框,由于网卡比较活跃,减少干扰过滤出 Provider Service 3000 端口号的网络交互
  • 图中我们可以很直观的看到熟悉的三次握手、HTTP 请求、四次挥手
  • Keep Alive Check:我们还发现每隔 1s Target Service 于 Provider Service 都会进行一次双向交互,这是为了:

对图例的细节进行分析:

  • 存在四次挥手,而且是在大概 5s 后,这个我们从 HTTP response 中得到验证

对 curl 三次结果做分析

从上述单个请求分析中,我们基本可以论证 curl手动触发第三次不符合预期的原因,重复说明一下原因:因为 TCP 连接只存在 5s ,超时后,自动断连了。

我们再次重复 3 次手动 curl

  1. 触发第一次
  2. 1s 后触发第二次
  3. 8s 后触发第三次(此时之前的 TCP 长连接已断连,需要重新连接)

具体操作如下,到这一步已经非常清晰了:

如何操作长连接

回到问题,这里列一些解法:

如何配置长连接,以及超时时长

  • 对于客户端,上述 Demo 已经很明确了,Node.js 上直接设置 http agent 即可。
  • 对于服务端,可以调整 keepalive timeout 增长 TCP 连接的时长,可以设置 Server.keepAliveTimeout属性,但是也要注意其可能频繁 TCP Keep-Alive Check,需要做好取舍,多次测试找到合适的阈值

Demo 里 10 次批量的请求,在 TCP 连接还没销毁前,二次并发调用时会重用,那么这个最大重用限制多少?

与客户端配置 Agent.maxFreeSockets相关:

  • 默认 256,即连接池的最大默认空闲容量,当下次请求来时会优先复用
  • 当超过时,客户端在 http 结束时会立即发起断连

并发数过大时,TCP 连接数会建很多么,是否有限制?

与客户端配置 Agent.maxSocketsAgent.maxTotalSockets相关:

  • 前者限制单 host、后者针对所有 host
  • Agent.maxSockets Node 0.12 以上就是不限制了
  • 设置此值的效果为:超出的数量的 HTTP 请求不会发出,直到 TCP 空闲
  • 例如设置为 1,则所有请求都会是串行的效果,TCP 连接也仅仅存在一个
  • 具体示例如下图,No.23 为第二个请求,在 No.19 第一个请求完全结束后才发出

TCP 连接数限制

通过 {Source IP, Source Port, Destination IP, Destination Port} 四元组确定唯一的 TCP 连接。

对于服务提供方:只需要一个暴露一个端口给客户端,即可接收无限数量的 TCP 连接,在不考虑内存的前提下,客户端的 IP, Port 只要不同即可。

对于客户端:连接数量限制在 2^16 - 1 内,即 65535 个端口,去掉 0 这个特殊端口。

客户端存在限制的核心原因:TCP 规范的要求

  • 端口号只能是 16 bits 内,如果超出可能会导致对方服务无法解析或解析错误
  • 以上为 Wireshark 的示例,整个 TCP header 都是固定顺序与固定格式的

作为客户端 65535 个数量是否够用

个人电脑当然够用的,假设每个程序 100 个 TCP 连接,同时运行 100 个程序,也才 10000 个罢了。

微服务中的一台服务:也是够用的

  • 假设你的服务都是短连接,每次客户端请求过来都要转发给相对应数量的上游其他服务,并且假设每个请求你都需要处理 5s
  • 那么 5s 你能接受的最大单机请求数是 6w+ 个,基本单个服务是达不到这个数量的。除非你接收一个请求,分散出 10+ 的请求。况且存在这么高的并发时,内存和 CPU 可能更先刚不住,而不需要先担心 TCP 的数量是否够用

HTTP 1.1 与 2

相比之下,HTTP 2 带来了如下特性:

  • 二进制,而不是文本
  • 完全多路复用,而不是有序和阻塞,故可以使用一个连接进行并行
  • 使用 Header 压缩来减少开销
  • 允许服务器主动将响应“推送”到客户端缓存中

具体参考:https://http2.github.io/faq/

那么,我们是不是可以把服务间的通信协议升级到 HTTP 2 来解决并发流量导致的重复 TCP 建连开销?

立即开干,以下是 Provider Server:

const http2 = require('http2');
const fs = require('fs');

const server = http2.createSecureServer(
  {
    key: fs.readFileSync('localhost-privkey.pem'),
    cert: fs.readFileSync('localhost-cert.pem'),
  },
  function (req, res) {
    res.end('ok');
    console.log('request');
  },
);

server.on('connection', function (socket) {
  console.log('new connection');
});

server.listen(3000);

我们创建了一个基于 TLS 的 HTTP 2,说明下为啥不使用 HTTP2 over TCP(即不加密的 HTTP 2):

  • 浏览器等客户端无法识别
  • Wireshark 无法识别(重点,不方便看明细)

以下是 Target Server。

const http2 = require('http2');
const http = require('http');

var server = http.createServer(async function (req, res) {
  if (req.url === '/batch') {
    await Promise.all(Array(10).fill(1).map(request));
  }
  res.end('ok');
});

server.listen(3001);

const client = http2.connect('https://localhost:3000');
async function request() {
  return new Promise((resolve) => {
    const req = client.request({ ':path': '/', ':method': 'GET' });
    req.on('data', () => {});
    req.on('end', resolve);
    req.end();
  });
}

curl http://localhost:3001/batch -v 进行测试结果:

  • TCP 连接只在 Target Server 启动时即建连,且不主动销毁
  • 批量 10 次请求,复用现有单个 TCP 连接,结果符合预期

注意事项

如果你想要按照上面的示例进行测试,有一些 TLS 带来的调试问题注意事项:

  • Provider Server:需要自行生成证书,参考:https://nodejs.org/api/http2.html#server-side-example
  • Provider Server:增加 Node.js 启动参数 node --tls-keylog=/somewhere/ssllogfile.txt provider-server.js,用于 Wireshark
  • Target Server:增加环境变量 NODE_TLS_REJECT_UNAUTHORIZED=0 node target-server.js解决自建证书的安全问题
  • Wireshark:配置日志文件,用于解析 TLS 层数据包

总结

文章主要探讨了 TCP 长连接的相关知识。首先通过示例解释了长连接的基本原理和流程,包括 TCP 连接、HTTP 请求、Keep Alive 检查等。然后分析了在手动 curl 触发第三次请求时的问题,说明了因为 TCP 连接只存在5秒,超时后会自动断连。

接着,给出了如何配置长连接的解决方案,包括客户端和服务端的设置,以及超时时长的调整。同时还讲解了一些与客户端配置相关的参数,如 Agent.maxFreeSockets、Agent.maxSockets 和 Agent.maxTotalSockets 等,并解释了 TCP 连接数的限制和客户端存在限制的原因。

最后,我们探讨了 HTTP 2 对于微服务架构的可用性,我认为是可以实践的,不过要去掉 TLS ,走 HTTP2 over TCP。

完,希望对大家有所帮助。

参考