使用PHP实现高性能Rate Limiting:基于Redis的滑动窗口与漏桶算法实践

PHP高性能Rate Limiting:基于Redis的滑动窗口与漏桶算法实践

大家好,今天我们来聊聊如何在PHP中实现高性能的Rate Limiting,也就是速率限制,或者说限流。 限流在Web应用中至关重要,它可以保护我们的服务免受恶意攻击、防止资源耗尽,并保证服务的稳定性和可用性。 本次分享我们将深入探讨两种常见的限流算法:滑动窗口和漏桶,并结合Redis,演示如何在PHP中高效地实现它们。

为什么要进行Rate Limiting?

在深入代码之前,我们先明确为什么要进行Rate Limiting。 想象一下,如果没有限流,恶意用户或爬虫可能会在短时间内发起大量请求,导致服务器负载过高,甚至崩溃。 更严重的是,DDoS攻击(分布式拒绝服务攻击)就是利用大量请求来瘫痪目标服务器。

Rate Limiting可以有效防止这些问题,它通过限制单个用户或IP地址在特定时间段内可以发起的请求数量,从而保护我们的服务。 典型的应用场景包括:

  • 防止暴力破解:限制登录接口的请求频率,防止恶意用户通过不断尝试密码来破解账户。
  • 防止恶意爬虫:限制爬虫抓取数据的速度,避免对服务器造成过大的压力。
  • 保护API接口:限制API接口的调用频率,防止被滥用。
  • 保证服务质量:在高并发场景下,限制用户的请求速度,保证服务的稳定性和响应速度。

Rate Limiting算法:滑动窗口与漏桶

我们接下来介绍两种常用的Rate Limiting算法:滑动窗口和漏桶。

1. 滑动窗口算法

滑动窗口算法是一种基于时间窗口的限流算法。 它将时间划分为多个固定大小的窗口,并记录每个窗口内的请求数量。 当新的请求到达时,算法会检查当前窗口内的请求数量是否超过了设定的阈值。 如果超过了,则拒绝该请求;否则,允许该请求通过,并将当前窗口的请求数量加1。

滑动窗口的优势在于它能够更精确地控制请求的速率,因为它考虑了时间因素。 例如,如果设置1分钟内允许100个请求,那么滑动窗口算法会确保在任何1分钟的时间段内,请求数量都不会超过100个。

2. 漏桶算法

漏桶算法将请求视为水滴,并将服务器视为一个固定容量的桶。 请求(水滴)以任意速率流入桶中,而桶以固定的速率流出水滴。 如果流入速度过快,导致桶溢出,则溢出的请求会被丢弃。

漏桶算法的优势在于它可以平滑请求的流量,使其以恒定的速率处理。 这对于防止突发流量冲击服务器非常有效。 类似于一个缓冲区,即使短时间内涌入大量请求,也能通过控制流出速率来保证服务器的稳定。

特性 滑动窗口 漏桶
流量控制 基于时间窗口,更精确 基于固定速率流出,更平滑
突发流量处理 允许一定程度的突发流量 平滑突发流量,防止服务器过载
实现复杂度 相对复杂 相对简单
适用场景 需要精确控制请求速率的场景 需要平滑流量,防止突发流量的场景

基于Redis实现滑动窗口

我们将使用Redis的有序集合(Sorted Set)来实现滑动窗口算法。 有序集合中的每个元素都包含一个value和一个score。 我们可以将请求的时间戳作为score,将用户的唯一标识(例如,IP地址或用户ID)作为value。

基本思路:

  1. 记录请求: 当收到一个请求时,我们将当前时间戳作为score,用户ID作为value,添加到Redis的有序集合中。
  2. 清理过期请求: 同时,我们需要清理掉时间窗口之外的请求记录。 也就是说,我们需要移除score小于当前时间戳减去时间窗口大小的元素。
  3. 判断是否超过阈值: 最后,我们统计有序集合中元素的数量,如果数量超过了设定的阈值,则拒绝该请求。

PHP代码示例:

<?php

class SlidingWindowRateLimiter {

    private $redis;
    private $keyPrefix;
    private $windowSize; // 时间窗口大小,单位:秒
    private $maxRequests; // 最大请求数量

