接口限流设计

0 阅读1分钟

解决什么问题

  • 保障核心业务

瞬时流量过高,服务被压垮

  • 防刷与防攻击

恶意用户高频光顾,导致服务器宕机(网络爬虫抓取数据、撞库登录尝试、DDoS攻击)

  • 防止雪崩

消息消费过快,导致数据库压力过大,性能下降甚至崩溃

服务器CPU飙升、响应变慢、请求积压。
慢导致客户端重试,重试导致更多请求,最终耗尽资源,服务彻底不可用。
更危险的是,故障会像多米诺骨牌一样,
从一个服务蔓延到依赖它的上游服务,导致整个链路崩溃(即雪崩效应

有什么方案

四种限流算法:固定窗口算法、滑动窗口算法、漏桶算法、令牌桶算法

固定窗口算法

请求到达时,计数器加1。如果计数器超过阈值,则丢弃请求。下一个时间窗口开始时,计数器重置

它将时间划分为固定的时间窗口(例如每分钟、每小时),在每个窗口内限制请求的总数
当请求到达时,检查当前时间窗口内的请求计数是否超过阈值,如果未超过则允许请求并计数加一,
否则拒绝请求,窗口结束时,计数器清零


import java.util.concurrent.atomic.AtomicLong;

public class FixedWindowRateLimiter {
    // 时间窗口大小,单位毫秒
    private final long windowSizeMs;
    // 窗口内允许的最大请求数
    private final int maxRequests;
    // 当前窗口的开始时间戳
    private final AtomicLong windowStart;
    // 当前窗口的请求计数器
    private final AtomicLong counter;

    public FixedWindowRateLimiter(long windowSizeMs, int maxRequests) {
        this.windowSizeMs = windowSizeMs;
        this.maxRequests = maxRequests;
        this.windowStart = new AtomicLong(System.currentTimeMillis());
        this.counter = new AtomicLong(0);
    }

    public boolean tryAcquire() {
        long currentTime = System.currentTimeMillis();
        long currentWindowStart = windowStart.get();

        // 如果当前时间已经超过当前窗口的结束时间,则重置窗口
        if (currentTime - currentWindowStart >= windowSizeMs) {
            // 尝试将窗口开始时间更新为当前时间,并重置计数器
            // 使用 CAS 保证线程安全,避免多个线程同时重置
            if (windowStart.compareAndSet(currentWindowStart, currentTime)) {
                counter.set(0);
            }
            // 如果 CAS 失败,说明其他线程已经重置了窗口,重新获取当前窗口开始时间
            currentWindowStart = windowStart.get();
        }

        // 获取当前窗口的请求计数
        long currentCount = counter.get();
        if (currentCount < maxRequests) {
            // 尝试递增计数器,使用 CAS 避免并发问题
            return counter.compareAndSet(currentCount, currentCount + 1);
        }
        return false;
    }
}


public class Demo {
    public static void main(String[] args) throws InterruptedException {
        // 创建一个窗口大小为 1 秒,最大请求数为 5 的限流器
        FixedWindowRateLimiter limiter = new FixedWindowRateLimiter(1000, 5);

        // 模拟 10 个请求
        for (int i = 0; i < 10; i++) {
            if (limiter.tryAcquire()) {
                System.out.println("请求 " + i + " 通过");
            } else {
                System.out.println("请求 " + i + " 被限流");
            }
            Thread.sleep(200); // 每隔 200ms 发送一次请求
        }
    }
}
优点
  • 实现简单,易于理解。
  • 内存占用小,只需记录窗口开始时间和计数器。
  • 在窗口内能平滑地限制流量。
缺点
  • 临界问题(边界突刺):在两个窗口的交界处,可能出现瞬间流量超过阈值的情况。
  • 例如窗口大小为 1 分钟,阈值为 100,第一个窗口的最后 1 秒内来了 100 个请求,第二个窗口的开始 1 秒内又来了 100 个请求, 那么在 2 秒内就处理了 200 个请求,超过了平均速率。 无法平滑处理突发流量,只能在窗口粒度上限制总量

滑动窗口算法

滑动窗口算法(Sliding Window)是对固定窗口算法的改进,旨在解决固定窗口在边界处的流量突刺问题。它通过将时间窗口进一步细分为多个小格子,并持续滑动窗口来更平滑地统计请求量

两种常见实现方式
  • 基于滑动日志

记录每个请求的时间戳,每次请求时统计当前时间往前一个窗口内的请求数。优点是精确,缺点是存储开销大。

  • 基于滑动窗口计数器

将时间窗口划分为固定数量的桶(比如5个桶,每个桶代表窗口的一部分),每个桶独立计数。随着时间推进,桶被循环使用。这是一种近似但高效的实现。

  • 基于滑动日志的实现(使用队列)

    import java.util.Queue; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.atomic.AtomicInteger;

    public class SlidingWindowLogRateLimiter { private final long windowSizeMs; // 窗口大小(毫秒) private final int maxRequests; // 最大请求数 private final Queue timestamps; // 存储请求时间戳

    public SlidingWindowLogRateLimiter(long windowSizeMs, int maxRequests) {
        this.windowSizeMs = windowSizeMs;
        this.maxRequests = maxRequests;
        this.timestamps = new ConcurrentLinkedQueue<>();
    }
    
    public synchronized boolean tryAcquire() {
        long now = System.currentTimeMillis();
        long boundary = now - windowSizeMs;
    
        // 移除窗口之前的时间戳
        while (!timestamps.isEmpty() && timestamps.peek() < boundary) {
            timestamps.poll();
        }
    
        // 检查当前窗口请求数
        if (timestamps.size() < maxRequests) {
            timestamps.offer(now);
            return true;
        }
        return false;
    }
    

    }

  • 基于滑动窗口计数器的实现(循环数组)

    import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong;

    public class SlidingWindowCounterRateLimiter { private final int windowSizeSeconds; // 窗口大小(秒) private final int maxRequests; // 最大请求数 private final int bucketCount; // 桶数量 private final AtomicLong[] bucketTimestamps; // 每个桶的时间戳(毫秒) private final AtomicInteger[] bucketCounters; // 每个桶的计数 private final AtomicLong currentBucketIndex; // 当前桶索引

    public SlidingWindowCounterRateLimiter(int windowSizeSeconds, int maxRequests, int bucketCount) {
        this.windowSizeSeconds = windowSizeSeconds;
        this.maxRequests = maxRequests;
        this.bucketCount = bucketCount;
        this.bucketTimestamps = new AtomicLong[bucketCount];
        this.bucketCounters = new AtomicInteger[bucketCount];
        for (int i = 0; i < bucketCount; i++) {
            bucketTimestamps[i] = new AtomicLong(0);
            bucketCounters[i] = new AtomicInteger(0);
        }
        this.currentBucketIndex = new AtomicLong(0);
    }
    
    public boolean tryAcquire() {
        long now = System.currentTimeMillis();
        long bucketTimeInterval = windowSizeSeconds * 1000L / bucketCount; // 每个桶的时间跨度
    
        // 计算当前时间应该落在哪个桶
        long currentBucket = (now / bucketTimeInterval) % bucketCount;
        long bucketStartTime = (now / bucketTimeInterval) * bucketTimeInterval;
    
        // 如果当前桶的时间戳不是当前时间周期,说明需要重置该桶
        if (bucketTimestamps[(int) currentBucket].get() != bucketStartTime) {
            // 使用 CAS 或 synchronized 保证重置的原子性,这里简化用 synchronized
            synchronized (this) {
                if (bucketTimestamps[(int) currentBucket].get() != bucketStartTime) {
                    bucketTimestamps[(int) currentBucket].set(bucketStartTime);
                    bucketCounters[(int) currentBucket].set(0);
                }
            }
        }
    
        // 统计整个窗口内的总请求数
        long windowStartTime = now - windowSizeSeconds * 1000L;
        int totalRequests = 0;
        for (int i = 0; i < bucketCount; i++) {
            if (bucketTimestamps[i].get() >= windowStartTime) {
                totalRequests += bucketCounters[i].get();
            }
        }
    
        if (totalRequests < maxRequests) {
            bucketCounters[(int) currentBucket].incrementAndGet();
            return true;
        }
        return false;
    }
    

    }

优点

内存占用固定(与桶数相关),性能高,适合高并发。

缺点

存在一定近似误差(因为统计的是每个桶的累积值,而不是精确到每个请求)。

漏桶算法

介绍

桶有一个固定容量(capacity),用来存放待处理的请求。 请求到达时,如果桶未满,则放入桶中;如果桶已满,则拒绝请求。 桶以一个恒定的速率(leakRate)漏水,即每隔一定时间从桶中取出一个请求进行处理。 与令牌桶相比,漏桶的输出速率是恒定的,可以很好地平滑突发流量,而令牌桶则允许一定程度的突发

基于队列的漏桶
import java.util.concurrent.*;

public class LeakyBucketQueueBased {
    private final BlockingQueue<Runnable> bucket;   // 桶队列
    private final ScheduledExecutorService scheduler; // 定时漏水线程池

    public LeakyBucketQueueBased(int capacity, long leakRatePerSecond) {
        this.bucket = new LinkedBlockingQueue<>(capacity);
        this.scheduler = Executors.newScheduledThreadPool(1);

        // 以固定速率漏水:每隔 (1000 / leakRatePerSecond) 毫秒处理一个请求
        long periodMs = 1000 / leakRatePerSecond;
        scheduler.scheduleAtFixedRate(() -> {
            Runnable task = bucket.poll(); // 非阻塞取任务
            if (task != null) {
                task.run(); // 执行任务
            }
        }, 0, periodMs, TimeUnit.MILLISECONDS);
    }

    // 尝试放入请求
    public boolean tryAcquire(Runnable task) {
        return bucket.offer(task);
    }

    // 关闭资源
    public void shutdown() {
        scheduler.shutdown();
    }

    // 示例
    public static void main(String[] args) throws InterruptedException {
        LeakyBucketQueueBased limiter = new LeakyBucketQueueBased(5, 2); // 容量5,每秒处理2个请求

        for (int i = 0; i < 10; i++) {
            int id = i;
            boolean accepted = limiter.tryAcquire(() -> {
                System.out.println("处理请求 " + id + " 时间: " + System.currentTimeMillis());
            });
            if (accepted) {
                System.out.println("请求 " + i + " 放入桶中");
            } else {
                System.out.println("请求 " + i + " 被限流(桶满)");
            }
            Thread.sleep(200); // 每200ms发送一个请求
        }

        Thread.sleep(5000);
        limiter.shutdown();
    }
}
基于计数器的漏桶
import java.util.concurrent.atomic.AtomicLong;

public class LeakyBucketCounterBased {
    private final int capacity;               // 桶容量
    private final double leakRatePerMs;        // 漏水速率(每毫秒漏多少水)
    private final AtomicLong water;             // 当前桶中的水量(剩余未处理请求数)
    private final AtomicLong lastLeakTimestamp; // 上次漏水时间戳

    public LeakyBucketCounterBased(int capacity, double leakRatePerSecond) {
        this.capacity = capacity;
        this.leakRatePerMs = leakRatePerSecond / 1000.0; // 转换为每毫秒速率
        this.water = new AtomicLong(0);
        this.lastLeakTimestamp = new AtomicLong(System.currentTimeMillis());
    }

    public boolean tryAcquire() {
        long now = System.currentTimeMillis();
        long last = lastLeakTimestamp.get();

        // 计算从上次漏水到现在应该漏掉的水量
        long elapsed = now - last;
        long leaked = (long) (elapsed * leakRatePerMs);

        // 更新水量:先减少漏掉的水,然后加1(如果没满)
        while (true) {
            long currentWater = water.get();
            long newWater = Math.max(0, currentWater - leaked); // 漏掉后剩余水量

            if (newWater < capacity) {
                // 桶未满,可以放入新水
                if (water.compareAndSet(currentWater, newWater + 1)) {
                    // 更新漏水时间(需要同步考虑多线程)
                    lastLeakTimestamp.compareAndSet(last, now);
                    return true;
                }
                // CAS失败,重试
            } else {
                // 桶已满,拒绝
                return false;
            }
        }
    }

    // 示例
    public static void main(String[] args) throws InterruptedException {
        LeakyBucketCounterBased limiter = new LeakyBucketCounterBased(5, 2.0); // 容量5,每秒漏2个

        for (int i = 0; i < 10; i++) {
            boolean accepted = limiter.tryAcquire();
            System.out.println("请求 " + i + " 结果: " + (accepted ? "通过" : "限流"));
            Thread.sleep(200);
        }
    }
}
优点

输出速率恒定,能很好地平滑突发流量,避免下游系统被冲垮。 实现简单,特别是基于队列的实现很直观。 内存占用可控(取决于桶容量)。

缺点

对于突发流量,虽然可以暂时缓存,但处理速度是恒定的,可能导致请求等待时间变长(如果桶容量较大且一直满)。 如果桶容量设置过小,会丢弃大量请求;设置过大,可能导致积压请求处理延迟过大。 不适用于需要允许瞬时高并发的场景(除非结合其他算法)

令牌桶算法

介绍

令牌桶算法(Token Bucket)是一种常用的限流算法,特别适合允许一定突发流量同时限制平均速率的场景。 它通过一个固定容量的桶来存放令牌,以恒定速率向桶中添加令牌, 请求到达时需要从桶中获取令牌才能被处理。如果没有令牌,请求要么被拒绝,要么等待

令牌桶与漏桶的对比
  • 令牌桶: 允许一定程度的突发,输出速率可能瞬时超过平均限流速率,但长期平均速率受限于令牌生成速率。
  • 漏桶: 输出速率恒定,无论输入如何突发,输出都被平滑为恒定速率。
基于定时器(ScheduledExecutorService)的实现
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;

public class TokenBucketScheduled {
    private final int capacity;              // 桶容量
    private final double refillTokensPerSecond; // 每秒添加的令牌数
    private final AtomicLong tokens;          // 当前令牌数
    private final ScheduledExecutorService scheduler;

    public TokenBucketScheduled(int capacity, double refillTokensPerSecond) {
        this.capacity = capacity;
        this.refillTokensPerSecond = refillTokensPerSecond;
        this.tokens = new AtomicLong(capacity); // 初始时桶是满的

        this.scheduler = Executors.newScheduledThreadPool(1);
        // 每隔 1/refillTokensPerSecond 秒添加一个令牌
        long periodMicros = (long) (1_000_000 / refillTokensPerSecond);
        scheduler.scheduleAtFixedRate(this::refill, periodMicros, periodMicros, TimeUnit.MICROSECONDS);
    }

    private void refill() {
        // 添加令牌,但不超过容量
        tokens.updateAndGet(current -> Math.min(current + 1, capacity));
    }

    public boolean tryAcquire() {
        // 尝试消耗一个令牌
        while (true) {
            long current = tokens.get();
            if (current > 0) {
                if (tokens.compareAndSet(current, current - 1)) {
                    return true;
                }
                // CAS 失败,重试
            } else {
                return false;
            }
        }
    }

    public void shutdown() {
        scheduler.shutdown();
    }

    // 示例
    public static void main(String[] args) throws InterruptedException {
        TokenBucketScheduled limiter = new TokenBucketScheduled(5, 2.0); // 容量5,每秒生成2个令牌

        for (int i = 0; i < 20; i++) {
            boolean allowed = limiter.tryAcquire();
            System.out.println("请求 " + i + ": " + (allowed ? "通过" : "限流"));
            Thread.sleep(200); // 每200ms发一个请求
        }
        limiter.shutdown();
    }
}
基于时间戳计算的实现(无额外线程)
import java.util.concurrent.atomic.AtomicLong;

public class TokenBucket {
    private final long capacity;              // 桶容量
    private final double refillTokensPerMillis; // 每毫秒添加的令牌数
    private final AtomicLong tokens;           // 当前令牌数
    private final AtomicLong lastRefillTimestamp; // 上次添加令牌的时间戳(毫秒)

    public TokenBucket(long capacity, double refillTokensPerSecond) {
        this.capacity = capacity;
        this.refillTokensPerMillis = refillTokensPerSecond / 1000.0;
        this.tokens = new AtomicLong(capacity);
        this.lastRefillTimestamp = new AtomicLong(System.currentTimeMillis());
    }

    public boolean tryAcquire() {
        return tryAcquire(1);
    }

    public boolean tryAcquire(int numTokens) {
        long now = System.currentTimeMillis();
        long last = lastRefillTimestamp.get();

        // 计算从上次更新到现在应该添加的令牌数
        long elapsed = now - last;
        long tokensToAdd = (long) (elapsed * refillTokensPerMillis);

        // 更新令牌数
        while (true) {
            long currentTokens = tokens.get();
            long newTokens = Math.min(capacity, currentTokens + tokensToAdd);

            if (newTokens >= numTokens) {
                // 有足够令牌,尝试消耗
                if (tokens.compareAndSet(currentTokens, newTokens - numTokens)) {
                    // 更新最后更新时间(使用CAS避免并发问题)
                    lastRefillTimestamp.compareAndSet(last, now);
                    return true;
                }
                // CAS失败,重试
            } else {
                // 令牌不足
                return false;
            }
        }
    }

    // 示例
    public static void main(String[] args) throws InterruptedException {
        TokenBucket limiter = new TokenBucket(5, 2.0); // 容量5,每秒生成2个令牌

        for (int i = 0; i < 20; i++) {
            boolean allowed = limiter.tryAcquire();
            System.out.println("请求 " + i + ": " + (allowed ? "通过" : "限流"));
            Thread.sleep(200);
        }
    }
}

主要框架

单机应用限流

如果你的应用是单体架构,或者只需要在单个JVM进程内进行限流,那么 Guava RateLimiter 是最简单直接的选择。它基于令牌桶算法,API非常简洁,只需一行代码就能创建限流器:
java
RateLimiter rateLimiter = RateLimiter.create(2.0); // 每秒生成2个令牌
它还提供了acquire()(阻塞获取)和tryAcquire()(非阻塞尝试)两种方式,可以灵活应对不同场景。

分布式系统限流

当你的应用部署在多个节点上,需要一个全局统一的限流阈值时,就需要用到分布式限流方案了。
Bucket4j 是一个基于令牌桶算法的Java库,它可以很好地与Redis等中间件集成,实现跨多个节点的分布式限流。如果你的技术栈是纯Java,希望以代码配置的方式实现限流,Bucket4j是个不错的选择。
Sentinel 则是更重量级的分布式系统流量防卫兵。它不仅支持限流,还提供熔断降级、系统负载保护、实时监控和控制台动态配置规则等强大功能,非常适合复杂的微服务架构

微服务与API网关限流

Spring Cloud Gateway 结合 Redis 可以实现基于令牌桶的分布式限流,这是Spring Cloud体系下的标准实践

总结

如果你在写一个小工具或单体应用,Guava 最顺手。
如果系统是分布式的,需要强有力的流量治理,Sentinel 是最佳拍档。
如果你偏爱函数式编程,或者需要灵活组合限流、熔断等多种能力,Resilience4j 值得一试