使用Redis实现Debt Rate Limiting,并可以配置惩罚时间,例如正常流量是1s最多5次,但是有脚本一秒请求了50次,此时脚本的还债时间就会特别长(请求限流不通过也算债务)
<?php
class DebtRateLimiter
{
private $redis;
// 配置参数
private $capacity = 5; // 桶容量 (正常峰值)
private $refillRate = 5; // 正常填充速率 (tokens/秒)
private $penaltyRefillRate = 0.1; // 惩罚期间填充速率 (tokens/秒,极慢)
private $maxDebtThreshold = -10; // 触发严厉惩罚的债务阈值 (低于此值进入深罚模式)
public function __construct($redisInstance)
{
$this->redis = $redisInstance;
}
/**
* 尝试请求
* @param string $key 唯一标识 (如 IP 或 User ID)
* @param int $cost 本次请求消耗 (默认 1)
* @return array ['allowed' => bool, 'retry_after' => float, 'debt' => float]
*/
public function allowRequest(string $key, int $cost = 1): array
{
$luaScript = <<<LUA
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill_rate = tonumber(ARGV[2])
local penalty_rate = tonumber(ARGV[3])
local debt_threshold = tonumber(ARGV[4])
local cost = tonumber(ARGV[5])
local now = tonumber(ARGV[6])
-- 获取当前状态
local bucket = redis.call("HMGET", key, "tokens", "last_time")
local tokens = tonumber(bucket[1])
local last_time = tonumber(bucket[2])
-- 初始化
if not tokens then
tokens = capacity
last_time = now
end
-- 计算时间差
local delta = now - last_time
local current_rate = refill_rate
-- 【核心债务逻辑】
-- 如果当前处于负债状态 (tokens < 0)
if tokens < 0 then
-- 如果债务非常深 (例如被脚本攻击),切换为惩罚速率
if tokens < debt_threshold then
current_rate = penalty_rate
else
-- 轻微负债,恢复速率减半作为警告
current_rate = refill_rate * 0.5
end
-- 注意:在负债模式下,我们依然计算恢复,但速度极慢
-- 某些策略会选择在负债期间完全不恢复 (delta = 0),视需求而定
end
-- 尝试恢复令牌 (不能超过容量)
local new_tokens = tokens + (delta * current_rate)
if new_tokens > capacity then
new_tokens = capacity
end
local allowed = false
local retry_after = 0
-- 尝试扣除成本
if new_tokens >= cost then
-- 正常通过
new_tokens = new_tokens - cost
allowed = true
else
-- 【透支逻辑】:允许透支产生债务,或者拒绝
-- 方案 A: 拒绝请求,但记录债务 (推荐用于防刷)
-- 方案 B: 允许请求,但令牌变负数 (你的需求描述倾向于这种"还债"感)
-- 这里采用方案 B:允许透支,产生负数令牌 (债务)
new_tokens = new_tokens - cost
allowed = false -- 虽然扣了,但因为是负数,业务层应视为拒绝或限流
-- 计算需要多久才能还清债务回到 0
-- 时间 = 债务绝对值 / 当前恢复速率
local debt = math.abs(new_tokens)
if current_rate > 0 then
retry_after = debt / current_rate
else
retry_after = 999999 -- 无限期
end
end
-- 更新 Redis
redis.call("HMSET", key, "tokens", new_tokens, "last_time", now)
-- 设置过期时间,避免内存泄漏 (例如容量/最小速率 * 2)
redis.call("EXPIRE", key, 3600)
return {allowed, retry_after, new_tokens}
LUA;
$now = microtime(true);
$args = [
$this->capacity,
$this->refillRate,
$this->penaltyRefillRate,
$this->maxDebtThreshold,
$cost,
$now
];
// 执行 Lua 脚本
// 注意:生产环境建议将 script 加载到 Redis (SCRIPT LOAD) 并使用 EVALSHA 优化
$result = $this->redis->eval($luaScript, [$key], count($args), ...$args);
// 注意:不同 Redis 客户端 eval 参数顺序可能不同,以上是基于 phpredis 的常见写法
// 如果使用的是 Predis,参数传递方式略有不同:$redis->eval($script, $numKeys, $key, $args...)
// 修正 phpredis 的 eval 调用签名: eval(script, args, num_keys)
// 上面写法可能有误,重新调整标准 phpredis 调用:
$fullArgs = array_merge([$key], $args);
$result = $this->redis->eval($luaScript, $fullArgs, 1);
return [
'allowed' => (bool)$result[0],
'retry_after' => (float)$result[1],
'debt' => (float)$result[2],
'message' => $result[0] ? 'Success' : 'Rate Limited (Debt Incurred)'
];
}
}
// --- 使用示例 ---
// $redis = new Redis();
// $redis->connect('127.0.0.1', 6379);
// $limiter = new DebtRateLimiter($redis);
// 模拟场景:
// 1. 正常请求 5 次 -> 通过
// 2. 第 6 次请求 -> 令牌变 -1,返回 allowed=false, retry_after=0.2s (假设正常速率)
// 3. 脚本瞬间并发 50 次 -> 令牌瞬间变成 -45
// 此时 tokens < -10 (阈值),触发 penalty_rate (0.1 tokens/s)
// 还债时间 = 45 / 0.1 = 450 秒! (这就是你要的"特别长"的惩罚)
/*
$result = $limiter->allowRequest('user_ip_1.2.3.4');
if (!$result['allowed']) {
header("Retry-After: " . ceil($result['retry_after']));
http_response_code(429);
echo "Too Many Requests. Debt: " . $result['debt'] . ". Wait: " . $result['retry_after'] . "s";
} else {
echo "OK";
}
*/