用户登录

技术文章

外部链接将在新标签页打开。

拓扑感知限流:从 Grace Blackwell 调度到 API 配额分配

一句话回答

把同一 API Key 的限流计数留在本机、跨节点合并压到 100ms 一次批量对账,p99 从 1.6ms 降到 0.03ms,代价是千分之二左右的配额误差。GPU 拓扑感知调度的放置、切片、反碎片三件事,可以直接搬到 API 限流与配额分配上。

核心要点 TL;DR
  • 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% 的劣化。

如何把限流计数器钉在本地?三套方案的取舍#

三套做法我都上线过,按推荐程度排:

  1. 本地令牌桶 + 批量对账(推荐,QPS 大于 500/节点):计数在进程内完成,后台每 100ms 把“已用额度”汇总回 Redis。
  2. 粘性路由 + 本地计数(推荐,多网关且要求强隔离):同一 apikey 永远落同一节点,本地计数就是权威,只在节点故障时从 Redis 恢复。
  3. 集中式 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/N4000120008000,掉 33%0
抗 k=1 预留 C/(N-k)60001200012000,不掉单节点超配 50%
抗 k=1 降级分配 C×(N-k)/N/(N-k)400080008000,不掉常态损失 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,各自有不同的速率上限,请求被均摊后经常出现“总量够、单账号被限”的局面。

我在一套聚合网关上的做法是三个动作:

  1. 探活改成带权:每个上游凭据记录近 5 分钟 429 率和 p95 延迟,权重 = 1/(1+429率),路由按权重而非轮询。轮询在凭据质量不齐时必然踩坑。
  2. 就地降级:某凭据 429 率超 20% 时,不把它从池里摘掉(摘掉会触发整体重排,等于把碎片又打散一次),而是临时把权重压到 0.05,冷却 30 秒后按 0.1 步长爬回。
  3. 周期整理:每 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)

本地令牌桶对账延迟会造成多少配额超发?
超发上界约为 (N-1)×R_node×W。三节点、每节点 6.67 req/s、窗口 100ms 时上界约 1.3 个请求,实测 60 秒内超发 2 次,占 1200 配额的 0.17%。
什么规模的 API 网关不适合做拓扑感知限流?
网关节点少于 3 台、单节点 QPS 低于 200 时,集中式 Redis 的 0.9ms p99 开销占比很小,直接 INCRBY 更省事。拓扑感知的收益来自“跨节点调用次数”,节点少就没有可省的。
粘性路由会不会在扩容时把配额打乱?
用 rendezvous hashing 而不是取模,节点增减时只有落在该节点上的 key 迁移,比例约 1/N。迁移期间该 key 的本地计数需从 Redis 重新拉取一次全量额度。

参考来源

  1. Grace Blackwell needs a new scheduler: 27% more usable GPUs from the same racks

以上为本文写作时的参考资料,引用观点以原文为准。

作者:沐小悠

本文约 22 分钟读完 · 关键词:拓扑感知调度、API 限流、配额分配、Grace Blackwell、Redis 令牌桶

更多文章 GET 示例接口

「」标签下的其他文章