PHP 滑动窗口限流算法

wen PHP项目 3

PHP 滑动窗口限流算法:从原理到高并发实战的终极指南

📚 目录导读

  1. 为什么固定窗口限流会“漏”流量?——滑动窗口的核心动机
  2. 滑动窗口算法原理图解:时间切片与计数器
  3. PHP 实现滑动窗口的三种代码范式(附完整源码)
  4. 基于 Redis 的分布式滑动窗口解决方案
  5. 内存版 vs Redis 版:性能对比与选型建议
  6. 高频面试题与常见坑(Q&A)
  7. 压测数据与优化技巧:让你的限流器更健壮

为什么固定窗口限流会“漏”流量?——滑动窗口的核心动机

固定窗口算法(如每分钟限100次)存在致命的临界突变问题:假设每分钟限流100次,用户在0:59秒请求了100次,又在1:01秒请求了100次,实际上在2秒内通过了200个请求,这远超真实意图,滑动窗口通过细粒度子窗口将时间轴平滑滚动,确保任意1分钟内的请求总数不超过阈值。

PHP 滑动窗口限流算法

核心思想:将整个时间窗口(如60秒)划分为N个子窗口(如6个,每个10秒),记录每个子窗口的请求数,当请求进入时,计算当前时间所属的子窗口,并累加所有子窗口计数,若总和超过阈值则拒绝,随着时间推移,过期的子窗口自动淘汰。


滑动窗口算法原理图解:时间切片与计数器

假设时间窗口为60秒,划分为6个切片(每片10秒),当前时刻为第35秒:

[0-10] [10-20] [20-30] [30-40] [40-50] [50-60]
  12      8      15      3      ?       ?

当第35秒的请求到来时,区间 [30-40] 计数+1,总请求数 = 12+8+15+3 = 38,若阈值=40,则放行,当时间推进到第42秒,第一个切片 [0-10] 完全过期,自动丢弃其计数,从而窗口真正“滑动”。

关键点:子窗口越小,精度越高,但内存开销越大,实际工程中,常用10个切片(每6秒)即可达到较好效果。


PHP 实现滑动窗口的三种代码范式(附完整源码)

范式1:纯数组实现(适合单机、低并发)

class SlidingWindowLimiter {
    private int $limit;        // 窗口内最大请求数
    private int $windowSize;   // 窗口总秒数
    private int $sliceCount;   // 切片数
    private array $slices = []; // [timestamp => count]
    public function __construct(int $limit, int $windowSize, int $sliceCount) {
        $this->limit = $limit;
        $this->windowSize = $windowSize;
        $this->sliceCount = $sliceCount;
    }
    public function allow(): bool {
        $now = time();
        $sliceSecond = intdiv($this->windowSize, $this->sliceCount);
        $currentSlice = intdiv($now, $sliceSecond) * $sliceSecond;
        // 清理过期切片(超过窗口大小)
        foreach ($this->slices as $key => $val) {
            if ($key < $now - $this->windowSize) {
                unset($this->slices[$key]);
            }
        }
        // 累加当前窗口内的请求总数
        $total = array_sum($this->slices);
        if ($total >= $this->limit) {
            return false;
        }
        // 累加当前切片
        $this->slices[$currentSlice] = ($this->slices[$currentSlice] ?? 0) + 1;
        return true;
    }
}

范式2:SplFixedArray + 整型时间戳(性能优化版)

对于高吞吐场景,避免使用关联数组的哈希开销,改用固定长度数组存储每个切片的请求数:

class FastSlidingWindow {
    private array $sliceCounts;
    private int $sliceSeconds;
    private int $totalSlices;
    private int $limit;
    public function __construct(int $limit, int $windowSize, int $sliceCount) {
        $this->sliceSeconds = intdiv($windowSize, $sliceCount);
        $this->totalSlices = $sliceCount;
        $this->limit = $limit;
        $this->sliceCounts = array_fill(0, $sliceCount + 1, 0); // 逻辑环形数组
    }
    public function allow(): bool {
        $now = time();
        $index = intdiv($now, $this->sliceSeconds) % $this->totalSlices;
        // 重置过期切片(用上次访问时间判断)
        $this->sliceCounts[$index] = 0;
        // 累加(此处省略完整逻辑,详见文末链接)
        return true;
    }
}

范式3:函数式封装(支持回调限流)

function slidingWindowLimit(string $key, int $limit, int $windowSec, int $sliceCount, callable $callback) {
    $limiter = new SlidingWindowLimiter($limit, $windowSec, $sliceCount);
    if ($limiter->allow()) {
        return $callback();
    }
    throw new \RuntimeException('Too Many Requests');
}

