NEW

免费试用已开放

立即开始

API · SDK · 文档与调试

企业微信 API 频控与重试怎么设计

更新于 2026-08-169 分钟

被限流时最常见的反应是把重试次数调大,然后限得更狠。原因是「限流」根本不是一件事:接口层在限调用速率,平台侧对高频集中的动作有整体约束,客户那边还在承受你的打扰频次。三者同时存在、表现相似,但只有第一种能靠退避重试解决。这篇讲怎么把它们拆开,各用各的办法;接口这一层按 wecomapi 的接入方式来讲。

三种「限流」,别用同一套策略

把所有和频率有关的问题混成一个「企微API频控」来处理,是这块最贵的设计错误。三者的成因、信号和正确反应都不一样:

  • 接口调用速率:确定性的,超速就被拒,退避之后必然恢复。这一类该退避重试,退对了没有残留影响。
  • 账号侧的行为节奏:不是接口在拒你,是平台对集中、高频的动作有整体约束。信号往往是滞后且间接的 —— 调用返回看着正常,动作却没有产生预期效果。这一类重试只会加速恶化,有效的手段只有降速和摊平。
  • 对象侧的打扰频次:接口和账号都没意见,是同一个客户被你三个业务模块各发了一条。这一类既不该重试也不该退避,该在发出之前就合并或丢弃。

分辨方法只要一句话:出问题时先问「立刻重试一次会不会更好」。第一类会更好,第二类会更糟,第三类毫无意义。这一问就能把策略分开。

落到工程上是三道各管各的闸门:进程外的速率限制器管第一类,任务调度的节奏管第二类,业务侧的去重与合并管第三类。任何一层都不该替另一层兜底。

先自己限流,别等被拒了再退

靠「被拒再退避」来控速,等于把限速的判断权交给了对面。代价是每触发一次就浪费一个请求,还把自己的错误率打上去。正确顺序是本地先限,wecomapi 返回的拒绝只作为兜底和校准信号。

令牌桶还是漏桶,取决于能不能接受突发。消息类调用通常可以:积压补发时把令牌一次吃掉、之后自然降速,体感比匀速排队好。涉及账号行为的批量动作则该用漏桶,恒速出水 —— 这类动作最怕一阵一阵。

  • 限流器的作用域必须和配额一致。配额通常按账号维度算,而你的服务大概率是多副本部署 —— 进程内的令牌桶在三个副本下就是三倍速率,等于没限。要么集中式计数,要么给每个副本静态切分配额。
  • 阈值不要贴着上限设。留两三成水位给重试、突发和统计误差。这部分是安全垫,不是浪费。
  • 定时任务的整点齐发是隐藏的突发源。所有人都把 cron 写成 0 分 0 秒,限流器再准也拦不住它们在同一秒互相挤。

具体的速率上限、配额维度与错误分类以 wecomapi 线上接口文档为准。不要把某次压测观察到的数值硬编码进代码 —— 它会在你早就忘了这件事之后失效,而且失效时不会有人提醒你。

退避重试的四个决定

一段能上生产的重试逻辑只有四件事要定:哪些错误重试、退避多久、最多熬多久、失败之后去哪。最容易写错的是第一件 —— 默认全部重试,是把偶发故障放大成雪崩的最快方式。

  1. 1分类。四类错误怎么分、各自该有什么反应,站内的「企业微信接口报错怎么分类处理」专门讲过,这里只取和重试直接相关的一条纪律:默认落在「不重试」那一档,只有被显式判定为容量类的才退避重试;结果未知的那一类要先去确认,不能盲发。
  2. 2退避。指数增长加随机抖动。抖动不是锦上添花:一批任务同时被拒、同时退避、同时重来,第二波会比第一波更集中,这就是自己给自己制造的二次踩踏。
  3. 3上限。同时限最大次数和总时长预算。只限次数的话,指数退避到第八次已经是几分钟以后,早过了业务的时效窗口,这时候发出去反而是负价值。
  4. 4出口。超预算的任务带着原始请求进死信队列,等人工或定时任务重放,而不是原地丢弃、也不是无限重试。
示意:只对判定为可重试的错误退避javascript
// 示意逻辑,错误分类与等待时长的判定以线上文档为准
async function withRetry(call, { tries = 5, budgetMs = 30_000 } = {}) {
  const deadline = Date.now() + budgetMs;
  for (let i = 0; ; i++) {
    try { return await call(); }
    catch (err) {
      // 参数/权限类直接抛,不浪费配额
      if (classify(err) === "fatal" || i >= tries - 1) throw err;
      // 服务端给了建议等待时长就按它退,否则指数退避
      const base = err.retryAfterMs ?? Math.min(500 * 2 ** i, 8_000);
      const wait = base * (0.5 + Math.random());   // 抖动,防二次踩踏
      if (Date.now() + wait > deadline) throw err; // 总时长也是上限
      await sleep(wait);
    }
  }
}

classify() 是这段代码里唯一需要按平台定制的部分:哪些响应算限流、哪些算参数错误,以 wecomapi 线上接口文档的错误分类为准,不要照抄示意里的判断和数值。

批量任务:打散比重试重要

