三平台 API 限流怎么绕开瓶颈?三个参数调完就顺了(附 Python 源码)

简介: 本文揭秘三平台(淘宝/京东/1688)API限流优化核心:不绕限,而提效。聚焦三个关键参数——动态并发(基于p95延迟计算)、分接口令牌桶(rate+burst)、AIMD自适应退避,并辅以配额账本监控。调参后撞限流次数从日均3400次降至12次,任务耗时缩短4.9倍。附完整Python实现。(239字)

三平台 API 限流怎么绕开瓶颈?三个参数调完就顺了(附 Python 源码)

先把话说清楚:平台的限流绕不过去。

改 IP、换 AppKey、注册多个应用分摊额度——这些做法短期有效,但只要平台做应用维度关联,一封就是全封,而且是永久性的。我们做这行两年,见过的封号案例里,一半是这么来的。

所以这篇文章讲的不是"绕开限制",是"别再浪费额度"。

因为真实情况是:撞限流的团队里,九成的瓶颈不是额度不够,是额度被烧在了没用的地方。我们自己的数据:调参前一个月撞限流 3400 次,调参后 12 次——平台的额度一次没变,变的只是我们自己怎么用。

三个参数,加一套配额账本。下面是完整的东西。


一、先搞清楚你在跟什么限流打交道

三个平台的限流模型长得完全不一样。用一套逻辑套三个平台,是这个话题里最常见的错误。

淘宝 京东 1688
限流维度 appkey × 接口 应用 × 接口 appkey 流量包
形态 日调用量 + QPS 双限 日调用量 + QPS 总量包 + QPS
典型报错 code: 7、26 业务码 + 429 429、业务码
失败调用算不算 算 算 部分算
恢复粒度 秒级 分钟级 秒级
是否可提额 可申请 走商务 可购买

这张表里有两行直接决定了架构,但大多数实现都踩了:

关键结论 1:限流是「接口 × 应用」维度的,不是全局的

淘宝的调用量配额是按接口分别计的。item_get 打满了,trade_list 一点不受影响。

所以全局令牌桶是错的。

我见过不止一个项目这么写:

# ❌ 错的
limiter = TokenBucket(rate=50)   # 全局 50 QPS

async def call(platform, api, **kw):
    await limiter.acquire()
    ...

后果是:铺货任务在猛拉详情接口,把全局令牌吃光,这时候一个物流查询请求进来,明明平台侧完全不限它,却要排队等 3 秒。

正确做法:桶的 key 是 (platform, api)。粒度对齐平台的限流维度,不多不少。粒度再细(比如按 item_id)没意义,因为平台不按这个限;粒度再粗(按平台)就浪费了额度。

关键结论 2:失败的调用也占配额

这条最反直觉,也最贵。

淘宝的调用量是按调用次数算的,不看成功失败。所以你签名错了、参数漏了,重试三次——四次调用全部计入配额,全部是浪费。

这就是为什么上一篇讲的重试分级在这里格外重要:fatal 类错误一次重试都不该有。它不只是"没用",它是在偷你还剩的额度。

顺带一个实现细节:不要只靠 code 判断错误类型。平台会新增错误码,你的映射表永远追不上。要加一层 message 关键词兜底:

def is_rate_limited(code: str, msg: str) -> bool:
    if code in ("7", "26", "429"):
        return True
    kw = ("limit", "too many", "频繁", "超限", "exceed", "throttl")
    return any(k in msg.lower() for k in kw)

二、瓶颈在哪:先算三笔账,再动参数

不先算账就调参,等于闭着眼睛拧螺丝。这三笔账都是 SQL 或日志就能算出来的,半小时的事。

账 1:配额花在哪个接口上了

接口              调用次数    占比     带来有效数据
item_get         412,000    68.3%    41,200 条
trade_list        88,000    14.6%    88,000 条
logistics_trace   71,000    11.8%     9,400 条   ← 注意这里
shop_info         32,000     5.3%    32,000 条

看到问题了吗:logistics_trace 占了 11.8% 的配额,只换来 9,400 条有效数据。因为大部分查询打在了已签收的订单上——早就没有新轨迹了。

