一次出站重试风暴的沙箱复盘:重试没退避,Agent 十几分钟烧穿整月额度

0 阅读15分钟

摘要: 一个 9,000 条的同步任务撞上"平台抽风夜":裸奔组 12 分半烧穿 20,000 次的月额度,任务只完成七成五,收尾还对着墙撞了五分多钟;守护组用六成额度把任务跑完(三十几条按重试上限主动放弃、留痕)。这篇按现象→排查→根因→方案→边界完整复盘整场风暴,把"重试"这层最容易被忽略的放大器拆开。全部参数与代码来自本机沙箱实测,可复跑。

开场:额度归零的早晨

交代结果:一个晚上,一个定时同步任务,把数据平台整个月的调用额度烧到了零。从开跑到额度见底,实测十二分半。没有攻击、没有入侵、没有一行代码抛异常——所有逻辑都在"按预期运行":请求失败 → 重试 → 还失败 → 再重试。它只是太勤奋了。

这是上篇《给 Agent 补一张出站责任清单》里"预算"那一格的现场版——清单结尾我提过一句:预算防的是"总量爆炸",不防"短时间内打太快"。这篇就是把"打太快"完整跑出来:三层单看都合理的逻辑,叠成一场十二分半的风暴。顺便交个底:全文数字来自本机沙箱——一个模拟数据平台加两组客户端,跑的是同一份任务,参数全部公开,脚本可复跑。不涉及真实生产环境。

先摊参数:一份任务,两组客户端,三门机制

复盘的规矩是把牌摊开。沙箱不玩黑盒——平台机制、客户端策略、故障注入概率,全在下面这张表里:

角色配置
模拟平台月配额 20,000 次计费调用;边缘令牌桶每秒补 30 个、突发 60;随机注入 25% 的 500 与 8% 的软限流(带 Retry-After: 2);正常延迟 150-250ms
裸奔组客户端9,000 条同步任务;每批 50 条;并发 12;每条每轮最多裸试 2 次(即到即重试);批次整体重放上限 3 次(即每批最多 4 轮);无预算、无断点
守护组客户端同一份任务;并发 8;每条最多 5 次尝试(指数退避 + 抖动 + 服从 Retry-After);逐条断点(成功即记账);任务预算 15,000 次
共同条件同一台机器(Node.js v25.8.2)、同一份任务清单、同一个"抽风夜"——每轮开跑前重置平台,故障注入对所有客户端一视同仁

两组的差异集中在失败之后怎么办——重试几次、等不等、有没有上限。接下来发生的所有事——压力的流向、额度的去向、第二天早上谁在翻日志——都从这一个分岔口开始。

现象:三块屏幕,三个视角

平台侧:一条均匀到让人心虚的曲线

先把平台自己的分钟级统计原样搬过来(计费口径:请求进入后端就算一次,500 也算;被拒绝的不算):

分钟总请求计费调用边缘拒绝软限流
01,5801,4750105
11,7431,6120131
21,7741,6350139
31,7341,6150119
41,7591,6080151
51,7351,6060129
61,7911,6440147
71,7521,6170135
81,7371,6080129
91,7431,5890154
101,7641,6220142
111,7641,6140150
122,674755834159
133,41601,616152
143,42401,626153
153,43001,627147
163,41801,620142
172,71601,274121

看"计费调用"这一列:前十二分钟,每分钟稳定消费 1,450-1,650 次——像打卡一样均匀。没有攻击者会这么礼貌,也没有哪个任务该这么重。额度见底的那一刻,服务端记在了第 748 秒(日志字段 quotaHitAtSec)。

再看烧穿之后那几行:请求速率不降反升,每分钟总请求从一千七涨到三千四。被拒的响应回来得快,客户端循环得更起劲了。边缘限流开始替它踩刹车,每分钟拦掉约一千六百次;软限流也一直在发"等两秒"的信号——全都白发了。

