php 限流思路

使用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";
}
*/
相关推荐
indexsunny2 小时前
互联网大厂Java面试实录:从Spring Boot到微服务架构的深度剖析
java·spring boot·redis·kafka·microservices·互联网大厂·面试经验
银河麒麟操作系统3 小时前
银河麒麟服务器操作系统IO机制详解
数据库·redis·缓存
sc_爬坑之路3 小时前
redis windows环境配置读写分离:一主一从 + Sentinel 完整实战
windows·redis·sentinel
czlczl200209254 小时前
插入时先写DB后写Redis?分布式中传统双写模式的缺陷
数据库·redis·分布式
无限码农4 小时前
2.1 网络编程 异步网络库zvnet
服务器·网络·php
闻哥5 小时前
深入剖析Redis数据类型与底层数据结构
java·jvm·数据结构·spring boot·redis·面试·wpf
小尔¥5 小时前
LNMP环境部署
运维·数据库·nginx·php
墨香幽梦客5 小时前
NoSQL数据库在企业中的应用:MongoDB与Redis的场景化选型对比
redis·mongodb·nosql
敲代码的嘎仔5 小时前
Java后端开发——Redis面试题汇总
java·开发语言·redis·学习·缓存·面试·职场和发展