title: 冷启动第一秒放进来 200 个连接数据库直接被打挂RateLimiter 的 WarmUp 和漏桶的死队列tags: [限流, 令牌桶, 漏桶, 算法, Java, 后端]category: 后端限流加了数据库还是被打挂我们有一个给 B 端客户用的批量导入接口平时 QPS 不高但每天早高峰会有一波集中调用。为了护住后面的数据库我在接口前加了一道 GuavaRateLimiter限到 50 QPS。代码很简单// 限到每秒 50 个许可 private final RateLimiter limiter RateLimiter.create(50.0); public ImportResult importBatch(BatchReq req) { limiter.acquire(); // 拿不到许可就阻塞等待 return importService.doImport(req); }上线后头几天风平浪静。直到某天早上应用因为 OOM 重启重启完成后第一秒就被灌进来 200 多个积压请求数据库连接池瞬间被打满后续所有请求全部超时数据库那台机器 CPU 直接 100%。当时的困惑是不是限了 50 QPS 吗怎么还会被冲垮后来读了一遍RateLimiter的源码才明白两件事第一RateLimiter.create(50)用的是SmoothBursty它允许把过去没用掉的令牌攒起来冷启动时桶里可能已经有几百个令牌所以重启后第一秒能一口气放行一大批第二真正的「冷启动保护」要用SmoothWarmingUp而我们对它的「预热」语义理解反了。另一个隐患在我们同时自研的「漏桶」实现里一个无界队列把请求全接住结果下游恢复不了时队列无限增长反倒成了 OOM 的第二个来源。令牌桶与漏桶的本质差异先说清楚两个算法的模型它们护系统的姿势完全不同令牌桶以固定速率往桶里丢令牌请求来了先取令牌取不到就等或拒。桶有容量上限能容忍短时突发攒下的令牌一次性放出去。漏桶请求像水一样进桶桶底下以固定速率漏水处理。桶满就溢出拒绝。它强制平滑输出不关心你前面来多猛下游速率恒定突发会被排队或丢弃。一句话对比令牌桶「允许你偶尔冲一下」漏桶「无论如何都匀速」两者一个在入口宽容、一个在出口整形。选错模型限流反而帮倒忙——我们这次就是「想要护数据库该用漏桶的匀速却用了令牌桶的突发」。RateLimiter 的两种实现Bursty 与 WarmingUpRateLimiter.create有两个工厂方法内部对应两套子类// 令牌桶SmoothBursty允许攒令牌支持突发 public static RateLimiter create(double permitsPerSecond) { return create(permitsPerSecond, SleepingStopwatch.createFromSystemTimer()); } // 预热型SmoothWarmingUp有预热期冷启动阶段速率从低到高爬升 public static RateLimiter create(double permitsPerSecond, long warmupPeriod, TimeUnit unit) { RateLimiter rateLimiter new SmoothWarmingUp(stopwatch, warmupPeriod, unit); rateLimiter.setRate(permitsPerSecond); return rateLimiter; }逐行拆解第 2 行create(double)走的是SmoothBursty它维护maxBurstSeconds默认 1 秒意味着桶里最多攒permitsPerSecond * 1个令牌。第 6 行create(double, warmupPeriod, unit)走SmoothWarmingUp多了一个预热时长参数比如 10 秒。SmoothBursty的突发能力来自它的reserve逻辑只要距离上次取令牌的时间足够长它就把这段时间累积的令牌都算给你。acquire()在冷启动瞬间可能一次性放行的数量约等于「空闲时长 × 速率」。我们的应用重启后空闲了几十秒桶里攒了几百令牌所以第一秒才会「放行 200」。SmoothWarmingUp的「预热」恰恰是为了反突发它让限流器在刚启动时只放很低的速率随预热期推进逐步爬到目标速率。我们当时误以为「WarmUp 是启动快、后面慢」其实是反的——它是「启动慢、后面快」专门用来给连接池、缓存等冷资源留出热身时间。把它用对重启后第一秒就不会再被冲爆。正确用法给数据库类接口加预热// 预热 10 秒期间速率从低逐步升到 50 QPS private final RateLimiter limiter RateLimiter.create(50.0, 10, TimeUnit.SECONDS); public ImportResult importBatch(BatchReq req) { if (!limiter.tryAcquire()) { // 拿不到立即拒不阻塞积压 throw new TooManyRequestsException(导入过于频繁请稍后重试); } return importService.doImport(req); }逐行拆解第 3 行create(50.0, 10, TimeUnit.SECONDS)让限流器用SmoothWarmingUp重启后的前 10 秒速率是逐步爬升的第一秒可能只放 5~10 个给数据库连接池留出建立连接、预热缓存的时间。第 6 行tryAcquire()改成「拿不到就拒」而不是acquire()的「拿不到就死等」。这点很关键如果请求在限流器外排队等待表面 QPS 是被限住了但线程却被占着连接池和线程池照样会被压垮。第 8 行直接抛业务异常把压力交给客户端重试比让服务端线程干等长。漏桶的死队列另一种 OOM我们另一条链路曾自研过漏桶用一个阻塞队列当「桶」public class LeakyBucket { private final int capacity; private final BlockingQueueRunnable bucket; private final ScheduledExecutorService leak Executors.newSingleThreadScheduledExecutor(); // 固定速率「漏水」 public LeakyBucket(int capacity, int leakRatePerSec) { this.capacity capacity; this.bucket new LinkedBlockingQueue(); // 无界队列 leak.scheduleAtFixedRate(this::leak, 0, 1000 / leakRatePerSec, TimeUnit.MILLISECONDS); } public boolean offer(Runnable task) { if (bucket.size() capacity) return false; // 满了才拒 return bucket.offer(task); // 没满就进队列等着 } }逐行拆解第 6 行new LinkedBlockingQueue()用了无界队列这是埋雷offer几乎永远不会因为「队列满」而拒绝所有请求都被接住堆在内存里。第 13 行bucket.size() capacity的判满逻辑其实对无界队列形同虚设——只要offer不抛异常请求就一直进。真正的问题在「漏水」速率当下游数据库长时间不可用时漏水线程处理不动bucket里的任务无限堆积最终把堆内存吃光触发 OOM。我们那次漏桶实现的 OOM根因就是这个无界队列而不是限流本身。修法是把队列改成有界并且让offer在队满时真正拒绝而不是默默接住this.bucket new ArrayBlockingQueue(capacity); // 有界满了 offer 返回 false // offer 时已满 - 直接拒绝而不是堆积 public boolean offer(Runnable task) { return bucket.offer(task); // 队满自动返回 false调用方据此拒流 }两种限流该怎么选维度令牌桶SmoothBursty令牌桶WarmingUp漏桶对突发流量的态度允许攒令牌容忍突发预热期抑制突发强制匀速突发排队/丢弃冷启动保护无反而易冲爆有速率渐升有恒定输出队列风险无队列无队列无界队列会 OOM适合场景秒杀入口、可突发的业务护 DB、护缓存、冷资源下游脆弱、必须匀速的链路我的经验护数据库、护第三方这种「慢热且脆弱」的依赖优先用漏桶式的匀速整形或者用带预热的令牌桶纯入口限流、允许短时突发的才用普通SmoothBursty。而无论哪种「拿不到就拒」都该优于「拿不到就等」。补充一个我们踩过的细节tryAcquire()不传等待时间时和acquire()一样会阻塞区别在于它返回布尔值让你自己决定拒流策略。如果下游恢复极慢即便用了tryAcquire拒流被拒的请求在客户端重试时仍会再次打进来形成「拒绝—重试—再拒绝」的脉冲。所以限流之外最好再配一层熔断例如 Sentinel 的慢调用比例规则让下游真正不可用时整条链路快速失败而不是在限流器边缘反复横跳把压力转化成无意义的重试风暴。复盘数字这次事故持续约 18 分钟期间导入接口成功率跌到 0连带把同库的订单查询也拖慢连接池被占满。加预热 改tryAcquire后同样早高峰 200 积压请求第一秒实际放行从 200 降到 12 个数据库连接池使用率峰值从 100% 降到 63%。那条自研漏桶的无界队列在改造前高峰期曾把单实例堆内存从 1.2 GB 推到 3.1 GB是有名的「慢泄漏」这次一并改成有界。我的取舍我不建议无脑用RateLimiter.create(permits)然后acquire()了事——那是给「能突发、能等待」的场景准备的拿来护数据库恰恰是反面。我的判断是凡是身后站着连接池、缓存、第三方慢依赖的接口一律用WarmingUptryAcquire组合宁可让客户端看到几个「稍后重试」也别让服务端线程在限流器外排长队。至于漏桶自研时一定要把「桶」做成有界无界队列不是漏桶是一个定时炸弹。思考题把RateLimiter.create(50)和RateLimiter.create(50, 10, SECONDS)分别放在一个「先 sleep 30 秒再瞬间打 300 个请求」的用例里打印前 10 次acquire()各自的等待时间你会清楚看到 Bursty 把积压令牌一次性放出、WarmingUp 把速率压在前几秒的差别。