Skip to content

分布式限流算法:令牌桶/漏桶/滑动窗口,Sentinel 核心源码分析

引言

在分布式系统中,每一个服务都不可能无限承受流量冲击。当瞬时流量超过系统承载能力时,如果不做限制,结果是——连接池被打满、CPU 飙到 100%、GC 频繁 Full GC,最终整个服务雪崩。限流,是系统自我保护的第一道防线。

但限流不是"挡住请求就行"那么简单。不同的业务场景对限流的诉求完全不一样:秒杀系统希望允许瞬间冲一波流量,数据库写入希望稳定匀速,API 网关需要精确到秒级的 QPS 控制。今天我们就从算法原理到源码实现,把分布式限流的核心问题讲透。

四种经典限流算法

1. 固定窗口计数器

最简单的方案:把时间分成固定窗口(比如 1 秒),每个窗口内维护一个计数器,请求到达 +1,超过阈值就拒绝,窗口切换时重置计数器。

java
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),每个格子独立计数。滑动窗口统计的是当前时间点往前推一个完整窗口的总和,而不是固定起止点。

java
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)

漏桶可以想象成一个底部有洞的水桶:水(请求)以任意速率倒入桶中,但以恒定速率从底部漏出,桶满了就溢出的水(请求被拒绝)。

核心特征:强制平滑流量,出速率恒定,超出的请求排队等待或直接丢弃。

java
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),意味着允许短时间的突发流量。

java
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 是阿里开源的流量防卫组件,其限流核心在 FlowSlotStatisticSlot 两个 ProcessorSlot 中。我们来看限流判断的核心链路。

核心数据结构:LeapArray

LeapArray 是 Sentinel 滑动窗口的底层实现,本质是一个环形数组(窗口格子)。

java
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

java
// 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 来执行具体的限流策略:

java
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);
    }
}

DefaultControllercanPass 方法:

java
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 参数,控制限流触发时的行为:

  1. 直接拒绝(DefaultController):超过阈值直接抛 FlowException,最简单也最粗暴。

  2. Warm Up(WarmUpController):冷启动时令牌桶容量缓慢增长,让系统从"冷"状态平滑过渡到"热"状态。核心是 coldFactor(默认 3),桶容量从 threshold / coldFactor 开始,在 warmUpPeriodSec 秒内逐渐增加到 threshold

  3. 匀速排队(RateLimiterController):让请求以固定间隔通过,类似漏桶的效果。只允许排队,超时的请求直接拒绝。

java
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 做全局计数器:

java
// 基于 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 环形数组做滑动窗口统计,FlowSlotFlowRuleCheckerTrafficShapingController 三级链路判断,支持直接拒绝、Warm Up、匀速排队三种效果。
  • 单机 vs 分布式:绝大多数场景单机限流就够了;分布式限流需要 Redis 协同,引入额外的延迟和一致性风险,只有全局 QPS 必须精确控制时才考虑。

选限流方案,不是比谁实现的算法更复杂,而是看你的业务场景需要什么级别的流量控制能力。先搞清楚业务特征,再选算法,最后落地实现。

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。