本文目录导读:

在 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 的 INCR 和 EXPIRE 配合时间窗口实现。
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 秒来达到类似滑动窗口的效果)。