任务侧:四分之三

180 个批次里,一百三十多个跑完了全程;9,000 个条目,真正拿到数据六千七百多个——七成五。剩下四分之一没跑完的条目里,绝大多数是被"额度烧穿"拖死的:四十多个批次在那之后全军覆没。

有个残酷的细节:它并不是"没拿到"数据。日志里躺着近一万五千次成功响应——只是没有断点、没有账本,成功过的条目下一秒又被当成未完成的重新取了一遍。数据拿到过,等于没拿到。

你的视角:第二天早上

我早上打开平台控制台,第一反应是"额度怎么是 0?被盗刷了?"然后我把凌晨的请求日志翻了一遍,越翻越沉默:每一笔都是自己人。时间、频率、模式,全对得上自家的任务。它把整月的额度用完这件事,它自己从头到尾都"不知道"——客户端统计里没有"额度"这个概念。

排查:三个镜头锁死凶手

把现场倒着放一遍。镜头一:额度消耗曲线。 分钟桶拉出来——均匀、坚决、无爆点。这一下就排除了"被盗刷"和突发攻击,指向一个持续全速运转的自家客户端。

镜头二:请求构成对账。 总账拆开看,是这样的:

39,954 次总请求
 ├─ 20,000 次计费——整月的额度,全部耗尽
 ├─ 8,852 次额度拒绝——烧穿之后,一头撞在墙上
 ├─ 8,597 次边缘拒绝——打超每秒 30 次的桶容量
 └─ 2,505 次软限流——平台说"等两秒",没等

将近两万次被拒,一次都没停手。

镜头三:客户端代码。 把裸奔组的失败处理摊开,三个硬伤一目了然——直接进下一节。

顺带说一句:上面这些数,服务端和客户端两边各记了一份,能对得上口径——两边互为印章。日志该怎么设计成"能对账",上一篇里展开过,这里不重复。

根因:三层"合理的"放大器

上篇预告过"重试叠重试",这里是现场版:两层重试都是这个客户端自己写的——单条一层"即到即重试",调度一层"整批重放"——再叠上最后一块拼图:没有人在管总数。

第一层:即到即重试。 裸奔组处理失败的代码长这样:

async function attemptItem(id) {
  for (let a = 0; a < ATTEMPTS; a++) {
    await sleep(PREP_MS);
    C.attempts++;
    const r = await once(id);
    if (r === 'ok') { C.ok++; C.itemsDone.add(id); return true; }
    // (对 429/500 等分类计数的几行从略)
    // 裸奔:无退避、不理会 Retry-After,下一次尝试立刻继续
  }
  return false;
}

跑起来的样子(控制台节选):

[naive] items=9000 batch=50 batches=180 conc=12 attempts=2 replays=3
[naive] batch 10/180 complete passes=2 attempts=1604 ok=1144 limit429=106 quota429=0 e500=354
[naive] batch 20/180 complete passes=2 attempts=3179 ok=2227 limit429=223 quota429=0 e500=729
[naive] batch 50/180 complete passes=2 attempts=8012 ok=5585 limit429=586 quota429=0 e500=1841

单条逻辑听起来毫无攻击性:"失败了?立刻再要一次。" 它撞上的每一次限流、每一次抽风,都会被"立刻再问一遍"接住。全篇近两万次被拒的响应里,它一次也没有读过响应头里的 Retry-After: 2。平台说"等我两秒",它翻译成了"现在不行,那我马上再问一次"。

第二层:批次整体重放。 把判定和重放两行连起来看:

await pool(ids, attemptItem);                  // 注意:每轮把整批 50 条全部重新请求
done = ids.every((id) => C.itemsDone.has(id)); // 实现里没有断点,已成功的也会被再取一遍

跑到底的样子(最后三个批次):