(这就是第三篇讲的:用 INSERT OR IGNORE + rowcount == 1 去重,加上 last_status != 'SIGNED' 过滤,这个接口的调用量能降 80%。降调用量比调参数有效一百倍,因为调参只是在分配额度,降量是在释放额度。)

账 2:有效请求率

有效请求率 = 真正拿到新数据的调用 / 总调用

低于 30% 说明你在"重复问同样的问题",该加缓存或做增量了。这个数字跟限流无关,但它决定了你需要多少额度。

账 3:配额燃烧曲线

这是最容易被忽略的一笔。日配额 10 万,如果前 6 小时用掉 8 万,剩下 18 小时就只能干等着——而客户投诉通常发生在下午。

# throttle/burn.py
from datetime import datetime
from zoneinfo import ZoneInfo

TZ = ZoneInfo("Asia/Shanghai")

# 封装好API供应商demo url=https://console.open.onebound.cn/console/?i=Lex
async def consume_quota(r, platform: str, api: str, n: int = 1) -> None:
    """每次调用都记账,按自然日归零"""
    day = datetime.now(TZ).strftime("%Y%m%d")
    key = f"quota:{platform}:{api}:{day}"
    await r.incrby(key, n)
    await r.expire(key, 90000)       # 25 小时,跨天自动过期


async def burn_ratio(r, platform: str, api: str, daily_quota: int) -> float:
    """
    燃烧比 = 已用比例 / 时间进度比例
    > 1.3 报警:按这个速度,今天不够用
    < 0.5 提示:额度有富余,可以提高并发
    """
    day = datetime.now(TZ).strftime("%Y%m%d")
    used = int(await r.get(f"quota:{platform}:{api}:{day}") or 0)

    now = datetime.now(TZ)
    elapsed = (now.hour * 3600 + now.minute * 60 + now.second) / 86400
    progress = max(elapsed, 1 / 1440)          # 防止零点除零

    return (used / daily_quota) / progress

这个 burn_ratio 是我们监控里最有用的一个指标。它能提前 4 小时预测"今天配额会不够",而不是等到撞墙了才知道。

而且它是双向的:比值 0.5 说明额度有富余,那段时间可以把并发调高一点,把活干完。


三、参数一:concurrency——它不是一个可以随便拍的数

标题说三个参数,第一个就打破你的直觉:并发不是自变量,它是因变量。

恒等式:

在途请求数 = QPS × 平均耗时

也就是说:

concurrency ≈ target_qps × p95_latency_seconds

举个具体的:目标 100 QPS,p95 延迟 300ms → 100 × 0.3 = 30。

为什么用 p95 不用平均值:因为撞墙往往发生在长尾。平均 200ms、p95 800ms 的接口,你按平均值算出来 20,实际会遇到"20 个位置里 5 个卡了 8 秒",然后队列瞬间堆起来,后面全在等。按 p95 算,留的余量正好覆盖长尾。

两个常见的错法:

错法 现象 后果
并发给太大(200) 90% 的请求在排队 内存和 fd 被占满,P99 恶化,还撞限流
并发给太小(10) 打不满目标 QPS 吞吐上不去,任务跑不完

第二种更容易被忽略。concurrency=10、延迟 300ms → 实际吞吐上限 33 QPS。你想跑 100 QPS,永远跑不到,而且看日志全是"正常返回",没有报错。

# throttle/gate.py
import asyncio


class GateBusy(Exception):
    pass


class Gate:
    """
    每个 (platform, api) 一个 Gate。
    并发 + 令牌桶 + AIMD,三件东西绑在一起,因为它们必须同步调整。
    """

    def __init__(self, name: str, bucket, adaptive, concurrency: int):
        self.name = name
        self.bucket = bucket
        self.adaptive = adaptive
        self.concurrency = concurrency
        self._sem = asyncio.Semaphore(concurrency)
