从计数器到滑动窗口的完整实现解析
目录导读
- 为什么需要接口限流?——从系统过载说起
- 限流的核心指标与常见算法对比
- 计数器算法:最朴素的实现与窗口边界问题
- 滑动窗口算法:时间片划分与精度提升
- 漏桶算法:恒定速率与突发流量处理
- 令牌桶算法:允许突发与平滑限流
- 分布式限流:Redis+Lua脚本的原子性保障
- 常见问答:限流源码面试高频问题
为什么需要接口限流?——从系统过载说起
在微服务架构中,高并发请求若不受控制,会导致数据库连接池耗尽、CPU飙升、服务雪崩,限流(Rate Limiting)的核心目标是控制请求速率在系统承载力阈值内,防止恶意攻击或突发流量冲垮服务。
核心场景:
- API网关层防止DDoS攻击
- 秒杀系统保护后端订单服务
- 第三方API调用配额管理
源码视角的底层原理: 任何限流实现最终都映射为时间窗口内请求计数与阈值比较的原子操作——这正是我们接下来要深入分析的关键。
限流的核心指标与常见算法对比
| 指标 | 说明 | 典型值 |
|---|---|---|
| QPS | 每秒查询数 | 1000 |
| TPS | 每秒事务数 | 500 |
| 并发数 | 同时处理连接数 | 200 |
四大主流算法对比:
- 计数器:固定时间窗口,实现简单但有临界突变问题
- 滑动窗口:细化时间片,精度高但内存占用大
- 漏桶:强制恒定输出,削峰填谷
- 令牌桶:允许突发,灵活控制平均速率
计数器算法:最朴素的实现与窗口边界问题
核心逻辑:
// 伪代码实现
long currentTime = System.currentTimeMillis() / 1000;
if (currentTime != currentWindowStart) {
currentWindowStart = currentTime;
counter = 0;
}
if (++counter > threshold) {
rejectRequest();
}
致命缺陷:窗口边界陷阱 假设阈值=100,请求集中在第1秒最后100ms和第2秒前100ms,实际1秒内发出了200个请求,但计数器认为每秒未超100,导致限流失效。
源码级优化: 增加时间窗口重叠检测(实际上滑动窗口正是为了修复此问题)。
滑动窗口算法:时间片划分与精度提升
本质思想: 将固定窗口划分为N个小格子(如10个100ms槽位),每次请求统计当前时间点往前1秒内的所有格子计数之和。
Java实现示例:
// 滑动窗口核心数据结构
public class SlidingWindow {
private final int[] slots; // 环形数组
private int head; // 当前头部索引
private long startTime; // 窗口起始时间
public boolean allowRequest() {
long now = System.currentTimeMillis();
int total = 0;
// 遍历所有有效槽位
for (int i = 0; i < slots.length; i++) {
long slotTime = startTime + i * slotDuration;
if (now - slotTime < windowSize) {
total += slots[(head + i) % slots.length];
}
}
if (total < threshold) {
slots[head]++; // 原子操作
return true;
}
return false;
}
}
优势: 解决了边界突变问题,精度=格子数量×时间粒度。
漏桶算法:恒定速率与突发流量处理
源码隐喻:水滴速率恒定,桶有容量限制
核心实现思路:
- 维护当前水量(累积请求)
- 每次请求检查桶是否满(容量上限)
- 按固定速率漏水(处理请求)
Go语言源码片段:
type LeakyBucket struct {
capacity int64
rate float64 // 处理速率:毫秒/请求
water int64
lastTime int64
}
func (lb *LeakyBucket) Allow() bool {
now := time.Now().UnixMilli()
// 先漏水:计算时间差内应漏掉的水量
elapsed := now - lb.lastTime
leakAmount := int64(float64(elapsed) * lb.rate)
if lb.water > leakAmount {
lb.water -= leakAmount
} else {
lb.water = 0
}
if lb.water < lb.capacity {
lb.water++
lb.lastTime = now
return true
}
return false
}
适用场景: 数据库写入、消息队列消费等需要严格速率控制的场景。
令牌桶算法:允许突发与平滑限流
核心差异: 桶内存储的是令牌而非请求,令牌以固定速率生成,请求到来时需获取令牌。
Guava RateLimiter源码解析(精简版):
private final long maxTokens; // 桶容量
private final long stableInterval; // 生成令牌间隔
private long storedTokens; // 当前令牌数
private long nextFreeTicketMicros; // 下一次可以提供令牌的时间点
public boolean tryAcquire(int permits) {
long nowMicros = clock.read();
resync(nowMicros); // 刷新令牌数
long microsToWait = nextFreeTicketMicros - nowMicros;
if (microsToWait > 0) {
return false; // 需要等待
}
storedTokens -= permits;
nextFreeTicketMicros += permits * stableInterval;
return true;
}
实际应用: API网关(如Nginx限流模块、Spring Cloud Gateway)。
面试高频题: “漏桶和令牌桶的核心区别是什么?”
答:漏桶强制输出速率恒定,令牌桶允许突发(只需桶内有令牌)且可控制平均速率。
分布式限流:Redis+Lua脚本的原子性保障
单机限流无法应对多实例场景,必须用中间件保证全局计数原子性。
Redis + Lua原子执行(SENTINEL模式常用):
-- 滑动窗口+Redis实现(只显示核心逻辑)
local key = KEYS[1]
local now = tonumber(ARGV[1])
local windowSize = tonumber(ARGV[2])
local threshold = tonumber(ARGV[3])
-- 移除窗口外的过期记录
redis.call('ZREMRANGEBYSCORE', key, 0, now - windowSize)
-- 计算当前窗口计数
local count = redis.call('ZCARD', key)
if count < threshold then
redis.call('ZADD', key, now, now .. '_' .. math.random())
redis.call('EXPIRE', key, windowSize)
return 1
end
return 0
为什么用Lua? 保证多步操作不可分割,Redis单线程模型执行Lua脚本天然原子。
常见问答:限流源码面试高频问题
Q1:计数器算法的临界突变问题如何在生产中避免?
A:使用滑动窗口,将窗口粒度细化到100ms甚至10ms,阿里的Sentinel和Hystrix均采用滑动窗口实现。
Q2:令牌桶算法初始状态下“冷启动”如何处理?
A:Guava的SmoothRateLimiter引入“预热期”:初始时令牌生成慢,随着时间逐渐加速到正常速率,防止系统被瞬间流量打爆(类似TCP慢启动)。
Q3:Nginx的limit_req模块使用的是哪种算法?
A:漏桶算法(配合burst和nodelay参数可实现类似令牌桶的效果)。
Q4:分布式限流中Redis单点故障如何处理?
A:结合本地限流(本地令牌桶)做降级,或使用Redis Sentinel/Cluster保障高可用。
Q5:如何选择适合业务的限流算法?
- 需要精确控制速率,不能超过阈值 → 漏桶
- 允许突发,关注平均速率 → 令牌桶
- 简单快速实现 → 计数器+滑动窗口
- 分布式场景 → Redis+Lua或Sentinel
接口限流的底层原理本质就是时间窗口+计数+原子操作的组合,从简单的计数器到复杂的分时滑动窗口,从单机到分布式,核心矛盾始终是精度与性能的平衡,掌握不同算法的源码实现思想,在遇到真实限流问题时,就能根据业务特点选择最合适的方案,而不是盲目套用工具。