消息发送的速率自适应:从固定间隔到 429 自愈的演进实践
消息发送的速率自适应从固定间隔到 429 自愈的演进实践目录为什么发送节奏不能写死速率控制方案谱系解析基础实现令牌桶进阶滑动窗口加并发上限自愈层429 检测与退避恢复实际落地经验小结1. 为什么发送节奏不能写死很多团队刚开始做批量消息推送时最常见的写法就是time.sleep(8)两条之间固定等 8 秒。这能跑但跑不了多久就会出问题。我们踩过的坑大致有四类定快了被限速8 秒一条看着保守但遇到账号本身当天已经触达过一批人剩余额度所剩无几再按固定节奏硬灌平台直接回429 Too Many Requests严重的连带着把账号限流几小时。定慢了浪费窗口反过来新号冷启动期平台其实给的额度宽松你却死守 8 秒一小时只能发 450 条本来能发的量白白浪费运营同学干着急。突发流量扎堆固定间隔假设消息是匀速产生的但真实系统里经常是「凌晨批量导入 5 万条白天零零散散补几条」。5 万条按 8 秒匀速发完要 110 个小时业务早就超时了。429 后不自愈最要命的是一旦收到限速错误如果代码只是把这条标失败、等下次重试重试时还是老节奏等于在同一个墙面上反复撞眼看着额度被耗尽。这四类的说到底是一回事发送节奏不是配置项是需要根据实时反馈动态调整的状态机。下面按这个认知把方案一层层演进出来。2. 速率控制方案谱系解析速率限制不是只有「sleep 一下」这一种做法。从简单到复杂常用的有四档方案核心思路优点缺点适用场景固定间隔每条之间 sleep 固定秒数实现一行代码不感知反馈浪费或撞墙极早期验证原型令牌桶按速率往桶里加令牌取走才能发平滑突发、实现简单桶大小难调仍不感知 429单账号稳态推送滑动窗口统计最近 N 秒内的发送数超阈值就等贴合「每秒最多 M 条」硬约束窗口边界有毛刺平台有明确 QPS 限制自适应在令牌桶/窗口之上叠加成功/失败反馈能 429 自愈、闲时提速状态多要小心震荡多账号、跨渠道生产环境我们最终落地的形态是「令牌桶做底、滑动窗口兜底并发、自适应层做 429 自愈」三层叠起来。下面把每一层的代码和坑点拆开讲。3. 基础实现令牌桶核心思路系统按rate个/秒的速度往桶里放令牌每次发送前先取一个令牌取不到就阻塞等待。这样即使瞬时涌进一堆消息发送速率也被桶的流出速度卡住了不会击穿平台。importtimeimportthreadingclassTokenBucket:def__init__(self,rate:float,capacity:float):self.raterate# 每秒补充的令牌数如 0.125 8秒/条self.capacitycapacity# 桶容量允许的最大突发self.tokenscapacity# 初始满桶self.lasttime.monotonic()self._lockthreading.Lock()defacquire(self,n:int1)-float:取 n 个令牌返回需要等待的秒数0 表示立即withself._lock:nowtime.monotonic()# 先按时间补令牌self.tokensmin(self.capacity,self.tokens(now-self.last)*self.rate)self.lastnowifself.tokensn:self.tokens-nreturn0.0# 令牌不够算要等多久deficitn-self.tokens waitdeficit/self.rate self.tokens0.0returnwait坑点提示最容易踩的是用time.time()而不是time.monotonic()算时间差。系统时间如果被 NTP 回拨哪怕几百毫秒令牌补足量会变成负数或不补速率直接失控。monotonic()保证单调递增这个细节值得专门记一笔。另外capacity别设太大我们最初图省事设成 100结果账号冷启动时被允许瞬间 burst 100 条直接触发风控后来压到 5 才平稳。4. 进阶滑动窗口加并发上限核心思路令牌桶管的是「平均速率」但平台往往还有「同一时刻最多 N 条在飞」的并发约束。比如限制「每秒最多 3 条」容易但「同时只能有 2 个发送请求没返回」这个维度令牌桶管不了。所以再加一层滑动窗口专门统计「当前正在飞」和「最近 1 秒内已发」的数量。importtimefromcollectionsimportdequeclassSlidingWindowLimiter:def__init__(self,max_per_window:int,window_sec:float):self.max_per_windowmax_per_window# 窗口内最多发多少条self.window_secwindow_sec# 窗口长度秒self.timestampsdeque()# 已发送的时间戳self.in_flight0# 当前在飞的请求数self._lockthreading.Lock()def_evict(self):cutofftime.monotonic()-self.window_secwhileself.timestampsandself.timestamps[0]cutoff:self.timestamps.popleft()defcan_send(self)-bool:withself._lock:self._evict()return(len(self.timestamps)self.in_flight)self.max_per_windowdefmark_sent(self):withself._lock:self.timestamps.append(time.monotonic())self.in_flightmax(0,self.in_flight-1)defmark_inflight(self):withself._lock:self.in_flight1坑点提示in_flight的递减必须在「请求返回或超时」两个分支都执行不能只写在成功回调里。我们线上出过一次事故某个渠道的 SDK 在超时后既不抛异常也不回调导致in_flight只增不减窗口永远满队列彻底卡死。后来改成给每个发送包一层带超时的 future超时也强制mark_sent()减计数才解决。5. 自愈层429 检测与退避恢复核心思路前面两层都还是「开环」的它们假设平台额度恒定。真实情况是平台会动态限流回429就是信号。自愈层要干两件事收到 429 时主动退避把速率砍半连续成功一段时间后再慢慢提速把速率恢复。这才让整套限流从「配置」变成「状态机」。classAdaptiveRecoverLimiter:def__init__(self,base_rate:float):self.ratebase_rate self.min_ratebase_rate/8# 最低降到 1/8self.max_ratebase_rate*2# 最高提到 2 倍self.cool_success0# 连续成功计数self._lockthreading.Lock()defon_429(self):withself._lock:self.ratemax(self.min_rate,self.rate/2)self.cool_success0print(f [自愈] 撞墙速率降至{self.rate:.4f}/s)defon_success(self):withself._lock:self.cool_success1# 连续 50 次成功才敢提一档避免震荡ifself.cool_success50andself.rateself.max_rate:self.ratemin(self.max_rate,self.rate*1.1)self.cool_success0print(f [自愈] 恢复速率升至{self.rate:.4f}/s)坑点提示提速的阈值一定要比降速「懒」得多。我们第一版对称处理429 砍半、成功 10 次就提一档结果出现震荡刚提速就又撞墙、砍半后又提速速率在两点之间来回跳日志刷屏还救不回来。改成「降速即刻、提速要连续 50 次成功」之后才稳。另一个细节429 不一定是 HTTP 状态码有些渠道把限流藏在响应体里比如{error:rate_limited}自愈层不能直接 try-catch 状态码得先归一化错误类型再判断。6. 实际落地经验这套三层限流我们在多个账号、多个发送渠道上跑了一段时间总结几条不依赖具体产品的经验① 把「速率」做成可热更新的配置别写死在代码里。不同账号等级、不同渠道的额度天差地别写死在代码里每次调参都要发版。我们最终把 base_rate / capacity / max_per_window 全抽成配置中心字段运维在后台改一个数发送进程下一分钟就生效。② 限流日志要能区分「真 429」和「假失败」。网络抖动、账号密码过期、号码格式错这些都会让发送失败但它们不该触发降速。我们只把明确的「频率/配额类」错误喂给自愈层其余错误照常走重试流程但不动速率。否则一次大规模号码清洗就会误触发全局降速连累正常账号。③ 给每个账号独立的限流状态别共享一个全局桶。全局桶听起来省事但一个账号被限流会拖慢所有账号。我们按账号维度各自维护令牌桶 窗口 自愈状态账号之间互不影响哪个被墙哪个自己慢下来。④ 留一个手动「熔断开关」。线上遇到过渠道侧故障所有请求都回 429自愈层疯狂降速到最低还不停。最后加了个手动开关运维确认是渠道问题后一键把该渠道所有账号的发送暂停故障恢复再打开。这个开关救过我们不止一次。7. 小结速率限制这东西初级写法是一行sleep但真要放到生产环境跑它其实是一个「感知反馈、动态调整」的小状态机。三层叠起来的核心就三句话令牌桶管平均速率别用系统时间算差值滑动窗口管并发和突发在飞计数超时也必须回收自愈层管 429降速要快、提速要懒别让速率来回震荡。如果你们的推送系统还在用固定间隔建议按这个顺序演进先用令牌桶替换sleep把速率参数化再加滑动窗口兜住并发上限最后叠自适应自愈层重点调好「降速倍数」和「提速成功阈值」这两个旋钮。从固定间隔改到三层限流核心逻辑大概 1–2 个工作日能落地但调参尤其各账号的 base_rate 和 capacity建议预留一周观察期用真实流量把阈值磨准。后续如果账号规模上到几百个可以考虑把限流状态外抽到 Redis做跨进程统一配额但小团队起步阶段进程内这三层完全够用。