[naive] batch 178/180 FAILED passes=4 attempts=39154 ok=14955 limit429=10691 quota429=8463 e500=5045
[naive] batch 179/180 FAILED passes=4 attempts=39554 ok=14955 limit429=10894 quota429=8660 e500=5045
[naive] batch 180/180 FAILED passes=4 attempts=39954 ok=14955 limit429=11102 quota429=8852 e500=5045

"这一批里有一条没回来?整批全部重新取。"已经成功的条目被反复重复请求。看上面那三行:ok 冻在 14,955 一动不动,attempts 每批还在 +400——重放机器一直空转到最后一刻。再对总账:近一万五千次成功响应,只对应六千七百多个条目——平均每个成功条目被重复取了两次多。为什么 50 条里总有一条失败?单条在一个轮次里的成功率约九成——50 条全成的概率不足 1%。于是几乎所有批次都在重放。

第三层:没有预算账本。 客户端里没有一个数字问过:"这个任务允许花多少?" 所以额度归零后它根本没停——把剩下的批次挨个重放到底,又对着墙撞了五分多钟、一万八千多次,直到最后一个批次"跑完"它才收工。

三层拆开看,每一层都是"合理的容错"——叠起来,就是这场风暴的全部账目:

第一层  即到即重试      失败立刻再要一次,不读 Retry-After
第二层  批次整体重放    有一条没成,整批 50 条重新请求
第三层  没有预算账本    额度归零照旧重放,直到"跑完"
──────────────────────────────────────────────
合  计  39,954 次请求 / 20,000 次计费 / 12 分半额度归零 / 进度停在七成五

预答一个大概率会出现的挑刺:裸奔组是不是为了复盘故意造出来的稻草人? 不是。三层里的任何一层都能在真实代码里找到原型——即到即重试是手写循环的默认形态,整批重放是批处理最省事的写法,没有上限是脚本的出厂状态;沙箱做的,只是把它们塞进同一个客户端、端到端跑一遍。两组参数并不完全一致(比如并发数),但全部摊在开头那张表里;真正把两边账目拉开的,是"失败之后"的那几步。想复现、想质疑,表就是入口。

方案:守护组的四处补丁

同一个平台、同一份任务,换守护组客户端。改动就四处,全部集中在"失败之后"的那几步——针对的正是上一节那三层放大器。

补丁一:Retry-After 优先。 这是 HTTP 标准响应头,平台在 429 里用它说"等多久",先照做:

if (r.status === 429 && r.xl !== 'quota') {
  // (计数与留痕的几行从略)
  const waitS = Math.min(parseInt(r.ra || '3', 10) || 3, 10);
  await sleep(waitS * 1000);              // 平台说等多久就等多久
}

跑起来后,审计日志里留下的记录:

{"t":854,"task":"report-sync-20261016-01","id":14,"attempt":1,"status":429,"wait_s":2,"event":"retry_after"}
{"t":1967,"task":"report-sync-20261016-01","id":16,"attempt":2,"status":429,"wait_s":2,"event":"retry_after"}

补丁二:指数退避加抖动。 500 没给任何等待提示——自己按次数翻倍等待、再加点抖动:

const wait = Math.min(300 * Math.pow(2, a - 1) + jitter(), 5000);
// (计数与留痕从略)
await sleep(wait);

再看一个完整案例——同一条目、两次退避、第三次拿下:

{"t":357,"task":"report-sync-20261016-01","id":2,"attempt":1,"status":500,"wait_ms":447,"event":"backoff"}
{"t":1119,"task":"report-sync-20261016-01","id":2,"attempt":2,"status":500,"wait_ms":721,"event":"backoff"}
{"t":2137,"task":"report-sync-20261016-01","id":2,"attempt":3,"status":200,"event":"ok"}

补丁三:逐条断点。 成功即记账(itemsDone)——成功过的条目不会再被请求第二次;失败的只重试它自己,不存在"整批重放"。

