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 窗口 / RTT
它适合估算稳定阶段的上限,但容易让人误以为 TCP 窗口是一个固定值。实际传输使用滑动窗口,允许在前一个数据包 ACK 返回前继续发送;真正限制在途数据量的是:
flight size = min(cwnd, rwnd)
throughput <= min(link bandwidth, flight size / RTT)
cwnd是发送端拥塞窗口,会随 ACK、拥塞控制和丢包动态变化。rwnd是接收端通告窗口,表示接收端还能容纳多少数据。flight size是已经发送但尚未被确认的数据量。
新连接的拥塞窗口通常还不足以填满链路。以常见的 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。它可以双向模拟:
- 传播延迟和 RTT;
- 链路带宽;
- FIFO / DropTail 队列;
- 可复现的随机丢包;
- TTL、校验和、队列峰值与丢包指标。
接收端不是简单丢弃 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 队列。结果是:
- Ping 平均 RTT:31.537 ms;
- 单 TCP
iperf3长流:769 Mbit/s,0 次重传; - 20 Mb JSON POST:8.291 MB/s,也就是约 66.3 Mbit/s;
- curl 总耗时:301.532 ms;
- 服务端读取 Body:230.593 ms;
- JSON Unmarshal:11.974 ms;
- SHA-256:1.522 ms;
- 服务端总工作时间:244.092 ms。
路由器最终双向队列归零,收包数与转发包数一致;随机丢包、队列丢包、内核丢包和发送错误全部为 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。要找出原环境为什么更慢,我会接着这样查:
- 用单连接
iperf3 -P 1测稳定 TCP 上限,不要先开多连接掩盖问题。 - 分别测试 2.5 MB、8 MiB、64 MiB 等请求大小,看速度是否随 Body 变大而上升。
- 用 curl 输出
time_connect、time_starttransfer、time_total和speed_upload,区分建连、上传和等待响应。 - 检查是否经过 TLS、反向代理、VPN、限流或额外网络跳数。
- 在发送端检查
cwnd、重传和接收窗口;抓包确认是否出现丢包、乱序或长时间 ACK 间隔。 - 让服务端分别记录读取 Body、JSON 解析、业务处理和响应写回耗时。
如果长流也只有 1.8 MB/s,瓶颈更可能在链路、窗口上限、丢包或限流。如果长流很快而小 POST 很慢,就应该优先检查慢启动、请求大小、连接是否复用以及应用层处理。
这次实验让我先排除了一个想当然的判断:RTT 低,短 POST 就应该很快。长流已经跑到数百 Mbit/s,20 Mb POST 仍花了约 7 到 8 个 RTT。至于原环境的 1.8 MB/s,还得沿着上面的检查继续找,不能直接归因于慢启动。