PHP突发流量怎么限流

wen PHP项目 1

本文目录导读:

PHP突发流量怎么限流

  1. 文章标题:PHP高并发下的“防洪闸”:突发流量限流实战指南(附代码与避坑手册)
  2. 目录导读(Table of Contents)
  3. 为什么你的服务器会“猝死”?——限流的必要性
  4. PHP限流三板斧:计数器、滑动窗口与令牌桶
  5. 高并发下的“隐形杀手”:Redis原子操作与Lua脚本
  6. 业务级限流:接口分级与用户画像
  7. 必踩的坑:Nginx层与PHP层限流的冲突与协同
  8. 压测与监控:如何验证你的限流策略有效?
  9. 常见问题解答(FAQ)与实战问答

PHP高并发下的“防洪闸”:突发流量限流实战指南(附代码与避坑手册)


目录导读(Table of Contents)

  1. 为什么你的服务器会“猝死”?——限流的必要性
  2. PHP限流三板斧:计数器、滑动窗口与令牌桶
  3. 高并发下的“隐形杀手”:Redis原子操作与Lua脚本
  4. 业务级限流:接口分级与用户画像
  5. 必踩的坑:Nginx层与PHP层限流的冲突与协同
  6. 压测与监控:如何验证你的限流策略有效?
  7. 常见问题解答(FAQ)与实战问答

为什么你的服务器会“猝死”?——限流的必要性

当突发流量(如秒杀、热点新闻、爬虫攻击)瞬间涌入,PHP-FPM进程数耗尽、数据库连接池被打满、CPU飙升——此时服务器不是变慢,而是直接宕机,核心原因在于:PHP本身是无状态的,每个请求都会重新编译执行,导致资源消耗是Java/Go的数十倍,限流不是“锦上添花”,而是保命底线


PHP限流三板斧:计数器、滑动窗口与令牌桶

1 固定窗口计数器(最简单的“土办法”)

$redis = new Redis();
$key = 'limit:user_'.$userId;
$current = $redis->incr($key);
if ($current == 1) {
    $redis->expire($key, 60); // 60秒窗口
}
if ($current > 100) {
    http_response_code(429);
    exit('请求过于频繁');
}

致命缺陷:临界问题——如果用户在59秒时请求100次,下一秒又请求100次,实际两秒内通过了200次。不推荐用于生产环境

2 滑动窗口(用ZSET解决临界问题)

$key = 'sliding:user_'.$userId;
$now = microtime(true);
$redis->zRemRangeByScore($key, 0, $now - 60); // 移除60秒前的记录
$count = $redis->zCard($key);
if ($count >= 50) {
    exit(429);
}
$redis->zAdd($key, $now, uniqid());
$redis->expire($key, 60);

优点:精确控制任何时间片内的请求数。缺点:ZSET占用内存较大(每秒可产生数千个member)。

3 令牌桶(平滑流量,防突发穿透)

class TokenBucket {
    private $rate;   // 每秒生成令牌数
    private $capacity; // 桶容量
    private $tokens;
    private $lastTime;
    public function __construct($rate, $capacity) {
        $this->rate = $rate;
        $this->capacity = $capacity;
        $this->tokens = $capacity; // 初始满桶(允许突发)
        $this->lastTime = microtime(true);
    }
    public function consume() {
        $now = microtime(true);
        $this->tokens = min($this->capacity, $this->tokens + ($now - $this->lastTime) * $this->rate);
        $this->lastTime = $now;
        if ($this->tokens < 1) {
            return false; // 拒绝请求
        }
        $this->tokens -= 1;
        return true;
    }
}

注意:单机内存版在多进程下会失效,必须用Redis+Lua保证原子性。


高并发下的“隐形杀手”:Redis原子操作与Lua脚本

问题incr + expire 是两个命令,非原子操作,如果进程在两者之间崩溃,key会永久存在。 终极解法:使用Lua脚本将命令打包,Redis保证脚本整体原子性。

-- 滑动窗口限流Lua脚本
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local maxCount = tonumber(ARGV[3])
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
local count = redis.call('ZCARD', key)
if count >= maxCount then
    return 0
