PHP项目如何实现计数器限流?

wen java案例 1

本文目录导读:

PHP项目如何实现计数器限流?

  1. 最简单的 PHP 文件计数器(单机,不推荐生产)
  2. 基于 Redis 的滑动窗口计数器(推荐分布式方案)
  3. 令牌桶算法(适合突发流量控制)
  4. Nginx + Redis 计数器(高性能方案)
  5. 生产级建议
  6. 选型建议

在 PHP 项目中实现计数器限流,通常有单机版(基于内存)和分布式版(基于 Redis)两种常见方案,以下是几种主流实现方式,从简单到生产级逐一说明。


最简单的 PHP 文件计数器(单机,不推荐生产)

这种方案适合极简的演示或临时测试,但存在并发安全问题和性能瓶颈

<?php
class FileRateLimiter {
    private $filePath;
    private $limit;      // 时间窗口内最大请求数
    private $window;     // 时间窗口(秒)
    public function __construct($limit = 10, $window = 60) {
        $this->filePath = sys_get_temp_dir() . '/rate_limit_counter.txt';
        $this->limit = $limit;
        $this->window = $window;
    }
    public function allowRequest() {
        $data = $this->readFile();
        $currentTime = time();
        $ip = $this->getClientIp();
        // 清理过期记录
        $data = array_filter($data, function($record) use ($currentTime) {
            return ($currentTime - $record['time']) < $this->window;
        });
        // 统计当前 IP 的请求次数
        $count = 0;
        foreach ($data as $record) {
            if ($record['ip'] === $ip) {
                $count++;
            }
        }
        if ($count >= $this->limit) {
            return false; // 被限流
        }
        // 添加新记录
        $data[] = ['ip' => $ip, 'time' => $currentTime];
        $this->writeFile($data);
        return true; // 允许请求
    }
    private function readFile() {
        if (!file_exists($this->filePath)) return [];
        $content = file_get_contents($this->filePath);
        return $content ? json_decode($content, true) : [];
    }
    private function writeFile($data) {
        file_put_contents($this->filePath, json_encode($data), LOCK_EX);
    }
    private function getClientIp() {
        return $_SERVER['REMOTE_ADDR'] ?? 'unknown';
    }
}
// 使用示例
$limiter = new FileRateLimiter(10, 60);
if (!$limiter->allowRequest()) {
    http_response_code(429);
    exit('请求过于频繁,请稍后再试');
}

问题

  • 文件锁在并发高时性能极差
  • 无法跨进程/跨服务器共享
  • 不适合生产环境

基于 Redis 的滑动窗口计数器(推荐分布式方案)

这是最常用的生产级方案,利用 Redis 的 INCREXPIRE 配合时间窗口实现。

1 简单版(固定时间窗口 + 过期)

<?php
class RedisFixedRateLimiter {
    private $redis;
    private $limit;
    private $window;
    public function __construct($redis, $limit = 10, $window = 60) {
        $this->redis = $redis;
        $this->limit = $limit;
        $this->window = $window;
    }
    public function allowRequest($key = 'default') {
        $ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
        $redisKey = "rate_limit:{$key}:{$ip}";
        $current = $this->redis->get($redisKey);
        if ($current === false) {
            // 第一次请求,设置过期时间
            $this->redis->setex($redisKey, $this->window, 1);
            return true;
        }
        if ((int)$current >= $this->limit) {
            return false; // 限流
        }
        $this->redis->incr($redisKey);
        return true;
    }
}
// 使用(需要先连上 Redis)
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$limiter = new RedisFixedRateLimiter($redis, 10, 60);

缺点:固定窗口的边界问题(59 秒和 01 秒可能连续请求 20 次)。

2 滑动窗口版(更精确)

使用 Redis 有序集合(ZSET),按时间戳记录每次请求。

<?php
class RedisSlidingWindowLimiter {
    private $redis;
    private $limit; 
    private $window;
    public function __construct($redis, $limit = 10, $window = 60) {
        $this->redis = $redis;
        $this->limit = $limit;
        $this->window = $window;
    }
    public function allowRequest($key = 'default') {
        $ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
        $redisKey = "sliding_limit:{$key}:{$ip}";
        $now = microtime(true);
        $windowStart = $now - $this->window;
        // Lua 脚本保证原子性
        $script = <<<LUA
            redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', ARGV[1])
            local count = redis.call('ZCARD', KEYS[1])
            if count < tonumber(ARGV[3]) then
                redis.call('ZADD', KEYS[1], ARGV[2], ARGV[2])
                redis.call('EXPIRE', KEYS[1], ARGV[4])
                return 1
            else
                return 0
            end
LUA;
        $result = $this->redis->eval(
            $script,
            [$redisKey, $windowStart, $now, $this->limit, $this->window],
            1 // keys 数量
        );
        return $result === 1;
    }
}

