消息发送的速率自适应:从固定间隔到 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.rate=rate# 每秒补充的令牌数,如 0.125 = 8秒/条self.capacity=capacity# 桶容量,允许的最大突发self.tokens=capacity# 初始满桶self.last=time.monotonic()self._lock=threading.Lock()defacquire(self,n:int=1)->float:"""取 n 个令牌,返回需要等待的秒数(0 表示立即)"""withself._lock:now=time.monotonic()# 先按时间补令牌self.tokens=min(self.capacity,self.tokens+(now-self.last)*self.rate)self.last=nowifself.tokens>=n:self.tokens-=nreturn0.0# 令牌不够,算要等多久deficit=n-self.tokens wait=deficit/self.rate self.tokens=0.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_window=max_per_window# 窗口内最多发多少条self.window_sec=window_sec# 窗口长度(秒)self.timestamps=deque()# 已发送的时间戳self.in_flight=0# 当前在飞的请求数self._lock=threading.Lock()def_evict(self):cutoff=time.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_flight=max(0,self.in_flight-1)defmark_inflight(self):withself._lock:self.in_flight+=1坑点提示:in_flight的递减必须在「请求返回或超时」两个分支都执行,不能只写在成功回调里。我们线上出过一次事故,某个渠道的 SDK 在超时后既不抛异常也不回调,导致in_flight只增不减,窗口永远满,队列彻底卡死。后来改成给每个发送包一层带超时的 future,超时也强制mark_sent()减计数才解决。
5. 自愈层:429 检测与退避恢复
核心思路:前面两层都还是「开环」的,它们假设平台额度恒定。真实情况是平台会动态限流,回429就是信号。自愈层要干两件事:收到 429 时主动退避(把速率砍半),连续成功一段时间后再慢慢提速(把速率恢复)。这才让整套限流从「配置」变成「状态机」。
classAdaptiveRecoverLimiter:def__init__(self,base_rate:float):self.rate=base_rate self.min_rate=base_rate/8# 最低降到 1/8self.max_rate=base_rate*2# 最高提到 2 倍self.cool_success=0# 连续成功计数self._lock=threading.Lock()defon_429(self):withself._lock:self.rate=max(self.min_rate,self.rate/2)self.cool_success=0print(f" [自愈] 撞墙,速率降至{self.rate:.4f}/s")defon_success(self):withself._lock:self.cool_success+=1# 连续 50 次成功才敢提一档,避免震荡ifself.cool_success>=50andself.rate<self.max_rate:self.rate=min(self.max_rate,self.rate*1.1)self.cool_success=0print(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,做跨进程统一配额,但小团队起步阶段,进程内这三层完全够用。