问题
这是 xintao 老师在其计算机网络实用技术专栏里提出的一个问题(墙裂推荐这个专栏)。
有 A 和 B 两个服务位于两个 IDC 中,A 服务需要访问 B 服务的 HTTP 接口。A 到 B 之间 ping 延迟为 200ms,B 服务的固定处理时间为 100ms。基于如下条件,计算 A 请求 B 的延迟是多少。
- TCP 需要重新建立连接
- 请求的大小是 16KiB
- 响应的大小是 20KiB
原始抓包分析
笔者读文章时初步分析的结果是 500ms,理由如下:
- 建立连接耗时 200ms。这是第 1/2 次握手完成的延迟,客户端在响应第三次握手后会立即发送数据,因此第三次握手不占用延迟。
- B 服务处理请求耗时 100ms。
- 响应数据从 B 服务返回到 A 服务耗时 200ms。
因此整体延迟为 200 + 100 + 200 = 500ms。
接下来我们看下 原blog 中给出的抓包文件:
可以看到数据在 0.905 秒时传输完成后,服务端向客户端发出了 FIN 包,整体耗时约为 900ms,我们来分析下原因。基于之前的实验,我们知道 TCP 数据传的慢可能有三个原因:
- 发得慢
- 传的慢(网络环境,拥塞控制)
- 收的慢
在这个实验中,并没有提到 A 和 B 的服务端问题,并且数据量不大,因此可以排除发和收的问题,初步可以认为是传的慢导致的,由此可以推测是拥塞控制问题。
TCP 为了避免一次性发送的数据过多,会采用 Slow Start 慢启动机制,一次性发送的数据不会超过 CWND(拥塞窗口)的限制。Linux 下默认 CWND 大小为 10 个 MSS(最大报文段长度),从抓包文件中可以看到 MSS 为 1460 字节,则 CWND 大小为 14600 字节约等于 14.6 KB,因此在初始阶段 A 和 B 最多只能一次性发送 14.25KB 的数据,超过了 A 的 16KB 和 B 的 20KB 的数据量,需要分多次发送。因此 A 和 B 都需要分两次发送数据,数据传输会占用 2 个 RTT。由此可以推测出整体延迟为:
- TCP 连接建立耗时 200ms
- A 发送数据,耗时 2 个 RTT,即 2 * 200ms = 400ms
- B 处理请求,耗时 100ms
- B 响应数据,耗时 2 个 RTT,即 2 * 200ms = 400ms
理论上计算为 200 + 400 + 100 + 400 = 1100ms。但这里有一个误区,响应和数据发送是可以并行的。A 的第二次发送和 B 的第一次数据响应只占用一个 RTT,即 B 在确认 A 的第二次数据发送时,也顺带完成了数据发送。
这里我们通过传输流来分析下:
-
- TCP 在 0.2 秒时收到服务端 ACK 后响应第三次握手(0.201s),并在 0.202s 开始发送数据,这里耗时 200ms。
-
- 0.202 ~ 0.203s 间,A 发送了 10 个包,每个数据大小为 1448,数据总量为 14480 字节,接近 CWND 大小,符合慢启动特性。
-
- 0.402s 收到服务端 ACK,这里完成第一次数据发送,耗时 200ms。
-
- 收到 ACK 后,A 在 0.402s 立即继续发送剩余数据,并在 0.602s 收到服务端 ACK,完成第二次数据发送,耗时 200ms。
-
- 0.602s ~ 0.702s 间,B 服务处理请求,耗时 100ms。
-
- 0.705s B 服务开始响应数据,连续发送若干数据包。
-
- 收到 ACK 后再 0.905s 再次发送若干数据,完成后发送 FIN 包,耗时 200ms。
因此总时长为 200 + 400 + 100 + 200 = 900ms。
场景复现 & 性能优化
这里我们使用 Linux tc 来模拟网络延迟复现上述的实验场景并进行性能优化。
- 服务端设置
# 设置 200ms 的延迟
tc qdisc add dev eth0 root netem delay 200ms
# 设置网卡 mtu 为 1500(服务端、客户端都要设置)
sudo ip link set dev eth0 mtu 1500
- 服务端程序
from http.server import BaseHTTPRequestHandler, HTTPServer
import time
class SimpleHandler(BaseHTTPRequestHandler):
def do_POST(self):
# 读取 16KB 请求体
length = int(self.headers.get('Content-Length'))
data = self.rfile.read(length)
print(f"Server received {len(data)} bytes from client.")
# 模拟处理时间 100ms
time.sleep(0.1)
# 返回 20KB 响应
response_data = b'B' * (20 * 1024)
self.send_response(200)
self.send_header('Content-Type', 'application/octet-stream')
self.send_header('Content-Length', str(len(response_data)))
self.end_headers()
self.wfile.write(response_data)
if __name__ == "__main__":
server_address = ('172.19.0.4', 9527)
print("Server is running on port 8000...")
httpd = HTTPServer(server_address, SimpleHandler)
httpd.serve_forever()
- 客户端程序
import requests
if __name__ == "__main__":
url = "http://172.19.0.4:9527"
data = b'A' * (16 * 1024) # 16KB
print(f"Client sending {len(data)} bytes to server...")
response = requests.post(url, data=data)
print(f"Client received response: {len(response.content)} bytes")
执行上述程序并抓包,得到的结果与之前的分析一致,整体耗时约为 900ms。接下来我们来看下如何进行优化。
优化思路
经过上面的分析,整体延迟有以下几个部分:
- 连接建立,耗时 1个 RTT
- 服务器处理延迟
- 数据发送+数据返回耗时 3 个 RTT
这里可以优化的点有两个:
- 省去三次握手
- 一个 RTT 内完成数据发送和响应
优化后理论上的最小延迟为 1 个 RTT + 服务器处理延迟,即 200ms + 100ms = 300ms。
1. 增大 MTU
之所以需要两个 RTT 来发送数据,是因为 MTU(最大传输单元)限制了 MSS 的大小,进而受 TCP 慢启动的影响,导致没办法将数据一次性发送完毕。因此如果网卡性能足够,可以通过增大 MTU 来增加数据传输的效率。
笔者使用的云服务器默认为 8500,这里使用如下命令改回来后再进行测试:
$ sudo ip link set dev eth0 mtu 1500
修改后再次执行程序,抓包结果如下,整体耗时约为 500ms。可以看到 MSS 已经变成了 8460 字节,这样的话 CWND 大小就变成了 84600 字节即 82Kb,足够一次性发送完毕数据。
2. 长连接+多路复用
如果 A 和 B 之间的连接是长连接,就可以省去三次握手的延迟。HTTP/2 和 HTTP/3 协议都支持多路复用,可以在一个连接上同时发送多个请求和响应,进一步减少延迟。
这里我们可以使用 HTTP/2 协议来实现多路复用。我们使用的 Python 做实验脚本,Python 的 http.server
模块默认只支持 HTTP/1.1,因此需要使用第三方库来支持 HTTP/2。我们对服务端和客户端进行如下修改:
- 服务端使用
hypercorn
和quart
库来支持 HTTP/2 协议。
# server_http2.py
from quart import Quart, request, Response
import asyncio
app = Quart(__name__)
@app.route("/", methods=["POST"])
async def index():
data = await request.body
print(f"Server received {len(data)} bytes")
await asyncio.sleep(0.1) # 模拟处理耗时
return Response(b'B' * 20480, mimetype="application/octet-stream") # 返回 20KB
if __name__ == "__main__":
import hypercorn.asyncio
import asyncio
config = hypercorn.Config()
config.bind = ["0.0.0.0:9527"]
config.alpn_protocols = ["h2"] # 启用 HTTP/2
asyncio.run(hypercorn.asyncio.serve(app, config))
- 客户端使用
httpx
库来支持 HTTP/2 协议。
# client_http2.py
import httpx,time
data = b"A" * 16384
with httpx.Client(http2=True) as client:
for i in range(3):
print(f"\nRequest {i+1}")
resp = client.post("http://172.19.0.4:9527", content=data)
print(f"Response size: {len(resp.content)} bytes")
time.sleep(1)
重新执行程序后抓包如下,可以看到从第二次数据发送开始,整体耗时约为 300ms,符合预期。
笔者这里用的是 Python 作为实验脚本,在实际生产环境中通常会使用像 Nginx 、Kong这样的网关对服务做代理,一般都会在网关开启 Keep-Alive 机制以及多路复用,以提升数据的传输性能。
另外在使用 TCP 长连接时需要注意,Linux 在系统层面有参数 net.ipv4.tcp_slow_start_after_idle
控制长连接空闲一段时间后是否继续使用慢启动算法,默认值为 1,表示继续使用慢启动算法。这样在长连接空闲一段时间后再次发送数据时,可能会受到慢启动的影响,导致延迟增加。如果需要避免这种情况,可以将该参数设置为 0。
# 禁用慢启动
echo 0 | sudo tee /proc/sys/net/ipv4/tcp_slow_start_after_idle