    public function __construct(Redis $redis, string $keyPrefix, int $windowSize, int $maxRequests) {
        $this->redis = $redis;
        $this->keyPrefix = $keyPrefix;
        $this->windowSize = $windowSize;
        $this->maxRequests = $maxRequests;
    }

    public function isAllowed(string $userId): bool {
        $key = $this->keyPrefix . ':' . $userId;
        $now = time();

        // 1. 清理过期请求
        $this->redis->zRemRangeByScore($key, 0, $now - $this->windowSize);

        // 2. 记录当前请求
        $this->redis->zAdd($key, $now, $now);

        // 3. 统计当前窗口内的请求数量
        $requestCount = $this->redis->zCard($key);

        // 4. 判断是否超过阈值
        if ($requestCount > $this->maxRequests) {
            return false; // 超过阈值,拒绝请求
        }

        // 设置过期时间,防止key无限增长
        $this->redis->expire($key, $this->windowSize + 1); // 略微延长过期时间,防止并发问题

        return true; // 允许请求
    }
}

// 使用示例
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);

$rateLimiter = new SlidingWindowRateLimiter($redis, 'user_rate_limit', 60, 100); // 每分钟最多100个请求

$userId = 'user123';

if ($rateLimiter->isAllowed($userId)) {
    // 处理请求
    echo "Request allowed for user: " . $userId . "n";
} else {
    // 拒绝请求
    http_response_code(429); // Too Many Requests
    echo "Rate limit exceeded for user: " . $userId . "n";
}

$redis->close();

?>

代码解释:

  • SlidingWindowRateLimiter类封装了滑动窗口算法的实现。
  • $redis:Redis连接对象。
  • $keyPrefix:Redis key的前缀,用于区分不同的限流策略。
  • $windowSize:时间窗口的大小,单位为秒。
  • $maxRequests:允许的最大请求数量。
  • isAllowed()方法:判断用户是否允许发起请求。
    • zRemRangeByScore():移除有序集合中score小于$now - $this->windowSize的元素,即清理过期请求。
    • zAdd():将当前请求的时间戳作为score,用户ID作为value,添加到有序集合中。
    • zCard():统计有序集合中元素的数量,即当前窗口内的请求数量。
    • expire():设置key的过期时间,防止key无限增长。 过期时间设置为 $this->windowSize + 1,是为了防止在高并发场景下,在清理过期请求和统计请求数量之间,有新的请求到达,导致统计结果不准确。 略微延长过期时间可以减少这种并发问题的影响。
  • 如果请求被拒绝,返回HTTP状态码429 (Too Many Requests)。

优化:

  • 使用Lua脚本: 可以将清理过期请求、记录当前请求和统计请求数量的操作封装成一个Lua脚本,并在Redis中原子性地执行。 这样可以避免并发问题,并提高性能。
  • 连接池: 使用Redis连接池可以减少连接的开销,提高性能。
  • 异步处理: 可以将请求记录操作异步处理,避免阻塞主线程。

Lua脚本示例:

-- KEYS[1]: Redis key
-- ARGV[1]: 当前时间戳
-- ARGV[2]: 时间窗口大小
-- ARGV[3]: 最大请求数量

local key = KEYS[1]
local now = tonumber(ARGV[1])
local windowSize = tonumber(ARGV[2])
local maxRequests = tonumber(ARGV[3])

-- 清理过期请求
redis.call('ZREMRANGEBYSCORE', key, 0, now - windowSize)

-- 记录当前请求
redis.call('ZADD', key, now, now)

-- 统计当前窗口内的请求数量
local requestCount = redis.call('ZCARD', key)

-- 判断是否超过阈值
if requestCount > maxRequests then
    return 0 -- 超过阈值
end

-- 设置过期时间
redis.call('EXPIRE', key, windowSize + 1)

return 1 -- 允许请求

PHP代码中使用Lua脚本:

<?php

class SlidingWindowRateLimiter {

    private $redis;
    private $keyPrefix;
    private $windowSize; // 时间窗口大小,单位:秒
    private $maxRequests; // 最大请求数量
    private $luaScript;