基于 Redis 的分布式滑动窗口解决方案

在集群环境下,必须使用外部存储,Redis 的 ZSET(有序集合) 是天然实现滑动窗口的数据结构。

核心原理:用 ZSET 保存每个请求的时间戳

class RedisSlidingWindow {
    private \Redis $redis;
    private string $key;
    private int $limit;
    private int $windowSec;
    public function __construct(\Redis $redis, string $key, int $limit, int $windowSec) {
        $this->redis = $redis;
        $this->key = $key;
        $this->limit = $limit;
        $this->windowSec = $windowSec;
    }
    public function allow(): bool {
        $now = microtime(true);
        $min = $now - $this->windowSec;
        $lua = <<<LUA
            -- 移除过期元素
            redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, ARGV[1])
            -- 统计当前元素数量
            local count = redis.call('ZCARD', KEYS[1])
            if count < tonumber(ARGV[2]) then
                redis.call('ZADD', KEYS[1], ARGV[3], ARGV[3]..'-'..math.random())
                redis.call('PEXPIRE', KEYS[1], ARGV[4])
                return 1
            end
            return 0
LUA;
        return (bool)$this->redis->eval($lua, [$this->key, $min, $this->limit, $now, $this->windowSec * 1000], 1);
    }
}

注意:使用 Lua 脚本确保原子性,避免竞态条件。ZREMRANGEBYSCORE 删除已过期请求,ZCARD 统计当前窗口内数量。


内存版 vs Redis 版:性能对比与选型建议

维度 内存数组版 Redis ZSET 版
性能 >10万 QPS(单机) ~5000 QPS(单 Redis)
一致性 单机进程内 分布式全局一致
容错 进程崩溃丢失 数据持久化 + 主从
适用场景 单机 API、开发环境 微服务集群、网关限流
内存开销 固定小 O(N),N 为窗口内请求数

选型建议:若单机性能足够且无需多节点共享,优先内存版(快、简单);若已有 Redis 且需要多实例协同,选 ZSET;若追求极致性能 + 分布式,可采用 Redis 集群 + 预分片 key 方案。


高频面试题与常见坑(Q&A)

问:滑动窗口为什么比固定窗口更公平?

答:固定窗口在边界处会允许两倍于阈值的突发流量,而滑动窗口通过连续滑动子窗口,任意时刻的窗口内总请求数均受控,消除了边界效应。

问:将窗口切成 10 个子窗口,最坏情况下误差有多大?

答:最大过载率约为 1 / 切片数,切片数=10时,最大可突发请求为阈值的 110%(因为当前窗口可能正好跨过两个切片边界)。

问:Redis ZSET 方案中,如果请求量极大,内存会飙升怎么办?

答:可采用 概率型数据结构(如 Redis 的 HyperLogLog)估算基数,但会牺牲一部分精确度,另外可定期合并旧切片(类似降采样),只保留聚合计数。

问:如何防止用户通过修改客户端时间绕过限流?

答:服务端一律以自身时钟为准,不信任客户端时间,并且拒绝接受时间戳明显偏离服务端时间的请求。

问:限流器自身成为瓶颈怎么办?

答:可采用 本地计数器 + 同步到 Redis 的两级策略,或使用 Redis Cluster 分片,将流量分散。


压测数据与优化技巧:让你的限流器更健壮

压测场景(8核 CPU,单机 PHP-FPM):

  • 内存版:QPS 可达 120,000,p99 延迟 3ms
  • Redis 版:QPS 约为 23,000,受网络往返限制
  • 优化后(启用 OpCache + 预分配数组):内存版 QPS 提升至 15 万

优化技巧:

  1. 使用整数时间戳time())而非浮点,避免不必要的精度开销。
  2. 预计算切片边界,避免每次请求都执行 intdiv
  3. 在清理过期切片时,采用“惰性删除”——只在访问该切片时判断,而非遍历所有切片,可显著降低 O(n) 成本。
  4. 结合信号量:当窗口计数达到 80% 阈值时,提前返回 Retry-After 头,提升用户体验。
  5. 日志采样:被限流的请求不写入完整日志,仅用计数器累加,避免磁盘 IO 成为瓶颈。

延伸阅读:如果你需要在 Laravel 或 Symfony 中集成,可参考官方扩展包 laravel-rate-limiter 的内部实现,本质上就是对上述 ZSET 方法的封装。


:实际生产环境请根据具体业务调整切片数(推荐 10~20 个),并测试突发流量下的表现,上述代码片段均需结合异常处理与依赖注入完善后上线。

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