批量群发、批量加好友、批量拉群这类任务,决定成败的不是重试写得多好,而是任务本身有没有被摊平。一个 for 循环把一万条请求怼出去,再讲究的重试逻辑也只是在给自己造第二波洪峰。企业微信接口限流这块,八成的线上事故都是这么来的。

打散有三个维度,少一个都不够。时间上把任务铺进一个足够长的窗口,条与条之间留随机间隔 —— 随机是为了避免多个批次在同一时刻叠成新的峰值,而固定间隔只是把峰值整体挪到下一个刻度上。账号上在多个 wecomapi 实例之间轮转,但每个实例仍然各守各的水位。对象上把同一个接收方在短时间内的多条动作合并成一条。

  • 批量任务要建模成有状态的作业,而不是一次 for 循环:作业怎么切分片、条目状态怎么落、断点续跑该恢复到哪一级,站内的「企业微信批量任务怎么调度」专门讲过,这里不重复。
  • 回到频控这一侧只需要记一条:打散治的是第二类,也就是账号侧的行为节奏;退避重试治的是第一类。批量任务被拒时先分清卡的是哪一类,再决定是拉长时间窗还是调退避参数 —— 这两件事在批量场景里经常被当成一件来处理。

放量前先用小批量跑一轮,看被拒率和实际效果再逐步加速。观察窗口要拉长:第二类信号往往滞后,当天看着正常不代表节奏没问题。

出向幂等键怎么选

讲事件回调的文章说的是入向幂等:平台重复投递同一个事件,你用事件 ID 去重。这里说的是反方向 —— 你自己重试了一个请求,怎么保证对面不会执行两次。两者是两套键、两张表,混用一张表迟早会互相污染。

出向幂等键有一条硬要求:在一个业务动作的所有重试之间保持不变,且由业务侧生成,不能由请求内容算出来。用请求体哈希做键是最常见的错误 —— 同一个客户先后收到两条一模一样的「收到,稍后回复」完全正当,哈希键会把第二条误判成重复吞掉,而这种 bug 在测试环境永远复现不了。

示意:键来自业务动作本身,重试期间保持不变javascript
// 示意逻辑,精确字段与幂等支持方式以 wecomapi 文档为准
const key = ["msg", accountId, toId, `ticket:${ticketId}:status`].join("|");

if (await store.seen(key)) return;   // 这个动作提交过了
await store.mark(key, "pending");    // 顺序不能反:先落库,再发请求
await withRetry(() => sendText({ guid, toId, content }));
await store.mark(key, "sent");
  • 好的键来自业务动作的自然唯一性:动作类型 + 发送账号 + 接收方 + 触发它的那个业务事件(工单号、订单号、批次里的行号)。
  • 保存期至少覆盖「最大重试窗口加人工重放窗口」。只留几分钟,等于半夜那次重放会原样再发一遍。
  • 顺序不能反:先落库标记待提交,再发请求,回来改状态。写成「先发再记」,进程在这两步之间被杀掉,你就永远不知道这条发过没有。

还有一种情况客户端解决不了:请求已经到达、响应在回来的路上丢了。这时幂等键只能防住你自己的重复提交。唯一可靠的补救是事后对账:把「已提交未确认」的记录留着,用 wecomapi 的事件回调与查询结果去核,而不是靠再发一次碰运气。

该盯的四个数

频控这块的可观测性不需要复杂看板,四个数就够判断,而且它们变动的先后顺序是有规律的:

  • 被拒率,按错误分类拆开看。限流类和参数类混在一张曲线里,等于没看。
  • 重试率与重试后的成功率。重试率涨说明水位设高了;重试后成功率低,说明分类写错了,你正在反复重试一批本来就不该重试的请求。
  • 端到端时延的 P99。成功率没掉但 P99 在涨,通常是退避在生效,意味着已经贴着上限跑了。
  • 死信堆积量,以及最老一条的年龄。只看数量会漏掉「量不大但一直没人管」这种更麻烦的情况。

四个数里最有预警价值的是重试率。它在成功率还完全正常时就会先动 —— 等成功率掉下来再看,人已经在事故里了。

常见问题

企业微信 API 的频率上限具体是多少?
不建议按任何二手数值写死。上限会随接口、账号维度和版本变化,以 wecomapi 线上接口文档为准。工程上更稳的做法是把速率做成可热调的配置,再用被拒率和重试率反过来校准,而不是把某次压测量到的数字硬编码进代码。
被限流了,把重试次数调大能发完吗?
只有接口调用速率这一类能靠重试熬过去,前提还是退避带抖动、有总时长上限。如果卡的是账号侧的行为节奏,加大重试等于把节奏踩得更密,结果只会更差 —— 正确反应是降速、拉长时间窗、把任务打散,必要时减少总量。
幂等键放 Redis 还是数据库?
看你怕丢什么。只做短窗口去重,Redis 够用也够快;但如果这条记录还要承担「到底发没发过」的事实来源、要支撑事后对账与重放,就必须落库。常见组合是 Redis 挡掉绝大多数重复判断,数据库负责不丢。

准备好动手了?

精确字段、鉴权与端点以线上文档为准;可在控制台创建密钥后联调。

相关文章