← Back to posts

Ping 28 ms,为什么单 TCP POST 只有 1.8 MB/s?

有一次向远端机器上传数据,Ping 大约是 28 ms,curl POST 却只有 1.8 MB/s。我起初觉得这个速度偏低,便搭了一个实验,看看 RTT 到底能解释多少。

RTT 之外,还得看请求有多大、拥塞窗口涨得多快,以及接收窗口、丢包和服务端处理。只拿 28 ms 去套一个公式,算不出这次 POST 应该有多快。

想先试试不同参数的影响,可以打开这个页面;后面再看真实 TCP 实验的结果:

打开 TCP 吞吐量实验室 →

固定窗口公式为什么不够

常见的粗略公式是:

吞吐量上限 = TCP 窗口 / RTT

它适合估算稳定阶段的上限,但容易让人误以为 TCP 窗口是一个固定值。实际传输使用滑动窗口,允许在前一个数据包 ACK 返回前继续发送;真正限制在途数据量的是:

flight size = min(cwnd, rwnd)
throughput <= min(link bandwidth, flight size / RTT)

新连接的拥塞窗口通常还不足以填满链路。以常见的 IW10 为例,连接开始时大约只能发送 10 个 MSS;在理想慢启动中,每经过一个 RTT,cwnd 才近似翻倍。短请求可能还没等窗口涨上去就结束了,平均速度自然比长流低。

这个请求比 BDP 还小

带宽时延积 BDP 表示想让链路持续繁忙,需要多少在途数据:

BDP = bandwidth * RTT / 8

例如 1 Gbit/s、28 ms RTT 的 BDP 是 3,500,000 字节,约 3.34 MiB。只有当有效窗口能增长到这个数量级,发送端才有机会填满 1 Gbit/s 的管道。

而 20 Mb 不是 20 MB,也不是 20 MiB:

20 Mb = 20,000,000 bit = 2,500,000 byte

这个请求体本身还小于上面的 BDP。它很可能在慢启动阶段完成,不能用稳定窗口的公式直接推导传输速度。

在 macOS 上模拟真实路由器

实验跑在 macOS 上。我用 Colima 启动 Linux 容器,连成三个节点、两个子网:

curl client -> Rust IPv4 router -> Go/Gin server

中间的 Rust 路由器通过 Linux AF_PACKET 收发真实 IPv4 包,不终止 TCP。它可以双向模拟:

接收端不是简单丢弃 Body。Go/Gin 服务会读取完整 JSON,显式执行 json.Unmarshal,再计算 payload 的 SHA-256,用来模拟应用层 CPU 负载,并返回每个阶段的耗时。

实验可以在仓库里复现:

cd experiments/tcp-http
./run-once.sh

run-matrix.sh 可以进一步组合不同 RTT 和请求大小,观察短流量何时逐渐接近稳定吞吐量。

一次 20 Mb POST 的实测

默认实验使用约 31.5 ms RTT、1 Gbit/s、零人为丢包和 4 MiB 队列。结果是:

路由器最终双向队列归零,收包数与转发包数一致;随机丢包、队列丢包、内核丢包和发送错误全部为 0。

同一路径上,长流是 769 Mbit/s,短 POST 是 66.3 Mbit/s,相差约 11.6 倍。链路没有变,换了请求大小和测量方式,得到的速度就差了一个数量级。

为什么 230 ms 很像慢启动

2,499,986 字节的 JSON payload 大约需要 1,713 个 1460-byte TCP segment。假设初始拥塞窗口为 10 个 segment,理想慢启动在第 k 个 RTT 内累计可发送的数据包数近似为:

10 * (2^k - 1)

7 个 RTT 只能累计约 1,270 个 segment,8 个 RTT 则可累计约 2,550 个。也就是说,这个请求大约需要 7 到 8 个 RTT 才能发完。

230.593 ms / 31.537 ms = 7.31 RTT

7.31 RTT 和上面的估算接近,慢启动值得继续查。不过,仅凭读取耗时还不能确定拥塞窗口实际怎样增长,需要结合发送端状态或抓包验证。

回到 1.8 MB/s,应该怎么查

实验里的 POST 跑到了 8.291 MB/s,并没有复现最初的 1.8 MB/s。要找出原环境为什么更慢,我会接着这样查:

  1. 用单连接 iperf3 -P 1 测稳定 TCP 上限,不要先开多连接掩盖问题。
  2. 分别测试 2.5 MB、8 MiB、64 MiB 等请求大小,看速度是否随 Body 变大而上升。
  3. 用 curl 输出 time_connect、time_starttransfer、time_total 和 speed_upload,区分建连、上传和等待响应。
  4. 检查是否经过 TLS、反向代理、VPN、限流或额外网络跳数。
  5. 在发送端检查 cwnd、重传和接收窗口;抓包确认是否出现丢包、乱序或长时间 ACK 间隔。
  6. 让服务端分别记录读取 Body、JSON 解析、业务处理和响应写回耗时。

如果长流也只有 1.8 MB/s,瓶颈更可能在链路、窗口上限、丢包或限流。如果长流很快而小 POST 很慢,就应该优先检查慢启动、请求大小、连接是否复用以及应用层处理。

这次实验让我先排除了一个想当然的判断:RTT 低,短 POST 就应该很快。长流已经跑到数百 Mbit/s,20 Mb POST 仍花了约 7 到 8 个 RTT。至于原环境的 1.8 MB/s,还得沿着上面的检查继续找,不能直接归因于慢启动。