    public function __construct(Redis $redis, string $keyPrefix, int $windowSize, int $maxRequests) {
        $this->redis = $redis;
        $this->keyPrefix = $keyPrefix;
        $this->windowSize = $windowSize;
        $this->maxRequests = $maxRequests;
        $this->luaScript = <<<LUA
-- KEYS[1]: Redis key
-- ARGV[1]: 当前时间戳
-- ARGV[2]: 时间窗口大小
-- ARGV[3]: 最大请求数量

local key = KEYS[1]
local now = tonumber(ARGV[1])
local windowSize = tonumber(ARGV[2])
local maxRequests = tonumber(ARGV[3])

-- 清理过期请求
redis.call('ZREMRANGEBYSCORE', key, 0, now - windowSize)

-- 记录当前请求
redis.call('ZADD', key, now, now)

-- 统计当前窗口内的请求数量
local requestCount = redis.call('ZCARD', key)

-- 判断是否超过阈值
if requestCount > maxRequests then
    return 0 -- 超过阈值
end

-- 设置过期时间
redis.call('EXPIRE', key, windowSize + 1)

return 1 -- 允许请求
LUA;
    }

    public function isAllowed(string $userId): bool {
        $key = $this->keyPrefix . ':' . $userId;
        $now = time();

        $result = $this->redis->eval($this->luaScript, [$key, $now, $this->windowSize, $this->maxRequests], 1);

        return $result == 1;
    }
}

// 使用示例
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);

$rateLimiter = new SlidingWindowRateLimiter($redis, 'user_rate_limit', 60, 100); // 每分钟最多100个请求

$userId = 'user123';

if ($rateLimiter->isAllowed($userId)) {
    // 处理请求
    echo "Request allowed for user: " . $userId . "n";
} else {
    // 拒绝请求
    http_response_code(429); // Too Many Requests
    echo "Rate limit exceeded for user: " . $userId . "n";
}

$redis->close();

?>

基于Redis实现漏桶算法

我们将使用Redis的字符串(String)类型来实现漏桶算法。 我们可以将桶的剩余容量存储在一个Redis字符串中。

基本思路:

  1. 初始化桶: 当第一次收到请求时,我们需要初始化桶的容量。
  2. 判断桶是否已满: 当收到新的请求时,我们首先检查桶的剩余容量是否足够。 如果不足,则拒绝该请求。
  3. 更新桶的剩余容量: 如果桶的剩余容量足够,则允许该请求通过,并将桶的剩余容量减去请求的大小。
  4. 以固定速率释放容量: 我们需要以固定的速率释放桶的容量,模拟漏桶的流出过程。 可以使用定时任务或后台进程来实现。

PHP代码示例:

<?php

class LeakyBucketRateLimiter {

    private $redis;
    private $keyPrefix;
    private $bucketCapacity; // 桶的容量
    private $leakRate; // 泄漏速率,单位:每秒泄漏的容量
    private $requestSize; // 每个请求的大小

    public function __construct(Redis $redis, string $keyPrefix, int $bucketCapacity, float $leakRate, int $requestSize = 1) {
        $this->redis = $redis;
        $this->keyPrefix = $keyPrefix;
        $this->bucketCapacity = $bucketCapacity;
        $this->leakRate = $leakRate;
        $this->requestSize = $requestSize;
    }

    public function isAllowed(string $userId): bool {
        $key = $this->keyPrefix . ':' . $userId;
        $now = microtime(true);  // Use microtime for more precise time tracking

        // 获取上次请求的时间
        $lastRequestTime = $this->redis->get($key . ':last_request_time');
        if ($lastRequestTime === false) {
            $lastRequestTime = $now; // 第一次请求
        } else {
            $lastRequestTime = (float)$lastRequestTime;
        }

        // 计算应该释放的容量
        $elapsedTime = $now - $lastRequestTime;
        $releasedCapacity = $elapsedTime * $this->leakRate;

        // 获取当前桶的剩余容量
        $remainingCapacity = $this->redis->get($key);
        if ($remainingCapacity === false) {
            $remainingCapacity = $this->bucketCapacity; // 第一次请求,初始化桶
        } else {
            $remainingCapacity = (int)$remainingCapacity;
        }

        // 增加释放的容量,但不能超过桶的容量
        $remainingCapacity = min($this->bucketCapacity, $remainingCapacity + $releasedCapacity);

        // 判断桶是否已满
        if ($remainingCapacity < $this->requestSize) {
            return false; // 超过阈值,拒绝请求
        }

        // 更新桶的剩余容量
        $remainingCapacity -= $this->requestSize;
        $this->redis->set($key, (int)$remainingCapacity);

        // 更新上次请求的时间
        $this->redis->set($key . ':last_request_time', $now);

        return true; // 允许请求
    }
}

