你的API被刷爆了怎么办?限流不是产品经理的拍脑袋,而是一套明确的算法。
场景:开放API被刷了
你写了一个免费天气查询API:https://api.weather.com/current?city=beijing
第三天,有人写了个爬虫每秒请求1000次。你的服务器CPU飙升,数据库连接池爆满,其他用户全部超时。
老板问:怎么解决?
你说:"限流------每个IP每分钟最多60次。"
老板:"实现一个。"
于是你开始思考:怎么限?
一、最简单但最坑:固定窗口计数器
1.1 思路
把时间切成固定长度窗口(比如1分钟),每个窗口内维护一个计数器。
-
请求来了,看当前在哪个窗口
-
如果计数器 < 限制,允许并+1
-
否则拒绝
1.2 代码
java
public class FixedWindowRateLimiter {
private final int limit;
private final long windowSizeMs;
private long currentWindowStart;
private int counter;
public FixedWindowRateLimiter(int limitPerMinute) {
this.limit = limitPerMinute;
this.windowSizeMs = 60_000;
this.currentWindowStart = System.currentTimeMillis();
this.counter = 0;
}
public synchronized boolean allow() {
long now = System.currentTimeMillis();
if (now - currentWindowStart >= windowSizeMs) {
// 进入下一个窗口
currentWindowStart = now;
counter = 0;
}
if (counter < limit) {
counter++;
return true;
}
return false;
}
}
1.3 致命缺陷:窗口边界突发
假设限制是1分钟60次。看这个时间线:
窗口1 [00:00:00, 00:00:60) : 第59秒时来了60次 → 允许 窗口2 [00:01:00, 00:02:00) : 第61秒时又来了60次 → 允许 结果:1秒内(第59秒~第61秒)涌入了120次请求!
这就是边界突发问题。攻击者可以卡在窗口交界处打爆你的系统。
二、改进版:滑动窗口计数器
2.1 核心思想
不切死窗口,而是用当前时间往前看一个窗口长度,计算这个滑动区间内的总请求数。
2.2 实现方式1:滑动窗口日志(精确但耗内存)
存储每个请求的时间戳,每次请求时删除窗口外的旧时间戳,然后判断剩余数量。
java
public class SlidingWindowLogRateLimiter {
private final int limit;
private final long windowSizeMs;
private final Queue<Long> timestamps = new LinkedList<>();
public SlidingWindowLogRateLimiter(int limitPerMinute) {
this.limit = limitPerMinute;
this.windowSizeMs = 60_000;
}
public synchronized boolean allow() {
long now = System.currentTimeMillis();
// 移除窗口外的时间戳
while (!timestamps.isEmpty() && timestamps.peek() < now - windowSizeMs) {
timestamps.poll();
}
if (timestamps.size() < limit) {
timestamps.offer(now);
return true;
}
return false;
}
}
优点 :精确
缺点:每个请求都要存储时间戳,内存占用高
2.3 实现方式2:滑动窗口计数器(推荐,更高效)
将窗口分成多个小格子(比如1分钟分成6个10秒的桶),用近似滑动。
原理:当前窗口的计数 = 上一个完整窗口的剩余部分比例 + 当前窗口已过部分。
公式:
当前窗口计数 = 上一个窗口计数 * (上一个窗口未过期的比例) + 当前窗口计数
java
public class SlidingWindowCounterRateLimiter {
private final int limit;
private final int windowSizeSec;
private final int slotNum; // 分成几个小格子
private final long slotTimeMs;
private final int[] counters;
private long currentSlotTime;
public SlidingWindowCounterRateLimiter(int limitPerMinute) {
this.limit = limitPerMinute;
this.windowSizeSec = 60;
this.slotNum = 6; // 分成6格,每格10秒
this.slotTimeMs = windowSizeSec * 1000 / slotNum;
this.counters = new int[slotNum];
this.currentSlotTime = (System.currentTimeMillis() / slotTimeMs) * slotTimeMs;
}
public synchronized boolean allow() {
long now = System.currentTimeMillis();
long nowSlot = (now / slotTimeMs) * slotTimeMs;
int slotIndex = (int)((nowSlot / slotTimeMs) % slotNum);
// 如果跨格子了,清理落后的格子
if (nowSlot != currentSlotTime) {
long slotsPassed = (nowSlot - currentSlotTime) / slotTimeMs;
for (long i = 1; i <= slotsPassed && i <= slotNum; i++) {
int idx = (int)((slotIndex - i + slotNum) % slotNum);
counters[idx] = 0;
}
currentSlotTime = nowSlot;
}
// 计算当前滑动窗口内的总计数
int total = 0;
for (int i = 0; i < slotNum; i++) {
total += counters[i];
}
if (total < limit) {
counters[slotIndex]++;
return true;
}
return false;
}
}
优点 :内存固定,性能高,精度可调(格子越多越精确)
工业界最常用 (如Redis的CL.THROTTLE就是类似思想)
三、令牌桶算法(允许突发)
3.1 原理
想象一个桶,以固定速率(比如每秒10个)往里面加令牌。请求来了,必须从桶里拿一个令牌;拿不到就拒绝。
桶有上限,允许积累令牌,所以能应对突发流量。
3.2 代码实现
java
public class TokenBucketRateLimiter {
private final long capacity; // 桶容量
private final long refillTokensPerSec; // 每秒补充令牌数
private long availableTokens;
private long lastRefillTime;
public TokenBucketRateLimiter(long capacity, long refillTokensPerSec) {
this.capacity = capacity;
this.refillTokensPerSec = refillTokensPerSec;
this.availableTokens = capacity;
this.lastRefillTime = System.nanoTime();
}
public synchronized boolean allow() {
refill();
if (availableTokens > 0) {
availableTokens--;
return true;
}
return false;
}
private void refill() {
long now = System.nanoTime();
double secondsPassed = (now - lastRefillTime) / 1_000_000_000.0;
long newTokens = (long)(secondsPassed * refillTokensPerSec);
if (newTokens > 0) {
availableTokens = Math.min(capacity, availableTokens + newTokens);
lastRefillTime = now;
}
}
}
3.3 什么时候用令牌桶?
-
允许短时间内的突发流量(比如10倍正常流量,持续几秒)
-
比如用户登录接口:平时没人用,但早上8点可能有波高峰
四、漏桶算法(强制平滑)
4.1 原理
请求来了先进桶,桶以固定速率"漏出"处理。桶满了就丢弃新请求。
和令牌桶的区别:
-
令牌桶:放令牌,请求拿令牌 → 允许突发输出
-
漏桶:请求进桶,以固定速率输出 → 强制平滑,不允许突发
4.2 代码实现
java
public class LeakyBucketRateLimiter {
private final long capacity;
private final long leakRatePerSec; // 每秒漏出多少
private long water; // 当前桶里水量
private long lastLeakTime;
public LeakyBucketRateLimiter(long capacity, long leakRatePerSec) {
this.capacity = capacity;
this.leakRatePerSec = leakRatePerSec;
this.water = 0;
this.lastLeakTime = System.nanoTime();
}
public synchronized boolean allow() {
leak(); // 先漏水
if (water < capacity) {
water++;
return true;
}
return false;
}
private void leak() {
long now = System.nanoTime();
double secondsPassed = (now - lastLeakTime) / 1_000_000_000.0;
long leaked = (long)(secondsPassed * leakRatePerSec);
if (leaked > 0) {
water = Math.max(0, water - leaked);
lastLeakTime = now;
}
}
}
4.3 什么时候用漏桶?
-
对流量平滑性要求极高,不允许任何突发
-
比如下游是个只能处理匀速流量的老系统或第三方API
五、对比与选型建议
| 算法 | 突发能力 | 平滑性 | 实现复杂度 | 典型场景 |
|---|---|---|---|---|
| 固定窗口 | 差(边界双倍突发) | 差 | 低 | 几乎不用 |
| 滑动窗口 | 一般(格子越细越好) | 中 | 中 | 最通用,防护API被刷 |
| 令牌桶 | 强(可积累令牌) | 中 | 中 | 允许突发,如登录、秒杀预热 |
| 漏桶 | 无(强制匀速) | 强 | 中 | 匀速处理,如转码任务排队 |
一句话:
-
通用防刷 → 滑动窗口计数器
-
需要应对突发流量 → 令牌桶
-
下游只能匀速处理 → 漏桶
六、生产环境注意事项
-
分布式限流:上述算法都是单机版。分布式下需要中心化存储(Redis + Lua脚本保证原子性),或者每个节点独立限流(总限制 = 单机限制 * 节点数)。
-
拒绝策略:限流触发后不是只能返回429。可以:
-
排队等待(漏桶天然支持)
-
降级返回缓存数据
-
让客户端稍后重试(带
Retry-After头)
-
-
动态调整:限流阈值应该可配置,甚至根据系统负载自动调整。
-
监控:记录被限流的次数,如果频繁触发,说明阈值设低了或被攻击了。
写在最后
面试时遇到"设计限流器",按照这个顺序讲:
-
先说固定窗口的坑(边界突发)
-
引出滑动窗口(画个时间轴)
-
对比令牌桶和漏桶(一个允许突发,一个强制平滑)
-
最后根据场景给建议
再写几行核心代码,面试官会觉得:"这人不只会背题,真的动手写过。"