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。
基本思路:
- 记录请求: 当收到一个请求时,我们将当前时间戳作为score,用户ID作为value,添加到Redis的有序集合中。
- 清理过期请求: 同时,我们需要清理掉时间窗口之外的请求记录。 也就是说,我们需要移除score小于当前时间戳减去时间窗口大小的元素。
- 判断是否超过阈值: 最后,我们统计有序集合中元素的数量,如果数量超过了设定的阈值,则拒绝该请求。
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字符串中。
基本思路:
- 初始化桶: 当第一次收到请求时,我们需要初始化桶的容量。
- 判断桶是否已满: 当收到新的请求时,我们首先检查桶的剩余容量是否足够。 如果不足,则拒绝该请求。
- 更新桶的剩余容量: 如果桶的剩余容量足够,则允许该请求通过,并将桶的剩余容量减去请求的大小。
- 以固定速率释放容量: 我们需要以固定的速率释放桶的容量,模拟漏桶的流出过程。 可以使用定时任务或后台进程来实现。
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命令可以用来实现分布式锁。 - 配置管理: 将限流策略配置化,方便动态调整。
- 监控和报警: 监控限流系统的运行状态,及时发现和解决问题。
- 灰度发布: 在上线新的限流策略时,可以先进行灰度发布,观察效果后再全面推广。
希望本次分享对您有所帮助! 谢谢大家。