ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

限流阈值拍脑袋定成 1000 QPS 那天,我们挡掉了 27% 的正常请求:令牌桶与漏桶的 4 个参数陷阱

限流阈值拍脑袋定成 1000 QPS 那天,我们挡掉了 27% 的正常请求:令牌桶与漏桶的 4 个参数陷阱

title: 限流阈值拍脑袋定成 1000 QPS 那天,我们挡掉了 27% 的正常请求:令牌桶与漏桶的 4 个参数陷阱
tags: [限流, 令牌桶, 漏桶, Guava, Redis, Java]
category: 后端


一次「限流生效了,但业务哭了」的上线

去年双十一前的一次压测演练,我们给商品详情接口加了限流,阈值定的是单机 1000 QPS。理由很朴素:压测跑到 1200 QPS 时 RT 开始劣化,留 20% 余量。

上线当天流量并不算大,峰值单机也就 700 QPS 左右。但客服那边开始收到反馈:有用户刷不出商品页,刷新几次又好了。

监控上看,限流的拒绝计数是每分钟 4000 多次。700 的平均 QPS,怎么会撞上 1000 的阈值?

答案是:平均值骗人。我们用的是固定窗口计数器,而真实流量在秒级上是尖刺状的。这篇把那次踩坑之后我们对令牌桶、漏桶做的对比和实现整理出来,环境是 JDK 11、Guava 31.0.1、Redis 6.2.6、Sentinel 1.8.4。

四种限流算法,差别在哪

先把概念摆清楚,很多人把「令牌桶」和「漏桶」当成一回事,其实它们的行为完全相反。

算法核心行为能否应对突发输出速率典型实现
固定窗口计数每个时间窗口内计数,超了就拒不能,且有临界问题不平滑自己写、Redis INCR
滑动窗口把窗口切成小格子滚动统计部分可以较平滑Sentinel
漏桶请求先入队,按固定速率流出不能,超出直接排队或丢弃恒定自实现队列
令牌桶按固定速率发令牌,请求取令牌,靠桶里的存量令牌允许突发Guava RateLimiter

我们最初踩的坑是固定窗口的临界问题:假设窗口是 1 秒、阈值 1000,如果第 0.9 秒来了 1000 个请求,第 1.1 秒又来了 1000 个,这两拨在各自窗口内都合法,但在 0.9~1.1 这 200 毫秒里系统实际扛了 2000 个请求。反过来,如果流量刚好卡在窗口边界,明明总量不高也会被拒。

我们那次拒绝了 27% 的正常请求,就是后一种情况。

令牌桶:Guava 是怎么做的

Guava 的RateLimiter是令牌桶的经典实现,但它有个很聪明的设计——它不真的维护一个桶,而是记录「下一次可以发放令牌的时间点」

// SmoothRateLimiter 的核心字段 abstract class SmoothRateLimiter extends RateLimiter { /** 当前桶里存了多少个令牌 */ double storedPermits; /** 桶的容量上限 */ double maxPermits; /** 发放一个令牌需要多少微秒,等于 1/qps */ double stableIntervalMicros; /** 下一次可以发放令牌的时刻(微秒) */ private long nextFreeTicketMicros = 0L; /** 按时间流逝补充令牌 */ void resync(long nowMicros) { if (nowMicros > nextFreeTicketMicros) { double newPermits = (nowMicros - nextFreeTicketMicros) / coolDownIntervalMicros(); storedPermits = min(maxPermits, storedPermits + newPermits); nextFreeTicketMicros = nowMicros; } } final long reserveEarliestAvailable(int requiredPermits, long nowMicros) { resync(nowMicros); long returnValue = nextFreeTicketMicros; // 能从存量里拿多少 double storedPermitsToSpend = min(requiredPermits, this.storedPermits); // 还差多少需要等待 double freshPermits = requiredPermits - storedPermitsToSpend; long waitMicros = storedPermitsToWaitTime(this.storedPermits, storedPermitsToSpend) + (long) (freshPermits * stableIntervalMicros); // 关键:把等待时间记在下一次上,本次请求立刻放行 this.nextFreeTicketMicros = LongMath.saturatedAdd(nextFreeTicketMicros, waitMicros); this.storedPermits -= storedPermitsToSpend; return returnValue; } }

这段代码有两个设计值得细看:

  • resync时间差除以间隔来算补充了多少令牌,不需要后台线程定时加令牌。这是一个典型的「用计算代替调度」的优化,省掉了一个定时任务和它带来的精度问题。
  • 第 30 行是 Guava 最有意思的地方:本次请求的等待时间会记账到下一次请求头上。也就是说当前这个请求可以立刻通过,代价由后面的请求承担。这叫「预消费」,好处是突发的第一个请求不会被延迟,坏处是如果只有一个孤立的大请求,它会白白占用后续配额。

