4

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

 9 months ago
source link: https://tech.kujiale.com/liao-liao-fu-wu-jian-de-wang-luo-tong-xin-tcp-yu-http/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

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

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

从几个问题入手:

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

服务间的长连接

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

ac71efda6a2343df9f78ed6bf9f5e00e~tplv-k3u1fbpfcp-zoom-1.image

我们期望服务间的连接是长连接,即 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 输出如下日志:

201e04f609ab4edf96622abbffae9eb8~tplv-k3u1fbpfcp-zoom-1.image

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

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

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

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

2d67f7c97e6544f9bef7f9e5406241dc~tplv-k3u1fbpfcp-zoom-1.image

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

2f97ab2d624a4d85bd12478727dd4d82~tplv-k3u1fbpfcp-zoom-1.image

对输出做一下分析:

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

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

Wireshark 网络分析

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

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

528088f7147d4e90aea610bd6bdf1947~tplv-k3u1fbpfcp-zoom-1.image

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

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

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

对单条请求做分析

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

c24540f108504588b6fc50ce410c40ef~tplv-k3u1fbpfcp-zoom-1.image

关于图例说明下:

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

对图例的细节进行分析:

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

1877ec6452cd4fa4ab04874d191e9037~tplv-k3u1fbpfcp-zoom-1.image

对 curl 三次结果做分析

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

我们再次重复 3 次手动 curl

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

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

20c9f74460114fbbbe166dc9e28ebeb2~tplv-k3u1fbpfcp-zoom-1.image

如何操作长连接

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

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

  • 对于客户端,上述 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 第一个请求完全结束后才发出

4bfed15f60fb41d5ad58651389ad1aa0~tplv-k3u1fbpfcp-zoom-1.image

TCP 连接数限制

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

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

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

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

  • 端口号只能是 16 bits 内,如果超出可能会导致对方服务无法解析或解析错误

b1023f76e60a4c85b12ca9eb5a47d755~tplv-k3u1fbpfcp-zoom-1.image7e7cc27e00624632be0911066bfde7dc~tplv-k3u1fbpfcp-zoom-1.image

  • 以上为 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 层数据包

fca0fd0178794696b036fb996ceaf53d~tplv-k3u1fbpfcp-zoom-1.image

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

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

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

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


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK