返回博客

2026.09.24

一次 API 调用 300 毫秒,我说大头在网关——绕开网关直连之后,为什么反而变成了 481?

我把延迟算到中转网关头上,没测直连就下了结论。补上直连对照,网关只占三分之一;换成直连接进游戏,延迟反而从 243 涨到 481。真凶是每次请求都重新握一遍 TLS。这篇给两个除法,用来判断你的延迟到底花在哪、网关限流会不会卡住你。

延迟方法论网络

绕开中转网关,改成直连官方接口,本来是为了更快。

接进游戏跑一局,延迟中位数 481 毫秒。走网关那条老路,同一个接口的中位数是 243。

绕了一个弯,慢了一倍。

更丢人的是这个弯是我自己指的。几天前我跟人说「这个 300 毫秒,模型本身应该不到 50,剩下全是网关层」。那句话说得很笃定,但我手里没有一个直连的数字,是纯拆账估出来的。

想直接拿判据的看第 3 章,一个减法定位连接开销;想看网关限流怎么算,看第 4 章。

1. 背景:一个每秒要做好几次决定的小游戏

我在自己机器上搭了个小游戏实验台:Flappy Bird 这种,每隔一小段时间问一次模型「现在跳还是不跳」。模型只回一个选项和概率,不写字,所以单次调用本来应该很快。

其中一个决策引擎是某家的云端决策 API。它有两个入口:一个是厂商自己的官方端点,一个是挂在某托管 AI 网关后面的转发入口。网关的好处是统一鉴权、顺手给你记账单和调用 ID;代价是多一跳。

我一开始用的是网关入口,体感延迟 300 毫秒上下。对一个游戏来说这很难受,于是有了那句「大头在网关」。

2. 第一次对照:网关只占三分之一

拿到官方直连的 key 之后,我做了个很小的对照:24 道公开数据集的题,每道题两个入口各打一次,AB/BA 交替排顺序,免得谁总是先跑占便宜。两边各用一个持久的 HTTP 会话,不重试,每对之间歇 2 秒。

入口 请求 成功 p50 p95
官方直连 24 24 165.2 ms 200.9 ms
网关转发 24 16 243.4 ms 296.5 ms

同一道题、同一时段配对相减,网关比直连多出来的中位数是 +80.2 ms。16 对里 15 对网关更慢,剩下那一对也只快了 0.4 毫秒,基本就是噪声。

所以网关的份额是:

网关占比 ≈ (网关 p50 − 直连 p50) ÷ 网关 p50
        = (243.4 − 165.2) ÷ 243.4 ≈ 32%

三分之一。我之前说的「剩下全是网关」,方向对了一点,量级错了一大截。直连本身就要 165 毫秒,这部分网关一分钱都不背。

两边答案也对了一下:16 对成功的配对全部选了同一个选项,概率最大只差 0.06,13 对完全一样。确实是同一个模型,网关没偷偷换东西。

到这里,结论看起来很简单:换直连,省 80 毫秒。

然后我把直连接进了游戏。

3. 481 毫秒:同一个直连,换了个调用方式

游戏跑在浏览器里,浏览器不能直接打那个官方接口(它对任何浏览器来源的跨域预检都回 400),只能由本机一个小代理转发。那个代理是我早先图省事写的:每来一个请求,起一个 curl 子进程发出去。

真浏览器跑一局,82 次请求 0 错误,p50 481 毫秒。

对照实验里直连才 165,怎么到游戏里就翻了三倍?怀疑对象有三个:curl 子进程的启动开销、Python 慢、或者别的什么。与其猜,不如把同一个请求用四种方式各打 8 次(第一发丢掉,剩 7 次算中位数):

调用方式 p50
每次起 curl 子进程,每次新 TLS 368 ms
Python 每次新建连接 323 ms
Python 复用同一条连接 162 ms
旧代理(内部就是 curl) 368 ms

一眼就看出来了。Python 和 curl 只差 45 毫秒,子进程不是主犯。真正的断层在「新建连接」和「复用连接」之间:

每请求连接开销 ≈ 新建连接 p50 − 复用连接 p50
              = 323 − 162 = 161 ms

这 161 毫秒就是每次都重新做一遍 TCP 握手加 TLS 握手。curl 自己的计时也对得上:光是到 TLS 握手完成,就已经过去 0.18 秒。说白了,每问一次「跳不跳」,都先跟服务器寒暄一轮「你好我是谁这是证书」,寒暄完才开始说正事。

这也解释了第 2 章那个对照为什么好看:对照脚本用的是持久会话,游戏代理用的是一次性 curl。我在实验里测的是一个复用连接的直连,接进游戏的是一个不复用连接的直连。实验结论没错,只是它测的东西跟我实际用的东西不一样。

