📝 摘要:下游三方有 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 拿不到令牌)后,无非三条路,各有各的代价和适用场景:

图:限流命中后的三种姿势取舍------直接丢(会丢消息,适用埋点 / 心跳)、阻塞等待(不丢但占线程、可能雪崩,适用后台任务)、延迟重投削峰(不丢不占线程、削峰填谷,本文选它)。
2.1 直接丢
最简单粗暴的写法:
java
if (!rateLimiter.tryAcquire()) {
log.warn("被限流了,丢了");
return;
}
doSend(msg);
适用场景:消息本身就是「丢了也不影响业务」的那种。比如:
- 监控埋点上报(采样一下就够)
- 同一用户的高频心跳保活(丢几个无所谓)
- 推荐流的实时打点(统计意义上的近似正确即可)
不适用场景:用户能感知到的业务消息。IM 推送、订单状态变更、支付结果通知------丢一个,对应的就是一个真实用户在等一条永远到不了的消息,PM 拍桌子完全合理。
我们的场景属于后者,方案一出局。
2.2 阻塞等待
第二种姿势,直接让线程在 rateLimiter.acquire() 上阻塞,等到能拿令牌再发:
java
rateLimiter.acquire(); // 阻塞,直到拿到令牌
doSend(msg);
适用场景 :调用方是后台任务、批处理脚本、消费者线程,且业务上下游对延迟不敏感。
不适用场景:调用方是 HTTP 入站请求、是 MQ 消费者池里的有限线程、是同步链路上的某一环。三个原因:
- 线程是稀缺资源。阻塞一个线程几秒,整个池子很快就被「等限流的消息」占满,后续正常消息进不来
- 上游会超时。如果调用方是 HTTP 接口,前端 30s 超时,你阻塞 35s 等令牌,最终前端收到的还是失败
- 雪崩风险。一旦阻塞队列开始堆积,恢复时所有阻塞线程会同时被唤醒,瞬间又把限流额度打满,再次阻塞------产生振荡
更隐蔽的问题:阻塞会掩盖真实负载。监控看到调用量很平稳,因为线程全在等着;实际待发消息已经堆成山,业务在悄悄出问题。
我们的场景是 MQ 消费者池,线程一共就那么几十个,方案二也出局。
2.3 延迟重投削峰
第三种姿势:拿不到令牌时不丢、不阻塞,把消息重新塞回延迟队列,过几秒再回来试。
java
if (!rateLimiter.tryAcquire()) {
retrier.retry(msg); // 入延迟队列,几秒后重试
return;
}
doSend(msg);
这个方案的核心思想是:把瞬时高峰摊平到一段时间内消化------经典的「削峰填谷」。三方 IM 限你 100 QPS,那你就把 200 QPS 的峰值,分摊到 2 秒内匀速发,刚好能贴着配额走完。

但这个方案不是免费的,有四个细节必须设计好,否则会从「消息丢了」演变成「消息更乱了」:
- 重投的延迟时长怎么定:太短再次撞墙,太长用户感知到延迟
- 重投次数封顶:万一一直拿不到令牌,不能死循环
- 重投批次的抖动:不抖会产生「同一秒一批消息齐发」的二次冲击
- 限流器自身异常时怎么处理:限流器挂了,业务也要跟着挂吗?
我们当时选了方案三,下面是完整设计。
三、方案落地
3.1 全局令牌桶:Redisson RRateLimiter
集群多个实例同时往三方 IM 发消息,本地令牌桶毫无意义(每个实例自己有 100 QPS,三个实例就是 300 QPS,照样被 ban)。必须用全局分布式令牌桶。
Redisson 自带 RRateLimiter,底层是 Redis + Lua 的原子计数,集群安全(RateType.OVERALL / PER_CLIENT 的区别、trySetRate / tryAcquire 语义详见 Redisson 官方文档 RateLimiter 一节):
java
RRateLimiter limiter = redissonClient.getRateLimiter("push_token_bucket_single_msg");
limiter.trySetRate(RateType.OVERALL, 90, 1, RateIntervalUnit.SECONDS);
// 实际可用 90 QPS(100 配额留 10% 安全余量给运营手抖)
boolean ok = limiter.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 补精度。
具体到代码:
java
public void retry(PushMsgEvent event) {
int maxRetries = config.getMaxRetries(); // 默认 3
if (event.getRetryCount() >= maxRetries) {
log.warn("重试耗尽丢弃, businessKey:{}", event.getBusinessKey());
dropCounter.increment();
return;
}
event.setRetryCount(event.getRetryCount() + 1);
int[] window = backoffWindow(event.getRetryCount());
// 延迟时长按 [min, max) 随机抖动
delayQueue.publish(event, randomBetween(window[0], window[1]));
}
private int[] backoffWindow(int attempt) {
switch (attempt) {
case 1: return new int[]{1, 3};
case 2: return new int[]{3, 8};
default: return new int[]{8, 15};
}
}
注意 retryCount 是写在 消息载体本身 上的,跟着消息序列化进延迟队列、反序列化时带出来------不需要额外存储。
整条重投链路串起来是这样------拿不到令牌就入延迟队列、到点回来再抢,重试次数写在消息里,超过上限才丢:

图:限流延迟重投链路------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 加随机抖动:
java
// 不要这样
delay = 5;
// 而是这样
delay = randomBetween(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 反直觉的设计:限流器自身异常时降级放行
这条经常引起争论,先把代码贴出来:
java
public boolean tryAcquire(String resource) {
try {
RRateLimiter limiter = redissonClient.getRateLimiter(key(resource));
ensureRate(limiter, resource); // 确保速率配置存在/对齐,实现见 4.1
return limiter.tryAcquire();
} catch (Exception e) {
log.error("限流器异常,降级放行, resource:{}", resource, e);
return true; // ←←← 异常时反而放行
}
}
为什么限流器挂了反而放行?很多人下意识反应是「Redis 抖动时应该阻断业务保护下游」。
仔细想想就会发现这个反应是错的。考虑两种状态:
- 限流器正常:在阻止「超出三方配额」的部分调用
- 限流器异常(Redis 抖动 / 网络丢包):完全不工作
如果在限流器异常时阻断业务,业务100% 不可用 ;如果放行,业务99% 正常(剩下 1% 会被三方限流挡掉,三方那边还会做兜底)。
换句话说:限流器是保护用户体验的,不是保护业务正确性的。它挂了,最坏情况是回到「没有限流的世界」,并不会让业务出错。但如果让它有权阻断业务,那它就从「保护层」变成了「依赖链上的单点故障」。
这条设计原则推广出去其实有更广的应用:所有非关键路径上的中间件,挂了的时候都应该降级,而不是阻断。监控挂了不能让业务挂、链路追踪挂了不能让业务挂、限流挂了也不能让业务挂。
设计原则讲完了,但落地过程中我们也踩了两个不算小的坑------一个让限流器形同虚设了半个月没人发现,另一个差点让运营热改 ini 时多实例数据打架。
四、踩坑两则
4.1 Redisson 3.15.5 getConfig() 在 key 不存在时抛 NumberFormatException
我们最早的 ensureRate 写法是这样的:
java
RateLimiterConfig config = limiter.getConfig();
Long current = config == null ? null : config.getRate();
if (current == null) {
limiter.trySetRate(...);
}
很合理对吧?先探测一下有没有配过,没配过再设。
结果是,这段代码永远走不到 trySetRate。
Redisson 3.15.5 的 getConfig() 实现是发 HGETALL key 拿配置 hash,里面期望有 rate / interval / type 三个字段。decode 闭包直接调 Integer.parseInt(map.get("rate"))。
当 key 不存在时 ,HGETALL 返回空 map ,map.get("rate") 是 null,parseInt(null) 抛 NumberFormatException: null。
栈轨迹是这样的:
text
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:
java
// 1. trySetRate 是 HSETNX 语义:未存在时写入 rate/interval/type 三字段;
// 已存在时不动,不重置令牌桶
limiter.trySetRate(RateType.OVERALL, qpsMax, 1, RateIntervalUnit.SECONDS);
// 2. 这步执行完,rate/interval/type 三字段保证存在,getConfig 不再触发 parseInt(null)
Long current = limiter.getConfig().getRate();
// 3. 如果其他实例先设了不同 qps(运营在本实例停机期间改过 ini),校正一次
if (current == null || 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_max=120,三个实例分别在不同时刻读到这个新值。
每个实例本地缓存一个 appliedQps,跟新值比对:
- 缓存 == null:本实例首次接触 →
trySetRate(HSETNX 不覆盖别人)→getConfig校准 → 不一致就setRate强制对齐 - 缓存 != null 且 == 新值:什么都不做
- 缓存 != null 且 != 新值:运营热改了 →
setRate推送到 Redis
最终一致性靠 setRate 强制对齐保证。短时间内多个实例可能互相覆盖一两次------但 setRate 写入的就是同一个 qpsMax 数值,覆盖 1 次和覆盖 10 次结果都一样,只要 ini 配置全局一致,最终都会收敛到正确值。
------ 限流方案讲完了,最后还差一块:怎么知道它在线上是真的有效在工作?
五、监控:怎么测集群单秒峰值 QPS
Prometheus 的 rate(counter[1m]) 给的是窗口均值 ,会把瞬时突刺打平。我们想看的是单秒峰值------「过去 60 秒里,最忙的那一秒发了多少」。
应用内按秒预聚合 + 一个 Lua 环形 hash 搞定:
lua
local idx = math.floor(tonumber(ARGV[1]) % tonumber(ARGV[3]));
local sf = 's' .. idx;
local cf = 'c' .. idx;
if redis.call('hget', KEYS[1], sf) ~= ARGV[1] then
redis.call('hset', KEYS[1], sf, ARGV[1]);
redis.call('hset', KEYS[1], cf, 0);
end;
redis.call('expire', KEYS[1], tonumber(ARGV[2]));
return redis.call('hincrby', KEYS[1], cf, 1);
设计要点:
- 单 key 单 hash,Cluster 安全
- 环形 60 槽 :第 N 秒落到
idx = N % 60,第 N+60 秒覆盖该槽位(自动过期,无需 cleanup) s<idx>存秒戳,c<idx>存计数:写入时秒戳变了就清零计数,是原子的(Lua 脚本整体原子执行)- TTL 120s:无流量自动回收,比窗口略大避免边界 race
- 字段封顶 2 * 60 = 120:永不增长
读取时:
java
Map<String, String> ring = redisson.<String, String>getMap(key).readAllMap();
long now = System.currentTimeMillis() / 1000;
long peak = 0;
for (int i = 0; i < 60; i++) {
String sec = ring.get("s" + i);
String cnt = ring.get("c" + i);
if (sec != null && cnt != null && now - Long.parseLong(sec) < 60) {
peak = Math.max(peak, Long.parseLong(cnt));
}
}
return peak;
应用把这个值注册成 Prometheus gauge 暴露出去,多实例上报同一个全局值(因为是直接读 Redis),Grafana 取 max 就是真实集群单秒峰值。
至此整套方案落地结束。最后把可以脱离 IM 这个具体场景、迁移到其他「下游有 QPS 配额」场景的几条原则收一收。
六、能带走的几条设计原则
- 限流命中后不一定要丢------能延迟重投就重投,能削峰填谷就别强行峰值
- 退避一定要带抖动------同步重试就是惊群,是新的限流场景的开始
- 重试耗尽要有 metrics + 告警------默默丢消息是最难定位的故障
- 限流器自身异常应该放行------它是体验保护层,不是业务依赖
- 初始化的探测顺序很关键------先动手再观察,比先观察再动手更稳(HSETNX 思维)
- 峰值监控要应用内预聚合------靠抓取间隔的 metrics 系统采不到瞬时突刺
延伸阅读
- 《RocketMQ 4.x 任意秒数延迟消息工程实战:MQ 粗延迟 + Redis 补精度 + MDC 透传》------本文的「延迟重投」依赖的底层能力
🏷️ 标签 :分布式限流 令牌桶 Redisson 削峰 RocketMQ Java