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

📅 发布时间:2026/8/4 15:28:35
限流阈值拍脑袋定成 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 BlockingQueueRunnable 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 allowedJava 侧调用Component public class RedisTokenBucketLimiter { private final StringRedisTemplate redisTemplate; private final DefaultRedisScriptLong 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 内存。那次事故我们最终怎么改的回到开头的问题。修复分三步算法从固定窗口换成令牌桶消除临界问题阈值从压测极限打八折改成按 P99 实际流量的 2 倍也就是从 1000 提到 1800。压测极限和限流阈值本来就不该是一个数——压测测的是系统崩溃点限流护的是系统不进入劣化区中间还应该有降级、扩容等好几道手段加了突发容忍桶容量设为速率的 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 个令牌的请求呢提示回去看reserveEarliestAvailable里returnValue返回的是修改前的nextFreeTicketMicros。你们的限流阈值是怎么定的有没有被自己的限流误伤过评论区说说。