补丁四:任务预算加熔断。 调用数封顶、到线立刻停、拒绝留痕。机制本身上一篇展开过,这里补一笔"线该定在哪"的实测账。

插一句实话。这条预算线的第一版,我拍脑袋设了"给 9,000 条任务预留 9,000 次调用"——结果它真在六千两百多条处熔断了(约七成进度),停得干净,BUDGET_EXCEEDED 留得明明白白。熔断没做错,错的是线:按熔断那一刻的数据回推,这个故障率下光正常跑完就要一万三千次上下。把线调到 15,000 重跑,才有了下面这组对照。

指标裸奔组守护组
总请求39,95413,114
其中计费调用20,000(整月额度归零)12,042(约六成)
被限流后的等待0 次(近两万次拒绝全部硬撞)1,072 次(全部服从 Retry-After)
任务完成6,747 / 9,000(七成五)8,966 / 9,000(99.6%;余 34 条按重试上限放弃、留痕)
用时12 分半烧穿,尾部再空转五分多钟(总 17 分 44 秒)16 分 37 秒(含四千多次计划内的等待)
结局额度归零,这个月再没额度可用任务跑到底,额度还剩约 8,000(四成)

对照表背后,是它留下的一摞审计日志(换个采样点,再抓几行):

{"t":358,"task":"report-sync-20261016-01","id":3,"attempt":1,"status":500,"wait_ms":380,"event":"backoff"}
{"t":367,"task":"report-sync-20261016-01","id":8,"attempt":1,"status":200,"event":"ok"}
{"t":1071,"task":"report-sync-20261016-01","id":3,"attempt":2,"status":200,"event":"ok"}
{"t":2461,"task":"report-sync-20261016-01","id":12,"attempt":3,"status":200,"event":"ok"}

两组客户端读起来的气质完全不一样:一边是"撞墙、撞墙、还是撞墙";另一边是"被叫停、等两秒、继续干活",每一步都留了字据。

边界:这套改造不解决什么

说边界之前,先把一笔最容易看错的账挑明:两组的墙钟时长几乎打平(裸奔组十七分多,守护组十六分半)——但一边烧光额度停在七成五,一边用六成额度把活干到了收尾。守护组没快多少,因为它把该等的时间真的等完了:上千次 Retry-After 说两秒就两秒,每一次 500 背后都跟着一次退避——这不是损耗,是按平台的节奏走。真正该比的是代价:每完成一个条目花掉的计费调用,裸奔组将近三次,守护组连一次半都不到。

一,退避会拉长任务时长。 对实时性敏感的任务(用户等着结果的那种),要压缩等待上限、并接受"部分条目稍后重试"的折中——不能照抄本沙箱的等待参数。

二,沙箱不是真实平台。 不同平台的限流口径(按 IP、按 token、按账号)和 Retry-After 的实现细节都不一样,接入前要用小流量实测,别迷信文档。

三,单机数字不能当容量规划。 这份对照证明的是"机制方向"和"数量级",你的真实放大倍数取决于你的故障率、重试层数和并发——要自己在预发环境测一遍。

四,参数调优救不了设计缺陷。 如果任务本身能错峰、能分批、能做成增量同步,优先级高于把退避参数调到完美。重试治理是兜底,不是主策略。

总结:重试是替对面做的决定

复盘写完,说点感性的。我自己也写过裸重试循环。早些年给一个回调对账写轮询,while (fail) { retry(); } 就这几行代码,扔上去跑了半天,被对方限流限到怀疑人生——那时候我才知道,"重试"不是一个技术细节,是你替对面的服务器做的决定:决定给它多少压力、替它消耗多少寿命、以及你打算为它准备多少耐心。

这场沙箱里,裸奔组的十二分半不是 bug 的速度,是**"没有治理的默认速度"**。它甚至没做错什么——它只是不知道自己的边界在哪里。而治理的全部意义,就是把这条边界写进代码里:等它说等,停在该停的地方,然后留一份账。