ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

冷启动第一秒放进来 200 个连接,数据库直接被打挂:RateLimiter 的 WarmUp 和漏桶的死队列

冷启动第一秒放进来 200 个连接,数据库直接被打挂:RateLimiter 的 WarmUp 和漏桶的死队列

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 与 WarmingUp

RateLimiter.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 BlockingQueue<Runnable> 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()了事——那是给「能突发、能等待」的场景准备的,拿来护数据库恰恰是反面。我的判断是:凡是身后站着连接池、缓存、第三方慢依赖的接口,一律用WarmingUp+tryAcquire组合,宁可让客户端看到几个「稍后重试」,也别让服务端线程在限流器外排长队。至于漏桶,自研时一定要把「桶」做成有界,无界队列不是漏桶,是一个定时炸弹。

思考题

RateLimiter.create(50)RateLimiter.create(50, 10, SECONDS)分别放在一个「先 sleep 30 秒再瞬间打 300 个请求」的用例里,打印前 10 次acquire()各自的等待时间,你会清楚看到 Bursty 把积压令牌一次性放出、WarmingUp 把速率压在前几秒的差别。

返回列表