本文目录导读:

- 文章标题:PHP接口限流实战指南:从令牌桶到Redis滑动窗口的完整实现
- 目录导读
- 为什么接口必须限流?
- 限流算法选型:谁最适合PHP?
- PHP代码实现核心方案(基于Redis)
- 分布式场景下的高阶技巧
- 防误杀与降级策略
- 性能与测试:防坑指南
- FAQ快问快答
PHP接口限流实战指南:从令牌桶到Redis滑动窗口的完整实现
目录导读
- 为什么接口必须限流? – 业务风险与架构韧性
- 限流算法选型 – 计数器、滑动窗口、令牌桶、漏桶的对比
- PHP代码实现核心方案 – 基于Redis的原子操作+平滑限流
- 分布式场景下的高阶技巧 – 一致性哈希与Lua脚本
- 防误杀与降级策略 – 动态配额与白名单机制
- 性能与测试 – 基准测试、压测报告及常见坑
- FAQ快问快答 – 10个高频问题精解
为什么接口必须限流?
现代API面临爬虫、刷单、恶意攻击和突发流量,未限流的接口会导致数据库连接耗尽、响应延迟飙升,甚至雪崩。限流本质是牺牲部分请求的即时性,换取整体系统的可用性,在PHP场景下,由于传统PHP-FPM的无状态特性,限流必须依赖外部存储(如Redis),而非进程内变量。
限流算法选型:谁最适合PHP?
| 算法 | 优点 | 缺点 | PHP适用性 |
|---|---|---|---|
| 固定窗口 | 实现简单(INCR+EXPIRE) |
临界突发(窗口边界双倍流量) | |
| 滑动窗口 | 平滑,精准控制 | Redis内存占用高(需ZSET) | |
| 令牌桶 | 允许突发流量,速率稳定 | 需维护令牌补充逻辑 | |
| 漏桶 | 强制平滑,绝对稳定 | 无法应对突发 |
首选令牌桶(允许秒杀场景的短时突发),次选滑动窗口(对时间精度要求苛刻时)。
PHP代码实现核心方案(基于Redis)
1 最简固定窗口(入门版)
// 每分钟限100次
$key = 'rate_limit:' . $userId . ':' . date('Y-m-d-H-i');
$current = Redis::incr($key);
if ($current === 1) {
Redis::expire($key, 60);
}
if ($current > 100) {
throw new \Exception('Too Many Requests', 429);
}
缺陷:第59秒和第61秒各100次,总请求可达200次。
2 真正的令牌桶(生产级)
使用Lua脚本保证原子性,避免竞态条件:
-- KEYS[1]: 令牌桶key, ARGV[1]: 容量, ARGV[2]: 每秒速率, ARGV[3]: 当前时间戳
local bucket = redis.call('HMGET', KEYS[1], 'last_refill', 'tokens')
local lastRefill = tonumber(bucket[1]) or tonumber(ARGV[3])
local tokens = tonumber(bucket[2]) or tonumber(ARGV[1])
local delta = math.max(0, tonumber(ARGV[3]) - lastRefill)
tokens = math.min(tonumber(ARGV[1]), tokens + delta * tonumber(ARGV[2]))
redis.call('HMSET', KEYS[1], 'last_refill', ARGV[3], 'tokens', tokens)
if tokens < 1 then
return 0 -- 拒绝
else
redis.call('HINCRBY', KEYS[1], 'tokens', -1)
return 1 -- 允许
end
PHP调用:
$result = Redis::eval($luaScript, 1, "user:'.$userId.'", $capacity, $rate, time());
if (!$result) { http_response_code(429); exit('Slow Down'); }
3 滑动窗口(ZSET精确版本)
$key = "sliding:" . $userId;
$now = microtime(true);
Redis::zadd($key, $now, $requestId);
Redis::zremrangebyscore($key, 0, $now - 60); // 移除60秒前的记录
$count = Redis::zcard($key);
if ($count > 50) {
Redis::zrem($key, $requestId); // 回滚本次
exit('限流');
}
Redis::expire($key, 60);
注意:单个用户每分钟50次,内存开销极小(每个请求约32字节)。
分布式场景下的高阶技巧
问题:负载均衡下PHP多实例,Redis单点瓶颈。
解决方案:
- 内存+Redis二级缓存:单实例用APCu本地计数,超过80%阈值再同步Redis,减少网络IO。
- Lua脚本防竞态:务必使用
EVAL执行原子操作,不要用WATCH重试,避免网络往返。
生产级Lua脚本(支持漏斗+令牌桶混合):
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local interval = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
redis.call('ZREMRANGEBYSCORE', key, 0, now - interval)
local count = redis.call('ZCARD', key)
if count >= limit then
return 0
else
redis.call('ZADD', key, now, now .. '-' .. math.random(100000))
redis.call('PEXPIRE', key, interval * 2)
return 1
end
防误杀与降级策略
- 白名单机制:内网IP/合作方商不限制,通过
in_array(ip, $whitelist)跳过。 - 动态配额:根据用户等级调整
$capacity,VIP用户限流阈值提升10倍。 - 降级熔断:当Redis不可用时,改用本地APCu(进程级)限流,牺牲精确度保可用性:
if (apcu_inc($key) > $fallbackLimit) { exit('fallback limit'); }
性能与测试:防坑指南
压测结果(Intel i7 + PHP7.4 + Redis6):
- 固定窗口:单机 8.5万 QPS
- Lua令牌桶:单机 4.2万 QPS
- ZSET滑动窗口:单机 1.8万 QPS(因ZADD时间复杂度O(logN))
常见坑:
- 不要用
file_put_contents记录日志,会锁IO卡死接口。 - Redis连接用
pconnect长连接,但注意phpredis扩展的坑(eval返回结果需严格处理)。 - 测试时用
ab -n 10000 -c 100 http://yourdomain/api/test,观察429状态码比例。
FAQ快问快答
Q1:限流为什么要用令牌桶而不是计数器?
A:计数器无法处理突发流量,令牌桶允许一次请求3个令牌来应对秒杀,且平均速率恒定。
Q2:用户维度限流和IP维度限流怎么结合?
A:直接用复合键IP:userId,或者先查IP再查用户,两层判断。
Q3:Nginx层限流与PHP层限流选哪个?
A:Nginx限流(limit_req_zone)应对DDoS,PHP层处理业务逻辑(如按用户ID限流),可同时使用。
Q4:Lua脚本有性能瓶颈吗?
A:Redis执行Lua时是原子且阻塞的,但脚本耗时极短(<1ms),远低于网络开销。
Q5:如何避免误封?
A:设置3倍惩罚后自动解封,例如超限10%封禁1分钟,之后滑动窗口自动恢复。
Q6:限流后返回什么HTTP状态码?
A:429 + Retry-After头,便于客户端主动等待。
Q7:Redis内存不够怎么办?
A:用unixsocket连接减少TCP开销,对ZSET设置TTL + maxmemory-policy allkeys-lru。
Q8:PHP-FPM的$_SERVER获取IP是不可靠的。
A:用HTTP_X_FORWARDED_FOR第一个值,但要防止伪造,可结合Nginx配置real_ip_header。
Q9:限流只适合写接口吗?
A:读接口也要限流,防止恶意爬虫抓取,建议读接口QPS控制在正常用户10倍以下。
Q10:开源方案推荐吗?
A:轻量需求用vyuldashev/laravel-rate-limiter,复杂场景直接用Redis API自研。
PHP限流不是简单的INCR+EXPIRE,而是需要结合业务、算法、存储的综合性设计,推荐先跑通Lua令牌桶,再逐步优化为混合模式,记住核心原则——限流必须做在业务逻辑之前,且独立于主流程,否则故障时反而拖垮系统,您可以根据自身系统负载,从本文代码直接改造上生产,欢迎交流压测数据。