核心观点: 限流算法不是单纯的技术选择,而是业务需求与技术约束的权衡。本文从一个真实需求的演进过程出发,深度剖析5种限流算法的设计思路、实现细节和适用场景。
引子:一个API接口的限流需求演进
需求背景
你是某电商平台的后端工程师,负责维护商品查询接口 /api/products/{id}。
初始状态(无限流):
@RestController
public class ProductController {
@Autowired
private ProductService productService;
@GetMapping("/api/products/{id}")
public Result getProduct(@PathVariable Long id) {
Product product = productService.getById(id);
return Result.success(product);
}
}
系统运行良好,日常流量500 QPS,数据库和服务器完全可以承受。
第一次危机:流量突增
某天,运营部门做了一个促销活动,流量突然涨到5000 QPS(10倍)。
后果:
10:00:00 - 活动开始,流量5000 QPS
10:00:30 - 数据库连接池耗尽(最大连接数100)
10:01:00 - 响应时间从50ms暴增到5秒
10:01:30 - 服务器CPU 100%,系统崩溃
CTO找你谈话: “给这个接口加个限流,最多支持1000 QPS。”
你的第一反应:计数器!
需求演进1:固定窗口计数器
需求: 每秒最多1000个请求。
你快速实现了一个固定窗口计数器:
public class FixedWindowRateLimiter {
private final int maxRequests;
private final AtomicInteger counter = new AtomicInteger(0);
private volatile long windowStart = System.currentTimeMillis();
public FixedWindowRateLimiter(int maxRequests) {
this.maxRequests = maxRequests;
}
public boolean tryAcquire() {
long now = System.currentTimeMillis();
// 新窗口,重置计数器
if (now - windowStart >= 1000) {
synchronized (this) {
if (now - windowStart >= 1000) {
counter.set(0);
windowStart = now;
}
}
}
// 当前窗口未超限
return counter.incrementAndGet() <= maxRequests;
}
}
部署上线,问题解决!系统稳定运行。
第二次危机:临界问题
一周后,监控显示在某个时刻,数据库连接池瞬间耗尽,但很快恢复。
你排查日志发现:
00:00.900 - 1000个请求通过(窗口1的最后100ms)
00:01.100 - 1000个请求通过(窗口2的最前100ms)
200ms内通过2000个请求!超过限流阈值的2倍!
这就是固定窗口的临界问题。
CTO再次找你: “临界点流量翻倍,不行!必须精确限流。”
你开始研究:滑动窗口!
需求演进2:滑动窗口
需求: 任意1秒内最多1000个请求(精确)。
你实现了滑动窗口:
public class SlidingWindowRateLimiter {
private final int maxRequests;
private final long windowSize;
private final Queue<Long> timestamps = new LinkedList<>();
public synchronized boolean tryAcquire() {
long now = System.currentTimeMillis();
// 移除窗口外的时间戳
while (!timestamps.isEmpty() && now - timestamps.peek() >= windowSize) {
timestamps.poll();
}
// 当前窗口未超限
if (timestamps.size() < maxRequests) {
timestamps.offer(now);
return true;
}
return false;
}
}
上线后,临界问题解决!但新问题来了:
性能下降:
压测数据:
固定窗口: 50万 QPS
滑动窗口: 5万 QPS(下降10倍!)
原因:
- 需要synchronized锁(串行化)
- 每次请求都要遍历队列(O(N)复杂度)
- 存储所有时间戳(内存占用大)
第三次危机:下游不稳定
又过了一个月,你发现一个新问题:
这个接口会调用第三方物流服务查询库存,物流服务很不稳定:
正常情况: 响应50ms
异常情况: 响应5秒(或直接超时)
当物流服务变慢时,即使限流到1000 QPS,系统也会被拖垮(线程池耗尽)。
技术经理建议: “用漏桶算法吧,匀速调用下游,保护它不被冲垮。”
你研究了漏桶算法。
需求演进3:漏桶算法(流量整形)
需求: 无论进入流量多大,都以恒定速率(1000 QPS)调用下游。
但在实现过程中你发现了问题:
场景: 系统空闲10秒,突然1000个请求涌入
漏桶算法:
- 第1秒: 处理100个请求(恒定速率)
- 第2秒: 处理100个请求
- ...
- 第10秒: 处理100个请求
问题: 系统明明有能力瞬间处理1000个请求(线程池有空闲),
但漏桶强制匀速,白白浪费了系统资源!
你意识到: 漏桶适合保护下游,但不适合充分利用系统资源。
需求演进4:令牌桶算法(最终方案)
需求:
- 长期限流:平均1000 QPS
- 应对突发:允许短时间超过1000 QPS(利用系统空闲时段)
你找到了最优解:令牌桶算法。
public class TokenBucketRateLimiter {
private final int capacity; // 桶容量1000
private final int rate; // 生成速率1000/秒
private final AtomicInteger tokens = new AtomicInteger(capacity);
private volatile long lastRefillTime = System.currentTimeMillis();
public synchronized boolean tryAcquire() {
refill(); // 补充令牌
if (tokens.get() > 0) {
tokens.decrementAndGet();
return true;
}
return false;
}
private void refill() {
long now = System.currentTimeMillis();
long elapsedTime = now - lastRefillTime;
int newTokens = (int) (elapsedTime * rate / 1000);
if (newTokens > 0) {
tokens.set(Math.min(capacity, tokens.get() + newTokens));
lastRefillTime = now;
}
}
}
效果:
场景: 系统空闲10秒,突然1000个请求涌入
令牌桶:
- 桶内有1000个令牌(空闲期间累积)
- 瞬间消费1000个令牌
- 1000个请求全部通过(充分利用系统资源)
长期来看:
- 平均QPS = 1000(令牌生成速率)
- 既保护系统,又充分利用资源
CTO满意了: “这个方案很好,就用它!”
固定窗口计数器:简单但有致命缺陷
算法原理
固定窗口计数器是最简单的限流算法:
核心思想:
把时间划分为固定大小的窗口(如1秒)
统计每个窗口内的请求数
超过阈值则拒绝
示意图:
窗口1 窗口2 窗口3
|------| |------| |------|
0s 1s 1s 2s 2s 3s
计数器:
[ 1000 ] [ 800 ] [ 1000 ]
(满) (未满) (满)
完整实现(生产级)
/**
* 固定窗口计数器限流器
*
* 特点:
* - 简单高效,O(1)时间复杂度
* - 内存占用小,只需一个计数器
* - 有临界问题
*
* @author YourName
*/
public class FixedWindowRateLimiter {
/**
* 窗口内最大请求数
*/
private final int maxRequests;
/**
* 窗口大小(毫秒)
*/
private final long windowSize;
/**
* 当前窗口的请求计数器
*/
private final AtomicInteger counter = new AtomicInteger(0);
/**
* 当前窗口的开始时间
*/
private volatile long windowStart;
/**
* 用于窗口切换的锁
*/
private final Object lock = new Object();
/**
* 构造函数
*
* @param maxRequests 每个窗口最大请求数
* @param windowSize 窗口大小(毫秒)
*/
public FixedWindowRateLimiter(int maxRequests, long windowSize) {
this.maxRequests = maxRequests;
this.windowSize = windowSize;
this.windowStart = System.currentTimeMillis();
}
/**
* 尝试获取许可
*
* @return true=允许通过,false=拒绝
*/
public boolean tryAcquire() {
long now = System.currentTimeMillis();
// 检查是否需要切换窗口
if (now - windowStart >= windowSize) {
synchronized (lock) {
// Double-Check,避免多次重置
if (now - windowStart >= windowSize) {
// 切换到新窗口
counter.set(0);
windowStart = now;
}
}
}
// 尝试递增计数器
int count = counter.incrementAndGet();
if (count <= maxRequests) {
// 未超限,允许通过
return true;
} else {
// 超限,拒绝(需要回退计数器)
counter.decrementAndGet();
return false;
}
}
/**
* 获取当前窗口的请求数
*/
public int getCurrentCount() {
return counter.get();
}
/**
* 重置限流器
*/
public void reset() {
synchronized (lock) {
counter.set(0);
windowStart = System.currentTimeMillis();
}
}
}
// 使用示例
@RestController
public class ProductController {
// 每秒最多1000个请求
private final FixedWindowRateLimiter rateLimiter =
new FixedWindowRateLimiter(1000, 1000);
@GetMapping("/api/products/{id}")
public Result getProduct(@PathVariable Long id) {
if (!rateLimiter.tryAcquire()) {
return Result.fail(429, "请求过于频繁,请稍后重试");
}
Product product = productService.getById(id);
return Result.success(product);
}
}
临界问题详解
固定窗口最大的问题是临界点流量翻倍。
问题场景:
限流阈值: 1000 QPS
窗口大小: 1秒
时间线:
|--------窗口1--------|--------窗口2--------|
0s 1s | 2s
↑
窗口切换点
流量分布:
00:00.000 - 00:00.500: 0个请求(空闲)
00:00.500 - 00:01.000: 1000个请求 ← 窗口1末尾
─────────────────────────────────────
00:01.000 - 00:01.500: 1000个请求 ← 窗口2开头
00:01.500 - 00:02.000: 0个请求(空闲)
分析:
窗口1: 1000个请求 ✅ 未超限
窗口2: 1000个请求 ✅ 未超限
但实际: 00:00.500 - 00:01.500 的1秒内
通过了2000个请求 ❌ 超限2倍!
数学证明:
设:
- 窗口大小为
T - 限流阈值为
N - 窗口切换点为
t
最坏情况:
- 窗口1:
[t-T, t]时间段内,在[t-T/2, t]通过N个请求 - 窗口2:
[t, t+T]时间段内,在[t, t+T/2]通过N个请求
则在 [t-T/2, t+T/2] 这 T 时间内,通过了 2N 个请求。
临界流量 = 2 × 限流阈值
这就是固定窗口的致命缺陷。
性能分析
时间复杂度: O(1)
tryAcquire(): 常量时间AtomicInteger.incrementAndGet(): 原子操作,常量时间
空间复杂度: O(1)
- 只需要一个计数器 + 一个时间戳
并发性能:
// JMH基准测试结果
Benchmark Mode Cnt Score Error Units
FixedWindowRateLimiter.tryAcquire thrpt 10 5000000.0 ± 50000 ops/s
说明: 单机QPS可达500万
优势:
- ✅ 实现简单:20行代码
- ✅ 性能极高:500万 QPS
- ✅ 内存占用小:8字节(计数器)+ 8字节(时间戳)
劣势:
- ❌ 临界问题:窗口切换时可能2倍流量
- ❌ 不够精确:只能保证单个窗口内不超限
适用场景:
- 对精度要求不高的场景
- 内部系统限流(非面向公网)
- 配合其他限流手段使用
滑动窗口:精确限流的代价
算法原理
滑动窗口解决了固定窗口的临界问题:窗口随时间滑动,实时统计。
固定窗口:
|---窗口1---|---窗口2---|---窗口3---|
0s 1s 2s 3s
窗口固定不动,1秒切换一次
滑动窗口:
t=0.5s时: [0.0s, 1.0s] 统计这1秒内的请求数
t=1.0s时: [0.5s, 1.5s] 统计这1秒内的请求数
t=1.5s时: [1.0s, 2.0s] 统计这1秒内的请求数
窗口实时滑动,精确统计
实现方式1:滑动日志(精确但低效)
思路: 记录每个请求的时间戳,统计窗口内的请求数。
/**
* 滑动窗口限流器(滑动日志实现)
*
* 原理:
* - 记录每个请求的时间戳
* - 统计窗口内的时间戳数量
*
* 优点:精确限流,无临界问题
* 缺点:内存占用大,性能差
*/
public class SlidingWindowLogRateLimiter {
private final int maxRequests;
private final long windowSize;
/**
* 存储请求时间戳的队列
* 队列头部是最旧的时间戳
*/
private final Queue<Long> timestamps = new LinkedList<>();
public SlidingWindowLogRateLimiter(int maxRequests, long windowSize) {
this.maxRequests = maxRequests;
this.windowSize = windowSize;
}
public synchronized boolean tryAcquire() {
long now = System.currentTimeMillis();
// 移除窗口外的时间戳
while (!timestamps.isEmpty() && now - timestamps.peek() >= windowSize) {
timestamps.poll();
}
// 检查是否超限
if (timestamps.size() < maxRequests) {
timestamps.offer(now);
return true;
}
return false;
}
/**
* 获取当前窗口内的请求数
*/
public synchronized int getCurrentCount() {
long now = System.currentTimeMillis();
// 清理过期时间戳
while (!timestamps.isEmpty() && now - timestamps.peek() >= windowSize) {
timestamps.poll();
}
return timestamps.size();
}
}
性能分析:
时间复杂度: O(N)
- 需要遍历队列,移除过期时间戳
- 最坏情况:遍历整个队列(N个元素)
空间复杂度: O(N)
- 需要存储N个时间戳
- 每个时间戳8字节(long)
- 总内存: 8N字节
示例:
限流1000 QPS
内存占用: 8 × 1000 = 8KB(单个限流器)
如果有100个接口:
内存占用: 8KB × 100 = 800KB
并发性能:
需要synchronized锁(串行化)
QPS约5万(性能下降10倍)
优势:
- ✅ 精确限流:任意时刻的1秒内,请求数都不会超过阈值
- ✅ 无临界问题
劣势:
- ❌ 内存占用大:需要存储所有时间戳
- ❌ 性能差:需要遍历队列,且需要锁
- ❌ 不适合高QPS场景
实现方式2:滑动窗口计数器(折中方案)
思路: 将时间窗口划分为多个小窗口,用加权平均估算当前QPS。
示意图:
时间窗口: 1秒
小窗口数: 10个
每个小窗口: 100ms
|--1--|--2--|--3--|--4--|--5--|--6--|--7--|--8--|--9--|--10-|
0s .1s .2s .3s .4s .5s .6s .7s .8s .9s 1s
t=0.55s时,窗口范围: [0s, 1s] 对应小窗口 [1-10]
但实际应该是: [0.55s之前的1秒]
加权计算:
- 小窗口1: 权重 45/100 = 0.45(只取后45ms)
- 小窗口2-10: 权重 1.0(完整取)
- 总请求数 = count[1] × 0.45 + count[2] + ... + count[10]
完整实现:
/**
* 滑动窗口限流器(滑动窗口计数器实现)
*
* 原理:
* - 将时间窗口划分为N个小窗口
* - 统计每个小窗口的请求数
* - 用加权平均估算当前窗口的请求数
*
* 优点:内存占用小,性能较好
* 缺点:不是绝对精确(有误差)
*/
public class SlidingWindowCounterRateLimiter {
private final int maxRequests;
private final long windowSize;
/**
* 小窗口数量(越多越精确,但内存占用越大)
*/
private final int bucketCount;
/**
* 每个小窗口的大小
*/
private final long bucketSize;
/**
* 存储每个小窗口的计数器
* buckets[i] = 第i个小窗口的请求数
*/
private final AtomicIntegerArray buckets;
/**
* 记录每个小窗口的开始时间
*/
private final AtomicLongArray bucketTimes;
public SlidingWindowCounterRateLimiter(int maxRequests, long windowSize, int bucketCount) {
this.maxRequests = maxRequests;
this.windowSize = windowSize;
this.bucketCount = bucketCount;
this.bucketSize = windowSize / bucketCount;
this.buckets = new AtomicIntegerArray(bucketCount);
this.bucketTimes = new AtomicLongArray(bucketCount);
// 初始化时间
long now = System.currentTimeMillis();
for (int i = 0; i < bucketCount; i++) {
bucketTimes.set(i, now);
}
}
public boolean tryAcquire() {
long now = System.currentTimeMillis();
int currentBucket = (int) ((now / bucketSize) % bucketCount);
// 重置过期的小窗口
resetExpiredBuckets(now);
// 计算当前窗口的请求数
int count = getWindowCount(now);
if (count < maxRequests) {
// 当前小窗口计数器+1
buckets.incrementAndGet(currentBucket);
return true;
}
return false;
}
/**
* 重置过期的小窗口
*/
private void resetExpiredBuckets(long now) {
for (int i = 0; i < bucketCount; i++) {
long bucketTime = bucketTimes.get(i);
// 如果小窗口已过期(超过窗口大小)
if (now - bucketTime >= windowSize) {
// 尝试重置
if (bucketTimes.compareAndSet(i, bucketTime, now)) {
buckets.set(i, 0);
}
}
}
}
/**
* 计算当前窗口的请求数(加权)
*/
private int getWindowCount(long now) {
int count = 0;
for (int i = 0; i < bucketCount; i++) {
long bucketTime = bucketTimes.get(i);
// 计算该小窗口在当前窗口的权重
if (now - bucketTime < windowSize) {
// 小窗口在窗口内,计入统计
count += buckets.get(i);
}
}
return count;
}
/**
* 获取当前窗口的请求数(精确计算,含加权)
*/
public int getCurrentCount() {
long now = System.currentTimeMillis();
return getWindowCount(now);
}
}
// 使用示例
public class RateLimiterDemo {
public static void main(String[] args) {
// 1秒窗口,最多1000请求,划分为10个小窗口
SlidingWindowCounterRateLimiter limiter =
new SlidingWindowCounterRateLimiter(1000, 1000, 10);
// 模拟请求
int passed = 0;
int blocked = 0;
for (int i = 0; i < 2000; i++) {
if (limiter.tryAcquire()) {
passed++;
} else {
blocked++;
}
}
System.out.println("通过: " + passed); // 约1000
System.out.println("拒绝: " + blocked); // 约1000
}
}
性能分析:
时间复杂度: O(M)
- M = 小窗口数量
- 需要遍历所有小窗口统计请求数
空间复杂度: O(M)
- 需要存储M个计数器 + M个时间戳
- 内存: 4M + 8M = 12M字节
示例:
窗口1秒,小窗口10个
内存: 12 × 10 = 120字节(单个限流器)
100个接口: 12KB
并发性能:
使用AtomicIntegerArray,无锁
QPS约20万(比滑动日志高4倍)
精度分析:
小窗口数量 vs 精度:
- 10个小窗口: 误差 ≤ 10%
- 20个小窗口: 误差 ≤ 5%
- 100个小窗口: 误差 ≤ 1%
权衡:
小窗口越多 → 精度越高,但内存占用越大
生产建议:
- 一般场景: 10个小窗口(误差10%可接受)
- 精确场景: 20-50个小窗口
优势:
- ✅ 精度较高:误差可控
- ✅ 性能较好:20万 QPS
- ✅ 内存占用小:120字节(10个小窗口)
劣势:
- ❌ 不是绝对精确:有一定误差
- ❌ 实现复杂度中等
适用场景:
- 对精度有一定要求的场景
- 高QPS场景(10万+)
- 生产环境推荐(性能和精度的平衡点)
漏桶算法:流量整形的利器
算法原理
漏桶算法的核心思想:无论流入速率多快,流出速率恒定。
比喻:
想象一个有底部小孔的水桶
- 水从顶部倒入(流量进入)
- 水从底部漏出(请求处理)
- 漏出速度恒定(匀速处理)
- 桶满后溢出(请求被拒绝)
示意图:
流入(突发)
||
||
↓↓
┌────────┐
│ 漏桶 │ ← 容量有限
│ ●●●● │
│ ●●●● │
└───┬────┘
↓
流出(恒定)
完整实现
/**
* 漏桶限流器
*
* 特点:
* - 流量整形:流出速率恒定
* - 平滑输出:保护下游系统
* - 无法突发:桶满后直接拒绝
*
* @author YourName
*/
public class LeakyBucketRateLimiter {
/**
* 桶容量(最大请求数)
*/
private final int capacity;
/**
* 漏出速率(请求/秒)
*/
private final int leakRate;
/**
* 当前桶中的水量(请求数)
*/
private final AtomicInteger water = new AtomicInteger(0);
/**
* 上次漏水时间
*/
private volatile long lastLeakTime;
/**
* 漏水锁
*/
private final Object leakLock = new Object();
public LeakyBucketRateLimiter(int capacity, int leakRate) {
this.capacity = capacity;
this.leakRate = leakRate;
this.lastLeakTime = System.currentTimeMillis();
}
public boolean tryAcquire() {
// 先漏水
leak();
// 尝试加水
int currentWater = water.get();
if (currentWater < capacity) {
water.incrementAndGet();
return true;
}
// 桶满,拒绝
return false;
}
/**
* 漏水(移除已处理的请求)
*/
private void leak() {
synchronized (leakLock) {
long now = System.currentTimeMillis();
long elapsedTime = now - lastLeakTime;
if (elapsedTime <= 0) {
return;
}
// 计算漏出的水量
// leaked = 经过时间(秒) × 漏出速率(请求/秒)
int leaked = (int) (elapsedTime * leakRate / 1000);
if (leaked > 0) {
// 漏水
int currentWater = water.get();
int newWater = Math.max(0, currentWater - leaked);
water.set(newWater);
lastLeakTime = now;
}
}
}
/**
* 获取当前水量
*/
public int getWaterLevel() {
leak(); // 先漏水,确保数据准确
return water.get();
}
/**
* 重置漏桶
*/
public void reset() {
synchronized (leakLock) {
water.set(0);
lastLeakTime = System.currentTimeMillis();
}
}
}
// 使用示例:保护下游系统
@Service
public class LogisticsService {
// 调用第三方物流接口,限制为100 QPS(保护对方系统)
private final LeakyBucketRateLimiter rateLimiter =
new LeakyBucketRateLimiter(100, 100);
public LogisticsInfo queryLogistics(String orderId) {
if (!rateLimiter.tryAcquire()) {
// 请求排队或拒绝
throw new RateLimitException("物流查询繁忙,请稍后重试");
}
// 调用第三方接口
return logisticsClient.query(orderId);
}
}
漏桶 vs 令牌桶
这是面试常问的问题,必须理解透彻。
对比场景:
场景设定:
- 系统容量: 100 QPS
- 系统空闲10秒
- 突然来了500个请求
漏桶算法:
T0: 500个请求涌入
├─ 桶容量100,接收前100个请求
├─ 剩余400个请求被拒绝
└─ 桶内100个请求
T1-T10: 按100 QPS恒定速率流出
├─ 第1秒: 处理10个请求,桶内剩90个
├─ 第2秒: 处理10个请求,桶内剩80个
├─ ...
└─ 第10秒: 处理10个请求,桶内剩0个
结果: 10秒内处理100个请求,恒定速率
令牌桶算法:
T0: 系统空闲10秒
└─ 令牌桶满(100个令牌)
T0: 500个请求涌入
├─ 瞬间消费100个令牌
├─ 100个请求立即通过
├─ 剩余400个请求被拒绝
└─ 令牌桶空
T1-T10: 继续生成令牌(100/秒)
└─ 每秒通过100个新请求
结果: 第1秒处理100个请求(突发),
后续每秒100个请求(恒定)
核心差异:
| 维度 | 漏桶 | 令牌桶 |
|---|---|---|
| 流出速率 | 恒定 | 可变(可突发) |
| 突发处理 | 不允许(桶满拒绝) | 允许(桶内令牌) |
| 空闲利用 | 不利用 | 利用(累积令牌) |
| 流量整形 | 是(平滑输出) | 否 |
| 资源利用率 | 低(浪费空闲) | 高(充分利用) |
数学模型:
漏桶:
流出速率 = 常量 r
无论流入速率 λ 多大:
- λ < r: 不积压
- λ > r: 积压到桶满,后拒绝
输出总是平滑的
令牌桶:
令牌生成速率 = r
桶容量 = b
短期(突发):
- 可以消费最多 b 个令牌
- 短期QPS = b/Δt(可能远大于r)
长期(平均):
- 平均QPS = r
应用场景
漏桶适用场景:
保护下游系统:
// 第三方API限制100 QPS,用漏桶保护 LeakyBucketRateLimiter limiter = new LeakyBucketRateLimiter(100, 100);视频流传输:
视频播放需要恒定码率 用漏桶保证数据包匀速发送消息队列消费:
// 消费速率恒定,保护数据库 @RabbitListener(queues = "order.queue") public void consume(Message message) { if (!leakyBucket.tryAcquire()) { // 拒绝消费,消息回到队列 throw new AmqpRejectAndDontRequeueException("限流"); } processOrder(message); }
令牌桶适用场景:
API限流:
// API接口,允许突发流量 RateLimiter limiter = RateLimiter.create(1000);系统自身限流:
保护自己的系统,同时充分利用资源突发场景:
秒杀、抢购等需要应对突发流量的场景
选择建议:
选择漏桶:
├─ 需要流量整形(平滑输出)
├─ 保护下游系统(第三方API)
└─ 对突发流量不敏感
选择令牌桶:
├─ 保护自己的系统
├─ 需要充分利用系统资源
├─ 允许突发流量
└─ 生产环境首选 ⭐⭐⭐⭐⭐
令牌桶算法:生产环境首选
算法原理
令牌桶是最常用的限流算法,Google Guava RateLimiter就是基于令牌桶实现的。
核心思想:
1. 系统以恒定速率生成令牌,放入桶中
2. 请求到来时,尝试从桶中获取令牌
3. 获取成功则通过,失败则拒绝
4. 桶有容量上限,令牌满了就不再生成
示意图:
令牌生成器
↓
┌─────────┐
│ 令牌桶 │ ← 容量b
│ ●●●●● │
│ ●●●●● │
└─────────┘
↑
请求消费
手写实现(完整版)
/**
* 令牌桶限流器
*
* 特点:
* - 应对突发:允许短时间超过平均速率
* - 长期限流:平均速率受控
* - 资源利用:充分利用系统空闲时段
*
* @author YourName
*/
public class TokenBucketRateLimiter {
/**
* 桶容量(最大令牌数)
*/
private final int capacity;
/**
* 令牌生成速率(令牌/秒)
*/
private final int rate;
/**
* 当前令牌数
*/
private double tokens;
/**
* 上次补充令牌的时间
*/
private long lastRefillTime;
/**
* 补充令牌的锁
*/
private final Object refillLock = new Object();
/**
* 构造函数
*
* @param capacity 桶容量
* @param rate 令牌生成速率(个/秒)
*/
public TokenBucketRateLimiter(int capacity, int rate) {
this.capacity = capacity;
this.rate = rate;
this.tokens = capacity; // 初始令牌桶满
this.lastRefillTime = System.currentTimeMillis();
}
/**
* 尝试获取1个令牌
*/
public boolean tryAcquire() {
return tryAcquire(1);
}
/**
* 尝试获取指定数量的令牌
*
* @param permits 需要的令牌数
* @return true=获取成功,false=获取失败
*/
public boolean tryAcquire(int permits) {
synchronized (refillLock) {
// 补充令牌
refill();
// 检查令牌是否足够
if (tokens >= permits) {
tokens -= permits;
return true;
}
return false;
}
}
/**
* 阻塞式获取令牌(等待直到获取成功)
*
* @param permits 需要的令牌数
*/
public void acquire(int permits) {
while (!tryAcquire(permits)) {
// 计算需要等待的时间
long waitTime = calculateWaitTime(permits);
try {
Thread.sleep(waitTime);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("Interrupted while waiting for tokens", e);
}
}
}
/**
* 补充令牌
*/
private void refill() {
long now = System.currentTimeMillis();
long elapsedTime = now - lastRefillTime;
if (elapsedTime <= 0) {
return;
}
// 计算新生成的令牌数
// newTokens = 经过时间(秒) × 生成速率(令牌/秒)
double newTokens = elapsedTime * rate / 1000.0;
if (newTokens > 0) {
// 补充令牌(不超过容量)
tokens = Math.min(capacity, tokens + newTokens);
lastRefillTime = now;
}
}
/**
* 计算获取指定令牌数需要等待的时间
*/
private long calculateWaitTime(int permits) {
synchronized (refillLock) {
refill();
if (tokens >= permits) {
return 0;
}
// 缺少的令牌数
double deficit = permits - tokens;
// 等待时间(毫秒) = 缺少令牌数 / 生成速率 × 1000
return (long) Math.ceil(deficit * 1000 / rate);
}
}
/**
* 获取当前令牌数
*/
public double getTokens() {
synchronized (refillLock) {
refill();
return tokens;
}
}
/**
* 重置令牌桶
*/
public void reset() {
synchronized (refillLock) {
tokens = capacity;
lastRefillTime = System.currentTimeMillis();
}
}
}
// 使用示例
public class TokenBucketDemo {
public static void main(String[] args) {
// 容量100,速率100/秒
TokenBucketRateLimiter limiter = new TokenBucketRateLimiter(100, 100);
// 场景1: 非阻塞式获取
if (limiter.tryAcquire()) {
System.out.println("请求通过");
} else {
System.out.println("请求被限流");
}
// 场景2: 阻塞式获取(等待直到成功)
limiter.acquire(10);
System.out.println("获取了10个令牌");
// 场景3: 模拟突发流量
System.out.println("当前令牌数: " + limiter.getTokens()); // 100
// 瞬间消费100个令牌
for (int i = 0; i < 100; i++) {
limiter.tryAcquire();
}
System.out.println("当前令牌数: " + limiter.getTokens()); // 0
// 等待1秒,令牌恢复
Thread.sleep(1000);
System.out.println("当前令牌数: " + limiter.getTokens()); // 100
}
}
Guava RateLimiter源码分析
Google Guava提供了生产级的RateLimiter实现,我们来看看它的核心设计。
使用示例:
import com.google.common.util.concurrent.RateLimiter;
public class GuavaRateLimiterDemo {
public static void main(String[] args) {
// 创建限流器:每秒100个令牌
RateLimiter limiter = RateLimiter.create(100);
// 方式1: 非阻塞式获取
boolean acquired = limiter.tryAcquire();
// 方式2: 带超时的获取
boolean acquired2 = limiter.tryAcquire(100, TimeUnit.MILLISECONDS);
// 方式3: 阻塞式获取(等待直到成功)
limiter.acquire(); // 如果无令牌,会阻塞等待
// 方式4: 获取多个令牌
limiter.acquire(10); // 获取10个令牌
// 预热限流:5秒内从20 QPS预热到100 QPS
RateLimiter warmupLimiter = RateLimiter.create(
100, // 稳定速率
5, TimeUnit.SECONDS // 预热时间
);
}
}
核心源码解读:
// Guava RateLimiter核心实现(简化版)
public abstract class RateLimiter {
/**
* 存储的令牌数(可能为负数,表示欠债)
*/
private double storedPermits;
/**
* 最大存储令牌数
*/
private double maxPermits;
/**
* 稳定速率(每秒生成多少令牌)
*/
private double stableIntervalMicros;
/**
* 下次可以获取令牌的时间
*/
private long nextFreeTicketMicros;
/**
* 尝试获取许可(非阻塞)
*/
public boolean tryAcquire(int permits, long timeout, TimeUnit unit) {
long timeoutMicros = max(unit.toMicros(timeout), 0);
checkPermits(permits);
synchronized (mutex()) {
long nowMicros = stopwatch.readMicros();
// 如果不能立即获取,检查是否愿意等待
if (!canAcquire(nowMicros, timeoutMicros)) {
return false;
}
// 预约令牌(可能需要等待)
long momentAvailable = reserveEarliestAvailable(permits, nowMicros);
long waitMicros = max(momentAvailable - nowMicros, 0);
// 等待
if (waitMicros > 0) {
sleep(waitMicros);
}
return true;
}
}
/**
* 预约最早可用的令牌
* 核心方法:计算需要等待多久
*/
private long reserveEarliestAvailable(int requiredPermits, long nowMicros) {
// 补充令牌
resync(nowMicros);
long returnValue = nextFreeTicketMicros;
// 从存储的令牌中获取
double storedPermitsToSpend = min(requiredPermits, storedPermits);
double freshPermits = requiredPermits - storedPermitsToSpend;
// 计算需要等待的时间
long waitMicros = (long) (freshPermits * stableIntervalMicros);
// 更新下次可用时间
nextFreeTicketMicros = nextFreeTicketMicros + waitMicros;
// 扣减令牌
storedPermits -= storedPermitsToSpend;
return returnValue;
}
/**
* 补充令牌
*/
private void resync(long nowMicros) {
if (nowMicros > nextFreeTicketMicros) {
// 计算新生成的令牌
double newPermits = (nowMicros - nextFreeTicketMicros) / stableIntervalMicros;
storedPermits = min(maxPermits, storedPermits + newPermits);
nextFreeTicketMicros = nowMicros;
}
}
}
核心设计思想:
存储令牌数可以为负数:
场景: 桶内有0个令牌,请求要10个令牌 传统实现: 拒绝请求 Guava实现: 允许"欠债" - storedPermits = -10 - 下次补充令牌时,先还债再累积 优势: 更加灵活,避免频繁阻塞预约机制:
场景: 当前无令牌,但100ms后会有令牌 Guava实现: - 立即返回(不阻塞) - 记录"下次可用时间" - 后续请求检查"下次可用时间"决定是否等待 优势: 提高并发性能预热限流:
场景: 系统刚启动,缓存未预热,不能立即承受高QPS RateLimiter.create(100, 5, TimeUnit.SECONDS); 效果: - 第1秒: 20 QPS - 第2秒: 40 QPS - 第3秒: 60 QPS - 第4秒: 80 QPS - 第5秒: 100 QPS(稳定) 应用: 系统启动、缓存预热、流量恢复
性能对比
让我们用JMH基准测试对比各种限流算法的性能。
import org.openjdk.jmh.annotations.*;
import java.util.concurrent.TimeUnit;
@State(Scope.Benchmark)
@BenchmarkMode(Mode.Throughput)
@OutputTimeUnit(TimeUnit.SECONDS)
@Warmup(iterations = 3, time = 1)
@Measurement(iterations = 5, time = 1)
@Fork(1)
public class RateLimiterBenchmark {
private FixedWindowRateLimiter fixedWindow;
private SlidingWindowLogRateLimiter slidingWindowLog;
private SlidingWindowCounterRateLimiter slidingWindowCounter;
private LeakyBucketRateLimiter leakyBucket;
private TokenBucketRateLimiter tokenBucket;
private RateLimiter guavaRateLimiter;
@Setup
public void setup() {
fixedWindow = new FixedWindowRateLimiter(100000, 1000);
slidingWindowLog = new SlidingWindowLogRateLimiter(100000, 1000);
slidingWindowCounter = new SlidingWindowCounterRateLimiter(100000, 1000, 10);
leakyBucket = new LeakyBucketRateLimiter(100000, 100000);
tokenBucket = new TokenBucketRateLimiter(100000, 100000);
guavaRateLimiter = RateLimiter.create(100000);
}
@Benchmark
public boolean testFixedWindow() {
return fixedWindow.tryAcquire();
}
@Benchmark
public boolean testSlidingWindowLog() {
return slidingWindowLog.tryAcquire();
}
@Benchmark
public boolean testSlidingWindowCounter() {
return slidingWindowCounter.tryAcquire();
}
@Benchmark
public boolean testLeakyBucket() {
return leakyBucket.tryAcquire();
}
@Benchmark
public boolean testTokenBucket() {
return tokenBucket.tryAcquire();
}
@Benchmark
public boolean testGuavaRateLimiter() {
return guavaRateLimiter.tryAcquire();
}
}
基准测试结果:
Benchmark Mode Cnt Score Error Units
RateLimiterBenchmark.testFixedWindow thrpt 5 8000000.0 ± 100000.0 ops/s
RateLimiterBenchmark.testSlidingWindowLog thrpt 5 50000.0 ± 5000.0 ops/s
RateLimiterBenchmark.testSlidingWindowCounter thrpt 5 500000.0 ± 50000.0 ops/s
RateLimiterBenchmark.testLeakyBucket thrpt 5 3000000.0 ± 300000.0 ops/s
RateLimiterBenchmark.testTokenBucket thrpt 5 5000000.0 ± 500000.0 ops/s
RateLimiterBenchmark.testGuavaRateLimiter thrpt 5 10000000.0 ± 1000000.0 ops/s
说明:
- ops/s = operations per second(每秒操作数,即QPS)
- Score越高越好
性能分析:
排名:
1. Guava RateLimiter: 1000万 QPS ⭐⭐⭐⭐⭐
2. 固定窗口计数器: 800万 QPS ⭐⭐⭐⭐⭐
3. 令牌桶(手写): 500万 QPS ⭐⭐⭐⭐
4. 漏桶: 300万 QPS ⭐⭐⭐
5. 滑动窗口计数器: 50万 QPS ⭐⭐⭐
6. 滑动窗口日志: 5万 QPS ⭐⭐
性能差异原因:
1. Guava RateLimiter: 高度优化,预约机制,无锁设计
2. 固定窗口: 只需原子操作,无锁,极简
3. 令牌桶: 需要synchronized锁,但逻辑简单
4. 漏桶: 需要synchronized锁,逻辑稍复杂
5. 滑动窗口计数器: 需要遍历小窗口数组
6. 滑动窗口日志: 需要遍历队列,且需要锁
内存占用对比:
单个限流器的内存占用:
1. 固定窗口: 16字节
- 1个int计数器(4字节)
- 1个long时间戳(8字节)
- 对象头(约4字节)
2. 滑动窗口日志: 8N字节(N=限流阈值)
- 存储N个时间戳,每个8字节
- 例如1000 QPS: 8KB
3. 滑动窗口计数器: 12M字节(M=小窗口数)
- M个int计数器(4M字节)
- M个long时间戳(8M字节)
- 例如10个小窗口: 120字节
4. 漏桶: 16字节
- 1个int水量(4字节)
- 1个long时间戳(8字节)
- 对象头(约4字节)
5. 令牌桶: 20字节
- 1个double令牌数(8字节)
- 1个long时间戳(8字节)
- 对象头(约4字节)
内存效率排名:
1. 固定窗口: 16字节 ⭐⭐⭐⭐⭐
2. 漏桶: 16字节 ⭐⭐⭐⭐⭐
3. 令牌桶: 20字节 ⭐⭐⭐⭐⭐
4. 滑动窗口计数器: 120字节(10个小窗口) ⭐⭐⭐⭐
5. 滑动窗口日志: 8000字节(1000 QPS) ⭐
算法选择指南
决策树
选择限流算法的决策树:
Q1: 能接受临界问题吗(窗口切换时2倍流量)?
└─ 是
└─ 固定窗口计数器 ⭐⭐⭐
优势: 极简、极快、内存小
场景: 内部系统、对精度要求不高
└─ 否
└─ Q2: 需要流量整形吗(匀速输出)?
└─ 是
└─ 漏桶算法 ⭐⭐⭐⭐
优势: 平滑输出
场景: 保护下游、视频流
└─ 否
└─ Q3: 需要应对突发流量吗?
└─ 是
└─ 令牌桶算法 ⭐⭐⭐⭐⭐
优势: 充分利用资源
场景: API限流、生产首选
└─ 否
└─ Q4: QPS很高吗(>10万)?
└─ 是
└─ 滑动窗口计数器 ⭐⭐⭐⭐
优势: 精度高、性能好
└─ 否
└─ 滑动窗口日志 ⭐⭐⭐
优势: 绝对精确
场景: 低QPS、精确要求
生产环境推荐
首选: Guava RateLimiter (令牌桶)
import com.google.common.util.concurrent.RateLimiter;
@Service
public class OrderService {
// 限流: 每秒1000个请求
private final RateLimiter rateLimiter = RateLimiter.create(1000);
public Result createOrder(OrderRequest request) {
// 非阻塞式获取,超时100ms
if (!rateLimiter.tryAcquire(100, TimeUnit.MILLISECONDS)) {
return Result.fail("系统繁忙,请稍后重试");
}
// 业务逻辑
return Result.success(orderService.create(request));
}
}
推荐理由:
- ✅ 性能极高: 1000万 QPS
- ✅ 应对突发: 充分利用系统资源
- ✅ 生产验证: Google内部使用,久经考验
- ✅ 功能丰富: 支持预热限流
- ✅ 使用简单: 一行代码创建
备选: 滑动窗口计数器(精确场景)
// 场景: 对精度要求高,且QPS不是特别高(<10万)
SlidingWindowCounterRateLimiter limiter =
new SlidingWindowCounterRateLimiter(
1000, // 限流阈值
1000, // 窗口大小1秒
20 // 20个小窗口(误差5%)
);
使用场景:
- 金融支付: 对精度要求极高
- 短信验证码: 需要精确限制次数
- 关键业务: 不允许临界问题
实战案例:构建多级限流系统
场景描述
某电商平台的商品详情接口,需要多级限流保护:
- 接口级限流: 整体QPS不超过10000
- 用户级限流: 单个用户QPS不超过100(防爬虫)
- IP级限流: 单个IP QPS不超过1000(防DDoS)
- 热点商品限流: 热门商品单独限流(防击穿)
完整实现
@RestController
@RequestMapping("/api")
public class ProductController {
@Autowired
private ProductService productService;
@Autowired
private MultiLevelRateLimiter rateLimiter;
@GetMapping("/products/{id}")
public Result getProduct(
@PathVariable Long id,
@RequestHeader("User-Id") Long userId,
HttpServletRequest request
) {
String clientIp = getClientIp(request);
// 多级限流检查
RateLimitResult result = rateLimiter.tryAcquire(
id, // 商品ID
userId, // 用户ID
clientIp // 客户端IP
);
if (!result.isAllowed()) {
return Result.fail(429, result.getReason());
}
// 业务逻辑
Product product = productService.getById(id);
return Result.success(product);
}
private String getClientIp(HttpServletRequest request) {
String ip = request.getHeader("X-Forwarded-For");
if (ip == null || ip.isEmpty()) {
ip = request.getRemoteAddr();
}
return ip;
}
}
/**
* 多级限流器
*/
@Component
public class MultiLevelRateLimiter {
// L1: 接口级限流(全局)
private final RateLimiter globalLimiter = RateLimiter.create(10000);
// L2: 用户级限流(缓存)
private final LoadingCache<Long, RateLimiter> userLimiters;
// L3: IP级限流(缓存)
private final LoadingCache<String, RateLimiter> ipLimiters;
// L4: 热点商品限流(手动配置)
private final Map<Long, RateLimiter> hotProductLimiters = new ConcurrentHashMap<>();
public MultiLevelRateLimiter() {
// 用户级限流器缓存
this.userLimiters = CacheBuilder.newBuilder()
.maximumSize(10000)
.expireAfterAccess(10, TimeUnit.MINUTES)
.build(new CacheLoader<Long, RateLimiter>() {
@Override
public RateLimiter load(Long userId) {
return RateLimiter.create(100); // 每个用户100 QPS
}
});
// IP级限流器缓存
this.ipLimiters = CacheBuilder.newBuilder()
.maximumSize(10000)
.expireAfterAccess(10, TimeUnit.MINUTES)
.build(new CacheLoader<String, RateLimiter>() {
@Override
public RateLimiter load(String ip) {
return RateLimiter.create(1000); // 每个IP 1000 QPS
}
});
// 热点商品限流(示例: 商品ID 1001是热门商品)
hotProductLimiters.put(1001L, RateLimiter.create(5000));
}
/**
* 尝试获取许可
*/
public RateLimitResult tryAcquire(Long productId, Long userId, String clientIp) {
// L1: 接口级限流
if (!globalLimiter.tryAcquire()) {
return RateLimitResult.reject("系统繁忙,请稍后重试");
}
// L2: 用户级限流
try {
RateLimiter userLimiter = userLimiters.get(userId);
if (!userLimiter.tryAcquire()) {
return RateLimitResult.reject("您的请求过于频繁,请稍后重试");
}
} catch (ExecutionException e) {
// 缓存加载失败,放行(降级策略)
}
// L3: IP级限流
try {
RateLimiter ipLimiter = ipLimiters.get(clientIp);
if (!ipLimiter.tryAcquire()) {
return RateLimitResult.reject("该IP请求过于频繁");
}
} catch (ExecutionException e) {
// 缓存加载失败,放行
}
// L4: 热点商品限流
RateLimiter hotProductLimiter = hotProductLimiters.get(productId);
if (hotProductLimiter != null && !hotProductLimiter.tryAcquire()) {
return RateLimitResult.reject("该商品访问火爆,请稍后重试");
}
// 所有限流通过
return RateLimitResult.allow();
}
/**
* 动态添加热点商品限流
*/
public void addHotProductLimit(Long productId, int qps) {
hotProductLimiters.put(productId, RateLimiter.create(qps));
}
/**
* 移除热点商品限流
*/
public void removeHotProductLimit(Long productId) {
hotProductLimiters.remove(productId);
}
}
/**
* 限流结果
*/
@Data
@AllArgsConstructor
public class RateLimitResult {
private boolean allowed;
private String reason;
public static RateLimitResult allow() {
return new RateLimitResult(true, null);
}
public static RateLimitResult reject(String reason) {
return new RateLimitResult(false, reason);
}
}
效果:
防护能力:
├─ 接口总QPS: 10000(保护系统整体容量)
├─ 单用户QPS: 100(防止单个用户过度占用)
├─ 单IP QPS: 1000(防止DDoS攻击)
└─ 热点商品QPS: 5000(防止缓存击穿)
案例:
正常用户(userId=1001, ip=192.168.1.1):
├─ 每秒最多发起100个请求(用户级限流)
├─ 如果共享IP(学校/公司),该IP所有用户总和不超过1000 QPS
└─ 访问热门商品(productId=1001),该商品总QPS不超过5000
恶意爬虫(userId=9999, ip=1.2.3.4):
├─ 伪装成单个用户,最多100 QPS(用户级限流)
├─ 使用单个IP,最多1000 QPS(IP级限流)
├─ 想要突破限制,需要大量IP+大量用户(成本极高)
└─ 被有效限制
总结
算法对比总表
| 算法 | 时间复杂度 | 空间复杂度 | 性能(QPS) | 精确度 | 突发支持 | 推荐度 | 适用场景 |
|---|---|---|---|---|---|---|---|
| 固定窗口 | O(1) | O(1) | 800万 | ⭐⭐ | ❌ | ⭐⭐⭐ | 内部系统、粗粒度限流 |
| 滑动窗口日志 | O(N) | O(N) | 5万 | ⭐⭐⭐⭐⭐ | ❌ | ⭐⭐ | 低QPS、精确要求 |
| 滑动窗口计数器 | O(M) | O(M) | 50万 | ⭐⭐⭐⭐ | ❌ | ⭐⭐⭐⭐ | 精确场景、中等QPS |
| 漏桶 | O(1) | O(1) | 300万 | ⭐⭐⭐ | ❌ | ⭐⭐⭐⭐ | 流量整形、保护下游 |
| 令牌桶 | O(1) | O(1) | 500万 | ⭐⭐⭐ | ✅ | ⭐⭐⭐⭐⭐ | API限流、生产首选 |
| Guava RateLimiter | O(1) | O(1) | 1000万 | ⭐⭐⭐⭐ | ✅ | ⭐⭐⭐⭐⭐ | 所有场景 |
核心要点
没有完美的算法,只有最适合的算法
- 根据业务需求选择
- 根据QPS要求选择
- 根据精度要求选择
生产环境推荐:
- 首选: Guava RateLimiter (令牌桶)
- 备选: 滑动窗口计数器(精确场景)
- 特殊场景: 漏桶(流量整形)
多级限流组合使用:
- 接口级 + 用户级 + IP级
- 全局限流 + 热点限流
- 单机限流 + 分布式限流
性能对比:
- Guava RateLimiter: 1000万 QPS (最快)
- 固定窗口: 800万 QPS
- 令牌桶: 500万 QPS
- 滑动窗口计数器: 50万 QPS
- 滑动窗口日志: 5万 QPS (最慢)
下一篇文章,我们将深入实战**《熔断降级实战:从Hystrix到Sentinel》**,包含完整的代码示例、生产配置和监控告警,敬请期待!
系列文章:
- ✅ 为什么需要熔断限流?从一次生产事故说起
- ✅ 限流算法深度解析:从计数器到令牌桶(本文)
- ⏳ 熔断降级实战:从Hystrix到Sentinel
- ⏳ Sentinel实战:规则配置与监控告警
- ⏳ 分布式限流:Redis+Lua方案详解
源码地址:
📢 如果这篇文章对你有帮助,欢迎点赞、收藏、转发!