利用固定窗口计数算法限流,精准控制第三方 API 调用频率

使用场景

在调用第三方 API 时,我们通常会在 API 文档中看到对接口访问频率的限制。为了确保不超出这些限制,使用限流算法来控制 API 调用频率是至关重要的。

在这里插入图片描述

在作为 API 的使用者时,我们通常需要参考并使用限流算法来控制 API 调用的频率,确保不超过提供方设定的访问限制。例如,我们可以使用固定窗口计数算法来有效管理调用频率。

如果你是 API 的提供者,常用的限流算法则会有所不同。令牌桶算法及其变体通常被广泛应用,它们不仅能有效控制请求速率,还允许一定程度的突发流量,从而提升系统的灵活性和可靠性。

在这里插入图片描述

使用固定窗口计数算法管理调用频率

LimitUtil 实现的限流机制属于 固定窗口计数算法(Fixed Window Counter) 的一种变体。

代码中下面两个参数至关重要,根据自己的需求修改。

  1. N:时间窗口内最多允许的请求次数。这个参数直接决定了在指定时间窗口内可以处理的最大请求量。如果超过这个数量,多余的请求将被延迟或拒绝。
  2. bucket:时间窗口的大小。这个参数定义了时间窗口的长度(通常以毫秒为单位)。它决定了在多长时间内会统计和限制请求次数。

通过合理设置这两个参数,可以有效控制 API 的调用频率,避免超出限制。

代码如下:

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

public class LimitUtil {
    // 用于循环访问 requestTimeList 的索引,volatile 保证多线程之间的可见性
    private static volatile int seq = 0;

    // 时间窗口大小,单位为毫秒,这里设置为1000毫秒(即1秒)
    private static final int bucket = 1000;

    // 用于存储最近 N 次请求的时间戳列表,volatile 保证线程安全
    private static volatile List<Long> requestTimeList;

    // 每个 bucket 时间窗口内最多允许的请求次数,这里按需求自定义
    private static final int N = 1;

    static {
        // 初始化 requestTimeList,为长度为 N 的列表,所有值均为 0L
        requestTimeList = new ArrayList<>(Collections.nCopies(N, 0L));
    }

    // 私有构造函数,防止实例化该工具类
    private LimitUtil() {
    }

    /**
     * 限流实现,使用 synchronized 修饰,表示对整个类上锁,保证线程安全
     *
     * @throws InterruptedException 如果线程被中断,则抛出该异常
     */
    public static synchronized void tryBeforeRun() throws InterruptedException {
        // 获取当前系统时间(毫秒)
        long now = System.currentTimeMillis();

        // 计算当前请求时间与 seq 索引处的上一次请求时间的间隔
        long interval = now - requestTimeList.get(seq);

        if (interval < 0) {
            // 如果当前时间早于上次请求时间(即 interval < 0),需要等待到上次请求的下一个时间点再执行
            // 计算等待时间,并让线程等待
            Thread.sleep(bucket - interval);

            // 递归调用自己,确保限流逻辑执行
            tryBeforeRun();
        }

        if (interval < bucket) {
            // 如果时间间隔小于 bucket 时间窗口,说明请求太快,需要延迟执行
            requestTimeList.set(seq, requestTimeList.get(seq) + bucket);
            Thread.sleep(bucket - interval);
        } else {
            // 否则,更新当前索引处的请求时间为当前时间
            requestTimeList.set(seq, now);
        }

        // 更新 seq,指向下一个索引位置,采用取模操作实现循环
        seq = (seq + 1) % requestTimeList.size();
    }
}

使用测试

