Predis分布式限流:Redis+Lua实现滑动窗口算法
你是否还在为接口频繁被调用导致系统崩溃而烦恼?是否在寻找一种简单高效的限流方案?本文将带你使用Predis客户端结合Redis和Lua脚本,轻松实现分布式环境下的滑动窗口限流,保障系统稳定运行。读完本文,你将掌握滑动窗口限流的原理、Predis中Lua脚本的使用方法以及完整的实现步骤。
限流原理与方案选择
在分布式系统中,限流是保护服务不被过载请求击垮的重要手段。常见的限流算法有固定窗口、滑动窗口、漏桶和令牌桶等。其中,滑动窗口算法能够更平滑地控制流量,避免固定窗口在窗口切换时可能出现的流量突增问题。
滑动窗口算法通过将时间窗口分成多个小的时间片,记录每个时间片内的请求数,然后根据当前时间计算最近窗口内的总请求数,从而判断是否允许新的请求。相比固定窗口,滑动窗口的流量控制更加精确,但实现也相对复杂。
使用Redis+Lua实现滑动窗口限流具有以下优势:
- 原子性:Lua脚本在Redis中以原子方式执行,避免分布式环境下的并发问题
- 高效性:Redis处理速度快,Lua脚本减少网络往返次数
- 一致性:集中式的Redis存储保证了分布式环境下的计数准确性
Predis与Lua脚本集成
Predis作为PHP的Redis客户端,提供了对Lua脚本的良好支持。通过继承ScriptCommand类,我们可以轻松实现基于Lua脚本的自定义命令。
ScriptCommand.php是Predis中Lua脚本命令的基类,主要提供以下功能:
getScript():抽象方法,子类需实现并返回Lua脚本内容getScriptHash():计算脚本的SHA1哈希,用于EVALSHA命令getKeysCount():指定作为KEYS参数的数量,支持负数表示从末尾开始计算
下面是一个简单的Lua脚本命令实现示例,该命令只对已存在的键进行自增操作:
use Predis\Command\ScriptCommand;
class IncrementExistingKeysBy extends ScriptCommand
{
public function getKeysCount()
{
return -1; // 所有参数除最后一个外都作为KEYS
}
public function getScript()
{
return <<<LUA
local cmd, insert = redis.call, table.insert
local increment, results = ARGV[1], { }
for idx, key in ipairs(KEYS) do
if cmd('exists', key) == 1 then
insert(results, idx, cmd('incrby', key, increment))
else
insert(results, idx, false)
end
end
return results
LUA;
}
}
要在Predis中注册和使用自定义命令,可以在客户端配置中添加命令映射:
$client = new Predis\Client($single_server, [
'commands' => [
'increxby' => 'IncrementExistingKeysBy',
],
]);
// 使用自定义命令
$client->increxby('foo', 'foofoo', 'foobar', 50);
更多使用示例可以参考lua_scripting_abstraction.php。
滑动窗口限流实现
Lua脚本实现滑动窗口算法
下面是实现滑动窗口限流的Lua脚本,主要逻辑是:
- 记录每个请求的时间戳
- 移除窗口外的时间戳
- 统计窗口内的请求数
- 判断是否允许请求通过并返回结果
local key = KEYS[1]
local windowSize = tonumber(ARGV[1])
local limit = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
-- 计算窗口起始时间
local windowStart = now - windowSize
-- 移除窗口之外的时间戳
redis.call('ZREMRANGEBYSCORE', key, 0, windowStart)
-- 获取当前窗口内的请求数
local count = redis.call('ZCARD', key)
-- 判断是否超过限制
if count < limit then
-- 添加当前请求的时间戳
redis.call('ZADD', key, now, now .. ':' .. math.random())
-- 设置键过期时间,避免内存泄漏
redis.call('EXPIRE', key, windowSize / 1000 + 1)
return 1 -- 允许请求
end
return 0 -- 拒绝请求
PHP实现滑动窗口限流
基于上述Lua脚本,我们可以实现一个滑动窗口限流的PHP类:
use Predis\Command\ScriptCommand;
class SlidingWindowRateLimit extends ScriptCommand
{
public function getKeysCount()
{
return 1; // 第一个参数作为KEY
}
public function getScript()
{
return <<<LUA
local key = KEYS[1]
local windowSize = tonumber(ARGV[1])
local limit = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local windowStart = now - windowSize
redis.call('ZREMRANGEBYSCORE', key, 0, windowStart)
local count = redis.call('ZCARD', key)
if count < limit then
redis.call('ZADD', key, now, now .. ':' .. math.random())
redis.call('EXPIRE', key, windowSize / 1000 + 1)
return 1
end
return 0
LUA;
}
}
// 注册自定义命令
$client = new Predis\Client([
'scheme' => 'tcp',
'host' => '127.0.0.1',
'port' => 6379,
], [
'commands' => [
'slidingwindowratelimit' => 'SlidingWindowRateLimit',
],
]);
// 使用限流命令
function isAllowed($client, $key, $windowSize, $limit) {
$now = microtime(true) * 1000; // 当前时间戳(毫秒)
return $client->slidingwindowratelimit($key, $windowSize, $limit, $now) === 1;
}
// 示例:限制1分钟内最多100次请求
$key = 'user:123:api';
$windowSize = 60 * 1000; // 60秒(毫秒)
$limit = 100; // 限制请求数
if (isAllowed($client, $key, $windowSize, $limit)) {
echo "请求允许";
} else {
echo "请求被限流";
}
代码解析
-
Lua脚本部分:
- 使用ZSET存储请求时间戳,score和member都使用时间戳加随机数,避免重复
- 先移除窗口外的时间戳,再统计当前窗口内的请求数
- 如果未超过限制,则添加当前请求时间戳并设置键过期时间
-
PHP部分:
- 继承
ScriptCommand实现自定义限流命令 - 注册自定义命令到Predis客户端
- 封装
isAllowed函数,方便业务代码调用
- 继承
限流策略优化
1. 分布式环境下的时钟同步
由于滑动窗口算法依赖时间戳,分布式环境下各服务器的时钟偏差可能导致限流不准确。可以通过以下方式解决:
- 使用NTP服务同步所有服务器时钟
- 由Redis服务器提供时间戳,修改Lua脚本如下:
-- 使用Redis服务器时间,避免客户端时钟不一致问题
local now = redis.call('TIME')[1] * 1000 + redis.call('TIME')[2] / 1000
2. 预热限流
对于一些需要预热的服务,可以采用渐进式限流策略,逐渐增加允许的请求数:
/**
* 预热限流
* @param int $currentTime 当前时间(秒)
* @param int $startTime 开始时间(秒)
* @param int $warmupTime 预热时间(秒)
* @param int $maxLimit 最大限制数
* @return int 当前限制数
*/
function getWarmupLimit($currentTime, $startTime, $warmupTime, $maxLimit) {
$elapsed = $currentTime - $startTime;
if ($elapsed >= $warmupTime) {
return $maxLimit;
}
return (int)($maxLimit * ($elapsed / $warmupTime));
}
3. 多维度限流
实际应用中可能需要从多个维度进行限流,如用户维度、IP维度、接口维度等。可以通过组合多个key来实现:
// 用户+接口维度限流
$userKey = "rate_limit:user:{$userId}:api:{$apiName}";
// IP维度限流
$ipKey = "rate_limit:ip:{$ipAddress}:api:{$apiName}";
// 同时检查多个维度
if (isAllowed($client, $userKey, $windowSize, $userLimit) &&
isAllowed($client, $ipKey, $windowSize, $ipLimit)) {
// 处理请求
} else {
// 拒绝请求
}
完整示例与使用方法
下面是一个完整的滑动窗口限流实现,包括Lua脚本和PHP代码:
use Predis\Client;
use Predis\Command\ScriptCommand;
// 定义滑动窗口限流命令
class SlidingWindowRateLimit extends ScriptCommand
{
public function getKeysCount()
{
return 1;
}
public function getScript()
{
return <<<LUA
local key = KEYS[1]
local windowSize = tonumber(ARGV[1])
local limit = tonumber(ARGV[2])
-- 使用Redis服务器时间
local time = redis.call('TIME')
local now = time[1] * 1000 + time[2] / 1000
local windowStart = now - windowSize
redis.call('ZREMRANGEBYSCORE', key, 0, windowStart)
local count = redis.call('ZCARD', key)
if count < limit then
redis.call('ZADD', key, now, now .. ':' .. math.random())
redis.call('EXPIRE', key, windowSize / 1000 + 1)
return {1, count + 1} -- 返回允许状态和当前计数
end
return {0, count} -- 返回拒绝状态和当前计数
LUA;
}
}
// 初始化Predis客户端
$client = new Client([
'scheme' => 'tcp',
'host' => '127.0.0.1',
'port' => 6379,
], [
'commands' => [
'slidingwindowratelimit' => 'SlidingWindowRateLimit',
],
]);
// 限流服务类
class RateLimiter
{
private $client;
public function __construct(Client $client)
{
$this->client = $client;
}
/**
* 检查是否允许请求
* @param string $key 限流标识
* @param int $windowSize 窗口大小(毫秒)
* @param int $limit 限制请求数
* @return array [是否允许, 当前计数]
*/
public function isAllowed($key, $windowSize, $limit)
{
list($allowed, $count) = $this->client->slidingwindowratelimit($key, $windowSize, $limit);
return [(bool)$allowed, (int)$count];
}
}
// 使用示例
$rateLimiter = new RateLimiter($client);
$key = 'user:123:api:login';
$windowSize = 60 * 1000; // 60秒
$limit = 5; // 每分钟最多5次请求
list($allowed, $count) = $rateLimiter->isAllowed($key, $windowSize, $limit);
if ($allowed) {
echo "请求允许,当前计数: {$count}/{$limit}";
} else {
http_response_code(429); // Too Many Requests
echo "请求过于频繁,请稍后再试。当前计数: {$count}/{$limit}";
}
总结与注意事项
使用Predis+Redis+Lua实现滑动窗口限流是一种高效、可靠的分布式限流方案。在实际应用中,还需要注意以下几点:
- 合理设置窗口大小和限制数量:根据业务需求和系统承载能力进行调整
- 避免Redis成为瓶颈:可以考虑Redis集群或读写分离架构
- 处理Redis异常:当Redis不可用时,可以采用降级策略,如允许请求通过或使用本地限流
- 监控与告警:对限流情况进行监控,及时发现异常流量
通过合理使用本文介绍的滑动窗口限流方案,可以有效保护系统免受流量冲击,提高系统的稳定性和可用性。更多Predis的使用示例可以参考examples/目录下的文件。
参考资料
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考



