分布式限流算法:令牌桶/漏桶/滑动窗口,Sentinel 核心源码分析
引言
在分布式系统中,每一个服务都不可能无限承受流量冲击。当瞬时流量超过系统承载能力时,如果不做限制,结果是——连接池被打满、CPU 飙到 100%、GC 频繁 Full GC,最终整个服务雪崩。限流,是系统自我保护的第一道防线。
但限流不是"挡住请求就行"那么简单。不同的业务场景对限流的诉求完全不一样:秒杀系统希望允许瞬间冲一波流量,数据库写入希望稳定匀速,API 网关需要精确到秒级的 QPS 控制。今天我们就从算法原理到源码实现,把分布式限流的核心问题讲透。
四种经典限流算法
1. 固定窗口计数器
最简单的方案:把时间分成固定窗口(比如 1 秒),每个窗口内维护一个计数器,请求到达 +1,超过阈值就拒绝,窗口切换时重置计数器。
public class FixedWindowRateLimiter {
private final long windowSizeMs; // 窗口大小,毫秒
private final int maxRequests; // 窗口内允许的最大请求数
private long windowStart; // 当前窗口开始时间
private int counter; // 当前窗口计数器
public FixedWindowRateLimiter(long windowSizeMs, int maxRequests) {
this.windowSizeMs = windowSizeMs;
this.maxRequests = maxRequests;
this.windowStart = System.currentTimeMillis();
this.counter = 0;
}
public synchronized boolean tryAcquire() {
long now = System.currentTimeMillis();
if (now - windowStart >= windowSizeMs) {
windowStart = now;
counter = 0;
}
if (counter < maxRequests) {
counter++;
return true;
}
return false;
}
}问题:临界突变。假设阈值 5 请求/秒,窗口是 [0s, 1s)。如果 0.9s 到 1.0s 之间来了 5 个请求,1.0s 到 1.1s 之间又来了 5 个请求——虽然两个窗口各自都没超,但 0.2 秒内实际发生了 10 个请求,流量尖刺完全没被削平。
2. 滑动窗口
把固定窗口进一步细分为 N 个小格子(比如 1 秒分 10 格,每格 100ms),每个格子独立计数。滑动窗口统计的是当前时间点往前推一个完整窗口的总和,而不是固定起止点。
public class SlidingWindowRateLimiter {
private final int windowSize; // 窗口总格子数
private final long windowMs; // 窗口总时长,毫秒
private final long slotMs; // 每个格子时长
private final int[] slots; // 格子计数器数组
private final int maxRequests; // 阈值
private int head; // 当前格子索引
private long lastSlotTime; // 上次更新格子时间
public SlidingWindowRateLimiter(int windowSize, long windowMs, int maxRequests) {
this.windowSize = windowSize;
this.windowMs = windowMs;
this.slotMs = windowMs / windowSize;
this.slots = new int[windowSize];
this.maxRequests = maxRequests;
this.head = 0;
this.lastSlotTime = System.currentTimeMillis();
}
public synchronized boolean tryAcquire() {
long now = System.currentTimeMillis();
// 推进到当前时间所属的格子
long elapsed = now - lastSlotTime;
int slotsToAdvance = (int) (elapsed / slotMs);
if (slotsToAdvance > 0) {
// 清空滑过的格子,重置计数器
for (int i = 0; i < Math.min(slotsToAdvance, windowSize); i++) {
head = (head + 1) % windowSize;
slots[head] = 0;
}
lastSlotTime = now;
}
// 统计当前窗口内所有格子
int total = 0;
for (int i = 0; i < windowSize; i++) {
total += slots[i];
}
if (total < maxRequests) {
slots[head]++;
return true;
}
return false;
}
}滑动窗口把固定窗口的"突变"问题变成了"平滑的统计",格子越细,效果越好,但内存开销也线性增长。Sentinel 的 LeapArray 就是环形数组的滑动窗口实现,默认 1 秒分 2 格,精度已经足够。
3. 漏桶(Leaky Bucket)
漏桶可以想象成一个底部有洞的水桶:水(请求)以任意速率倒入桶中,但以恒定速率从底部漏出,桶满了就溢出的水(请求被拒绝)。
核心特征:强制平滑流量,出速率恒定,超出的请求排队等待或直接丢弃。
public class LeakyBucketRateLimiter {
private final long capacity; // 桶容量
private final long leakRate; // 漏水速率,请求/秒
private long water; // 当前水量
private long lastLeakTime; // 上次漏水时间
public LeakyBucketRateLimiter(long capacity, long leakRate) {
this.capacity = capacity;
this.leakRate = leakRate;
this.water = 0;
this.lastLeakTime = System.nanoTime();
}
public synchronized boolean tryAcquire() {
long now = System.nanoTime();
// 先漏水:根据时间差计算漏掉的水量
long elapsed = now - lastLeakTime;
long leaked = (elapsed / 1_000_000_000L) * leakRate;
water = Math.max(0, water - leaked);
lastLeakTime = now;
if (water < capacity) {
water++;
return true;
}
return false;
}
}漏桶的致命缺点:无法应对突发流量。即使桶是空的,请求进来的速度也被限制在固定的出水速率。对于秒杀、抢购这类允许短时间"冲一波"的场景,漏桶不合适。
4. 令牌桶(Token Bucket)
令牌桶是目前最通用的限流算法。系统以恒定速率向桶里放令牌,请求来的时候必须拿到令牌才能通过。桶里最多存一定量的令牌(burst capacity),意味着允许短时间的突发流量。
public class TokenBucketRateLimiter {
private final long capacity; // 桶容量(最大令牌数)
private final long refillRate; // 令牌生成速率,个/秒
private long tokens; // 当前令牌数
private long lastRefillTime; // 上次补充令牌时间
public TokenBucketRateLimiter(long capacity, long refillRate) {
this.capacity = capacity;
this.refillRate = refillRate;
this.tokens = capacity; // 初始满桶
this.lastRefillTime = System.nanoTime();
}
public synchronized boolean tryAcquire() {
long now = System.nanoTime();
// 补充令牌
long elapsed = now - lastRefillTime;
long newTokens = (elapsed / 1_000_000_000L) * refillRate;
tokens = Math.min(capacity, tokens + newTokens);
lastRefillTime = now;
if (tokens > 0) {
tokens--;
return true;
}
return false;
}
}令牌桶同时具备平滑流量和允许突发两个特性,适用面很广。Guava 的 RateLimiter 就是令牌桶实现,但做了关键改进:支持预消费(SmoothBursty),允许透支未来 N 秒的令牌,让突发流量更平滑地分布到后续时间段。
算法选型决策
| 算法 | 平滑度 | 突发支持 | 适用场景 |
|---|---|---|---|
| 固定窗口 | 差 | 有突变窗口 | 基本不推荐 |
| 滑动窗口 | 中 | 取决于格子数 | 精度要求低的 API 网关 |
| 漏桶 | 强 | 无 | 数据库写入、IO 限流 |
| 令牌桶 | 强 | 可控 | 秒杀、API 调用、一般场景 |
一句话决策:大多数场景无脑选令牌桶。如果业务要求"无论怎么突发,后端都不能超过固定速率",选漏桶(比如数据同步到下游第三方系统,对方承诺的 QPS 是 100,不能超)。
Sentinel 核心源码分析
Sentinel 是阿里开源的流量防卫组件,其限流核心在 FlowSlot 和 StatisticSlot 两个 ProcessorSlot 中。我们来看限流判断的核心链路。
核心数据结构:LeapArray
LeapArray 是 Sentinel 滑动窗口的底层实现,本质是一个环形数组(窗口格子)。
public class LeapArray<T> {
// 窗口总时长(毫秒)
private int windowLengthInMs;
// 样本数(格子数)
private int sampleCount;
// 窗口总时长(毫秒)
private int intervalInMs;
// 环形数组,存储每个格子的数据
private final AtomicReferenceArray<WindowWrap<T>> array;
public LeapArray(int sampleCount, int intervalInMs) {
this.windowLengthInMs = intervalInMs / sampleCount;
this.sampleCount = sampleCount;
this.intervalInMs = intervalInMs;
this.array = new AtomicReferenceArray<>(sampleCount);
}
// 根据当前时间计算所属格子索引
public int calculateWindowIdx(long timeMillis) {
long timeId = timeMillis / windowLengthInMs;
return (int) (timeId % array.length());
}
// 获取当前时间对应的窗口,如果已过期则重置
public WindowWrap<T> currentWindow(long timeMillis) {
int idx = calculateWindowIdx(timeMillis);
long windowStart = timeMillis - timeMillis % windowLengthInMs;
while (true) {
WindowWrap<T> old = array.get(idx);
if (old == null) {
WindowWrap<T> window = new WindowWrap<>(windowLengthInMs, windowStart, newEmptyBucket());
if (array.compareAndSet(idx, null, window)) {
return window;
}
Thread.yield();
} else if (windowStart == old.windowStart()) {
return old;
} else if (windowStart > old.windowStart()) {
if (array.compareAndSet(idx, old, new WindowWrap<>(windowLengthInMs, windowStart, newEmptyBucket()))) {
return old;
}
Thread.yield();
} else {
return old;
}
}
}
}每个格子(WindowWrap)里存的是 MetricBucket,包含多个计数器(成功数、失败数、异常数、RT 总和等),用 LongAdder 实现,保证高并发下的统计准确性。
限流判断链路:FlowSlot → FlowRuleChecker → TrafficShapingController
// FlowSlot 的入口
public class FlowSlot extends AbstractLinkedProcessorSlot<DefaultNode> {
@Override
public void entry(Context context, ResourceWrapper resourceWrapper, DefaultNode node, int count, boolean prioritized, Object... args) throws Throwable {
checkFlow(resourceWrapper, context, node, count, prioritized);
fireEntry(context, resourceWrapper, node, count, prioritized, args);
}
void checkFlow(ResourceWrapper resource, Context context, DefaultNode node, int count, boolean prioritized) throws BlockException {
// 获取该资源的所有限流规则
List<FlowRule> rules = FlowRuleManager.getRules(resource.getName());
if (rules == null) return;
for (FlowRule rule : rules) {
// 逐个规则检查,任何一个不通过就抛异常
if (!FlowRuleChecker.passCheck(rule, context, node, count, prioritized)) {
throw new FlowException(rule.getLimitApp(), rule);
}
}
}
}FlowRuleChecker 根据规则类型,选择对应的 TrafficShapingController 来执行具体的限流策略:
public class FlowRuleChecker {
public static boolean passCheck(FlowRule rule, Context context, DefaultNode node, int count, boolean prioritized) {
// 获取该规则的统计节点
StatisticNode statisticNode = selectNodeByStrategy(rule, context, node);
if (statisticNode == null) return true;
// 根据规则控制行为选择控制器
TrafficShapingController controller = rule.getController();
if (controller == null) {
controller = new DefaultController(rule.getCount(), rule.getGrade());
}
return controller.canPass(statisticNode, count, prioritized);
}
}DefaultController 的 canPass 方法:
public class DefaultController implements TrafficShapingController {
private double count; // 阈值
private int grade; // 限流维度(QPS 或线程数)
@Override
public boolean canPass(Node node, int acquireCount, boolean prioritized) {
// 获取当前滑动窗口的统计值
long curCount = getCurrentCount(node);
// 如果当前值 + 请求数 <= 阈值,通过
if (curCount + acquireCount > count) {
// 如果开启了优先级,尝试借用下一个窗口的额度
if (prioritized && grade == RuleConstant.FLOW_GRADE_QPS) {
long currentTime = TimeUtil.currentTimeMillis();
long waitInMs = node.tryOccupyNext(currentTime, acquireCount, count);
if (waitInMs < OccupyTimeoutProperty.getOccupyTimeout()) {
node.addOccupyPass(acquireCount);
// 增加占用计数
node.addWaitingRequest(currentTime + waitInMs, acquireCount);
node.addOccupiedPass(acquireCount);
sleep(waitInMs);
return true;
}
}
return false;
}
return true;
}
private long getCurrentCount(Node node) {
if (grade == RuleConstant.FLOW_GRADE_QPS) {
// 取滑动窗口 1 秒内的平均 QPS
return node.passQps();
} else {
// 线程数限流
return node.curThreadNum();
}
}
}Sentinel 的三种限流效果
Sentinel 的 FlowRule 支持 controlBehavior 参数,控制限流触发时的行为:
直接拒绝(DefaultController):超过阈值直接抛
FlowException,最简单也最粗暴。Warm Up(WarmUpController):冷启动时令牌桶容量缓慢增长,让系统从"冷"状态平滑过渡到"热"状态。核心是
coldFactor(默认 3),桶容量从threshold / coldFactor开始,在warmUpPeriodSec秒内逐渐增加到threshold。匀速排队(RateLimiterController):让请求以固定间隔通过,类似漏桶的效果。只允许排队,超时的请求直接拒绝。
public class RateLimiterController implements TrafficShapingController {
private final int maxQueueingTimeMs; // 最大排队等待时间
private final double count; // 阈值
private final AtomicLong latestPassedTime = new AtomicLong(-1);
@Override
public boolean canPass(Node node, int acquireCount, boolean prioritized) {
if (acquireCount <= 0) return true;
if (count <= 0) return false;
long currentTime = TimeUtil.currentTimeMillis();
// 计算期望的通过时间:上次通过时间 + 间隔
long costTime = Math.round(1.0 / count * 1000);
long expectedTime = costTime + latestPassedTime.get();
if (expectedTime <= currentTime) {
// 没有排队需求,直接通过
latestPassedTime.set(currentTime);
return true;
} else {
// 需要排队,计算等待时间
long waitTime = expectedTime - currentTime;
if (waitTime > maxQueueingTimeMs) {
return false; // 超时,拒绝
}
long oldTime = latestPassedTime.addAndGet(costTime);
try {
long realWaitTime = oldTime - TimeUtil.currentTimeMillis();
if (realWaitTime > maxQueueingTimeMs) {
latestPassedTime.addAndGet(-costTime);
return false;
}
if (realWaitTime > 0) {
Thread.sleep(realWaitTime);
}
return true;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
}
}
}
}分布式限流 vs 单机限流
以上讨论的都是单机限流——每个实例独立统计。在小规模部署(2-5 个节点)时,单机限流 + 水平扩展基本够用,因为每个实例的 QPS 大致相等,阈值设置为 总目标 / 节点数 即可。
但当你需要精确的全局 QPS 控制时,就需要分布式限流了。常见做法是用 Redis 做全局计数器:
// 基于 Redis 的分布式滑动窗口限流(Lua 脚本)
private static final String LUA_SCRIPT =
"local key = KEYS[1]\n" +
"local now = tonumber(ARGV[1])\n" +
"local windowMs = tonumber(ARGV[2])\n" +
"local maxRequests = tonumber(ARGV[3])\n" +
"local slotMs = tonumber(ARGV[4])\n" +
"local slotKey = now - (now % slotMs)\n" +
"redis.call('ZREMRANGEBYSCORE', key, 0, now - windowMs)\n" +
"local count = redis.call('ZCARD', key)\n" +
"if count < maxRequests then\n" +
" redis.call('ZADD', key, now, slotKey .. ':' .. now)\n" +
" redis.call('EXPIRE', key, windowMs / 1000 + 1)\n" +
" return 1\n" +
"end\n" +
"return 0\n";分布式限流的代价是每次请求都要一次 Redis 网络调用(约 1ms),高并发下 Redis 本身会成为瓶颈。而且 Redis 挂了怎么办?如果 Redis 不可用,限流策略一般是放行(fail-open),而不是拒绝所有请求。因为分布式系统里,宁可短暂超限也不可误杀流量。
总结
- 四种限流算法:固定窗口(不推荐)、滑动窗口(精度可控)、漏桶(强制平滑)、令牌桶(兼顾突发)。其中令牌桶是默认首选。
- Sentinel 源码核心:
LeapArray环形数组做滑动窗口统计,FlowSlot→FlowRuleChecker→TrafficShapingController三级链路判断,支持直接拒绝、Warm Up、匀速排队三种效果。 - 单机 vs 分布式:绝大多数场景单机限流就够了;分布式限流需要 Redis 协同,引入额外的延迟和一致性风险,只有全局 QPS 必须精确控制时才考虑。
选限流方案,不是比谁实现的算法更复杂,而是看你的业务场景需要什么级别的流量控制能力。先搞清楚业务特征,再选算法,最后落地实现。