用起来很简单,但参数含义要搞清楚:

@Service public class ProductQueryService { // 每秒发放 800 个令牌,预热期 3 秒(冷启动时速率从低到高爬升) private final RateLimiter limiter = RateLimiter.create(800, 3, TimeUnit.SECONDS); public ProductVO query(Long skuId) { // tryAcquire 带超时:最多等 50ms,等不到就快速失败 if (!limiter.tryAcquire(1, 50, TimeUnit.MILLISECONDS)) { throw new RateLimitException("商品查询繁忙,请稍后重试"); } return doQuery(skuId); } }

逐行说明:

  • RateLimiter.create(800, 3, SECONDS)创建的是SmoothWarmingUp冷启动时实际速率低于 800,需要 3 秒爬升到位。这个特性对于依赖连接池、缓存预热的服务很有用,避免刚启动就被打满。
  • tryAcquire(1, 50, MILLISECONDS)允许排队 50 毫秒。这个值不能拍脑袋——它直接叠加到接口 RT 上。我们设 50ms 是因为该接口 P99 是 120ms,多等 50ms 还能接受。
  • 如果用无参acquire(),线程会一直阻塞直到拿到令牌。在 Web 容器里这么写等于慢性自杀,Tomcat 线程会被挨个耗光。这是我见过最常见的误用。

漏桶:什么时候它反而更合适

漏桶的行为是「无论你来多快,我都按固定速率处理」。它不允许突发,这在某些场景恰恰是优点。

我们有个对接第三方物流接口的场景,对方明确要求「每秒不超过 20 次调用,超了封 IP」。这种场景必须用漏桶,因为令牌桶允许突发,桶里攒了 20 个令牌时一瞬间打出去 20 个请求,对方那边看到的就是瞬时超频。

public class LeakyBucketLimiter { private final BlockingQueue<Runnable> queue; private final ScheduledExecutorService leaker; public LeakyBucketLimiter(int capacity, int ratePerSecond) { this.queue = new ArrayBlockingQueue<>(capacity); this.leaker = Executors.newSingleThreadScheduledExecutor( r -> new Thread(r, "leaky-bucket-worker")); long intervalMs = 1000L / ratePerSecond; // 固定速率取出任务执行,这就是"漏"的动作 leaker.scheduleAtFixedRate(this::leak, 0, intervalMs, TimeUnit.MILLISECONDS); } /** 返回 false 表示桶满,请求被丢弃 */ public boolean submit(Runnable task) { return queue.offer(task); } private void leak() { Runnable task = queue.poll(); if (task != null) { try { task.run(); } catch (Exception e) { log.error("漏桶任务执行失败", e); } } } }

关键点:

  • ArrayBlockingQueue的容量就是桶容量。桶满时offer返回 false,请求被丢弃——这个丢弃动作必须被业务感知到,不能吞掉。
  • scheduleAtFixedRate保证了固定的出水速率。注意如果某个任务执行超时,下一次调度会被推迟,实际速率会低于设定值。所以leak里最好只做「提交到另一个线程池」,不要同步执行耗时逻辑。
  • 单线程漏水是刻意的:多线程会破坏「固定速率」这个语义。

这个实现有个我们踩过的坑:最初leak()里直接同步调用第三方接口,对方偶尔响应慢到 3 秒,导致漏桶速率从 20/s 掉到不足 1/s,队列迅速堆满,正常请求全被丢。后来改成leak()只负责从队列取出并丢给业务线程池,速率才稳住。

分布式场景:Redis + Lua 的令牌桶

单机限流在多实例部署下会失真。我们 8 个实例各限 1000 QPS,总量就是 8000,跟预期完全不是一回事。分布式限流的标准做法是 Redis + Lua 保证原子性:

-- token_bucket.lua -- KEYS[1]: 桶的 key -- ARGV[1]: 桶容量 ARGV[2]: 每秒发放速率 ARGV[3]: 当前时间戳(毫秒) ARGV[4]: 本次请求令牌数 local key = KEYS[1] local capacity = tonumber(ARGV[1]) local rate = tonumber(ARGV[2]) local now = tonumber(ARGV[3]) local requested = tonumber(ARGV[4]) local bucket = redis.call('HMGET', key, 'tokens', 'timestamp') local tokens = tonumber(bucket[1]) local lastTime = tonumber(bucket[2]) if tokens == nil then tokens = capacity lastTime = now end -- 按时间差补充令牌,与 Guava 的 resync 思路一致 local delta = math.max(0, now - lastTime) tokens = math.min(capacity, tokens + delta * rate / 1000) local allowed = 0 if tokens >= requested then tokens = tokens - requested allowed = 1 end redis.call('HMSET', key, 'tokens', tokens, 'timestamp', now) -- 设置过期时间,避免冷 key 永久占内存 redis.call('PEXPIRE', key, math.ceil(capacity / rate * 1000) + 1000) return allowed

Java 侧调用:

@Component public class RedisTokenBucketLimiter { private final StringRedisTemplate redisTemplate; private final DefaultRedisScript<Long> script; public RedisTokenBucketLimiter(StringRedisTemplate redisTemplate) { this.redisTemplate = redisTemplate; this.script = new DefaultRedisScript<>(); this.script.setLocation(new ClassPathResource("lua/token_bucket.lua")); this.script.setResultType(Long.class); } public boolean tryAcquire(String resource, int capacity, int ratePerSecond) { // 时间戳统一由 Redis 侧或调用方提供,不要用各实例的本地时间 Long result = redisTemplate.execute( script, Collections.singletonList("rl:" + resource), String.valueOf(capacity), String.valueOf(ratePerSecond), String.valueOf(System.currentTimeMillis()), "1"); return result != null && result == 1L; } }

两个必须注意的地方:

  • 时间戳来源。上面代码用的是应用侧System.currentTimeMillis(),如果各实例时钟不同步,令牌补充会算错。更稳妥的做法是在 Lua 里用redis.call('TIME'),但那样脚本就不是纯函数了,主从复制模式下有兼容性要求(Redis 5 之后默认 effect 复制,可以用)。我们线上用的是 NTP 同步 + 应用侧时间,误差控制在 50ms 内够用。
  • PEXPIRE 必须加。我们最早忘了加,一个按用户 ID 限流的场景跑了两周,Redis 里堆了 300 多万个 key,占了 1.2GB 内存。

那次事故我们最终怎么改的

回到开头的问题。修复分三步:

  1. 算法从固定窗口换成令牌桶,消除临界问题;
  2. 阈值从"压测极限打八折"改成"按 P99 实际流量的 2 倍",也就是从 1000 提到 1800。压测极限和限流阈值本来就不该是一个数——压测测的是系统崩溃点,限流护的是系统不进入劣化区,中间还应该有降级、扩容等好几道手段;
  3. 加了突发容忍,桶容量设为速率的 1.5 倍,允许短时尖刺。

改完之后的数据:

  • 拒绝请求数从每分钟 4000+ 降到每分钟 12 次左右(真实的恶意刷单);
  • 接口 P99 从 120ms 变成 128ms(令牌桶本身有微小开销);
  • 大促当天峰值单机 1600 QPS,未触发限流,RT 稳定在 140ms 以内。

顺带一提,那 8ms 的 P99 上涨里,有一部分是 Redis 一次往返。所以我们最终采用的是两级限流:单机 Guava 做粗粒度兜底(不需要网络往返),Redis 做精确的全局配额,只在关键接口上开。

我的取舍判断

  • 对外接口、有明确调用频率约定的场景—— 用漏桶。它的价值就是「输出绝对平滑」,别指望它扛突发。
  • 面向用户的读接口—— 用令牌桶。用户行为天然有尖刺,一刀切会误伤。
  • 写接口、资源消耗大的接口—— 我倾向于用信号量(并发数限制)而不是 QPS 限制。QPS 1000 的接口如果 RT 从 10ms 劣化到 1s,实际并发会到 1000,系统早崩了。限并发比限 QPS 更贴近资源的真实约束,这一点我觉得被严重低估了。
  • 不建议一上来就上分布式限流。Redis 挂了怎么办、网络抖动怎么办、降级策略是什么,这三个问题想不清楚就先用单机的。我们线上仍有一半接口只用单机限流。

阈值这件事,我的经验是:没有一个数字是拍脑袋能定对的。先用宽松阈值上线,观察两周真实流量分布(尤其是秒级的 P99,不是分钟平均),再收紧。反过来做的代价,就是我们那 27%。

思考题

GuavaRateLimiter的「预消费」机制下,如果一个请求申请 100 个令牌而速率只有 10/s,这个请求会等 10 秒吗?下一个只申请 1 个令牌的请求呢?

提示:回去看reserveEarliestAvailablereturnValue返回的是修改前nextFreeTicketMicros

你们的限流阈值是怎么定的?有没有被自己的限流误伤过?评论区说说。

返回列表