# 封装好API供应商demo url=https://console.open.onebound.cn/console/?i=Lex
    async def __aenter__(self):
        # 先占并发槽,再等令牌。
        # 反过来的话,等令牌的请求会把槽全占住,
        # 明明桶里还有令牌也没人能拿到(真实的坑,别问怎么知道的)
        try:
            await asyncio.wait_for(self._sem.acquire(), timeout=1.0)
        except asyncio.TimeoutError:
            raise GateBusy(f"{self.name} concurrency={self.concurrency} 已满")

        try:
            await self.bucket.wait(1)
        except Exception:
            self._sem.release()
            raise
        return self

    async def __aexit__(self, exc_type, exc, tb):
        self._sem.release()
        if exc is None:
            await self.adaptive.on_success()
        elif getattr(exc, "rate_limited", False):
            await self.adaptive.on_limited()
        return False

注意 __aenter__ 里那个顺序,以及那个 1 秒超时。并发满了就立刻告诉调用方"忙",别让它排队——排队会掩盖真实压力,等服务崩了你才发现队列已经三千长了。


四、参数二:令牌桶——rate 管长期,burst 管瞬时

这两个是文章标题里的第二个参数,但其实是两个数:速率决定长期,桶深决定瞬时。

为什么必须有 burst

电商接口调用是脉冲式的,不是均匀分布的。一次铺货 500 个商品,就是突然一批并发请求涌进来。你的令牌桶如果 burst = rate(每秒回一个就只能存一个),那这 500 个请求会被均匀摊到 500/rate 秒里——本来 10 秒能干完的活,变成 50 秒。

burst 该多大

两个约束同时要满足:

① burst ≥ 平台单秒允许的突发量 × 0.5   (别浪费突发能力)
② burst + rate × 1s ≤ 平台单秒硬上限    (别打穿)

经验值 burst = rate × 2 ~ 5。太小失去突发能力,太大直接撞墙。

举个反例:我们最早给淘宝 item_get 设 rate=80, burst=500。因为那个接口日配额够大,想着"一次多放点没关系"。结果每 6 秒就有一次 500 连发的脉冲,平台侧按秒窗口算,直接 429。rate 明明只有 80,但瞬时到了 500。

分布式令牌桶(Lua + Redis)

单机令牌桶在多实例部署下是灾难:4 个 worker 各算各的,实际 QPS 是配置值的 4 倍。所以必须集中式。

-- throttle/bucket.lua
-- KEYS[1] 桶 key
-- ARGV[1] rate(每秒回多少)ARGV[2] burst(最大存量)
-- ARGV[3] now_ms             ARGV[4] want(这次要几个)
local rate  = tonumber(ARGV[1])
local burst = tonumber(ARGV[2])
local now   = tonumber(ARGV[3])
local want  = tonumber(ARGV[4])

local d      = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(d[1])
local ts     = tonumber(d[2])

if tokens == nil then
  tokens = burst
  ts = now
end

local delta = math.max(0, now - ts) / 1000
tokens = math.min(burst, tokens + delta * rate)

if tokens < want then
  local need_ms = math.ceil((want - tokens) / rate * 1000)
  return {
   0, need_ms}
end

tokens = tokens - want
redis.call('HMSET', KEYS[1], 'tokens', tokens, 'ts', now)
redis.call('PEXPIRE', KEYS[1], math.ceil(burst / rate * 1000) + 5000)
return {
   1, 0}
# throttle/bucket.py
import asyncio
import time
from pathlib import Path


class RateLimitTimeout(Exception):
    def __init__(self, key: str, wait: float):
        super().__init__(f"{key} 排队超时,还需 {wait:.2f}s")
        self.rate_limited = True


class TokenBucket:
    def __init__(self, redis, key: str, rate: float, burst: int):
        self.r = redis
        self.key = f"rl:{key}"
        self.rate = rate
        self.burst = burst
        self._lua = Path("throttle/bucket.lua").read_text(encoding="utf-8")
        self._sha_id: str | None = None

    async def _script_sha(self) -> str:
        if self._sha_id is None:
            self._sha_id = await self.r.script_load(self._lua)
        return self._sha_id

    async def acquire(self, want: int = 1) -> float:
        """返回 0 表示拿到;> 0 表示还差多少秒"""
        now_ms = int(time.time() * 1000)
        ok, wait_ms = await self.r.evalsha(
            await self._script_sha(), 1, self.key,
            self.rate, self.burst, now_ms, want,
        )
        return 0.0 if ok else wait_ms / 1000

    async def wait(self, want: int = 1, timeout: float = 60.0) -> None:
        deadline = time.monotonic() + timeout
        while True:
            delay = await self.acquire(want)
            if delay == 0:
                return
            if time.monotonic() + delay > deadline:
                raise RateLimitTimeout(self.key, delay)
            await asyncio.sleep(delay)

