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";
}
*/
相关推荐
__土块__4 小时前
一次电商秒杀系统架构评审:从本地锁到分布式锁的演进与取舍
java·redis·高并发·分布式锁·redisson·架构设计·秒杀系统
小旭95275 小时前
Spring Data Redis 从入门到实战:简化 Redis 操作全解析
java·开发语言·spring boot·redis·spring
weixin199701080165 小时前
《米思米商品详情页前端性能优化实战》
前端·性能优化·php
lingggggaaaa5 小时前
PHP原生开发篇&文件安全&上传监控&功能定位&关键搜索&1day挖掘
android·学习·安全·web安全·php
无责任此方_修行中5 小时前
Redis 的"三面"人生:开源世界的权力转移
redis·后端·程序员
ningmengjing_6 小时前
从零推导出 Redis
数据库·redis
eLIN TECE6 小时前
Mac安装Redis步骤
redis·macos·bootstrap
一个有温度的技术博主7 小时前
告别“竹篮打水”:Redis单点瓶颈与分布式缓存架构全解析
redis·分布式·缓存
李李李勃谦7 小时前
Flutter 框架跨平台鸿蒙开发 - 正则测试应用
flutter·华为·php·harmonyos
jwn9998 小时前
Laravel 7.x核心特性全解析
php·laravel