深入浅出:限流的三个核心思路与实践

在分布式系统和高并发场景下,保护系统免于被突如其来的流量冲垮是至关重要的。想象一下,在一个电商平台的秒杀活动中,如果没有有效的保护措施,海量的请求可能会瞬间耗尽数据库连接、拖垮服务器,导致整个服务不可用。限流 正是这样一道重要的防线,它通过控制单位时间内能够处理的请求数量,来确保系统在承受范围内平稳运行,同时保障核心服务的可用性。

限流不仅仅是为了防止系统崩溃,它还有助于:

  • 保证服务质量:为合法用户提供稳定、可预期的服务体验。
  • 防止恶意攻击:抵御如DDoS攻击、爬虫刷接口等恶意流量。
  • 实现成本控制:对于按使用量计费的云服务,限流可以避免因意外流量导致的巨额成本。

本文将深入探讨限流的三个经典思路:计数器算法滑动窗口算法漏桶与令牌桶算法,分析其原理、优缺点,并给出实践建议。

目录#

  1. 思路一:计数器算法
  2. 思路二:滑动窗口算法
  3. 思路三:漏桶与令牌桶算法
  4. 总结与最佳实践
  5. 参考资料

思路一:计数器算法#

计数器算法是最简单、最直观的限流方法。

原理#

其核心思想是:在一个固定的时间窗口内,对请求进行计数。当请求数超过设定的阈值时,就触发限流(如拒绝或排队),直到时间窗口重置,计数器归零。

  • 关键参数
    • threshold:时间窗口内的最大请求数阈值。
    • interval:时间窗口大小(如1秒、1分钟)。

优缺点#

  • 优点
    • 实现简单:逻辑清晰,易于理解和实现。
    • 内存友好:通常只需要存储一个计数器和窗口开始时间。
  • 缺点
    • 临界问题(Boundary Issue):这是计数器算法最显著的缺点。假设我们限制每分钟100个请求。如果在时间窗口的最后1秒(如第59秒)和第2个时间窗口的第1秒,分别涌入100个请求,那么在短短2秒内系统实际处理了200个请求,这很可能超出系统的承受能力。

实践与代码示例#

计数器算法适用于对精度要求不高的简单场景。

示例:使用 Redis 实现 PHP 计数器限流

<?php
function isRateLimited($userId, $action, $limit, $interval) {
    $redis = new Redis();
    $redis->connect('127.0.0.1', 6379);
 
    // 构造唯一的键,例如:rate_limit:user_123:login
    $key = "rate_limit:user_{$userId}:{$action}";
    
    // 获取当前计数
    $currentCount = $redis->get($key);
    if ($currentCount === false) {
        // 键不存在,说明是窗口内的第一个请求,设置键并过期时间
        $redis->setex($key, $interval, 1);
        return false; // 不限流
    }
 
    if ($currentCount < $limit) {
        // 计数器加1
        $redis->incr($key);
        return false; // 不限流
    } else {
        return true; // 限流
    }
}
 
// 使用示例:检查用户123的登录操作是否在60秒内超过5次
if (isRateLimited(123, 'login', 5, 60)) {
    http_response_code(429);
    echo "请求过于频繁,请稍后再试。";
    exit;
}
// 正常处理请求

思路二:滑动窗口算法#

滑动窗口算法是对计数器算法的改进,旨在解决其临界问题。

原理#

它将固定的时间窗口划分为更小的时间片。窗口在时间轴上“滑动”,而不是在固定点重置。每次请求到来时,系统会统计当前时间点往前推一个窗口大小内的所有请求数量。

  • 关键参数
    • threshold:窗口内的最大请求数阈值。
    • windowSize:窗口大小。
    • sliceNum:将窗口划分为的时间片数量。

例如,将1分钟的窗口划分为6个10秒的时间片。当一个新的请求在第50秒到达时,我们计算从第10秒到第50秒(最近4个时间片)内的总请求数,而不是死板地从第0秒开始计算。

优缺点#

  • 优点
    • 平滑流量:有效缓解了计数器算法的临界问题,流量控制更加平滑和精确。
    • 可调整精度:通过调整时间片的大小,可以在精度和内存开销之间取得平衡(时间片越小越精确,但存储成本越高)。
  • 缺点
    • 实现相对复杂:需要维护多个时间片的计数。
    • 内存占用更高:需要存储多个时间片的数据。

实践与代码示例#

滑动窗口是现代API网关和限流库(如Spring Cloud Gateway, Alibaba Sentinel)中广泛采用的算法。

示例:简单滑动窗口思路(伪代码)

from time import time
 
class SlidingWindow:
    def __init__(self, limit, window_size_sec):
        self.limit = limit
        self.window_size_sec = window_size_sec
        # 使用一个列表来存储每次请求的时间戳
        self.requests = []
 
    def allow_request(self):
        now = time()
        # 1. 移除过期的时间戳(超出当前窗口范围的请求)
        cutoff_time = now - self.window_size_sec
        # 保留时间戳大于 cutoff_time 的请求
        self.requests = [req_time for req_time in self.requests if req_time > cutoff_time]
 
        # 2. 检查当前窗口内的请求数是否超过阈值
        if len(self.requests) < self.limit:
            self.requests.append(now)
            return True  # 允许请求
        else:
            return False # 拒绝请求
 