// 使用示例
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);

$rateLimiter = new LeakyBucketRateLimiter($redis, 'user_leaky_bucket', 100, 10, 1); // 桶容量100,每秒泄漏10,每个请求消耗1

$userId = 'user123';

for ($i = 0; $i < 120; $i++) {
    if ($rateLimiter->isAllowed($userId)) {
        // 处理请求
        echo "Request allowed for user: " . $userId . " - " . $i . "n";
    } else {
        // 拒绝请求
        http_response_code(429); // Too Many Requests
        echo "Rate limit exceeded for user: " . $userId . " - " . $i . "n";
    }
    usleep(100000); // Sleep for 100ms (0.1 seconds)
}

$redis->close();

?>

代码解释:

  • LeakyBucketRateLimiter类封装了漏桶算法的实现。
  • $redis:Redis连接对象。
  • $keyPrefix:Redis key的前缀,用于区分不同的限流策略。
  • $bucketCapacity:桶的容量。
  • $leakRate:泄漏速率,单位为每秒泄漏的容量。
  • $requestSize:每个请求的大小。
  • isAllowed()方法:判断用户是否允许发起请求。
    • 获取上次请求的时间戳,并根据泄漏速率计算应该释放的容量。
    • 获取当前桶的剩余容量,并增加释放的容量,但不能超过桶的容量。
    • 判断桶的剩余容量是否足够处理当前请求。
    • 更新桶的剩余容量,并存储到Redis中。
    • 更新上次请求的时间戳。

优化:

  • 使用Lua脚本: 可以将获取剩余容量、计算释放容量、判断是否允许请求和更新剩余容量的操作封装成一个Lua脚本,并在Redis中原子性地执行。 这样可以避免并发问题,并提高性能。
  • 连接池: 使用Redis连接池可以减少连接的开销,提高性能。

Lua脚本示例:

-- KEYS[1]: Redis key (remaining capacity)
-- KEYS[2]: Redis key (last request time)
-- ARGV[1]: 当前时间戳
-- ARGV[2]: 桶的容量
-- ARGV[3]: 泄漏速率
-- ARGV[4]: 请求的大小

local capacityKey = KEYS[1]
local lastRequestTimeKey = KEYS[2]
local now = tonumber(ARGV[1])
local bucketCapacity = tonumber(ARGV[2])
local leakRate = tonumber(ARGV[3])
local requestSize = tonumber(ARGV[4])

-- 获取上次请求的时间
local lastRequestTime = redis.call('GET', lastRequestTimeKey)
if lastRequestTime == false then
    lastRequestTime = now
else
    lastRequestTime = tonumber(lastRequestTime)
end

-- 计算应该释放的容量
local elapsedTime = now - lastRequestTime
local releasedCapacity = elapsedTime * leakRate

-- 获取当前桶的剩余容量
local remainingCapacity = redis.call('GET', capacityKey)
if remainingCapacity == false then
    remainingCapacity = bucketCapacity
else
    remainingCapacity = tonumber(remainingCapacity)
end

-- 增加释放的容量,但不能超过桶的容量
remainingCapacity = math.min(bucketCapacity, remainingCapacity + releasedCapacity)

-- 判断桶是否已满
if remainingCapacity < requestSize then
    return 0 -- 超过阈值
end

-- 更新桶的剩余容量
remainingCapacity = remainingCapacity - requestSize
redis.call('SET', capacityKey, remainingCapacity)

-- 更新上次请求的时间
redis.call('SET', lastRequestTimeKey, now)

return 1 -- 允许请求

PHP代码中使用Lua脚本:

<?php

class LeakyBucketRateLimiter {

    private $redis;
    private $keyPrefix;
    private $bucketCapacity; // 桶的容量
    private $leakRate; // 泄漏速率,单位:每秒泄漏的容量
    private $requestSize; // 每个请求的大小
    private $luaScript;