修法很朴素:代理对每个上游只保持一条长连接,加一把锁,断了就重建再试一次,把 curl 整个拿掉。再跑一局:

125 次请求,0 错误,p50 167 ms,p95 223 ms(之前 481 / 605)。

跟裸的复用连接 162 毫秒只差 5 毫秒。这 5 毫秒我归给本机代理那一跳加游戏循环,但这是推断,没有单独拆开量过。

顺手还有个小插曲:那个代理代码里留着一句老注释,说某个 Python 标准库客户端「会被 CDN 403」,所以才改用 curl。我带自定义 User-Agent 重试,没能复现。一句没人复查过的注释,让每个请求都多背了一轮握手。

4. 网关的另一笔账:限流错误在甩锅

第 2 章表里网关有 8 次失败,全是 429。响应体写的是:

The upstream provider is currently experiencing high demand. Please retry shortly.

读起来像是后面的模型厂商扛不住了。但响应头里写着另一回事:

X-Ratelimit-Limit-Requests: 30
X-Ratelimit-Remaining-Requests: 0
X-Ratelimit-Limit-Tokens: 250000
X-Ratelimit-Remaining-Tokens: 233750
Retry-After: 10

token 配额还剩 93%,卡死的是网关自己每 10 秒 30 次的请求数上限。同一时段直连 24 次一次限流都没碰到,也没返回任何限流头。报错文案说是上游忙,其实是网关自己的闸门。

对闭环游戏来说,这个闸门比那 80 毫秒致命得多:

可持续决策频率 ≤ 请求数上限 ÷ 窗口秒数
              = 30 ÷ 10 = 3 次/秒

每秒最多问 3 次。一个每一两百毫秒就想问一次的游戏,跑满一个窗口就会撞上 429,然后被要求等 10 秒。实时模式下,鸟早掉下去了。

你自己的账就这么算:看限流头里的请求数和窗口,除一下,跟你的调用频率比。比你需要的低,延迟再好看也没用。

5. 怎么识破别人的 API 延迟数字

这次让我学会先问一句:这个数字里有没有握手?

网上很多「XX 接口延迟实测」,方法是写个循环,每次 curl 一下,取平均。按上面的表,这种测法里每一发都含着约 160 毫秒的建连开销。如果对方服务器在另一个大洲,这部分还会更大。你拿到的是「建连 + 服务」,不是「服务」。

反过来也一样。一个用持久会话测出来的漂亮数字,搬到每次新建连接的代码里,就会像我这样涨三倍。

所以看到任何延迟数字,先问两件事:

  • 它是每次新建连接,还是复用连接?
  • 它报的是同一个客户端、同一时段、同一题的配对差,还是两次不同时间跑出来的两个 p50 硬减?

我第一次那个「300 毫秒大头在网关」,两条都没问,也没有直连基线,本质上是把一个没测过的分母拿来分账。

6. 这次实验有哪些地方不够硬

  • 样本很小。路由对照 24 对,只有 16 对成功;拆层探针每种方式只有 7 个有效样本。量级可信,小数点后面别当真。
  • 网关的 p50 243.4 是在 16 次成功请求上算的,那 8 次 429 被排除了。如果把限流等待也算进去,网关的真实体验要差得多。
  • 游戏里那 481 毫秒是浏览器实跑,网关的 243 是 Python 脚本测的,两边测量环境并不对等。「绕了一圈慢了一倍」这个对比是量级上的,不是严格配对。
  • 只测了一个地点、一个时段,没单独量网络往返时间,所以服务端那 150 毫秒左右里有多少是物理距离、多少是模型计算,我拆不开。
  • curl 的计时只记下了 TLS 完成那一刻(0.18 秒),DNS 和 TCP 各占多少没存下来。
  • 浏览器扩展那条路我也改成了直连,但没在真 Chrome 里装载跑过,那部分还不能算验证完。

7. 下次遇到「这个 API 太慢」,按这个顺序查

  1. 先测一个直连基线,用复用连接,同一台机器。没有这个数,任何「大头在哪」的说法都是猜。
  2. 同一个请求,分别用「每次新建连接」和「复用同一条连接」各打几次,相减。差值就是你每次付的握手税。
  3. 查你实际在跑的代码:HTTP 客户端是不是每次都新建?有没有在循环里起子进程?代理有没有保持长连接?
  4. 如果中间有网关,同一时段同题配对打直连和网关,看配对差的中位数,别拿两个 p50 硬减。
  5. 翻限流响应头,用「请求数上限 ÷ 窗口秒数」算出你能持续的最高频率,跟你的实际调用频率比。
  6. 报错文案说「上游忙」时,看一眼 Remaining 那几个头,确认到底是谁的闸门。
  7. 代码里写着「某某库不能用」的老注释,动手绕开之前先复现一次。