一个容易忽略的点:lua 脚本用 evalsha 而不是 eval。eval 每次都传脚本全文,一个大脚本几百字节,高频调用时这部分网络开销不小;evalsha 只传 40 字节的 sha1。用 script_load 缓存,节点重启后 evalsha 会报 NOSCRIPT,需要捕获后重新 load(上面为了简洁省略了,生产环境要补)。


五、参数三:退避曲线——退多快,恢复多慢

前两个参数决定"会不会撞",这一个决定"撞了之后多久恢复"。它是一对系数(降幅 + 恢复步长),合成一条曲线。

三种错法,按危害排序:

错法 代码 为什么错
固定 sleep await asyncio.sleep(1) 撞墙期间每秒都撞一次,1 秒变 1 小时;而且每次失败都烧配额
直接放弃 raise 大批任务失败,客户重跑,额度烧得更多
无脑重试 for _ in range(5) 雪崩,还可能触发平台的"恶意调用"风控

正确做法是 AIMD(加法增、乘法减),但标准 AIMD 有个缺陷:它不记得上次撞在哪。

所以每次恢复都要重新"试探"过去,第二次撞在同一个位置。加一个临界点记忆就能解决:

# throttle/adaptive.py
import time


class AdaptiveRate:
    """
    AIMD + 临界点记忆

    撞限流     → rate *= 0.5        (乘法降,退得快)
    连续成功 N → rate += step        (加法升,进得慢)
    记忆       → 记下撞墙时的 rate,恢复不超过它的 90%

    最后这条是关键:没有它,每次恢复都会重新撞在同一个地方。
    """

    def __init__(self, rate: float, min_rate: float = 1.0,
                 max_rate: float = 500.0, recover_after: int = 50,
                 step: float = 1.0, memory_ratio: float = 0.9):
        self.rate = rate
        self.min_rate = min_rate
        self.max_rate = max_rate
        self.recover_after = recover_after
        self.step = step
        self.memory_ratio = memory_ratio

        self.ceiling: float | None = None    # 上次撞墙时的速率
        self._ok_streak = 0
        self._last_hit = 0.0

    async def on_success(self):
        self._ok_streak += 1
        if self._ok_streak < self.recover_after:
            return
        self._ok_streak = 0

        new_rate = self.rate + self.step
        if self.ceiling is not None:
            new_rate = min(new_rate, self.ceiling * self.memory_ratio)
        self.rate = min(self.max_rate, new_rate)

    async def on_limited(self):
        now = time.monotonic()
        # 5 秒内的第二次撞击才更新 ceiling,
        # 避免一次偶发毛刺把 ceiling 永久压低
        if now - self._last_hit > 5.0 or self.ceiling is None:
            self.ceiling = max(self.ceiling or 0, self.rate)
        self._last_hit = now

        self._ok_streak = 0
        self.rate = max(self.min_rate, self.rate * 0.5)

memory_ratio=0.9 这个值有讲究:太低(0.5)恢复太保守,明明还有余量却上不去;太高(0.99)会频繁在边界试探。0.9 是实测下来最平衡的——撞墙一次后,稳定在临界值的 90%,之后很少再撞。

还有一个必须做但很少有文章提的点:on_limited 之后,那个请求不能再立刻重试。它要退回去排队,等新速率生效。否则你降了速率,但手上这批已经在途的请求还在猛冲,等于没降。


六、把三个参数装到一起

# throttle/registry.py
import asyncio


