【知识浅谈】限流及限流算法

54 阅读6分钟

前言

在现代互联网架构中 高并发是每个系统都必须面对的挑战

在这种情况下 为了保证系统服务的高可用 采取合适的容灾备份策略是必不可少的

当流量过大时 应该通过合适的措施来保证系统的可用性

常见的保护措施有降级 限流等...

在我所做的营销场景的抽奖项目中 针对核心接口 如抽奖等

在特定时间节点往往会出现流量过大的问题

为了保证接口的可用性 服务的稳定性 需要采取容灾措施

我们的方案是对接口做限流措施 如果触发限流则拒绝请求 基于动态配置中心来管控限流和降级开关

这对于当前的中小型系统是足够使用的

image.png

如果系统过大 需要建立更为强大 丰富的系统架构

如图:

image.png

限流算法

固定窗口计数法

a.在固定的时间区间内 维护当前区间的最大请求数量以及一个计数器 即当前区间的当前请求数量

b.在请求到达时 获取当前时间窗口 如果是新窗口,重置计数器 如果是当前窗口 计数器+1

c.如果计数器大于当前窗口的最大请求数量 则触发限流 否则通过

这种限流实现简单 但是存在临界问题 限流粒度不够精确

例如:限制 1 秒 5 次,在 0.9s 时来了 5 次,1.1s 又来了 5 次,实际在 0.9~1.1s 的 0.2s 内就有 10 次请求,可能压垮系统。

优点: 实现简单

缺点: 限流粒度不够精确

滑动窗口

核心思想:不设置固定的时间窗口 而是以当前时间为窗口的终点 统计窗口起点到终点的请求次数

如果请求超过了最大请求数量 就触发限流

优点:相比固定窗口 滑动窗口能够避免窗口边界的流量突发造成的临界问题 实现了更加精确的限流

缺点:实现比较复杂 需要额外记录每个请求的时间戳 资源消耗略高

常见实现:

通过有序队列 如Redis的Zset 以时间戳为score 高效获取从当前时间到之前的一段时间范围的请求数量

Lua脚本示例:

-- 清理过期请求
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, tonumber(ARGV[1]) - tonumber(ARGV[2]))
-- 获取当前窗口请求数
local count = redis.call('ZCARD', KEYS[1])
if count < tonumber(ARGV[3]) then
    redis.call('ZADD', KEYS[1], ARGV[1], ARGV[4])  -- 添加新请求
    return 1
else
    return 0
end
特性固定窗口滑动窗口
实现复杂度简单中等
内存占用低(只存计数)较高(需存每个请求的时间戳)
限流精度低(有边界问题)高(平滑)
适用场景粗粒度限流精确限流、防突发

漏桶算法

漏桶算法是一种经典限流算法

核心思想是:以恒定速率处理请求,平滑突发流量,防止系统被瞬时高峰压垮。

类比:

把请求看作水

当请求到达时 相当于向桶里添加水

如果桶满了(突发请求太多了) ,水就会溢出(请求被拒绝)

如果桶没有满(系统能够承载),请求被接收

同时如果桶里有水(有请求待处理) 由于桶的底部是漏的 会按照一定速率流出(系统处理请求)

可以发现漏桶算法可以有效控制请求的数量 和处理速度

优点:严格限速,平滑流量的进出,防止突发流量冲击系统

缺点:不能处理突发流量

典型应用场景:

  • 网络协议中的流量控制(如TCP)

  • API网关限流(严格匀速)

令牌桶算法

令牌桶是一种灵活、高效的限流算法,既能限制平均请求速率,又允许一定程度的突发流量,非常适合对高并发有需求的C端系统

想象一个以固定速度往里加水的桶:

  • 令牌=水

  • 桶容量 = 最大令牌数n

  • 生成令牌的速度 = 平均限流速率

  • 每个请求 = 消耗x个令牌

系统会按照固定速率往水里添加令牌 并且桶有最大容量 令牌满了就不会再加

当请求达到时

如果桶里有令牌 则拿走令牌 请求通过

如果没有令牌 则触发限流 拒绝请求

在实际的代码中 并不会直接实现"系统会按照固定速率往水里添加令牌"

因为这需要一个定时任务去频繁执行 会带来一定的开销

而是维护一个上次补充令牌的时间 计算到当前为止 会添加多少令牌

代码示例:

import java.util.concurrent.atomic.AtomicLong;

public class TokenBucketRateLimiter {
    private final long capacity;          // 桶容量(最大令牌数)
    private final long refillRatePerSec;  // 每秒生成令牌数
    private final long refillIntervalNs;  // 生成一个令牌所需纳秒数

    private final AtomicLong tokens;      // 当前令牌数
    private final AtomicLong lastRefillTime; // 上次补充令牌的时间(纳秒)

    public TokenBucketRateLimiter(long capacity, long refillRatePerSec) {
        this.capacity = capacity;
        this.refillRatePerSec = refillRatePerSec;
        this.refillIntervalNs = 1_000_000_000L / refillRatePerSec; // 纳秒
        this.tokens = new AtomicLong(capacity); // 初始满桶
        this.lastRefillTime = new AtomicLong(System.nanoTime());
    }

    /**
     * 尝试获取一个令牌
     * @return true 表示允许,false 表示被限流
     */
    public boolean tryAcquire() {
        long now = System.nanoTime();
        long last = lastRefillTime.get();
        
        // 计算自上次以来应生成的令牌数
        long tokensToAdd = (now - last) / refillIntervalNs;
        
        if (tokensToAdd > 0) {
            // CAS 更新令牌数和时间(避免并发问题)
            while (true) {
                long currentTokens = tokens.get();
                long newTokens = Math.min(capacity, currentTokens + tokensToAdd);
                long expectLast = lastRefillTime.get();
                if (lastRefillTime.compareAndSet(expectLast, now) && tokens.compareAndSet(currentTokens, newTokens)) {
                    break;
                }
                // 若 CAS 失败,重新读取
                last = lastRefillTime.get();
                now = System.nanoTime();
                tokensToAdd = (now - last) / refillIntervalNs;
            }
        }

        // 尝试消费一个令牌
        long currentTokens = tokens.get();
        while (currentTokens > 0) {
            if (tokens.compareAndSet(currentTokens, currentTokens - 1)) {
                return true;
            }
            currentTokens = tokens.get();
        }
        return false; // 无令牌,限流
    }
}



public class Main {
    public static void main(String[] args) throws InterruptedException {
        TokenBucketRateLimiter limiter = new TokenBucketRateLimiter(5, 2); // 容量5,每秒2个令牌
        for (int i = 0; i < 8; i++) {
            boolean allowed = limiter.tryAcquire();
            System.out.println("Request " + (i + 1) + ": " + (allowed ? "ALLOWED" : "DENIED"));
            Thread.sleep(100); // 每100ms一次(10次/秒,超过2次/秒)
        }
    }
}

优点

  • 允许突发流量

  • 可以控制平均速率

  • 资源消耗低 只需计数 不需要存储其他资源

缺点

  • 实现相对复杂

  • 需要处理并发问题

令牌桶和漏桶的比较

特性令牌桶漏桶
是否允许突发
流量是否平滑错 输入可突发对 流量平滑
适用场景API 限流、高并发场景的用户请求控制网络流量整形、严格匀速处理
实现复杂度中等中等
用户体验更友好 大部分情况下能快速响应一般,流量较高时容易触发限流 包括拒绝 重试等待

小结

这篇文章简要讲解了 限流的必要性限流算法的基础概念 从个人笔记中整理而来