PHP项目固定窗口如何简化实现限流逻辑

wen PHP项目 28

本文目录导读:

PHP项目固定窗口如何简化实现限流逻辑

  1. 方案一:基于文件/内存的纯 PHP 实现(单机、无需外部依赖)
  2. 方案二:基于 Redis 的原子操作(单机/分布式,推荐)
  3. 方案三:基于 APCu (PHP 扩展) 的超轻量实现(单机、SAPI 模式如 FPM)
  4. 方案四:基于 Memcached 的 add 命令(替代 Redis 的轻量方案)
  5. 总结与选型建议

在 PHP 项目中实现固定窗口限流,通常是为了控制某个时间窗口(如 1 秒、1 分钟)内的请求次数。核心思路是:在窗口起始时记录计数,窗口内递增计数,窗口结束时重置计数。

下面介绍几种从简单到稍复杂的简化实现方案,重点在如何用最少的代码“无侵入”地实现


基于文件/内存的纯 PHP 实现(单机、无需外部依赖)

这是最简化的方式,适合单机部署、非高并发场景。

逻辑:

  1. 用一个文件(或共享内存、APCu)存储:{窗口起始时间戳}:{当前计数}
  2. 请求来后,读取数据,如果当前时间 - 起始时间 > 窗口大小,则重置(新窗口)。
  3. 计数 +1,如果超过阈值,拒绝。

极简代码示例(使用文件锁保证原子性):

<?php
function fixedWindowRateLimiter(string $key, int $limit, int $windowSeconds): bool {
    $file = sys_get_temp_dir() . "/rate_limit_{$key}";
    $fp = fopen($file, 'c+'); // 打开或创建
    // 加独占锁,防止并发写错乱
    flock($fp, LOCK_EX);
    $data = fread($fp, 1024);
    $data = $data ? json_decode($data, true) : ['start' => 0, 'count' => 0];
    $now = time();
    // 如果窗口过期,重置窗口
    if ($data['start'] + $windowSeconds <= $now) {
        $data = ['start' => $now, 'count' => 0];
    }
    $data['count']++;
    $allowed = $data['count'] <= $limit;
    // 写回文件
    ftruncate($fp, 0);
    rewind($fp);
    fwrite($fp, json_encode($data));
    fclose($fp); // 释放锁
    return $allowed;
}
// 使用示例:每分钟最多100次请求
if (fixedWindowRateLimiter('api_home', 100, 60)) {
    echo "请求通过";
} else {
    http_response_code(429);
    echo "请求过多,请稍后重试";
}

简化点:

  • 不需要安装 Redis / Memcached。
  • 不需要定时任务或 Cron 去清理过期数据,自动在请求时判断。
  • 代码可直接放在业务逻辑入口处。

弱点: 文件 I/O 慢,并发高时文件锁会成为瓶颈,不适合分布式。


基于 Redis 的原子操作(单机/分布式,推荐)

这是最标准、性能较好的固定窗口实现,利用 Redis 的 INCREXPIRE

逻辑:

  1. {redis_key}:{当前分钟的时间戳} 作为键。
  2. INCR 递增。
  3. 如果是第一次请求(返回1),设置 EXPIRE 为窗口长度 + 若干秒,防止内存泄漏。
  4. 判断当前值是否超过阈值。

极简代码:

<?php
// 假设 $redis 是已经连接好的 \Redis 对象
function rateLimitFixedWindow(\Redis $redis, string $key, int $limit, int $windowSeconds): bool {
    // 窗口键:key:当前窗口的起始时间戳(粒度到秒或分钟)
    $windowKey = $key . ':' . intval(time() / $windowSeconds) * $windowSeconds;
    $current = $redis->incr($windowKey);
    // 第一次请求,设置过期时间(窗口长度 + 1 秒,避免刚好过期)
    if ($current === 1) {
        $redis->expire($windowKey, $windowSeconds + 1);
    }
    return $current <= $limit;
}
// 使用
$allowed = rateLimitFixedWindow($redis, 'sms_send', 5, 60); // 60秒内最多5次
if ($allowed) {
    echo "允许发送短信";
} else {
    echo "限流中";
}

