本文目录导读:

在PHP项目中,令牌桶算法应对突发流量瞬间峰值,核心思路是:允许一定程度的突发,但严格控制长期平均速率,相比漏桶算法的“强制平峰”,令牌桶更适合需要处理突发请求的场景(如秒杀、活动页)。
以下是具体的实现方案和优化策略:
核心原理:先存后取
- 桶容量:允许的最大突发量,例如桶容量=1000,意味着在0流量后,可以瞬间处理1000个请求(因为桶里攒了1000个令牌)。
- 令牌生成速率:长期平均速率,例如1秒生成100个令牌,平均QPS就是100。
- 应对突发:瞬间峰值时,只要桶里有剩余令牌(之前空闲积累的),就可以直接取用,无需等待,从而平滑应对峰值。
PHP实现方案(推荐使用Redis)
PHP是请求-响应模式的,进程间不共享内存,因此需要用Redis(或MongoDB、Memcached等)作为中央令牌桶的存储。
基于Redis Sorted Set(精确计数,适合高精度) 适用于需要精确控制单个IP或用户粒度的限流。
<?php
class RedisTokenBucket {
private $redis;
private $key; // bucket的key,如 "token_bucket:api:user_123"
private $capacity; // 桶容量(最大突发)
private $rate; // 每秒生成令牌数
private $windowSize; // 滑动窗口大小(秒)
public function __construct($redis, $key, $capacity, $rate) {
$this->redis = $redis;
$this->key = $key;
$this->capacity = $capacity;
$this->rate = $rate;
// 窗口大小设置为容量/速率(保证令牌不会过期太久)
$this->windowSize = max(1, intval($capacity / $rate) + 1);
}
public function acquire($tokens = 1) {
$now = microtime(true); // 高精度时间戳
$lastTimestamp = $this->redis->hGet($this->key, 'last_timestamp') ?: $now;
$tokensInBucket = (float)($this->redis->hGet($this->key, 'tokens') ?: $this->capacity);
// 计算从上次请求到现在的令牌补充量
$elapsed = $now - $lastTimestamp;
$newTokens = $elapsed * $this->rate;
$tokensInBucket = min($this->capacity, $tokensInBucket + $newTokens);
if ($tokensInBucket < $tokens) {
// 丢弃请求或返回拒绝
return false;
}
// 扣减令牌
$tokensInBucket -= $tokens;
// 原子更新(使用Redis Lua脚本确保原子性)
$script = <<<LUA
local key = KEYS[1]
local now = tonumber(ARGV[1])
local tokens = tonumber(ARGV[2])
local capacity = tonumber(ARGV[3])
local rate = tonumber(ARGV[4])
local elapsed = now - tonumber(redis.call('hget', key, 'last_timestamp') or now)
local newTokens = tonumber(redis.call('hget', key, 'tokens') or capacity)
newTokens = math.min(capacity, newTokens + elapsed * rate)
if newTokens < ARGV[5] then
return 0
end
newTokens = newTokens - ARGV[5]
redis.call('hset', key, 'tokens', newTokens)
redis.call('hset', key, 'last_timestamp', now)
return 1
LUA;
return $this->redis->eval($script, [$this->key, $now, $tokensInBucket, $this->capacity, $this->rate, $tokens], 1);
}
}
// 使用示例
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$bucket = new RedisTokenBucket($redis, 'api:rate_limit', 1000, 100); // 容量1000,速率100/s
if ($bucket->acquire()) {
// 处理请求
} else {
http_response_code(429);
echo 'Too Many Requests';
}
基于Redis Hash + 过期时间(简单高效,适合API限流) 利用Redis的TTL自动过期,适合大多数Web API限流。
<?php
class SimpleRedisTokenBucket {
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;
// 初始化一个满的桶
$this->redis->hMset($key, ['tokens' => $capacity, 'last_refill' => microtime(true)]);
$this->redis->expire($key, 60); // 自动过期,释放内存
}
public function tryConsume($tokens = 1) {
$lua = <<<LUA
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local tokens_needed = tonumber(ARGV[4])
local tokens = tonumber(redis.call('hget', key, 'tokens') or capacity)
local last_refill = tonumber(redis.call('hget', key, 'last_refill') or now)
local elapsed = now - last_refill
local refill = math.floor(elapsed * rate)
-- 补充令牌
tokens = math.min(capacity, tokens + refill)
if tokens >= tokens_needed then
tokens = tokens - tokens_needed
redis.call('hset', key, 'tokens', tokens)
redis.call('hset', key, 'last_refill', now)
return 1
else
return 0
end
LUA;
return $this->redis->eval($lua, [$this->key, $this->capacity, $this->rate, microtime(true), $tokens], 1);
}
}
应对突发流量瞬间峰值的3个关键优化
-
选择合适的桶容量和速率
- 容量 = 允许的最大瞬时并发数,例如秒杀活动,容量设为1000,表示瞬间最多处理1000个请求。
- 速率 = 后端能稳定处理的能力(如数据库、下游API的QPS)。
- 公式建议:
容量 ≈ 后端平均处理时间 × 速率,例如接口平均耗时200ms,速率100/s,则容量设为20左右,但如果需要应对突发,可以放大到2-5倍。
-
使用原子操作避免竞态
- 必须用Lua脚本或
Redis MULTI/EXEC事务。 - 不要先读再写(
GET + SET),否则高并发下会导致令牌超发。 - 推荐使用Redis + Lua(Redis 2.6+内置),速度极快,原子性有保证。
- 必须用Lua脚本或
-
降低单次请求开销
- 批量请求:如果一次请求需要消耗多个令牌(如批量写),可以在Lua脚本里一次性扣减,避免多次网络往返。
- 异步填充:PHP在请求结束时,可以异步(如使用
register_shutdown_function)更新令牌桶,但更推荐让Redis在Lua中实时计算(如上面的方案),不依赖定时器。
为什么不直接用PHP本地内存/共享内存?
- PHP进程隔离:每个worker进程独立,无法共享令牌。
- APCu/共享内存:虽然可以,但如果有多台Web服务器,仍然需要Redis。
- 唯一推荐:Redis(或MongoDB、Memcached)作为中央限流器。
其他应对突发流量的手段(与令牌桶配合)
| 手段 | 作用 | 与令牌桶的关系 |
|---|---|---|
| CDN加速 | 静态资源缓存,减少回源 | 前置过滤,降低令牌桶压力 |
| Web服务器限流 (Nginx) | limit_req_zone + limit_req |
在应用层之前,拒绝明显超限的请求 |
| 消息队列 | 请求入队,后端消费 | 令牌桶控制入队速率,队列平滑后端负载 |
| 熔断降级 | 后端故障时快速丢弃请求 | 保护令牌桶算法不因后端超时而失效 |
面试追问点(加分项)
- Q: 令牌桶桶容量为N,突发流量超过N怎么办?
A: 请求直接返回429,这是理想设计:允许N的突发,但不会更多。
- Q: 如何动态调整速率?
- A: 在Redis中存储
rate字段,Lua脚本从Hash中读取,实现热更新,或者用配置中心+定时任务更新Redis。
- A: 在Redis中存储
- Q: PHP脚本退出后令牌如何更新?
- A: 不需要PHP脚本持续运行,每次请求来时,根据“上次更新时间和当前时间”计算应该补充多少令牌(基于时间差),这是令牌桶算法精髓——时间驱动。
对于PHP项目:
- 使用Redis + Lua实现令牌桶,保证原子性和高性能。
- 桶容量与速率需根据业务预估:容量越大,应对突发能力越强,但要注意后端能否扛住。
- 高并发下,令牌桶算法本身只是第一道防线,建议与CDN、Nginx限流、服务熔断组合使用,形成多层防护。