PHP 固定窗口计数器

wen PHP项目 3

PHP 固定窗口计数器:从原理到高并发实战的完整指南

目录导读

  1. 什么是固定窗口计数器算法 – 核心概念与数学定义
  2. PHP实现固定窗口计数器的三种方案 – Redis / 文件锁 / APCu对比
  3. 代码实战:基于Redis的固定窗口限流器 – 完整可运行代码
  4. 常见陷阱与性能优化 – 原子性问题、时钟跳跃、内存爆炸
  5. 固定窗口 vs 滑动窗口 vs 令牌桶 – 选型决策树
  6. 高频问答(FAQ) – 解决你90%的疑问

什么是固定窗口计数器算法

固定窗口计数器(Fixed Window Counter)是最简单的限流算法,它的核心思想是:

PHP 固定窗口计数器

将时间划分为固定的窗口(如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(微随机))打散请求

性能优化技巧

  1. 合并Redis请求:用pipeline批量操作
  2. 本地二级缓存:在PHP进程内缓存前10ms的计数结果,减少Redis请求
  3. 分级限流:普通用户限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:为什么我的计数器偶尔超过阈值? 排查步骤:

  1. 确认Lua脚本原子性(不要用GET+INCR分开执行)
  2. 确认EXPIPRE设置正确(防止窗口切换失效)
  3. 检查是否有多个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']);

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