Predis分布式限流:Redis+Lua实现滑动窗口算法

Predis分布式限流:Redis+Lua实现滑动窗口算法

【免费下载链接】predis A flexible and feature-complete Redis client for PHP. 【免费下载链接】predis 项目地址: https://gitcode.com/gh_mirrors/pr/predis

你是否还在为接口频繁被调用导致系统崩溃而烦恼?是否在寻找一种简单高效的限流方案?本文将带你使用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脚本,主要逻辑是:

  1. 记录每个请求的时间戳
  2. 移除窗口外的时间戳
  3. 统计窗口内的请求数
  4. 判断是否允许请求通过并返回结果
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 "请求被限流";
}

代码解析

  1. Lua脚本部分

    • 使用ZSET存储请求时间戳,score和member都使用时间戳加随机数,避免重复
    • 先移除窗口外的时间戳,再统计当前窗口内的请求数
    • 如果未超过限制,则添加当前请求时间戳并设置键过期时间
  2. 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实现滑动窗口限流是一种高效、可靠的分布式限流方案。在实际应用中,还需要注意以下几点:

  1. 合理设置窗口大小和限制数量:根据业务需求和系统承载能力进行调整
  2. 避免Redis成为瓶颈:可以考虑Redis集群或读写分离架构
  3. 处理Redis异常:当Redis不可用时,可以采用降级策略,如允许请求通过或使用本地限流
  4. 监控与告警:对限流情况进行监控,及时发现异常流量

通过合理使用本文介绍的滑动窗口限流方案,可以有效保护系统免受流量冲击,提高系统的稳定性和可用性。更多Predis的使用示例可以参考examples/目录下的文件。

参考资料

【免费下载链接】predis A flexible and feature-complete Redis client for PHP. 【免费下载链接】predis 项目地址: https://gitcode.com/gh_mirrors/pr/predis

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值