本文目录导读:

- 为什么下游服务需要限流?——从一次“雪崩”事故说起
- PHP限流核心算法对比
- 实战代码:基于Redis实现分布式令牌桶限流(含Lua脚本)
- 优雅降级:当限流被触发后,PHP如何响应客户端?
- 常见问答:限流与熔断的区别?QPS阈值如何设定?
- 总结:限流是架构韧性的一部分,而非“临时补丁”
**
《PHP限流实战:如何用“令牌桶”与“滑动窗口”保护下游服务不被冲垮》
目录导读
- 为什么下游服务需要限流?——从一次“雪崩”事故说起
- PHP限流核心算法对比:计数器、滑动窗口、令牌桶、漏桶
- 实战代码:基于Redis实现分布式令牌桶限流(含Lua脚本)
- 优雅降级:当限流被触发后,PHP如何响应客户端?
- 常见问答:限流与熔断的区别?QPS阈值如何设定?
- 限流是架构韧性的一部分,而非“临时补丁”
为什么下游服务需要限流?——从一次“雪崩”事故说起
假设你的PHP应用调用一个第三方支付API,该API承诺最高支持2000 QPS,某个促销活动瞬间带来5000 QPS的请求,PHP进程来不及等待响应,直接堆积大量阻塞连接,最终数据库连接池耗尽,应用整体崩溃——这就是典型的“上游流量高峰压垮下游服务”场景。
限流的核心价值在于:以牺牲少量请求(返回429或缓存数据)为代价,保障核心链路可用性,它不是拒绝用户,而是为“重要的请求”留出生存空间。
PHP限流核心算法对比
| 算法 | 原理简述 | 优点 | 缺点 | PHP适用场景 |
|---|---|---|---|---|
| 固定窗口 | 每秒重置计数器 | 实现极简单 | 窗口边界瞬时双倍流量 | 极轻量单机限流 |
| 滑动窗口 | 按秒切片,滑动统计最近N秒 | 平滑,无边界突刺 | 内存占用随精度增加 | 单机高精度控制 |
| 令牌桶 | 匀速生成令牌,有桶容量上限 | 允许突发流量 | 需要异步生成或脚本原子操作 | 分布式+突发容忍(推荐) |
| 漏桶 | 匀速处理请求,溢出则丢弃 | 完全平滑 | 无法处理突发 | 严格匀速下游(如WebSocket) |
对于PHP调用微服务/RPC/第三方API,令牌桶 + Redis 是最普遍且兼顾突发与平滑的解法。
实战代码:基于Redis实现分布式令牌桶限流(含Lua脚本)
思路:每个用户/IP/接口为一个Key,以固定速率(如每秒10个)向桶中加令牌,桶容量为20,请求时尝试取1个令牌,取不到则拒绝。
<?php
class TokenBucketLimiter {
private $redis;
private $key;
private $capacity; // 桶容量
private $rate; // 每秒补充令牌数
public function __construct($redis, $key, $capacity, $rate) {
$this->redis = $redis;
$this->key = $key;
$this->capacity = $capacity;
$this->rate = $rate;
}
public function tryAcquire($requestNum = 1) {
$lua = <<<LUA
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local requestNum = tonumber(ARGV[3])
local now = tonumber(ARGV[4])
local tokenInfo = redis.call('HMGET', key, 'tokens', 'last_time')
local tokens = tonumber(tokenInfo[1])
local lastTime = tonumber(tokenInfo[2])
-- 初始化
if tokens == nil then
tokens = capacity
lastTime = now
end
-- 计算时间流逝产生的令牌数量
local deltaTokens = (now - lastTime) * rate / 1000
if deltaTokens > 0 then
tokens = math.min(capacity, tokens + deltaTokens)
end
if tokens >= requestNum then
tokens = tokens - requestNum
redis.call('HMSET', key, 'tokens', tokens, 'last_time', now)
redis.call('EXPIRE', key, 2) -- 防止长期不用的Key占用内存
return 1
else
redis.call('HMSET', key, 'tokens', tokens, 'last_time', lastTime)
return 0
end
LUA
$result = $this->redis->eval($lua, [$this->key, $this->capacity, $this->rate, $requestNum, microtime(true)*1000], 1);
return $result == 1;
}
}
// 使用示例
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$limiter = new TokenBucketLimiter($redis, 'api:payment:user123', 20, 10);
if (!$limiter->tryAcquire()) {
http_response_code(429);
echo json_encode(['code'=>429, 'msg'=>'Too Many Requests, 请稍后重试']);
exit;
}
// 正常调用下游服务
$result = $yourClient->callPaymentApi($data);
关键点:
- 使用Lua脚本保证“取令牌+更新状态”的原子性,避免并发超发。
- 时间单位用毫秒,避免时间戳轮询误差。
EXPIRE2秒是为了即使Redis没有删除过期Key,也不会无限累积。
优雅降级:当限流被触发后,PHP如何响应客户端?
限流不等于“直接报错”,建议策略:
- HTTP 429 + Retry-After 头:明确告知客户端“你太快了”,并提示多久后重试。
header('Retry-After: 2'); // 2秒后再试 - 降级数据:如果下游是商品库存,返回缓存中的“缺货”提示,而不是空白页。
- 异步队列兜底:对非实时请求(如消息推送),把超限请求写入Redis队列,由后台Worker慢慢消费。
常见问答:限流与熔断的区别?QPS阈值如何设定?
Q1:限流和熔断(Circuit Breaker)是不是一回事?
不是。限流是“控制入口流量大小”,保护下游不过载;熔断是“当下游已经出错时,快速失败,不再尝试连接”,保护上游线程不堆积,支付API连续返回5次异常,熔断器打开,PHP直接短路,不再调用,两者常配合使用。
Q2:限流阈值应该设多少?
不能拍脑袋,必须基于压测数据,方法:
- 先无限制压测下游,找到其“最大稳定QPS”(例如1500 QPS)。
- 取这个值的80%作为限流阈值(即1200 QPS),留出20%冗余用于应付抖动。
- 如果下游是数据库,还需考虑连接池上限,阈值公式:
阈值 = 连接池大小 * 每个连接每秒可处理请求数。
Q3:分布式多个PHP实例如何共享限流状态?
使用Redis/Tair等中间件作为集中存储即可(如本文的Lua脚本),每个PHP实例看到的是同一个桶。
限流是架构韧性的一部分,而非“临时补丁”
很多开发者认为“限流就是在代码里加一个if判断”,这远远不够,真正的限流设计需要:
- 算法选型(当前项目容忍突发吗?)
- 存储选型(单机内存 or Redis集群?)
- 降级预案(返回什么数据给用户?)
- 监控告警(触发限流时,日志要记录全链路TraceID)
最后一句忠告:限流是“物理防御”,而熔断是“魔法防御”,先封装限流组件,再配置熔断,最后加上动态配置中心(如Apollo),你就能从容应对流量洪峰。把保护下游服务当作一种默认习惯,而不是事故后的反思。
文章结束