简化点:

  • 利用 Redis 的 INCR 原子性,无需 WATCH/MULTI 事务。
  • 使用时间戳分桶(time() / $windowSeconds 的整数部分)自然实现窗口切分。
  • 无需单独存储起始时间,键本身已经包含了时间分片信息。

弱点: 在窗口边界有“突刺”问题(比如59秒的请求和60秒第0秒的请求,可能在短时间内超过限制),如果想要更平滑,可以考虑滑动窗口算法(如 Redis Sorted Set 或 Cell-Rate 算法)。


基于 APCu (PHP 扩展) 的超轻量实现(单机、SAPI 模式如 FPM)

APCu 是共享内存的缓存,可以跨 PHP 进程共享数据(注意:CLI 模式下不共享)。

思路: 类似 Redis 但不需要安装 Redis 服务。

<?php
function apcuRateLimit(string $key, int $limit, int $windowSeconds): bool {
    $windowKey = $key . ':' . intval(time() / $windowSeconds) * $windowSeconds;
    $current = apcu_inc($windowKey, 1, $success);
    if (!$success) {
        // 未设置过,apcu_inc 不会自动设置初始值,需要手动初始化
        apcu_add($windowKey, 1, $windowSeconds + 1);
        $current = 1;
    }
    return $current <= $limit;
}

注意: apcu_inc 在键不存在时返回 false,需要结合 apcu_add,也可以直接用 apcu_cas,但 APCu 缺乏原生的“递增并返回”的原子性保证(对并发写入有极小的竞态可能,但绝大多数场景够用)。

简化点: 无外部服务依赖,速度极快(内存操作)。

弱点:

  • 限单机,且 PHP-FPM 多进程模式下,每个进程对 APCu 的操作是原子的(APCu 锁机制),但多进程同时 apcu_inc 仍然正确,但 apcu_add + apcu_inc 两步不能保证完全原子,可以改为只使用 apcu_inc 并接受第一次返回 false 的情况重试或手动加锁。
  • 更严谨的做法:使用 apcu_cas(检查并设置),但相对复杂。

基于 Memcached 的 add 命令(替代 Redis 的轻量方案)

如果项目使用 Memcached 而非 Redis,可以利用其 add + increment

逻辑:

  1. 尝试 add 初始化键(带过期时间),如果成功且返回 true(说明这是窗口第一次),设置计数为2(因为下一步会再加1)。
  2. add 返回 false(键已存在),增加 increment
  3. 检查 increment 的返回值是否 <= 阈值。

代码需要处理 addincrement 的原子性问题,不如 Redis 简洁,所以不推荐,但可供参考。


总结与选型建议

方案 依赖 原子性 分布式支持 性能 代码复杂度
文件锁 高(文件锁)
Redis Redis 高(INCR)
APCu APCu扩展 中等 极高
Memcached Memcached 中等(add+incr)

简化实现原则:

  1. 单机开发/测试:优先使用文件方案(方案一)。
  2. 生产环境单机/小集群:如果有 Redis,用方案二,代码最简洁。
  3. 不想用外部服务且服务器有 APCu:用方案三,注意 apcu_add + apcu_inc 的原子性,或者用 apcu_store + apcu_cas 实现更精确的计数(但复杂度会增加)。
  4. 需要完全避免窗口边界突刺:虽然固定窗口无法完全平滑,但可以接受;否则考虑滑动窗口(如 Redis Sorted Set)。

一个真正的简化技巧:

将限流逻辑封装成 PSR-15中间件前置Filter,只写一次,通用于所有路由,不要在业务代码里到处调用 fixedWindowRateLimiter(...)

// 中间件伪代码
class RateLimitMiddleware {
    public function process(Request $request, RequestHandler $handler): Response {
        $key = 'rate:'.$request->getUri()->getPath();
        if (!rateLimitFixedWindow($redis, $key, 100, 60)) {
            return new JsonResponse(['error' => 'Too Many Requests'], 429);
        }
        return $handler->handle($request);
    }
}

这样,业务代码本身完全不知道限流的存在——这才是真正的“简化”

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