PHP 固定窗口计数器:从原理到高并发实战的完整指南
目录导读
- 什么是固定窗口计数器算法 – 核心概念与数学定义
- PHP实现固定窗口计数器的三种方案 – Redis / 文件锁 / APCu对比
- 代码实战:基于Redis的固定窗口限流器 – 完整可运行代码
- 常见陷阱与性能优化 – 原子性问题、时钟跳跃、内存爆炸
- 固定窗口 vs 滑动窗口 vs 令牌桶 – 选型决策树
- 高频问答(FAQ) – 解决你90%的疑问
什么是固定窗口计数器算法
固定窗口计数器(Fixed Window Counter)是最简单的限流算法,它的核心思想是:

将时间划分为固定的窗口(如1秒、1分钟),每个窗口内维护一个计数器,当请求到达时,若计数器 < 阈值则允许通过并+1;否则直接拒绝。
数学定义:
- 窗口长度
W(秒) - 阈值
N(请求数/窗口) - 当前时间戳
t→ 窗口ID =floor(t / W) - 判断条件:
counter[windowID] < N
典型应用:API限流(如每秒100次)、登录防爆破(每分钟5次)、爬虫防护。
核心优点:实现极简、内存占用O(1)、适合分布式(只需一个共享存储)。
核心缺陷:临界突变问题,例如窗口为1分钟,阈值为100,若第59秒来了100个请求,第60秒(新窗口)又能来100个请求,导致2秒内200个请求涌入。
PHP实现固定窗口计数器的三种方案
| 方案 | 存储介质 | 原子性 | 性能 | 适用场景 |
|---|---|---|---|---|
| Redis | 内存数据库 | ✅ INCR/DECR原子 | 10万+ QPS | 分布式系统、生产环境 |
| 文件锁 | 本地文件 | ✅ flock | 1万 QPS | 单机、简易部署 |
| APCu | PHP共享内存 | ⚠️ 需配合锁 | 5万 QPS | 单机、高性能 |
方案选择建议:
- 有Redis直接用Redis(最稳妥)
- 无Redis但多进程 → 文件锁
- 单进程(如CLI脚本)→ APCu最快
代码实战:基于Redis的固定窗口限流器
<?php
/**
* Redis固定窗口计数器限流器
* 支持集群环境,原子操作防并发
*/
class FixedWindowRateLimiter
{
private $redis;
private $keyPrefix;
private $windowSize; // 窗口长度(秒)
private $maxRequests; // 阈值
public function __construct($redis, $keyPrefix = 'rate_limit', $windowSize = 60, $maxRequests = 100)
{
$this->redis = $redis;
$this->keyPrefix = $keyPrefix;
$this->windowSize = $windowSize;
$this->maxRequests = $maxRequests;
}
/**
* 尝试通过限流
* @param string $identifier 用户ID或IP
* @return bool true=允许,false=拒绝
*/
public function allow($identifier)
{
// 当前窗口ID = 当前时间戳 / 窗口大小(取整)
$windowId = intdiv(time(), $this->windowSize);
$key = "{$this->keyPrefix}:{$identifier}:{$windowId}";
// Lua脚本保证原子性(判断+递增)
$lua = <<<LUA
local current = redis.call('GET', KEYS[1])
if current and tonumber(current) >= tonumber(ARGV[1]) then
return 0
else
redis.call('INCR', KEYS[1])
redis.call('EXPIRE', KEYS[1], ARGV[2])
return 1
end
LUA;
return (bool)$this->redis->eval($lua, [$key], [$this->maxRequests, $this->windowSize + 1]);
}
/**
* 获取当前窗口已使用计数
*/
public function getCurrentCount($identifier)
{
$windowId = intdiv(time(), $this->windowSize);
$key = "{$this->keyPrefix}:{$identifier}:{$windowId}";
return (int)$this->redis->get($key);
}
}
// ---- 使用示例 ----
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$limiter = new FixedWindowRateLimiter($redis, 'api', 60, 100);
$ip = $_SERVER['REMOTE_ADDR'];
if ($limiter->allow($ip)) {
// 处理业务逻辑
echo "请求成功";
} else {
http_response_code(429);
echo "请求过于频繁,请稍后再试";
}
代码说明:
- 使用Lua脚本隔离原子性,避免竞态条件
- EXPIRE设置为窗口大小+1秒,防止键过期后计数器未重置
- 每个窗口独立key,天然支持多窗口并行
常见陷阱与性能优化
陷阱1:Redis键内存爆炸
问题:每个用户每窗口一个key,高并发下内存暴涨。 解法:设置合理的EXPIRE(窗口+1秒),旧key自动清理,若用户量极大,可增加key过期随机化。
陷阱2:时钟跳跃(NTP同步)
问题:服务器时间向前调整,新窗口提前启用 → 瞬间涌入大量请求。 解法:
- 使用
time()而非微秒,容忍1秒误差 - 关键场景用Redis的
TIME命令取服务器时间(集群模式注意)
陷阱3:窗口边界请求堆积
问题:固定窗口的临界突变问题。 解法:
- 如果要求严格,改用滑动窗口(下文对比)
- 或在业务层增加小延迟(如usleep(微随机))打散请求
性能优化技巧
- 合并Redis请求:用pipeline批量操作
- 本地二级缓存:在PHP进程内缓存前10ms的计数结果,减少Redis请求
- 分级限流:普通用户限100/秒,VIP限200/秒(用不同key)
固定窗口 vs 滑动窗口 vs 令牌桶
| 特性 | 固定窗口 | 滑动窗口 | 令牌桶 |
|---|---|---|---|
| 实现复杂度 | |||
| 平滑度 | 差(临界突变) | 好 | 极好(允许突发) |
| 空间消耗 | O(1) | O(N) N=窗口内单位段 | O(1) |
| 允许突发流量 | ❌ 严格禁止 | ❌ 严格禁止 | ✅ 允许 |
| 推荐场景 | 简单限流、日志审计 | 需平滑限制 | 接口防刷、流量整形 |
选型决策树:
- 只要求“不超过N/单位时间” → 固定窗口(最简单)
- 要求“每秒钟都平滑”→ 滑动窗口(需Redis ZSET)
- 要求“允许偶尔突发,但平均受限”→ 令牌桶(推荐Predis+Lua)
高频问答(FAQ)
Q1:固定窗口计数器能用于分布式全局限流吗?
✅ 可以,只要所有PHP节点共享同一个Redis(或集群),key设计为全局唯一(如rate_limit:user123:123456)即可,注意Redis集群模式需要确保相同key落到同一slot(可用hash tag)。
Q2:如果Redis宕机怎么办? 使用降级策略:Redis不可用时,默认放行(或改用APCu),可在Redis连接失败时捕获异常并返回true。
Q3:如何实现“每用户每分钟限10次”但允许用户一次突发10个? 固定窗口天然允许同一窗口内连续请求,所以直接设置maxRequests=10,窗口=60秒即可满足。
Q4:为什么我的计数器偶尔超过阈值? 排查步骤:
- 确认Lua脚本原子性(不要用GET+INCR分开执行)
- 确认EXPIPRE设置正确(防止窗口切换失效)
- 检查是否有多个Redis实例(需要共享连接池)
Q5:PHP-FPM多进程模式下,文件锁方案是否可行?
可行但性能差。flock($fp, LOCK_EX)会阻塞等待,高并发成性能瓶颈,推荐使用Redis或APCu。
Q6:固定窗口限流返回什么HTTP状态码?
通常返回429 Too Many Requests,并包含Retry-After头(如Retry-After: 30)。
http_response_code(429);
header('Retry-After: ' . ($limiter->windowSize - time() % $limiter->windowSize));
echo json_encode(['error' => 'rate_limit_exceeded']);