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