摘要下游三方有 QPS 配额限流命中后到底怎么办本文对比直接丢、阻塞等待、延迟重投三种姿势的取舍落地基于 Redisson 全局令牌桶 RRateLimiter命中即入延迟队列削峰重投用退避加随机抖动破解惊群重试耗尽兜底加 metrics 告警并给出限流器异常降级放行、应用内环形 hash 采集单秒峰值 QPS 等踩坑经验。本文示例代码基于 Redisson 3.15.5。这版本已经偏旧当前 3.40文中提到的几个坑高版本已修。但很多企业出于线上别动稳定的中间件的稳健考虑还在用 3.x 老版本文的姿势设计跟 Redisson 版本无关3.15 上跑得通的高版本只会更稳。一、问题先抛出来我们做了一个 IM 推送服务上游各种营销 / 通知 / 异步任务都往里塞消息下游对接一家三方 IM 提供商。三方 IM 卖账号给我们的时候合同里白纸黑字写了一行单账号 QPS 配额 100。后端工程师看到这一行通常有两种反应「100 QPS我业务峰值还达不到」半年后被运营营销活动打脸「100 QPS那我加个限流器就行了」半个月后被产品经理拍桌子说消息丢了我们当时是后一种。线上某天高峰瞬时调用打到 200 QPS三方直接 429 ban 了几个用户的消息。复盘会上 PM 当着大家面问了一句很有杀伤力的话「为什么消息会丢我们的限流是用来保护谁的」这个问题我后来想了很久。今天这篇就是想跟你掰扯清楚——限流命中后到底该怎么办直觉上有三种姿势每种姿势都有人用每种都有它适用的场景直接丢阻塞等待延迟重投削峰下面挨个聊一下取舍。二、三种姿势的取舍限流命中tryAcquire拿不到令牌后无非三条路各有各的代价和适用场景拿到限流命中要发消息先 tryAcquire 拿令牌正常发送拿不到令牌怎么办直接丢最简单 · 会丢消息适用埋点 / 心跳等可丢消息阻塞等待不丢 · 占线程 · 可能雪崩适用后台任务 · 延迟不敏感延迟重投削峰不丢 · 不占线程 · 削峰填谷适用用户可感知消息 · 线程稀缺本文选它图限流命中后的三种姿势取舍——直接丢会丢消息适用埋点 / 心跳、阻塞等待不丢但占线程、可能雪崩适用后台任务、延迟重投削峰不丢不占线程、削峰填谷本文选它。2.1 直接丢最简单粗暴的写法if(!rateLimiter.tryAcquire()){log.warn(被限流了丢了);return;}doSend(msg);适用场景消息本身就是「丢了也不影响业务」的那种。比如监控埋点上报采样一下就够同一用户的高频心跳保活丢几个无所谓推荐流的实时打点统计意义上的近似正确即可不适用场景用户能感知到的业务消息。IM 推送、订单状态变更、支付结果通知——丢一个对应的就是一个真实用户在等一条永远到不了的消息PM 拍桌子完全合理。我们的场景属于后者方案一出局。2.2 阻塞等待第二种姿势直接让线程在rateLimiter.acquire()上阻塞等到能拿令牌再发rateLimiter.acquire();// 阻塞直到拿到令牌doSend(msg);适用场景调用方是后台任务、批处理脚本、消费者线程且业务上下游对延迟不敏感。不适用场景调用方是 HTTP 入站请求、是 MQ 消费者池里的有限线程、是同步链路上的某一环。三个原因线程是稀缺资源。阻塞一个线程几秒整个池子很快就被「等限流的消息」占满后续正常消息进不来上游会超时。如果调用方是 HTTP 接口前端 30s 超时你阻塞 35s 等令牌最终前端收到的还是失败雪崩风险。一旦阻塞队列开始堆积恢复时所有阻塞线程会同时被唤醒瞬间又把限流额度打满再次阻塞——产生振荡更隐蔽的问题阻塞会掩盖真实负载。监控看到调用量很平稳因为线程全在等着实际待发消息已经堆成山业务在悄悄出问题。我们的场景是 MQ 消费者池线程一共就那么几十个方案二也出局。2.3 延迟重投削峰第三种姿势拿不到令牌时不丢、不阻塞把消息重新塞回延迟队列过几秒再回来试。if(!rateLimiter.tryAcquire()){retrier.retry(msg);// 入延迟队列几秒后重试return;}doSend(msg);这个方案的核心思想是把瞬时高峰摊平到一段时间内消化——经典的「削峰填谷」。三方 IM 限你 100 QPS那你就把 200 QPS 的峰值分摊到 2 秒内匀速发刚好能贴着配额走完。但这个方案不是免费的有四个细节必须设计好否则会从「消息丢了」演变成「消息更乱了」重投的延迟时长怎么定太短再次撞墙太长用户感知到延迟重投次数封顶万一一直拿不到令牌不能死循环重投批次的抖动不抖会产生「同一秒一批消息齐发」的二次冲击限流器自身异常时怎么处理限流器挂了业务也要跟着挂吗我们当时选了方案三下面是完整设计。三、方案落地3.1 全局令牌桶RedissonRRateLimiter集群多个实例同时往三方 IM 发消息本地令牌桶毫无意义每个实例自己有 100 QPS三个实例就是 300 QPS照样被 ban。必须用全局分布式令牌桶。Redisson 自带RRateLimiter底层是 Redis Lua 的原子计数集群安全RateType.OVERALL/PER_CLIENT的区别、trySetRate/tryAcquire语义详见 Redisson 官方文档 RateLimiter 一节RRateLimiterlimiterredissonClient.getRateLimiter(push_token_bucket_single_msg);limiter.trySetRate(RateType.OVERALL,90,1,RateIntervalUnit.SECONDS);// 实际可用 90 QPS100 配额留 10% 安全余量给运营手抖booleanoklimiter.tryAcquire();几个工程细节OVERALL而不是PER_CLIENT前者是「集群全局共用一个桶」后者是「每个客户端实例自己一个桶」。我们要前者配额留余量合同写 100 别配 100配 90。三方实际限流通常是滑窗瞬时突刺很容易越界桶 key 按资源分单聊接口一个桶single_msg群发接口一个桶batch_msg。三方各接口配额一般独立混在一个桶里会互相吃额度3.2 限流命中后入延迟队列重投延迟队列我们用 RocketMQ 的延迟消息线上已经在用了没必要另起炉灶。关于 RocketMQ 任意秒数延迟消息怎么落地可以看 《RocketMQ 4.x 任意秒数延迟消息工程实战》。RocketMQ 4.x 原生只支持固定档位要支持「8 秒延迟」「23 秒延迟」这种任意秒数需要 MQ 粗延迟 Redis 补精度。具体到代码publicvoidretry(PushMsgEventevent){intmaxRetriesconfig.getMaxRetries();// 默认 3if(event.getRetryCount()maxRetries){log.warn(重试耗尽丢弃, businessKey:{},event.getBusinessKey());dropCounter.increment();return;}event.setRetryCount(event.getRetryCount()1);int[]windowbackoffWindow(event.getRetryCount());// 延迟时长按 [min, max) 随机抖动delayQueue.publish(event,randomBetween(window[0],window[1]));}privateint[]backoffWindow(intattempt){switch(attempt){case1:returnnewint[]{1,3};case2:returnnewint[]{3,8};default:returnnewint[]{8,15};}}注意retryCount是写在消息载体本身上的跟着消息序列化进延迟队列、反序列化时带出来——不需要额外存储。整条重投链路串起来是这样——拿不到令牌就入延迟队列、到点回来再抢重试次数写在消息里超过上限才丢是否否 · 次数 1到点回来是 · 重试耗尽待发消息tryAcquire拿到令牌?发送成功retryCount≥ maxRetries?入延迟队列按退避窗口随机抖动1~3s / 3~8s / 8~15s丢弃 打点告警push.retry.drop图限流延迟重投链路——tryAcquire 拿不到令牌就入延迟队列按退避窗口随机抖动到点回来再抢retryCount 超过 maxRetries 才丢弃并打点告警。3.3 关键退避 随机抖动为什么要抖动想象一下反面教材假设我们偷懒、不用 3.2 那种[min, max)随机窗口而是写死「失败 5 秒后再试」。18:00:00 这一秒瞬时来了 200 条消息前 90 条拿到令牌发出去后 110 条全部走 retry统一延迟 5 秒。那 18:00:05 这一秒会发生什么110 条消息同时回来抢令牌加上 18:00:05 本身的常规流量瞬时 QPS 又冲到 200——完全复刻了 5 秒前的限流场景。然后这一波又被 retry又是 5 秒后……这就是所谓的「惊群效应」Thundering Herd被某个事件挡住的一批请求会同步重试反复冲击下游。解法是给每条 retry 加随机抖动// 不要这样delay5;// 而是这样delayrandomBetween(3,8);5 秒变成「3 到 8 秒之间随机」之后那 110 条 retry 会均匀散布到 5 秒窗口内每秒平均 22 条叠加常规流量也能维持在配额内。3.4 重试耗尽兜底maxRetries默认 3 次3 次累计窗口 [12, 26] 秒。如果三次都拿不到令牌——说明已经不是「瞬时高峰」而是「持续过载」了——这时候继续 retry 没意义反而堆积越来越多的延迟消息最终会把延迟队列也撑爆。所以第 4 次进入 retry 就丢。但这里要做两件事打log.warn把 businessKey、from/to 记下来便于客诉时溯源增加一个push.retry.dropcounter配 5 分钟 drop N 的告警阈值第二点很多人会忘。重试耗尽默默丢消息是最难发现的故障——业务无感、监控无声、用户在那边静默等一个永远不到的通知。一定要有 metrics。3.5 反直觉的设计限流器自身异常时降级放行这条经常引起争论先把代码贴出来publicbooleantryAcquire(Stringresource){try{RRateLimiterlimiterredissonClient.getRateLimiter(key(resource));ensureRate(limiter,resource);// 确保速率配置存在/对齐实现见 4.1returnlimiter.tryAcquire();}catch(Exceptione){log.error(限流器异常,降级放行, resource:{},resource,e);returntrue;// ←←← 异常时反而放行}}为什么限流器挂了反而放行很多人下意识反应是「Redis 抖动时应该阻断业务保护下游」。仔细想想就会发现这个反应是错的。考虑两种状态限流器正常在阻止「超出三方配额」的部分调用限流器异常Redis 抖动 / 网络丢包完全不工作如果在限流器异常时阻断业务业务100% 不可用如果放行业务99% 正常剩下 1% 会被三方限流挡掉三方那边还会做兜底。换句话说限流器是保护用户体验的不是保护业务正确性的。它挂了最坏情况是回到「没有限流的世界」并不会让业务出错。但如果让它有权阻断业务那它就从「保护层」变成了「依赖链上的单点故障」。这条设计原则推广出去其实有更广的应用所有非关键路径上的中间件挂了的时候都应该降级而不是阻断。监控挂了不能让业务挂、链路追踪挂了不能让业务挂、限流挂了也不能让业务挂。设计原则讲完了但落地过程中我们也踩了两个不算小的坑——一个让限流器形同虚设了半个月没人发现另一个差点让运营热改 ini 时多实例数据打架。四、踩坑两则4.1 Redisson 3.15.5getConfig()在 key 不存在时抛 NumberFormatException我们最早的ensureRate写法是这样的RateLimiterConfigconfiglimiter.getConfig();Longcurrentconfignull?null:config.getRate();if(currentnull){limiter.trySetRate(...);}很合理对吧先探测一下有没有配过没配过再设。结果是这段代码永远走不到trySetRate。Redisson 3.15.5 的getConfig()实现是发HGETALL key拿配置 hash里面期望有rate / interval / type三个字段。decode 闭包直接调Integer.parseInt(map.get(rate))。当 key 不存在时HGETALL返回空 mapmap.get(rate)是nullparseInt(null)抛NumberFormatException: null。栈轨迹是这样的java.lang.NumberFormatException: null at java.lang.Integer.parseInt(Integer.java:542) at org.redisson.RedissonRateLimiter$1.decode(RedissonRateLimiter.java:271) ...异常被业务层的try { ... } catch (Exception e) { return true; }兜住了业务完全无感——只是ensureRate永远炸在第一行、trySetRate永远调不到、限流器从来没真的工作过。然后高峰来了三方一限我们PM 拍桌子。修复方案就一个字反过来。先trySetRate再getConfig// 1. trySetRate 是 HSETNX 语义未存在时写入 rate/interval/type 三字段// 已存在时不动不重置令牌桶limiter.trySetRate(RateType.OVERALL,qpsMax,1,RateIntervalUnit.SECONDS);// 2. 这步执行完rate/interval/type 三字段保证存在getConfig 不再触发 parseInt(null)Longcurrentlimiter.getConfig().getRate();// 3. 如果其他实例先设了不同 qps运营在本实例停机期间改过 ini校正一次if(currentnull||current!qpsMax){limiter.setRate(RateType.OVERALL,qpsMax,1,RateIntervalUnit.SECONDS);}调换顺序的关键意义trySetRate是无副作用的初始化操作已存在不动它执行完三个关键字段保证存在后续getConfig就不会撞parseInt(null)那条 bug 路径用getConfig的返回值做配置一致性校准比「catch NumberFormatException 当作 null」的兜底姿势更优雅——根本不让 Redisson 走进那条 decode 路径连Unable to decode data的 ERROR 日志也没了。4.2 配置热改时的多实例一致性运营改push_qps_max120三个实例分别在不同时刻读到这个新值。每个实例本地缓存一个appliedQps跟新值比对缓存 null本实例首次接触 →trySetRateHSETNX 不覆盖别人→getConfig校准 → 不一致就setRate强制对齐缓存 ! null 且 新值什么都不做缓存 ! null 且 ! 新值运营热改了 →setRate推送到 Redis最终一致性靠setRate强制对齐保证。短时间内多个实例可能互相覆盖一两次——但setRate写入的就是同一个qpsMax数值覆盖 1 次和覆盖 10 次结果都一样只要 ini 配置全局一致最终都会收敛到正确值。—— 限流方案讲完了最后还差一块怎么知道它在线上是真的有效在工作五、监控怎么测集群单秒峰值 QPSPrometheus 的rate(counter[1m])给的是窗口均值会把瞬时突刺打平。我们想看的是单秒峰值——「过去 60 秒里最忙的那一秒发了多少」。应用内按秒预聚合 一个 Lua 环形 hash 搞定localidxmath.floor(tonumber(ARGV[1])%tonumber(ARGV[3]));localsfs..idx;localcfc..idx;ifredis.call(hget,KEYS[1],sf)~ARGV[1]thenredis.call(hset,KEYS[1],sf,ARGV[1]);redis.call(hset,KEYS[1],cf,0);end;redis.call(expire,KEYS[1],tonumber(ARGV[2]));returnredis.call(hincrby,KEYS[1],cf,1);设计要点单 key 单 hashCluster 安全环形 60 槽第 N 秒落到idx N % 60第 N60 秒覆盖该槽位自动过期无需 cleanupsidx存秒戳cidx存计数写入时秒戳变了就清零计数是原子的Lua 脚本整体原子执行TTL 120s无流量自动回收比窗口略大避免边界 race字段封顶 2 * 60 120永不增长读取时MapString,Stringringredisson.String,StringgetMap(key).readAllMap();longnowSystem.currentTimeMillis()/1000;longpeak0;for(inti0;i60;i){Stringsecring.get(si);Stringcntring.get(ci);if(sec!nullcnt!nullnow-Long.parseLong(sec)60){peakMath.max(peak,Long.parseLong(cnt));}}returnpeak;应用把这个值注册成 Prometheus gauge 暴露出去多实例上报同一个全局值因为是直接读 RedisGrafana 取max就是真实集群单秒峰值。至此整套方案落地结束。最后把可以脱离 IM 这个具体场景、迁移到其他「下游有 QPS 配额」场景的几条原则收一收。六、能带走的几条设计原则限流命中后不一定要丢——能延迟重投就重投能削峰填谷就别强行峰值退避一定要带抖动——同步重试就是惊群是新的限流场景的开始重试耗尽要有 metrics 告警——默默丢消息是最难定位的故障限流器自身异常应该放行——它是体验保护层不是业务依赖初始化的探测顺序很关键——先动手再观察比先观察再动手更稳HSETNX 思维峰值监控要应用内预聚合——靠抓取间隔的 metrics 系统采不到瞬时突刺延伸阅读《RocketMQ 4.x 任意秒数延迟消息工程实战MQ 粗延迟 Redis 补精度 MDC 透传》——本文的「延迟重投」依赖的底层能力️ 标签分布式限流令牌桶Redisson削峰RocketMQJava