前言
在现代互联网架构中 高并发是每个系统都必须面对的挑战
在这种情况下 为了保证系统服务的高可用 采取合适的容灾备份策略是必不可少的
当流量过大时 应该通过合适的措施来保证系统的可用性
常见的保护措施有降级 限流等...
在我所做的营销场景的抽奖项目中 针对核心接口 如抽奖等
在特定时间节点往往会出现流量过大的问题
为了保证接口的可用性 服务的稳定性 需要采取容灾措施
我们的方案是对接口做限流措施 如果触发限流则拒绝请求 基于动态配置中心来管控限流和降级开关
这对于当前的中小型系统是足够使用的
如果系统过大 需要建立更为强大 丰富的系统架构
如图:
限流算法
固定窗口计数法
a.在固定的时间区间内 维护当前区间的最大请求数量以及一个计数器 即当前区间的当前请求数量
b.在请求到达时 获取当前时间窗口 如果是新窗口,重置计数器 如果是当前窗口 计数器+1
c.如果计数器大于当前窗口的最大请求数量 则触发限流 否则通过
这种限流实现简单 但是存在临界问题 限流粒度不够精确
例如:限制 1 秒 5 次,在 0.9s 时来了 5 次,1.1s 又来了 5 次,实际在 0.9~1.1s 的 0.2s 内就有 10 次请求,可能压垮系统。
优点: 实现简单
缺点: 限流粒度不够精确
滑动窗口
核心思想:不设置固定的时间窗口 而是以当前时间为窗口的终点 统计窗口起点到终点的请求次数
如果请求超过了最大请求数量 就触发限流
优点:相比固定窗口 滑动窗口能够避免窗口边界的流量突发造成的临界问题 实现了更加精确的限流
缺点:实现比较复杂 需要额外记录每个请求的时间戳 资源消耗略高
常见实现:
通过有序队列 如Redis的Zset 以时间戳为score 高效获取从当前时间到之前的一段时间范围的请求数量
Lua脚本示例:
-- 清理过期请求
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, tonumber(ARGV[1]) - tonumber(ARGV[2]))
-- 获取当前窗口请求数
local count = redis.call('ZCARD', KEYS[1])
if count < tonumber(ARGV[3]) then
redis.call('ZADD', KEYS[1], ARGV[1], ARGV[4]) -- 添加新请求
return 1
else
return 0
end
| 特性 | 固定窗口 | 滑动窗口 |
|---|---|---|
| 实现复杂度 | 简单 | 中等 |
| 内存占用 | 低(只存计数) | 较高(需存每个请求的时间戳) |
| 限流精度 | 低(有边界问题) | 高(平滑) |
| 适用场景 | 粗粒度限流 | 精确限流、防突发 |
漏桶算法
漏桶算法是一种经典限流算法
核心思想是:以恒定速率处理请求,平滑突发流量,防止系统被瞬时高峰压垮。
类比:
把请求看作水
当请求到达时 相当于向桶里添加水
如果桶满了(突发请求太多了) ,水就会溢出(请求被拒绝)
如果桶没有满(系统能够承载),请求被接收
同时如果桶里有水(有请求待处理) 由于桶的底部是漏的 会按照一定速率流出(系统处理请求)
可以发现漏桶算法可以有效控制请求的数量 和处理速度
优点:严格限速,平滑流量的进出,防止突发流量冲击系统
缺点:不能处理突发流量
典型应用场景:
-
网络协议中的流量控制(如TCP)
-
API网关限流(严格匀速)
令牌桶算法
令牌桶是一种灵活、高效的限流算法,既能限制平均请求速率,又允许一定程度的突发流量,非常适合对高并发有需求的C端系统
想象一个以固定速度往里加水的桶:
-
令牌=水
-
桶容量 = 最大令牌数n
-
生成令牌的速度 = 平均限流速率
-
每个请求 = 消耗x个令牌
系统会按照固定速率往水里添加令牌 并且桶有最大容量 令牌满了就不会再加
当请求达到时
如果桶里有令牌 则拿走令牌 请求通过
如果没有令牌 则触发限流 拒绝请求
在实际的代码中 并不会直接实现"系统会按照固定速率往水里添加令牌"
因为这需要一个定时任务去频繁执行 会带来一定的开销
而是维护一个上次补充令牌的时间 计算到当前为止 会添加多少令牌
代码示例:
import java.util.concurrent.atomic.AtomicLong;
public class TokenBucketRateLimiter {
private final long capacity; // 桶容量(最大令牌数)
private final long refillRatePerSec; // 每秒生成令牌数
private final long refillIntervalNs; // 生成一个令牌所需纳秒数
private final AtomicLong tokens; // 当前令牌数
private final AtomicLong lastRefillTime; // 上次补充令牌的时间(纳秒)
public TokenBucketRateLimiter(long capacity, long refillRatePerSec) {
this.capacity = capacity;
this.refillRatePerSec = refillRatePerSec;
this.refillIntervalNs = 1_000_000_000L / refillRatePerSec; // 纳秒
this.tokens = new AtomicLong(capacity); // 初始满桶
this.lastRefillTime = new AtomicLong(System.nanoTime());
}
/**
* 尝试获取一个令牌
* @return true 表示允许,false 表示被限流
*/
public boolean tryAcquire() {
long now = System.nanoTime();
long last = lastRefillTime.get();
// 计算自上次以来应生成的令牌数
long tokensToAdd = (now - last) / refillIntervalNs;
if (tokensToAdd > 0) {
// CAS 更新令牌数和时间(避免并发问题)
while (true) {
long currentTokens = tokens.get();
long newTokens = Math.min(capacity, currentTokens + tokensToAdd);
long expectLast = lastRefillTime.get();
if (lastRefillTime.compareAndSet(expectLast, now) && tokens.compareAndSet(currentTokens, newTokens)) {
break;
}
// 若 CAS 失败,重新读取
last = lastRefillTime.get();
now = System.nanoTime();
tokensToAdd = (now - last) / refillIntervalNs;
}
}
// 尝试消费一个令牌
long currentTokens = tokens.get();
while (currentTokens > 0) {
if (tokens.compareAndSet(currentTokens, currentTokens - 1)) {
return true;
}
currentTokens = tokens.get();
}
return false; // 无令牌,限流
}
}
public class Main {
public static void main(String[] args) throws InterruptedException {
TokenBucketRateLimiter limiter = new TokenBucketRateLimiter(5, 2); // 容量5,每秒2个令牌
for (int i = 0; i < 8; i++) {
boolean allowed = limiter.tryAcquire();
System.out.println("Request " + (i + 1) + ": " + (allowed ? "ALLOWED" : "DENIED"));
Thread.sleep(100); // 每100ms一次(10次/秒,超过2次/秒)
}
}
}
优点
-
允许突发流量
-
可以控制平均速率
-
资源消耗低 只需计数 不需要存储其他资源
缺点
-
实现相对复杂
-
需要处理并发问题
令牌桶和漏桶的比较
| 特性 | 令牌桶 | 漏桶 |
|---|---|---|
| 是否允许突发 | 是 | 否 |
| 流量是否平滑 | 错 输入可突发 | 对 流量平滑 |
| 适用场景 | API 限流、高并发场景的用户请求控制 | 网络流量整形、严格匀速处理 |
| 实现复杂度 | 中等 | 中等 |
| 用户体验 | 更友好 大部分情况下能快速响应 | 一般,流量较高时容易触发限流 包括拒绝 重试等待 |
小结
这篇文章简要讲解了 限流的必要性 和 限流算法的基础概念 从个人笔记中整理而来