优点

  • 精确控制滑动窗口
  • Lua 脚本保证原子性
  • 自动过期清理

令牌桶算法(适合突发流量控制)

令牌桶允许短时间内的突发请求,但长期平均速率受控。

<?php
class RedisTokenBucketLimiter {
    private $redis;
    private $rate;      // 每秒生成令牌数
    private $capacity;  // 最大桶容量
    public function __construct($redis, $rate = 5, $capacity = 10) {
        $this->redis = $redis;
        $this->rate = $rate;
        $this->capacity = $capacity;
    }
    public function allowRequest($key = 'default') {
        $ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
        $redisKey = "token_bucket:{$key}:{$ip}";
        $script = <<<LUA
            local key = KEYS[1]
            local rate = tonumber(ARGV[1])
            local capacity = tonumber(ARGV[2])
            local now = tonumber(ARGV[3])
            local bucket = redis.call('HGETALL', key)
            local tokens = capacity
            local lastRefill = now
            if #bucket > 0 then
                tokens = tonumber(bucket[2]) or capacity
                lastRefill = tonumber(bucket[4]) or now
            end
            -- 计算应补充的令牌
            local elapsed = now - lastRefill
            local refill = elapsed * rate
            if refill > 0 then
                tokens = math.min(capacity, tokens + refill)
            end
            if tokens >= 1 then
                tokens = tokens - 1
                redis.call('HMSET', key, 'tokens', tokens, 'lastRefill', now)
                redis.call('EXPIRE', key, 2) -- 短过期,避免堆积
                return 1
            else
                return 0
            end
LUA;
        $result = $this->redis->eval(
            $script,
            [$redisKey, $this->rate, $this->capacity, microtime(true)],
            1
        );
        return $result === 1;
    }
}

Nginx + Redis 计数器(高性能方案)

PHP 运行在 Nginx 后面,可以在 Nginx 层直接用 limit_req_zone 配合 Redis 实现,性能更好。 但如果你需要在 PHP 层做更灵活的控制(例如按用户 ID 限流而非 IP),仍建议使用 PHP + Redis 方案。


生产级建议

1 封装成中间件

// Laravel/Symfony 风格中间件
class RateLimitMiddleware {
    public function handle($request, $next) {
        $limiter = app(RateLimiter::class);
        $key = $request->ip() . ':' . $request->path();
        if (!$limiter->allowRequest($key, 100, 60)) {
            return response('Too Many Requests', 429);
        }
        return $next($request);
    }
}

2 返回标准 HTTP 429

header('HTTP/1.1 429 Too Many Requests');
header('Retry-After: 60'); // 告诉客户端多久后重试
header('X-RateLimit-Limit: 100');
header('X-RateLimit-Remaining: 0');
exit('请求过于频繁');

3 异常与降级

  • 当 Redis 连接失败时,建议放行请求(不做限速),避免服务完全不可用。
  • 记录限流日志到独立文件或日志系统。

4 性能优化

  • 使用 Redis Pipeline 批量操作(如果一次请求需要检查多个限流键)
  • 设置合理的 Redis 连接池
  • 考虑使用 localcache(如 APCu)配合 Redis(双缓存),减少 Redis 压力

选型建议

场景 推荐方案
单机小项目、演示 文件计数器(不推荐生产)
中小型分布式项目 Redis 滑动窗口 ZSET
需要应对突发流量 令牌桶
精确按秒控制 滑动窗口或令牌桶
极致性能、Nginx 前端 Nginx limit_req + Redis
云原生、微服务 使用云平台 API 网关限流(如 AWS API Gateway)

核心要点

  • 原子性操作:使用 Redis Lua 脚本或 INCR 配合 EXPIRE(单命令)
  • 滑动窗口 > 固定窗口
  • 设置合理的过期时间,避免 Redis 内存泄漏
  • 返回友好的错误信息和 header

如果你的项目不是特别复杂,Redis 固定窗口 + 过期时间 已经足够应对大部分场景(可以通过设置窗口为 1 秒来达到类似滑动窗口的效果)。

抱歉,评论功能暂时关闭!