class Throttle:
    """
    按 (platform, api) 生成 Gate。
    参数不是写死的常量,而是从配置里读——因为三个平台的三个参数值完全不同。
    """

    def __init__(self, redis, config: dict):
        self.r = redis
        self.cfg = config
        self._gates: dict[tuple[str, str], Gate] = {
   }
        self._lock = asyncio.Lock()

    async def get(self, platform: str, api: str) -> Gate:
        key = (platform, api)
        gate = self._gates.get(key)
        if gate is not None:
            return gate

        async with self._lock:                       # 双检锁,避免并发建重复桶
            gate = self._gates.get(key)
            if gate is not None:
                return gate

            c = self.cfg.get(platform, {
   }).get(api, self.cfg["default"])
            bucket = TokenBucket(self.r, f"{platform}:{api}",
                                 rate=c["rate"], burst=c["burst"])
            gate = Gate(
                name=f"{platform}/{api}",
                bucket=bucket,
                adaptive=AdaptiveRate(rate=c["rate"]),
                concurrency=c["concurrency"],
            )
            self._gates[key] = gate
            return gate

    async def call(self, platform: str, api: str, fn, **kwargs):
        gate = await self.get(platform, api)

        # 速率被 AIMD 调过之后,令牌桶要跟上
        gate.bucket.rate = gate.adaptive.rate

        async with gate:
            try:
                return await fn(**kwargs)
            except Exception as e:
                if getattr(e, "rate_limited", False):
                    # 这里退避一下,让降速生效再回来
                    await asyncio.sleep(1.0)
                raise

配置长这样(三个平台的值完全不一样,这就是为什么不能写死):

# throttle.yaml
default:
  rate: 20
  burst: 60
  concurrency: 8

taobao:
  item_get:      {
    rate: 80, burst: 300, concurrency: 24 }   # 日配额大,可冲
  trade_list:    {
    rate: 30, burst: 90,  concurrency: 9 }    # 敏感接口,保守
  logistics_trace: {
    rate: 10, burst: 20, concurrency: 3 }   # 限流最严

jd:
  item_get:      {
    rate: 40, burst: 120, concurrency: 12 }
  order_list:    {
    rate: 15, burst: 45,  concurrency: 5 }

alibaba:
  item_get:      {
    rate: 25, burst: 75,  concurrency: 8 }

七、调完之后的数字

同一批任务(10 万商品铺货 + 3 万订单同步),平台的额度一次没改:

指标 调参前 调参后 靠什么
撞限流次数 / 天 3,400 12 burst 调小 + AIMD 记忆
平均 QPS 38(峰值乱冲到 200) 稳定 92 桶速率 + 并发按 p95 算
任务耗时 5h20m(一半在退避) 1h05m 三件叠加
配额利用率 61%(失败也烧) 97% 重试分级 + 分接口桶
配额燃烧比 下午 3 点就 > 1.5 全天稳定在 0.95~1.05 burn_ratio 监控

最值得看的是第三行。耗时从 5 小时 20 分降到 1 小时 05 分,但 QPS 只从 38 涨到 92——按比例算应该只快 2.4 倍,实际快了 4.9 倍。

多出来的那部分是"不再退避"。调参前那 5 小时里,有一半时间是在 sleep 等重试。限流最大的成本不是吞吐低,是时间被 sleep 吃掉了。


八、四个反直觉的结论

1. 并发调大不会更快,只会让长尾更慢。
并发超过 QPS × p95延迟 之后,多出来的请求只是在队列里等着,同时占用内存和文件句柄。真正的吞吐由令牌桶的 rate 决定,不由并发决定。

2. burst 是三个参数里最危险的一个。
rate 决定长期,出不了大事;burst 决定瞬时,设大了直接打穿平台窗口。而且它出问题的表现很迷惑——平均 QPS 明明没超,但就是天天撞 429。

3. 撞限流后立刻降速,恢复反而更快。
因为撞墙期间每次失败都在烧配额,而且失败请求也要等超时。降速看着"退了一步",实际上是止损。

4. 减少请求数比调任何参数都有效。
调参最多把效率从 61% 提到 97%,这是 1.6 倍。而把物流查询从 71,000 次降到 12,000 次,是 6 倍的额度释放。调参是分配问题,降量是供给问题。 先做降量。


九、最后

三个参数,说到底是在回答三个问题:

  • concurrency:同时在途多少个(由 QPS 和延迟推出来,不是拍的)
  • rate / burst:每秒放多少个,能突然放多少个(长期与瞬时的分离)
  • backoff:撞了之后退多快、恢复多慢(决定故障时长,不是决定吞吐)

再加一个不在标题里但最重要的:先算账。三笔账算完,你大概会发现真正的瓶颈不在限流上。