end
redis.call('ZADD', key, now, now .. '-' .. math.random(100000))
redis.call('PEXPIRE', key, window)
return 1

调用方式

$lua = <<<LUA
...(上述脚本)
LUA;
$result = $redis->eval($lua, 1, $key, microtime(true)*1000, 60000, 100);

业务级限流:接口分级与用户画像

单纯靠技术限流会误伤正常用户,分层策略:

  • 一级接口(如登录):单IP 5次/分钟,单用户 20次/小时。
  • 二级接口(如订单提交):单用户 10次/秒,需配合验证码。
  • 三级接口(如商品浏览):单IP 100次/分钟,无需严格限制。

进阶:对已登录用户,根据其购买力、行为评分动态调整阈值,VIP用户允许突发双倍流量。


必踩的坑:Nginx层与PHP层限流的冲突与协同

误区:在Nginx配置了limit_req,就以为PHP层不用写代码了。
冲突场景:Nginx返回503/429后,PHP应用仍可能收到大量重试请求,导致PHP进程被无效请求占用。
正确姿势

  1. Nginx负责IP级别粗粒度限制(防爬虫)。
  2. PHP负责用户级别细粒度限制(防刷接口)。
  3. 错误码统一:Nginx的429状态码需透传给前端,PHP内部拒绝时也返回同一结构JSON。
limit_req_zone $binary_remote_addr zone=ip_limit:10m rate=10r/s;
location /api/ {
    limit_req zone=ip_limit burst=20 nodelay;
    fastcgi_pass php_backend;
}

压测与监控:如何验证你的限流策略有效?

工具:Apache Bench(ab)、wrk、JMeter。
关键指标

  • 限流触发时,CPU/内存应保持平稳,不出现累积。
  • Redis QPS不应超过单实例的 8-10万/秒。
  • 检查php_errors.log中是否有超时或连接池占满日志。

自检清单

  1. ab -n 10000 -c 200压测,观察429返回比例是否精确等于设定阈值。
  2. 使用redis-cli --stat观察ZSET键数量是否持续增长(内存泄漏风险)。
  3. 必须对限流器本身做熔断——如果Redis挂了,应放行所有请求(降级),而非拦截所有请求。

常见问题解答(FAQ)与实战问答

Q1:限流会误伤WebSocket长连接吗?
A:WebSocket握手后不再是HTTP请求,限流只针对握手阶段,若要限制消息频率,需在业务层单独设计。

Q2:多个PHP实例(集群)下,单机限流有意义吗?
A:无意义,必须使用集中式存储(Redis),本地APCu仅适合开发调试。

Q3:如果用户疯狂刷新导致Redis被写满怎么办?
A:给ZSET的member加上随机后缀(如时间戳+随机数),避免内存碎片,同时设置maxmemoryvolatile-ttl淘汰策略。

Q4:如何实现“排队等待”而非直接拒绝?
A:使用消息队列(如Redis List),当限流触发时,将请求ID推入List,后台Workers处理完后异步回调,适合耗时操作。

Q5:代码中是否要加try...catch捕获Redis异常?
A:必须!如果Redis宕机,应file_put_contents记录日志,并返回200,否则会引发雪崩效应。

情景问答

:我的接口被刷,但Nginx返回了503,为什么前端还是看到10秒超时?
:Nginx的limit_req默认以非阻塞方式处理,但若burst参数设置过小,请求会在队列中等待,建议设置nodelay参数,或者将Nginx限流放在server块,而非location块,避免与PHP进程互锁。


限流不是“多写几行代码”那么简单,它需要结合业务场景、部署架构、缓存性能综合设计。记住一个原则:最好的限流是让用户无感知、让攻击者无处发力、让系统平滑过载,切忌盲目照搬网上的代码——你的Redis版本、PHP扩展、业务模型都可能成为隐藏的雷

参考建议:文档中涉及的Lua脚本强制要求Redis 3.2+,生产环境务必升级到5.0以上,并开启--enable-debug进行长期观测。

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