本文目录导读:

PHP 完全可以实现令牌桶算法,而且实现方式很灵活,下面是几种不同场景的实现方式:
简单内存实现(单进程/单机)
<?php
class TokenBucket {
private $capacity; // 桶容量
private $tokens; // 当前令牌数
private $rate; // 令牌生成速率(个/秒)
private $lastRefill; // 上次补充时间
public function __construct($capacity, $rate) {
$this->capacity = $capacity;
$this->tokens = $capacity; // 初始满桶
$this->rate = $rate;
$this->lastRefill = time();
}
public function consume($tokens = 1) {
$this->refill();
if ($this->tokens >= $tokens) {
$this->tokens -= $tokens;
return true;
}
return false;
}
private function refill() {
$now = time();
$interval = $now - $this->lastRefill;
if ($interval > 0) {
$newTokens = $interval * $this->rate;
$this->tokens = min($this->capacity, $this->tokens + $newTokens);
$this->lastRefill = $now;
}
}
// 获取当前令牌数
public function getTokens() {
$this->refill();
return $this->tokens;
}
}
// 使用示例
$bucket = new TokenBucket(10, 2); // 容量10,每秒生成2个令牌
if ($bucket->consume()) {
echo "请求通过\n";
} else {
echo "请求被限流\n";
}
?>
Redis 分布式实现(多机/集群)
<?php
class RedisTokenBucket {
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 consume($tokens = 1) {
$luaScript = <<<LUA
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local tokens = tonumber(ARGV[3])
local now = tonumber(ARGV[4])
-- 获取当前令牌数
local currentTokens = redis.call('get', key)
if currentTokens == false then
currentTokens = capacity
else
currentTokens = tonumber(currentTokens)
end
-- 获取上次补充时间
local lastRefill = redis.call('get', key .. '_time')
if lastRefill == false then
lastRefill = now
else
lastRefill = tonumber(lastRefill)
end
-- 计算需要补充的令牌数
local elapsed = now - lastRefill
if elapsed > 0 then
local newTokens = elapsed * rate
currentTokens = math.min(capacity, currentTokens + newTokens)
redis.call('set', key .. '_time', now)
end
-- 尝试消耗令牌
if currentTokens >= tokens then
currentTokens = currentTokens - tokens
redis.call('set', key, currentTokens)
redis.call('expire', key, 3600)
return 1
else
redis.call('set', key, currentTokens)
redis.call('expire', key, 3600)
return 0
end
LUA;
$result = $this->redis->eval(
$luaScript,
[$key, $this->capacity, $this->rate, $tokens, time()],
1
);
return $result === 1;
}
}
// 使用示例
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$bucket = new RedisTokenBucket($redis, 'api_limit', 100, 10);
if ($bucket->consume()) {
echo "请求通过\n";
} else {
echo "请求被限流\n";
}
?>
高性能版本(带毫秒级精度)
<?php
class PreciseTokenBucket {
private $capacity;
private $tokens;
private $rate;
private $lastRefillMicro;
public function __construct($capacity, $rate) {
$this->capacity = $capacity;
$this->tokens = $capacity;
$this->rate = $rate;
$this->lastRefillMicro = microtime(true);
}
public function consume($tokens = 1) {
$this->refill();
if ($this->tokens >= $tokens) {
$this->tokens -= $tokens;
return true;
}
return false;
}
private function refill() {
$now = microtime(true);
$interval = $now - $this->lastRefillMicro;
if ($interval > 0.001) { // 最小补充间隔1ms
$newTokens = $interval * $this->rate;
$this->tokens = min($this->capacity, $this->tokens + $newTokens);
$this->lastRefillMicro = $now;
}
}
}
// 使用示例
$bucket = new PreciseTokenBucket(100, 20); // 容量100,每秒20个令牌
for ($i = 0; $i < 10; $i++) {
usleep(50000); // 50ms
if ($bucket->consume()) {
echo "第{$i}次请求通过\n";
} else {
echo "第{$i}次请求被拒绝\n";
}
}
?>
框架集成示例(Laravel)
<?php
// 在 Laravel 中创建一个中间件
namespace App\Http\Middleware;
use Closure;
use Illuminate\Support\Facades\Redis;
use Illuminate\Http\Request;
class RateLimiter
{
public function handle(Request $request, Closure $next, $capacity = 100, $rate = 10)
{
$key = 'rate_limit:' . $request->ip();
$luaScript = <<<LUA
-- Lua脚本实现令牌桶
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
if not redis.call('exists', key) then
redis.call('hset', key, 'tokens', capacity, 'time', now)
end
local data = redis.call('hgetall', key)
local tokens = tonumber(data[2])
local lastTime = tonumber(data[4])
local elapsed = now - lastTime
tokens = math.min(capacity, tokens + elapsed * rate)
if tokens >= 1 then
tokens = tokens - 1
redis.call('hset', key, 'tokens', tokens, 'time', now)
redis.call('expire', key, 60)
return 1
else
redis.call('hset', key, 'tokens', tokens, 'time', now)
redis.call('expire', key, 60)
return 0
end
LUA;
$result = Redis::eval(
$luaScript,
[$key, $capacity, $rate, time()],
1
);
if (!$result) {
return response()->json(['message' => 'Too Many Requests'], 429);
}
return $next($request);
}
}
?>
使用现成库
也可以使用现成的 PHP 库:
// 通过 Composer 安装
// composer require predis/predis
// 使用 Predis + Redis 实现限流
$client = new Predis\Client();
$bucket = $client->createRateLimiter('api', 100, 10);
if ($bucket->acquire()) {
// 请求通过
}
关键要点
- 并发安全:生产环境建议使用 Redis + Lua 脚本,保证原子性
- 精度控制:根据需求选择秒级或毫秒级精度
- 容量管理:合理设置桶容量和生成速率
- 内存考虑:内存实现适合单机,分布式必须用 Redis
- 性能优化:使用 Lua 脚本减少网络往返
令牌桶算法的优势在于允许一定程度的突发流量(桶容量),同时又限制了平均速率,非常适合 API 限流场景。