日常业务系统中,限流一般分为两种限流场景:

  • 1、基于服务自身保护的应用自身限流。比如每一个启动的 springboot 实例服务都可以受理最大每秒150个请求的量,服务需要保护自身不被击垮对启动的每一个服务实例都进行限流处理。
  • 2、基于业务系统入口的总限流。比如某业务对外提供了一个api接口,系统经过实测日常可以承载最大10000每秒的请求频率,但是该接口是提供给很多供应商同时使用的,因业务规则实际需要,要求对某个具体渠道的调用最大限流是50,这种就是在总入口处进行限流)。

其中第1种场景,使用 Java 内存级的限流即可实现。
对于第2种场景,需要使用例如 Redis 这样高性能的共享存储的方式来实现。

基于 Java 代码的限流

本文使用 Java JUC 包中的 ConcurrentSkipListMapConcurrentLinkedQueue 集合来实现滑动窗口限流。

示例一,使用 ConcurrentSkipListMap

import java.util.concurrent.ConcurrentSkipListMap;
import java.util.concurrent.TimeUnit;

public class SlidingWindowRateLimiter {
    private final long windowSizeMs;
    private final int maxRequests;
    private final ConcurrentSkipListMap<Long, Integer> requestTimestamps;

    public SlidingWindowRateLimiter(long windowSizeMs, int maxRequests) {
        this.windowSizeMs = windowSizeMs;
        this.maxRequests = maxRequests;
        this.requestTimestamps = new ConcurrentSkipListMap<>();
    }

    public boolean allowRequest() {
        long currentTime = System.currentTimeMillis();
        long startTime = currentTime - windowSizeMs;

        // 移除超过时间窗口的请求
        requestTimestamps.headMap(startTime, false).clear();

        // 统计当前时间窗口内的请求数量
        int currentRequests = requestTimestamps.size();

        if (currentRequests < maxRequests) {
            requestTimestamps.put(currentTime, 1);
            return true;
        } else {
            return false;
        }
    }

    public static void main(String[] args) throws InterruptedException {
        SlidingWindowRateLimiter limiter = new SlidingWindowRateLimiter(1000, 5);

        for (int i = 0; i < 10; i++) {
            if (limiter.allowRequest()) {
                System.out.println("Request " + i + " allowed at " + System.currentTimeMillis());
            } else {
                System.out.println("Request " + i + " denied at " + System.currentTimeMillis());
            }
            TimeUnit.MILLISECONDS.sleep(100);
        }
    }
}

示例二,使用 ConcurrentLinkedQueue

import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.TimeUnit;

public class SlidingWindowRateLimiter2 {
    private final long windowSizeMs;
    private final int maxRequests;
    private final ConcurrentLinkedQueue<Long> requestTimestamps;

    public SlidingWindowRateLimiter2(long windowSizeMs, int maxRequests) {
        this.windowSizeMs = windowSizeMs;
        this.maxRequests = maxRequests;
        this.requestTimestamps = new ConcurrentLinkedQueue<>();
    }

    public boolean allowRequest() {
        long currentTime = System.currentTimeMillis();
        long startTime = currentTime - windowSizeMs;

        // 移除超过时间窗口的请求
        while (!requestTimestamps.isEmpty() && requestTimestamps.peek() < startTime) {
            requestTimestamps.poll();
        }

        // 统计当前时间窗口内的请求数量
        int currentRequests = requestTimestamps.size();

        if (currentRequests < maxRequests) {
            requestTimestamps.add(currentTime);
            return true;
        } else {
            return false;
        }
    }

    public static void main(String[] args) throws InterruptedException {
        SlidingWindowRateLimiter limiter = new SlidingWindowRateLimiter(1000, 5);

        for (int i = 0; i < 10; i++) {
            if (limiter.allowRequest()) {
                System.out.println("Request " + i + " allowed at " + System.currentTimeMillis());
            } else {
                System.out.println("Request " + i + " denied at " + System.currentTimeMillis());
            }
            TimeUnit.MILLISECONDS.sleep(100);
        }
    }
}

对比总结:

ConcurrentSkipListMap 的主要特性就是进行查找、插入和删除操作时更高效,内部是基于跳表结构实现的。可以保证Key的顺序。
ConcurrentLinkedQueue 的顾名思义就是队列,查找效率相对较低,但是内存占用比 ConcurrentSkipListMap 少一点。顺序严格安装入队的顺序。

  • 如果时间窗口内的请求数量较大,并且你需要高效的查找和移除操作,推荐使用 ConcurrentSkipListMap。它提供了有序性和高效的 O(log n) 操作,适合大规模数据的处理。
  • 如果时间窗口内的请求数量较小,并且你更关心内存开销和插入/删除的效率,推荐使用 ConcurrentLinkedQueue。它提供了 O(1) 的插入和删除操作,适合小规模数据的处理。

绝大部分的应用,其实不比太纠结,两者随便选用。

基于 Redis 的限流脚本

在实际项目应用中,我们的服务实例是多个的,在内存中使用有序集合来实现限流就不可行了,下面是 Redis 使用 lua 脚本进行限流的脚本,可以参考使用:

--KEYS[1]: 限流 key
--ARGV[1]: 限流窗口,毫秒
--ARGV[2]: 当前时间戳(作为score)
--ARGV[3]: 阈值
--ARGV[4]: score 对应的唯一value
-- 1\. 移除开始时间窗口之前的数据
redis.call('zremrangeByScore', KEYS[1], 0, ARGV[2]-ARGV[1])
-- 2\. 统计当前元素数量
local res = redis.call('zcard', KEYS[1])
-- 3\. 是否超过阈值
if (res == nil) or (res < tonumber(ARGV[3])) then
    redis.call('zadd', KEYS[1], ARGV[2], ARGV[4])
    redis.call('expire', KEYS[1], ARGV[1]/1000)
    return 0
else
    return 1
end

(END)