限流命中后该怎么办:直接丢、阻塞等待、延迟重投三种姿势的取舍

📝 摘要:下游三方有 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 当着大家面问了一句很有杀伤力的话:

「为什么消息会丢?我们的限流是用来保护谁的?」

这个问题我后来想了很久。今天这篇就是想跟你掰扯清楚------限流命中后,到底该怎么办?

直觉上有三种姿势,每种姿势都有人用,每种都有它适用的场景:

  1. 直接丢
  2. 阻塞等待
  3. 延迟重投削峰

下面挨个聊一下取舍。

二、三种姿势的取舍

限流命中(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 秒内匀速发,刚好能贴着配额走完。

但这个方案不是免费的,有四个细节必须设计好,否则会从「消息丢了」演变成「消息更乱了」:

  1. 重投的延迟时长怎么定:太短再次撞墙,太长用户感知到延迟
  2. 重投次数封顶:万一一直拿不到令牌,不能死循环
  3. 重投批次的抖动:不抖会产生「同一秒一批消息齐发」的二次冲击
  4. 限流器自身异常时怎么处理:限流器挂了,业务也要跟着挂吗?

我们当时选了方案三,下面是完整设计。

三、方案落地

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 就丢。但这里要做两件事:

  1. log.warn 把 businessKey、from/to 记下来,便于客诉时溯源
  2. 增加一个 push.retry.drop counter,配 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 返回空 mapmap.get("rate")nullparseInt(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 拍桌子。

修复方案 就一个字:反过来 。先 trySetRategetConfig

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 配额」场景的几条原则收一收。

六、能带走的几条设计原则

  1. 限流命中后不一定要丢------能延迟重投就重投,能削峰填谷就别强行峰值
  2. 退避一定要带抖动------同步重试就是惊群,是新的限流场景的开始
  3. 重试耗尽要有 metrics + 告警------默默丢消息是最难定位的故障
  4. 限流器自身异常应该放行------它是体验保护层,不是业务依赖
  5. 初始化的探测顺序很关键------先动手再观察,比先观察再动手更稳(HSETNX 思维)
  6. 峰值监控要应用内预聚合------靠抓取间隔的 metrics 系统采不到瞬时突刺

延伸阅读


🏷️ 标签分布式限流 令牌桶 Redisson 削峰 RocketMQ Java

相关推荐
用户8181870627461 天前
第5章 ClassCastException:类型擦除与桥接方法引发的陷阱
后端
爱勇宝1 天前
《道德经》第 9 章:别把系统和自己都推到过载
前端·后端·程序员
shengjk11 天前
未来最好的代码,可能是人类完全看不懂的
后端
枫叶V1 天前
AI Agent 运行数小时不崩盘:聊聊正在升温的 Durable Execution
后端
枫叶V1 天前
AgentOps 正在成为新热点:AI Agent 从 Demo 到生产,还缺什么?
后端·agent
寒蝉1281 天前
我写了个终端盯盘工具,按一下 b 键就能假装在编译代码
后端
渣男教父1 天前
Python自动化操作 Word 和 PDF
后端·python
Lcos1 天前
从 llama.cpp 到可调度 AI Worker:LLM Runtime 容器化部署踩坑实录
后端
依晨照旧1 天前
PostgreSQL 常用命令速查:MySQL 用户平滑上手,psql 元命令 + 库表管理 + 运维排查一篇通
后端·postgresql