我下面使用 100 个线程并发调用 API,并在调用 API 之前引入限流方法。通过打印每次 API 调用的时间戳,可以观察并验证限流算法的效果以及 API 调用频率的变化。

    @Test
    public void testLimitUtil() {
        // 100个线程并发测试
        ThreadUtil.concurrencyTest(100, () -> {
            try {
                LimitUtil.tryBeforeRun();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            this.api();
        });
    }

    private void api() {
        long now = System.currentTimeMillis();
        System.out.println("API 调用时间: " + now / 1000 + "秒,由线程 " + Thread.currentThread().getName() + " 执行");
        // 模拟业务逻辑的处理
    }

设定的 N = 1 和 bucket = 1000 的参数配置。

API 调用时间: 1724903993秒,由线程 hutool-4 执行
API 调用时间: 1724903994秒,由线程 hutool-9 执行
API 调用时间: 1724903995秒,由线程 hutool-7 执行
API 调用时间: 1724903996秒,由线程 hutool-6 执行
API 调用时间: 1724903997秒,由线程 hutool-8 执行
API 调用时间: 1724903998秒,由线程 hutool-2 执行
API 调用时间: 1724903999秒,由线程 hutool-5 执行
API 调用时间: 1724904000秒,由线程 hutool-100 执行
更多的就不展示了。

通过测试结果,可以确认:
限流机制有效:API 调用频率严格控制在每秒一次,符合设定的参数。
无并发问题:每个 API 调用的时间戳显示在不同的秒数,说明限流机制成功避免了多个线程在同一时间窗口内同时调用 API。

一秒钟执行五次

一秒钟执行五次只需要把N调整为5即可。

    // 每个 bucket 时间窗口内最多允许的请求次数,这里按需求自定义
    private static final int N = 5;

测试部分结果如下,符合预期,一秒钟执行最多执行5次api。

API 调用时间: 1724910227秒,由线程 hutool-25 执行
API 调用时间: 1724910227秒,由线程 hutool-23 执行
API 调用时间: 1724910227秒,由线程 hutool-10 执行
API 调用时间: 1724910227秒,由线程 hutool-24 执行
API 调用时间: 1724910227秒,由线程 hutool-5 执行

API 调用时间: 1724910228秒,由线程 hutool-8 执行
API 调用时间: 1724910228秒,由线程 hutool-38 执行
API 调用时间: 1724910228秒,由线程 hutool-3 执行
API 调用时间: 1724910228秒,由线程 hutool-9 执行
API 调用时间: 1724910228秒,由线程 hutool-4 执行

代码逻辑演示如下。

在这里插入图片描述

适用场景

这种算法适用于简单的场景,比如在1秒内只允许某个操作执行一次。它的实现简单,适合于限制在某段时间内的请求次数,但可能会在窗口边界处出现短时间内的突发流量问题(即"临界点问题"),这也是固定窗口计数算法的一般特点。

临界点问题验证

    public static void main(String[] args) throws InterruptedException {
        testCriticalPoint();
    }

    public static void testCriticalPoint() throws InterruptedException {
        // 第一个请求,在接近时间窗口的末尾发起
        Thread.sleep(950); // 等待950毫秒,使其接近1秒窗口的结束
        System.out.println("第一次请求: " + System.currentTimeMillis() / 1000 + " 秒");
        LimitUtil.tryBeforeRun();

        // 第二个请求,在下一个时间窗口的开始发起
        Thread.sleep(50); // 等待50毫秒,跨越到下一个时间窗口
        System.out.println("第二次请求: " + System.currentTimeMillis() / 1000 + " 秒");
        LimitUtil.tryBeforeRun();
    }

运行结果:

第一次请求: 1724909724 秒
第二次请求: 1724909724 秒

在上述测试中,LimitUtil 在这两个请求之间没有进行限流,这说明在临界点时 LimitUtil 允许超出预期的请求数量,临界点问题确实存在。

分析:
临界点问题发生的原因在于,固定窗口计数算法只关注窗口内的请求数量,而没有考虑跨越窗口边界时的请求情况。
在这个例子中,虽然两个请求是在不同的时间窗口发起的,但由于时间窗口的计算是以固定的时间单位为基础(例如以秒为单位),因此可能会在窗口边界附近发生短时间内处理多个请求的情况。

解决方案:
为了避免临界点问题,可以考虑使用以下方法:

**滑动窗口计数算法(**Sliding Window Counter):
通过对请求的时间戳进行更精细的记录,并在窗口内的每个时刻统计请求数量,避免在窗口边界处出现突发流量。

令牌桶算法(Token Bucket Algorithm):
通过生成和消耗令牌来控制请求速率,允许一定的突发流量,同时控制平均请求速率,平滑处理流量。

滑动窗口平均算法(Sliding Window Rate Limiting):
将时间窗口进一步细分为更小的子窗口,计算多个子窗口的平均请求数量,避免在窗口边界处出现流量峰值。

  • 14
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值