# 使用示例:限制每秒10个请求
limiter = SlidingWindow(limit=10, window_size_sec=1)
for i in range(15):
    if limiter.allow_request():
        print(f"请求 {i} 被允许")
    else:
        print(f"请求 {i} 被限流")

在实际应用中,通常会使用Redis的ZSet(有序集合)或环形队列来高效实现滑动窗口。


思路三:漏桶与令牌桶算法#

前两种算法主要关注请求数量,而漏桶和令牌桶算法则更关注请求的速率流量的平滑度

漏桶算法#

  • 原理:想象一个底部有固定大小出水口的漏桶。
    1. 请求像水一样以任意速率流入桶中。
    2. 如果桶满了,多余的请求(水)就会被溢出(拒绝)。
    3. 桶底以一个恒定的速率处理请求(水流出)。
  • 特点强行限制了数据的传输速率,无论上游流量多汹涌,下游的处理速度始终是恒定的。这非常适合保护下游系统,例如防止数据库被突发流量击垮。

令牌桶算法#

  • 原理:想象一个以恒定速率向桶里放入令牌的令牌桶。
    1. 令牌以固定的速率(如每秒10个)被添加到桶中,直到桶满为止。
    2. 每个请求需要从桶中获取一个(或多个)令牌才能被处理。
    3. 如果桶中有足够的令牌,请求被允许,令牌被消耗。
    4. 如果桶为空,请求则被拒绝或等待。
  • 特点:允许一定程度的突发流量。如果桶中有积攒的令牌,系统可以瞬间处理大量请求(最多不超过桶的大小)。这比漏桶算法更具弹性,更符合实际业务中可能出现的“突发”场景。

漏桶 vs. 令牌桶#

特性漏桶算法令牌桶算法
核心目标平滑流量,输出恒定速率限制平均速率,但允许突发流量
流量形状严格的输出速率,无突发有突发,只要桶中有令牌
适用场景保护下游系统,防止被冲垮应对正常流量中的突发,如秒杀、API调用
实现难度相对简单相对复杂

实践与代码示例#

令牌桶算法是业界最常用、最灵活的限流算法之一。Google Guava 库提供了优秀的实现。

示例:使用 Google Guava 的 RateLimiter (Java)

import com.google.common.util.concurrent.RateLimiter;
 
public class TokenBucketDemo {
    public static void main(String[] args) {
        // 创建一个每秒产生2个令牌的限流器
        RateLimiter rateLimiter = RateLimiter.create(2.0); // 2 permits per second
 
        for (int i = 0; i < 10; i++) {
            // 尝试获取1个令牌,如果没有则等待,直到获取成功
            double waitTime = rateLimiter.acquire();
            System.out.println("处理请求 " + i + ", 等待了 " + waitTime + " 秒");
 
            // 非阻塞尝试
            /*
            if (rateLimiter.tryAcquire()) {
                // 成功获取令牌,处理请求
                System.out.println("处理请求 " + i);
            } else {
                // 获取失败,立即返回或执行降级策略
                System.out.println("请求被限流 " + i);
            }
            */
        }
    }
}

总结与最佳实践#

算法核心思路优点缺点适用场景
计数器固定窗口计数简单、高效临界问题、不够平滑简单校验、对精度要求不高
滑动窗口将窗口细分并滑动缓解临界问题、更精确实现和存储比计数器复杂API网关、精细化的接口限流
漏桶以恒定速率处理绝对平滑输出、保护下游无法应对合理突发数据库访问、严格限制处理速率
令牌桶定期放入令牌,消耗令牌允许突发、灵活弹性实现相对复杂绝大多数业务场景,如秒杀、API调用

最佳实践建议:

  1. 分层与分级:不要只在一个层面做限流。可以在接入层(Nginx)、网关层(如Spring Cloud Gateway)和应用层(如Sentinel)同时设置限流,形成多级防护。
  2. 动态配置:限流规则(如阈值)应该是可动态配置的,以便在业务高峰或系统扩容时快速调整,无需重启服务。
  3. 区别对待:对不同用户、不同API实施不同的限流策略。例如,对VIP用户设置更高的限流阈值,对核心接口给予更多资源。
  4. 友好的降级策略:当触发限流时,不应简单地返回“500 Error”,而应返回 429 Too Many Requests 状态码,并给出清晰的提示信息,或返回一个默认的降级内容(如排队中、稍后重试等)。
  5. 监控与告警:对限流事件进行监控和记录,当限流频繁触发时,应及时告警,这可能是系统容量不足或遭受攻击的信号。

参考资料#

  1. Google Guava RateLimiter Documentation
  2. Alibaba Sentinel GitHub Wiki
  3. Nginx Limit Req Module
  4. Spring Cloud Gateway - RequestRateLimiter
  5. Wikipedia: Leaky bucket, Token bucket