本文目录导读:

PHP项目分布式限流如何基于Redis实现:从原理到实战的完整指南
目录导读
- 分布式限流的核心痛点
- Redis为何成为限流首选?
- 三种主流限流算法详解
- 1 固定窗口算法(计数器法)
- 2 滑动窗口算法(时间轮)
- 3 令牌桶算法
- PHP+Redis实现分布式限流实战
- 1 环境准备与安装
- 2 固定窗口限流代码实现
- 3 滑动窗口Lua脚本实现
- 4 令牌桶算法完整示例
- 高并发场景下的优化策略
- 常见问题FAQ
分布式限流的核心痛点
在单体架构中,限流只需在应用层用APC或文件锁即可实现,但当项目扩展为分布式集群(比如3台PHP服务器),问题就来了:
- 请求分散:用户A的请求可能落在Server1,用户B在Server2,单机计数器无法感知全局流量
- 临界突变:若未使用分布式锁,同一秒内多个服务器同时读取
$redis->get('count'),会导致限流失效 - 性能瓶颈:每次请求都调用
Redis可能拖慢响应,需要原子化操作与Lua脚本优化
行业案例:某电商平台大促时,因未实现分布式限流,导致3台API服务器合计QPS达到5000,但单机仅能承载2000,最终数据库被打爆,改用Redis限流后,全局QPS被精准控制在6000以内。
Q:为什么不直接使用Redis的INCR加过期时间?
A:对!INCR配合EXPIRE就是最简单的固定窗口算法,但若窗口最后1秒突增流量,可能冲破边界——比如第59秒1000个请求,第0秒又1000个请求,实际1秒内承受了2000,这就是“窗口交界问题”。
Redis为何成为限流首选?
Redis实现分布式限流的3大优势:
- 原子性:Redis单线程模型+
Lua脚本确保原子操作,避免竞态 - 高速缓存:基于内存操作,P99延迟通常<1ms,且支持
PIPELINE批量操作 - 过期机制:
TTL自动清理过期计数器,无需人工回收
对比其他方案: | 方案 | 一致性 | 性能 | 复杂度 | |------|--------|------|--------| | 本地内存缓存 | 低 | 高 | 低 | | MySQL+锁 | 高 | 低 | 中 | | Redis | 高 | 高 | 中 |
Q:Redis单点故障怎么办?
A:生产环境必须部署Redis Sentinel或Cluster集群,限流数据允许丢失时可用哨兵模式(CAP权衡),若不接受误差则用Redis Cluster + 读写分离,大型项目可用Codis或Twemproxy中间件。
三种主流限流算法详解
1 固定窗口算法(计数器法)
原理:每1秒(或自定义窗口)一个计数器,INCR后检查是否超过阈值。
实现:INCR key -> EXPIRE key 1
优缺点:简单但存在“窗口跳跃”问题——例如10秒内平均请求100,但第9-10秒突增200,第10-11秒又突增200,实际2秒内承受400。
2 滑动窗口算法(时间轮)
原理:将时间切分为更小粒度(如100ms),用有序集合ZSET存储每个请求的时间戳,ZREMRANGEBYSCORE清除旧数据,ZCARD统计当前窗口请求数。
优势:彻底解决窗口边界问题。
代价:内存占用稍高(需存储时间戳),但业务层面可接受。
3 令牌桶算法
原理:以固定速率向桶内添加令牌,每个请求消耗1个令牌,桶容量限制最大突发流量。
实现思路:
LLEN获取令牌数- 若令牌不足,则拒绝或排队
- 后台定时任务/Redis
EVAL脚本动态补充令牌
适用场景:允许突发流量(如秒杀开始前用户提前等待),且希望流量均匀。
PHP+Redis实现分布式限流实战
1 环境准备
# 安装Redis扩展 pecl install redis # PHP配置 extension=redis.so
2 固定窗口限流(最简实现)
<?php
class RateLimiter {
private $redis;
private $maxRequests = 100;
private $window = 1; // 秒
public function __construct() {
$this->redis = new Redis();
$this->redis->connect('127.0.0.1', 6379);
}
public function allow($key) {
$count = $this->redis->incr($key);
if ($count == 1) {
$this->redis->expire($key, $this->window);
}
return $count <= $this->maxRequests;
}
}
注意:此方法简单但存在窗口交界问题,建议配合Lua优化。
3 滑动窗口Lua脚本(推荐)
利用Redis ZREM + ZCARD 原子操作:
-- slide_window.lua
local key = KEYS[1]
local now = ARGV[1]
local window = ARGV[2]
local limit = tonumber(ARGV[3])
-- 清除1秒前的旧数据
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
-- 统计当前窗口请求数
local current = redis.call('ZCARD', key)
if current < limit then
redis.call('ZADD', key, now, now)
redis.call('EXPIRE', key, window)
return 1
else
return 0
end
PHP调用:
$result = $this->redis->eval(
file_get_contents('slide_window.lua'),
['user:api:123', microtime(true), 1, 100],
1
);
if ($result == 0) {
throw new \Exception('请求过快,请稍后再试');
}
4 令牌桶算法完整示例
<?php
class TokenBucket {
private $redis;
private $bucketKey;
private $capacity = 200; // 桶容量
private $addPerSecond = 50; // 每秒补充令牌数
public function __construct($bucketKey = 'token_bucket:api') {
$this->redis = new Redis();
$this->redis->connect('127.0.0.1', 6379);
$this->bucketKey = $bucketKey;
}
public function consume($tokens = 1) {
$script = <<<LUA
local key = KEYS[1]
local now = tonumber(ARGV[1])
local capacity = tonumber(ARGV[2])
local rate = tonumber(ARGV[3])
local tokens = tonumber(ARGV[4])
-- 获取当前令牌数(默认桶满)
local current = redis.call('GET', key)
if not current then
current = capacity
else
current = tonumber(current)
end
-- 计算上次补充后应添加的令牌数(通过TTL推断时间差)
local lastTime = redis.call('GET', key .. ':last_time')
if not lastTime then
lastTime = now
else
lastTime = tonumber(lastTime)
end
local elapsed = now - lastTime
local addTokens = math.floor(elapsed * rate)
-- 不能超过桶容量
current = math.min(current + addTokens, capacity)
-- 判断是否足够
if current >= tokens then
redis.call('SET', key, current - tokens)
redis.call('SET', key .. ':last_time', now)
redis.call('EXPIRE', key, 10)
return 1
else
return 0
end
LUA;
return $this->redis->eval($script, [
$this->bucketKey,
time(),
$this->capacity,
$this->addPerSecond,
$tokens
], 1);
}
}
Q:补充令牌的时间差如何保证准确?
A:使用Redis的GET记录上次更新时间,每次请求时计算时间差,若长时间无请求,脚本会自动补满至capacity,算是一种“惰性补充”模式,足以应对大部分场景。
高并发场景下的优化策略
1 Lua脚本原子化
所有限流逻辑必须放在EVAL中执行,避免PHP代码与Redis多次通信造成的竞态。
2 减少Redis连接开销
使用pconnect持久连接+长连接池,或在框架(如Laravel/Symfony)中开启Redis连接复用。
3 本地缓存降级
当Redis故障时,回退到APCu本地缓存限流(允许大误差),代码示例:
if (!$redisAllowed && apcu_inc('local_limit', 1, $ttl=1) > 200) {
// 拒绝请求
}
4 热key拆分
对单个用户限流时,key用user:{userId};对接口限流时,key用api:/order,若单一key请求量极大,可做“分片哈希”:
hash_ring('api:order:' . (crc32($clientIp) % 16))
常见问题FAQ
Q1:限流阈值如何确定?
A:基于系统压测结果,例如单机上PHP-FPM处理能力是300 QPS,3台服务器则设全局限流为300 * 3 * 0.8 = 720(保留20%缓冲)。
Q2:限流后应该返回什么HTTP状态码?
A:标准是429 Too Many Requests,并在响应头中加入Retry-After: 5(秒),可自主选择返回JSON或HTML。
Q3:如何处理“流量尖刺”?
A:推荐令牌桶算法,允许短时突发(如限速100/s,但桶容量200,允许瞬间200请求),或者使用滑动窗口配合“预热”机制。
Q4:Redis内存会爆吗?
A:滑动窗口用ZSET存储时间戳,建议对key设置24小时EXPIRE,同时配合定期清理(Redis自动过期),若QPS为10万,1天数据约需:100000 * 24 * 3600 * 8字节 ≈ 69GB,务必使用集群分片及合理窗口(例如滑动窗口只保留最近10秒数据)。
Q5:是否要在业务代码中硬编码限流?
A:最佳实践是封装成PHP中间件(如Laravel的ThrottleRequests),或集成到API网关中,避免业务代码冗余。
通过以上完整实现,你的PHP分布式项目可以精准控制流量洪峰,有效防御恶意爬虫和突发请求。限流不是为了拒绝用户,而是为了保护系统以服务更多的真实请求。