    public function __construct(Redis $redis, string $keyPrefix, int $bucketCapacity, float $leakRate, int $requestSize = 1) {
        $this->redis = $redis;
        $this->keyPrefix = $keyPrefix;
        $this->bucketCapacity = $bucketCapacity;
        $this->leakRate = $leakRate;
        $this->requestSize = $requestSize;
        $this->luaScript = <<<LUA
-- KEYS[1]: Redis key (remaining capacity)
-- KEYS[2]: Redis key (last request time)
-- ARGV[1]: 当前时间戳
-- ARGV[2]: 桶的容量
-- ARGV[3]: 泄漏速率
-- ARGV[4]: 请求的大小

local capacityKey = KEYS[1]
local lastRequestTimeKey = KEYS[2]
local now = tonumber(ARGV[1])
local bucketCapacity = tonumber(ARGV[2])
local leakRate = tonumber(ARGV[3])
local requestSize = tonumber(ARGV[4])

-- 获取上次请求的时间
local lastRequestTime = redis.call('GET', lastRequestTimeKey)
if lastRequestTime == false then
    lastRequestTime = now
else
    lastRequestTime = tonumber(lastRequestTime)
end

-- 计算应该释放的容量
local elapsedTime = now - lastRequestTime
local releasedCapacity = elapsedTime * leakRate

-- 获取当前桶的剩余容量
local remainingCapacity = redis.call('GET', capacityKey)
if remainingCapacity == false then
    remainingCapacity = bucketCapacity
else
    remainingCapacity = tonumber(remainingCapacity)
end

-- 增加释放的容量,但不能超过桶的容量
remainingCapacity = math.min(bucketCapacity, remainingCapacity + releasedCapacity)

-- 判断桶是否已满
if remainingCapacity < requestSize then
    return 0 -- 超过阈值
end

-- 更新桶的剩余容量
remainingCapacity = remainingCapacity - requestSize
redis.call('SET', capacityKey, remainingCapacity)

-- 更新上次请求的时间
redis.call('SET', lastRequestTimeKey, now)

return 1 -- 允许请求
LUA;
    }

    public function isAllowed(string $userId): bool {
        $capacityKey = $this->keyPrefix . ':' . $userId;
        $lastRequestTimeKey = $this->keyPrefix . ':' . $userId . ':last_request_time';
        $now = microtime(true);

        $result = $this->redis->eval($this->luaScript, [$capacityKey, $lastRequestTimeKey, $now, $this->bucketCapacity, $this->leakRate, $this->requestSize], 2);

        return $result == 1;
    }
}

// 使用示例
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);

$rateLimiter = new LeakyBucketRateLimiter($redis, 'user_leaky_bucket', 100, 10, 1); // 桶容量100,每秒泄漏10,每个请求消耗1

$userId = 'user123';

for ($i = 0; $i < 120; $i++) {
    if ($rateLimiter->isAllowed($userId)) {
        // 处理请求
        echo "Request allowed for user: " . $userId . " - " . $i . "n";
    } else {
        // 拒绝请求
        http_response_code(429); // Too Many Requests
        echo "Rate limit exceeded for user: " . $userId . " - " . $i . "n";
    }
    usleep(100000); // Sleep for 100ms (0.1 seconds)
}

$redis->close();

?>

如何选择合适的算法?

选择哪种算法取决于您的具体需求。

  • 滑动窗口: 如果需要精确控制请求速率,例如,限制API接口的调用频率,那么滑动窗口算法是一个不错的选择。
  • 漏桶: 如果需要平滑请求流量,防止突发流量冲击服务器,例如,在高并发场景下,保证服务的稳定性,那么漏桶算法更适合。

总结

本次分享我们深入探讨了如何在PHP中使用Redis实现高性能的Rate Limiting。 我们详细讲解了滑动窗口和漏桶两种常见的限流算法,并提供了完整的PHP代码示例。 通过结合Redis的强大功能,我们可以轻松地构建出可靠、高效的限流系统,保护我们的服务免受恶意攻击,并保证服务的稳定性和可用性。

实践技巧与建议

在实际应用中,还需要考虑以下几点:

  • 精确的时间: 确保服务器的时间同步,避免因为时间偏差导致限流策略失效。
  • 分布式环境: 在分布式环境中,需要使用分布式锁来保证限流的原子性。 Redis的SETNX命令可以用来实现分布式锁。
  • 配置管理: 将限流策略配置化,方便动态调整。
  • 监控和报警: 监控限流系统的运行状态,及时发现和解决问题。
  • 灰度发布: 在上线新的限流策略时,可以先进行灰度发布,观察效果后再全面推广。

希望本次分享对您有所帮助! 谢谢大家。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注