4. 限流算法基础(一):计数器算法原理、实现方式、优缺点分析

在催收触达系统中,API 网关需要应对突发流量、异常重试以及爬虫攻击。计数器算法是最直观的限流手段,它通过统计单位时间内的请求次数来决定是否放行。本节将深入剖析其原理、代码实现与适用边界。

4.1 算法原理

计数器算法维护一个时间窗口(如 1 秒)和一个计数器。每当请求到达时,执行以下逻辑:

  1. 检查时间窗口:若当前时间已超出窗口起始时间 + 窗口长度,则重置计数器并更新窗口起始时间。
  2. 递增计数器:将计数器加 1。
  3. 阈值判断:若计数器 ≤ 阈值,放行请求;否则拒绝请求。
核心公式:
窗口内累计请求数 ≤ 阈值 → 通过;否则 → 拒绝。

下图展示了计数器算法在 1 秒窗口、阈值 5 的场景下的工作流程:

计数器算法限流流程(窗口 1s / 阈值 5) 请求到达 是否在同一个时间窗口? 否 重置计数器 是 计数器 +1 计数器 ≤ 阈值? 是 放行请求 否 拒绝请求(429)

4.2 实现方式

计数器算法在单机环境与分布式环境下的实现差异较大。以下分别给出 Java 伪代码与 Redis 实现。

4.2.1 单机实现(Java 伪代码)

public class CounterRateLimiter {
    private final long windowSizeMillis;   // 窗口大小,单位毫秒
    private final int threshold;           // 阈值
    private long windowStart;              // 当前窗口起始时间戳
    private int counter;                   // 当前计数器

    public CounterRateLimiter(long windowSizeMillis, int threshold) {
        this.windowSizeMillis = windowSizeMillis;
        this.threshold = threshold;
        this.windowStart = System.currentTimeMillis();
        this.counter = 0;
    }

    public synchronized boolean tryAcquire() {
        long now = System.currentTimeMillis();
        // 1. 检查是否进入新窗口
        if (now - windowStart >= windowSizeMillis) {
            windowStart = now;
            counter = 0;
        }
        // 2. 递增计数器
        counter++;
        // 3. 判断是否超过阈值
        return counter <= threshold;
    }
}
注意:单机实现必须加 synchronized 或使用 AtomicInteger 保证线程安全。高并发下锁竞争可能成为瓶颈。

4.2.2 分布式实现(Redis + Lua)

利用 Redis 的 INCR 和 EXPIRE 命令,配合 Lua 脚本保证原子性:

-- Lua 脚本:counter_rate_limiter.lua
local key = KEYS[1]               -- 限流 key,如 "rate_limit:api:v1"
local threshold = tonumber(ARGV[1]) -- 阈值
local windowSeconds = tonumber(ARGV[2]) -- 窗口大小(秒)

local current = redis.call('INCR', key)
if current == 1 then
    -- 第一次访问,设置过期时间
    redis.call('EXPIRE', key, windowSeconds)
end

if current > threshold then
    return 0  -- 拒绝
else
    return 1  -- 放行
end

调用方通过 EVALSHA 执行该脚本,返回 1 表示放行,0 表示拒绝。

4.3 优缺点分析

维度 说明
优点
  • 实现简单,代码量少,易于理解与维护。
  • 内存占用极低,仅需存储窗口起始时间与计数器。
  • 在均匀流量场景下限流效果准确。
缺点
  • 临界突变问题:窗口切换瞬间可能涌入 2 倍阈值的请求(见下方图示)。
  • 精度受窗口大小限制:窗口越小,计数器重置越频繁,性能开销增大。
  • 无法应对突发流量:窗口内均匀分布与瞬间打满阈值,算法无法区分。

4.4 临界突变问题详解

计数器算法最典型的缺陷是“窗口边界突刺”。假设阈值 5,窗口 1 秒:

  • 第 0.9 秒时计数器为 5,后续请求被拒绝。
  • 第 1.0 秒窗口重置,计数器归零。
  • 第 1.01 秒又有 5 个请求同时到达,全部放行。

实际在 0.2 秒内(0.9s ~ 1.1s)通过了 10 个请求,远超阈值。下图直观展示该问题:

计数器算法临界突变问题示意(阈值 5 / 窗口 1s) 0.0s 0.9s 1.1s 窗口 1(0.0s ~ 1.0s) 窗口 2(1.0s ~ 2.0s) ×5 ×5 ⚠ 0.9s ~ 1.1s 内实际通过 10 个请求 窗口切换瞬间,计数器归零,导致流量突刺
催收场景警示:若催收系统在整点或整秒批量发送触达请求,计数器算法的临界突变可能导致下游催收通道被瞬间打满,引发通道超时或封禁。建议结合滑动窗口或令牌桶算法使用。

4.5 适用场景与选型建议

场景 推荐度 原因
内部测试环境限流 ★★★★★ 实现简单,流量可控,无需高精度
非核心 API 的粗粒度限流 ★★★★☆ 可接受少量突刺,维护成本低
催收触达核心通道限流 ★★☆☆☆ 临界突变风险高,建议使用滑动窗口
高精度计费或配额控制 ★☆☆☆☆ 无法保证窗口内均匀分布,易产生争议
实践建议:在催收触达系统中,计数器算法可作为“第一道防线”用于拦截明显超量的请求(如每秒超过 1000 次),再配合更精细的限流算法做二次控制。切勿在核心催收通道单独使用计数器算法。