最后再说一次开头那句话:这套东西的目标是把额度用满,不是突破额度。前者是工程,后者是违规。我们做接口服务这些年,见过太多为了省点调用量去搞多 AppKey 分摊的,最后账号全封、客户数据全断——那个损失比省下来的钱大得多。

代码里 bucket.lua 和 adaptive.py 是不依赖业务的,可以单独抽出来用;registry.py 的配置结构按你们自己的接口清单改。所有阈值都建议先跑一周观察日志再定,别照抄我这几个数——平台额度、你的客户结构、调用模式都不一样。


附:文中淘宝 item_get 的 rate=80 是我们账号的实际额度推导出来的,不同账号、不同应用等级差别很大。接入前先用第七篇那套 StageTimer 跑一天,看真实 QPS 曲线和平台报错分布,再定这三个数。

相关文章
|
云安全 安全 网络安全
|
4天前
|
JavaScript 安全 前端开发
Nodejs Brotli decoder 代码执行漏洞 (CVE-2026-111497)10629
Node.js发布安全更新修复Brotli解码器高危RCE漏洞(CVE-2026-111497),建议受影响版本用户立即升级。
1842 1
|
10天前
|
人工智能 API 开发工具
DeepSeek Harness 神插件:白嫖 13 家大厂模型
`dsh-our-free-model` 是 DeepSeek Harness 神级插件:零配置、零API Key、零账号,聚合13家大厂免费模型(含DeepSeek V4.1 Flash、Kimi K3等),支持本地网关、用量看板与自动降级,真正实现“白嫖即用”。
1649 36
|
4天前
|
安全 应用服务中间件 Go
PHP2Shell 漏洞拆解:WordPress 与 nginx 为何中招
PHP2Shell 双 RCE 已遭在野利用:WordPress、Drupal 全在射程内,nginx 其实另有洞。拆成因与修复顺序
PHP2Shell 漏洞拆解:WordPress 与 nginx 为何中招
|
7天前
|
Web App开发 人工智能 前端开发
2026 年 AI 浏览器怎么选?Ego Lite、Tabbit、Chrome DevTools MCP 深度对比,开发者到底该用哪个?
2026年AI浏览器选型指南:Ego Lite专注多Agent协同(Mac优先),Tabbit国产首选,原生支持Windows与Codex控制;Chrome DevTools MCP非浏览器而是开发者利器,深度集成Console/Network/Performance调试能力,让AI真正“看懂并验证”前端代码。
923 2
|
11天前
|
安全 Linux 芯片
【万字长文】Ollama下载、安装、配置、使用保姆级教程(2026最新图解)
Ollama 是一款免费开源的本地大模型运行工具,支持 DeepSeek、Qwen 等主流模型,一条命令即可下载运行。2026 最新版 v0.35.1 官方安装包(1.47GB)已提供国内直连下载,纯本地、离线可用、无广告、无隐私泄露风险。
1038 2
|
11天前
|
JSON 程序员 数据格式
最新版Notepad++下载、安装、配置保姆级教程(附官网安装包)
Notepad++ 是一款免费开源、轻量快速的 Windows 文本编辑器,支持多标签、语法高亮、代码折叠与宏录制,体积小、启动快、资源占用低,兼容 x64/x32/ARM 架构,是程序员和文字工作者的高效利器。
1154 0
|
15天前
|
存储 人工智能 安全
千问办公官网入口网页版登录:qwenwork.cn 注册送2000积分、登录送100积分
阿里云千问办公(QwenWork)是AI智能工作平台,支持一句话生成PPT、表格、网页、视频及数据分析。个人免费版送2000积分+每日登录赠100分,含1GB存储、5个发布页;企业版198元/席/月起。阿里千问办公QwenWork官网:https://t.aliyun.com/U/0VCTGt 阿里AI工作平台,一句话完成数据分析、PPT 生成、视频剪辑、网页搭建等复杂任务
|
10天前
|
人工智能 开发者
Qoder CN 大使计划正式开启|无限创造,等你一起
全年免费积分、优先内测、活动支持与更多共创机会,邀请用 Qoder 把想法变成现实的你。
666 6

热门文章

最新文章