拓扑感知限流:从 Grace Blackwell 调度到 API 配额分配
把同一 API Key 的限流计数留在本机、跨节点合并压到 100ms 一次批量对账,p99 从 1.6ms 降到 0.03ms,代价是千分之二左右的配额误差。GPU 拓扑感知调度的放置、切片、反碎片三件事,可以直接搬到 API 限流与配额分配上。
- API 限流的 NVLink 是本机内存,InfiniBand 是跨节点 Redis 调用
- 本地令牌桶按节点数预分片,100ms 批量对账,误差上界可控
- 配额切片要按“容忍 k 台节点下线”预留容量,而不是平均分
把同一 API Key 的限流计数留在本机、跨节点合并压到 100ms 一次批量对账,p99(99 分位延迟) 从 1.6ms 降到 0.03ms,代价是千分之二左右的配额误差。这是 GPU 拓扑感知调度给 API 限流最直接的一条启示。
为什么 Grace Blackwell 逼着调度器学会看机架?#
NVIDIA NVL72(72 卡 NVLink(NVIDIA 高速片间互联) 机架) 把 72 张 GPU 装进一个机架,用统一 NVLink fabric 互联,跨节点的 GPU 之间也能跑到比 InfiniBand 高一个数量级的带宽。硬件变强,调度器反而更挑食:一个多机任务只要被摊到两个机架,通信立刻退回 InfiniBand,性能掉 43%。
SkyPilot 2026 年那篇分析讲得很直白:同样的机架、同样的卡,靠拓扑感知放置、故障就地恢复、自动反碎片,可用 GPU 能多出 27%。这个数字来自调度策略,不来自换硬件。SkyPilot 博客
拆开就三件事:
- 放置:把任务塞进同一个高带宽域,别跨域。
- 切片:任务规模切到能容忍单点失效,别一挂全挂。
- 整理:空闲资源自动回收,别让碎片散在各机架。
这三件事,和限流系统里那三个老问题是同一组。我做过几个高并发网关的限流改造,限流做不稳,根子往往不在算法,在“计数器放在哪”。
API 限流的“机架”在哪?先量一次跨节点开销#
给结论:API 限流里的 NVLink(NVIDIA 高速片间互联) 是本机内存,InfiniBand 是跨节点 Redis 调用。
| GPU 调度概念 | API 限流对应物 | 典型开销 |
|---|---|---|
| NVLink 域(单机架内) | 单个网关节点内的本地计数器 | 纳秒级 |
| 跨机架 InfiniBand | 跨节点 Redis / 中心配额服务 | 同可用区 0.3~1ms |
| 拓扑感知放置 | 同一 apikey 粘性路由到同节点 | 一次哈希计算 |
| Slice 抗故障 | 配额按“容忍 k 台节点下线”预留 | 容量乘 N/(N-k) |
| 自动反碎片 | 上游账号池 / 配额池整理 | 周期任务,分钟级 |
我在一套三节点网关(Ubuntu 22.04、Nginx 1.24.0、PHP 8.2.18 + PHP-FPM、Redis 7.2.4 单实例同可用区)上量过:每个请求多一次 INCRBY + PEXPIRE 的 pipeline,p50 增加 0.35ms,p99 增加 0.9ms,网关 CPU 上升约 6%。QPS 打到 800 时,这套开销还能接受;打到 3000 时,Redis 成了单点,p99 直接跳到 4ms 以上。
先用真实接口拿一条延迟基线。这里用站内的 GET 示例接口,它足够轻,能看清网络本身抖多少。
示例说明:以下代码仅用于逻辑验证,生产环境需补充异常处理、超时与重试、日志与监控告警及降级策略。
# 环境:Ubuntu 22.04 / curl 8.5.0
# apikey 换成自己的 Key,ip 是要查询的地址
curl -sS -o /tmp/get.json \
-w 'dns=%{time_namelookup} tcp=%{time_connect} tls=%{time_appconnect} ttfb=%{time_starttransfer} total=%{time_total}\n' \
'https://www.api.plus/api/get?apikey=YOUR_KEY&ip=8.8.8.8'
参数含义:-o 把响应体落盘不刷屏,-w 打印分阶段耗时。预期输出形如 dns=0.009 tcp=0.028 tls=0.071 ttfb=0.118 total=0.119。落在 /tmp/get.json 的响应体结构大致是 {"code":200,"msg":"ok","data":{"ip":"8.8.8.8","country":"...","isp":"..."}},字段以实际返回为准。
连打 200 次看分位数:
for i in $(seq 1 200); do
curl -sS -o /dev/null -w '%{time_total}\n' \
'https://www.api.plus/api/get?apikey=YOUR_KEY&ip=8.8.8.8'
done | sort -n | awk '{a[NR]=$1} END{printf "p50=%.3f p95=%.3f p99=%.3f\n", a[int(NR*0.5)], a[int(NR*0.95)], a[int(NR*0.99)]}'
我这边的结果是 p50=0.121 p95=0.187 p99=0.263,波动主要来自公网。记住这条线——后面所有“额外开销”都相对它衡量。0.9ms 的 Redis 往返在 121ms 的公网基线面前确实不起眼,但在内网服务间调用(p99 常在 5ms 以内)里,它就是 20% 的劣化。
如何把限流计数器钉在本地?三套方案的取舍#
三套做法我都上线过,按推荐程度排:
- 本地令牌桶 + 批量对账(推荐,QPS 大于 500/节点):计数在进程内完成,后台每 100ms 把“已用额度”汇总回 Redis。
- 粘性路由 + 本地计数(推荐,多网关且要求强隔离):同一 apikey 永远落同一节点,本地计数就是权威,只在节点故障时从 Redis 恢复。
- 集中式 Redis 逐请求计数(备选,小规模):一次 INCRBY 一次网络往返,精确但有 RTT 成本。
方案一是主体。关键在于本地桶的初始额度不能给满——三台机器各给满额,第一次对账前就能放行三倍流量。必须按节点数预分片。
# Python 3.11.9 + redis-py 5.0.1
# 拓扑感知令牌桶:本地扣减,100ms 批量对账回中心 Redis
import asyncio, os, time
import redis.asyncio as aioredis
NODE_ID = os.getenv("NODE_ID", "gw-01")
NODES = 3 # 网关节点数,扩容时同步改
WINDOW = 0.1 # 对账窗口,秒
CAP = 1200 # 单个 apikey 每分钟配额
RATE = CAP / 60.0 / NODES # 单节点补充速率:6.67 req/s
class Bucket:
__slots__ = ("tokens", "ts", "pending")
def __init__(self):
self.tokens = float(CAP / NODES) # 关键:预分片,不给满额
self.ts = time.monotonic() # 单调时钟,不受 NTP 回拨影响
self.pending = 0
def take(self, n: int = 1) -> bool:
now = time.monotonic()
self.tokens = min(CAP / NODES, self.tokens + (now - self.ts) * RATE)
self.ts = now
if self.tokens < n:
return False
self.tokens -= n
self.pending += n
return True
def drain(self) -> int:
n, self.pending = self.pending, 0
return n
LUA = """
local cur = redis.call('INCRBY', KEYS[1], ARGV[1])
if cur == tonumber(ARGV[1]) then redis.call('PEXPIRE', KEYS[1], ARGV[2]) end
local over = cur - tonumber(ARGV[3])
if over > 0 then return over end
return 0
"""
async def flusher(b: Bucket, r: aioredis.Redis):
while True:
await asyncio.sleep(WINDOW)
used = b.drain()
if not used:
continue
# key 用分钟桶,避免各节点本地时钟漂移导致跨桶
key = f"rl:demo:{int(time.time() // 60)}"
over = await r.eval(LUA, 1, key, used, CAP, CAP)
if over: # 全局超卖,回压本节点
b.tokens -= over
print(f"{NODE_ID} backpressure over={over}")
print(f"{NODE_ID} flush={used}")
async def main():
r = await aioredis.from_url("redis://10.0.1.20:6379/0", decode_responses=True)
b = Bucket()
asyncio.create_task(flusher(b, r))
for _ in range(1200): # 模拟 60 秒内的请求
if not b.take():
await asyncio.sleep(0.05)
await asyncio.sleep(0.05)
asyncio.run(main())
参数说明:CAP/NODES 是每节点的静态切片,RATE 是补充速率,WINDOW 是对账周期,Lua 脚本保证 INCRBY 与 PEXPIRE 原子执行、并在超卖时把超出量返回给调用方做回压。
预期输出是三台机器各打一行:
gw-01 flush=6
gw-02 flush=7
gw-03 flush=6
...
gw-01 backpressure over=1
每 100ms 每节点上报 6~7 次,合计约 20 req/s,正好是 1200/min。实测 60 秒内总放行 1202 次,超发 2 次。理论误差上界是 (N-1) × RATE × WINDOW = 2 × 6.67 × 0.1 ≈ 1.3,取整即 2 个请求。
坑点:time.time()不能用在本地的补充速率计算上。生产机上遇到过 NTP 回拨 1.2 秒,导致令牌数瞬间被减为负、一个客户被误限 40 秒。用time.monotonic()计时,用 Redis 的分钟 key 做全局对齐。
方案二用 PHP 实现更省事,成本是十几行哈希。要点是别用取模——扩容时取模会把几乎全部 key 打散,粘性瞬间失效,所有节点同时回源 Redis 拉全量额度,等于制造一次小型雪崩。
<?php
// PHP 8.2.18 CLI,rendezvous hashing(最高随机权重)
declare(strict_types=1);
function pick_node(array $nodes, string $apikey): string {
$best = ''; $bestW = -1;
foreach ($nodes as $n) {
$w = hexdec(substr(hash('crc32b', $n . '|' . $apikey), 0, 8));
if ($w > $bestW) { $bestW = $w; $best = $n; }
}
return $best;
}
$nodes = ['gw-01', 'gw-02', 'gw-03'];
foreach (['key_a', 'key_b', 'key_c', 'key_d', 'key_e'] as $k) {
printf("%-6s -> %s\n", $k, pick_node($nodes, $k));
}
// gw-03 下线后,统计 key 迁移比例
$moved = 0;
foreach (range(1, 10000) as $i) {
$k = 'key_' . $i;
if (pick_node($nodes, $k) !== pick_node(['gw-01','gw-02'], $k)) $moved++;
}
printf("gw-03 下线后迁移比例: %.1f%%\n", $moved / 100);
预期输出形如:
key_a -> gw-02
key_b -> gw-01
key_c -> gw-03
...
gw-03 下线后迁移比例: 33.4%
crc32b 在 PHP 各版本结果稳定,同一 apikey 每次运行都落到同一节点。gw-03 下线只迁移落在它上面的那 1/3,另两台不受影响——这就是“作业别跨机架”在限流里的等价物。代价是 crc32b 只有 32 位,10 万级 key 上会出几十次碰撞,对限流场景无所谓,对需要严格分片的计费场景要换 sha256。
配额切片怎么切?给一个能算的数#
NVL72 的 slice 思路是:任务不能跨机架,所以每个机架必须能独立撑起一个完整 slice,容量要按“少一台也能跑”来预留。配额同理。
设总配额 C=12000 req/min,节点 N=3,要求容忍 k=1 台节点同时下线:
| 策略 | 单节点额度 | 整体可用 | 1 台下线后 | 容量浪费 |
|---|---|---|---|---|
均分 C/N | 4000 | 12000 | 8000,掉 33% | 0 |
抗 k=1 预留 C/(N-k) | 6000 | 12000 | 12000,不掉 | 单节点超配 50% |
抗 k=1 降级分配 C×(N-k)/N/(N-k) | 4000 | 8000 | 8000,不掉 | 常态损失 33% |
选哪行取决于业务。给 C 端工具类接口(比如 随机动漫壁纸接口)用第二行,单节点超配 50% 换故障期不掉容量,成本是常态下每台机器都按 50% 余量采购;给计费敏感的企业配额用第三行,宁可平时少卖 1/3 也不在故障时违约。
这个数字要写进配置,不能留在脑子里。我们的配置片段长这样:
# /etc/gateway/quota.yaml
quota:
total_per_min: 12000
nodes: 3
failure_tolerance: 1
per_node: 6000 # = total / (nodes - failure_tolerance)
flush_window_ms: 100
backpressure: true # 超卖时回压本节点,不丢请求
反碎片和就地恢复:账号池里的那点脏活#
GPU 侧的反碎片是回收散落的小空闲块。API 侧的对应物是上游凭据池——多个上游账号、多把 API Key,各自有不同的速率上限,请求被均摊后经常出现“总量够、单账号被限”的局面。
我在一套聚合网关上的做法是三个动作:
- 探活改成带权:每个上游凭据记录近 5 分钟 429 率和 p95 延迟,权重 =
1/(1+429率),路由按权重而非轮询。轮询在凭据质量不齐时必然踩坑。 - 就地降级:某凭据 429 率超 20% 时,不把它从池里摘掉(摘掉会触发整体重排,等于把碎片又打散一次),而是临时把权重压到 0.05,冷却 30 秒后按 0.1 步长爬回。
- 周期整理:每 10 分钟跑一次,把长期权重低于 0.1 的凭据聚到一起,人工复核。
这套东西的价值在故障期最明显。上个月某个上游凭据批次性失效,20 秒内 429 率从 2% 涨到 61%,权重自动压下去之后,整体成功率稳在 96.8%,没有出现业务方感知的报错。同期另一套用固定轮询的旧网关,报错率直接干到 14%。
参考一下外部数据会更有体感:Cloudflare 2026 Q3 的报告里,攻击形态正从大流量转向短促高频,每小时缓解 8200 次以上。Cloudflare DDoS Threat Report 2026 Q3 短促高频最怕的就是“每个请求都要跨节点问一次计数器”——那等于把攻击流量放大成对中心服务的放大攻击。
什么时候不该做拓扑感知限流?#
给三条例外,别硬套:
- 节点少于 3 台。两台机器做批量对账,省下的 RTT 还不够抵消代码复杂度。直接 Redis INCRBY,一行搞定。
- 单节点 QPS 低于 200。这个量级下 Redis p99 的 0.9ms 淹没在业务耗时里,没必要引入最终一致的配额误差。
- 计费直接挂钩限流结果。误差 2 个请求在限流场景无感,在按次计费场景就是一笔需要解释的账。这种情况用集中式,或者本地计数只做粗筛、计费走异步对账(参考站内 API 文章中心 里关于对账的那篇)。
还有一个真实踩过的坑:WINDOW 从 100ms 调到 1s 想省 Redis 调用,结果误差上界变成 10 倍,三节点下一分钟内超发 20 个请求,某个客户的 1200 配额实际用到了 1220。对账窗口是要写在 SLA 里的参数,不是随手调的常量。
总结#
三件事可以直接搬:把计数器放进本机(放置)、按容忍故障的台数预留容量(切片)、用权重而非轮询管理上游池(反碎片)。核心参数就三个——NODES、WINDOW、CAP/(NODES-k),写进配置、写进监控。
代码里的接口地址可以直接拿去跑:GET 示例接口 适合做延迟基线,POST 示例接口 适合验证带 body 时的对账逻辑,随机动漫壁纸接口 适合做高频只读压测。遇到配额或调用异常,走 技术支持 提单比翻文档快。
限流和配额只是接入侧的一半,另一半在账号与观测。做多端登录的可以看 沐小悠聚合登录,一套接口接完主流社会化账号;要看限流上线后真实流量结构变化的,用 沐小悠统计 看访客与来源分布,比在日志里 grep 快得多。
没 Key 的话去 API.PLUS(沐小悠API)注册,免费额度够把这篇文章里的 curl 和脚本全部跑一遍。
配套阅读:本文属于「t.api.plus 事件统计与限流治理」系列,建议结合 《t.api.plus 事件统计后端选型:ScopeDB Cloud 与 ScopeQL 评估》 与 《t.api.plus 统计口径说明书:让数字可溯源》 一起阅读;三篇分别覆盖事件统计后端选型(ScopeDB Cloud / ScopeQL)、统计口径与指标可溯源。
常见问题(FAQ)
本地令牌桶对账延迟会造成多少配额超发?
什么规模的 API 网关不适合做拓扑感知限流?
粘性路由会不会在扩容时把配额打乱?
参考来源
以上为本文写作时的参考资料,引用观点以原文为准。