PHP后端限流算法比较:从计数器到漏桶,我的实战踩坑总结
作为一名在电商公司摸爬滚打多年的PHP工程师,我深刻体会到限流的重要性。还记得去年双十一,由于没有做好限流,我们的订单系统直接被突增的流量打垮,导致整个团队通宵抢修。从那以后,我系统研究了各种限流算法,并在实际项目中进行了验证。今天就来分享我的实战经验,希望能帮你少走弯路。
为什么我们需要限流?
在深入算法之前,先说说为什么限流如此重要。在分布式系统中,任何服务都有其处理能力的上限。如果没有限流保护,突发的流量高峰可能导致:
- 服务器资源耗尽,CPU、内存爆满
- 数据库连接数达到上限
- 响应时间急剧增加,用户体验下降
- 严重时整个服务不可用
记得那次事故后,我们花了三天时间才完全恢复数据,教训惨痛。所以,限流不是可选项,而是保护系统的必要手段。
计数器算法:简单但粗糙
计数器算法是最基础的限流方式,我在早期项目中经常使用。它的原理很简单:在固定时间窗口内,统计请求次数,超过阈值就拒绝请求。
class CounterLimiter
{
private $redis;
private $limit;
private $window;
public function __construct($limit = 100, $window = 60)
{
$this->redis = new Redis();
$this->redis->connect('127.0.0.1', 6379);
$this->limit = $limit;
$this->window = $window;
}
public function isAllowed($key)
{
$current = $this->redis->get($key);
if ($current === false) {
$this->redis->setex($key, $this->window, 1);
return true;
}
if ($current < $this->limit) {
$this->redis->incr($key);
return true;
}
return false;
}
}
// 使用示例
$limiter = new CounterLimiter(100, 60); // 每分钟100次
if (!$limiter->isAllowed('user_123')) {
throw new Exception('请求过于频繁');
}
踩坑提醒:计数器算法有个明显的缺陷——边界问题。比如设置每分钟100次限制,如果在59秒时来了100个请求,下一秒又来了100个请求,实际上在2秒内处理了200个请求,这可能会压垮系统。我在实际使用中就遇到过这个问题。
滑动窗口算法:计数器的升级版
为了解决计数器算法的边界问题,我引入了滑动窗口算法。它将时间窗口划分为多个小窗口,每个小窗口独立计数,通过滑动的方式消除边界效应。
class SlidingWindowLimiter
{
private $redis;
private $limit;
private $window;
private $segments;
public function __construct($limit = 100, $window = 60, $segments = 10)
{
$this->redis = new Redis();
$this->redis->connect('127.0.0.1', 6379);
$this->limit = $limit;
$this->window = $window;
$this->segments = $segments;
}
public function isAllowed($key)
{
$now = time();
$segmentSize = $this->window / $this->segments;
$currentSegment = floor($now / $segmentSize);
// 清理过期窗口
$oldSegments = $currentSegment - $this->segments;
for ($i = 0; $i <= $oldSegments; $i++) {
$this->redis->del($key . ':' . $i);
}
// 统计当前窗口内请求数
$total = 0;
for ($i = 0; $i < $this->segments; $i++) {
$segmentKey = $key . ':' . ($currentSegment - $i);
$count = $this->redis->get($segmentKey) ?: 0;
$total += $count;
}
if ($total >= $this->limit) {
return false;
}
// 增加当前段计数
$currentKey = $key . ':' . $currentSegment;
$this->redis->incr($currentKey);
$this->redis->expire($currentKey, $this->window);
return true;
}
}
实战经验:滑动窗口算法在精确度上有明显提升,但实现相对复杂,对Redis的操作次数也更多。我在高并发场景下使用时,发现Redis的QPS明显上升,需要适当调整段的数量来平衡精度和性能。
漏桶算法:平滑流量利器
漏桶算法是我在需要平滑处理请求时最喜欢用的方案。想象一个漏桶,请求像水一样流入,以固定速率流出,当桶满时就拒绝新请求。
class LeakyBucketLimiter
{
private $redis;
private $capacity; // 桶容量
private $rate; // 流出速率(个/秒)
public function __construct($capacity = 100, $rate = 10)
{
$this->redis = new Redis();
$this->redis->connect('127.0.0.1', 6379);
$this->capacity = $capacity;
$this->rate = $rate;
}
public function isAllowed($key)
{
$now = microtime(true);
$bucketKey = $key . ':bucket';
$lastKey = $key . ':last_time';
// 获取桶中当前水量和上次更新时间
$currentWater = $this->redis->get($bucketKey) ?: 0;
$lastTime = $this->redis->get($lastKey) ?: $now;
// 计算流出的水量
$outflow = ($now - $lastTime) * $this->rate;
$currentWater = max(0, $currentWater - $outflow);
if ($currentWater < $this->capacity) {
// 桶未满,允许请求
$currentWater++;
$this->redis->set($bucketKey, $currentWater);
$this->redis->set($lastKey, $now);
return true;
}
return false;
}
}
踩坑提醒:漏桶算法能很好地平滑流量,但无法应对突发流量。在电商秒杀场景中,我最初使用漏桶算法,结果发现虽然系统稳定,但用户体验很差——所有请求都被均匀处理,失去了秒杀的”秒”感。
令牌桶算法:灵活应对突发流量
令牌桶算法结合了漏桶的平滑和应对突发流量的能力,是我现在的主力方案。系统以固定速率生成令牌放入桶中,每个请求需要获取令牌,桶空时拒绝请求。
class TokenBucketLimiter
{
private $redis;
private $capacity; // 桶容量
private $rate; // 令牌生成速率(个/秒)
public function __construct($capacity = 100, $rate = 10)
{
$this->redis = new Redis();
$this->redis->connect('127.0.0.1', 6379);
$this->capacity = $capacity;
$this->rate = $rate;
}
public function isAllowed($key, $tokens = 1)
{
$now = microtime(true);
$tokensKey = $key . ':tokens';
$lastKey = $key . ':last_time';
// 获取当前令牌数和上次更新时间
$currentTokens = $this->redis->get($tokensKey) ?: $this->capacity;
$lastTime = $this->redis->get($lastKey) ?: $now;
// 计算新生成的令牌数
$newTokens = ($now - $lastTime) * $this->rate;
$currentTokens = min($this->capacity, $currentTokens + $newTokens);
if ($currentTokens >= $tokens) {
// 有足够令牌,允许请求
$currentTokens -= $tokens;
$this->redis->set($tokensKey, $currentTokens);
$this->redis->set($lastKey, $now);
return true;
}
return false;
}
}
// 使用示例:允许突发流量
$limiter = new TokenBucketLimiter(100, 10); // 容量100,每秒生成10个令牌
if (!$limiter->isAllowed('api_order', 1)) {
throw new Exception('系统繁忙,请稍后重试');
}
实战经验:令牌桶在秒杀场景中表现优异。我们设置较大的桶容量,允许在活动开始瞬间处理大量请求,然后以固定速率处理后续请求。这样既保证了系统不被压垮,又提供了较好的用户体验。
如何选择合适的限流算法?
经过多次实战,我总结出以下选择建议:
- 计数器算法:适合简单的频率限制,如短信验证码、登录尝试等
- 滑动窗口:需要更精确控制时使用,但要注意Redis性能
- 漏桶算法:需要绝对平滑流量的场景,如数据库写入、消息队列消费
- 令牌桶算法:大多数API限流场景,特别是需要应对突发流量的情况
在实际项目中,我通常会在网关层使用令牌桶进行全局限流,在具体的业务服务中根据需求选择其他算法。记得要做好监控和告警,及时调整限流参数。
部署和监控建议
限流配置不是一劳永逸的,需要持续优化:
# 监控Redis限流key的命中情况
redis-cli monitor | grep "limiter"
# 查看系统负载与限流触发的关系
watch -n 1 "echo 'Load:'; uptime; echo 'Limiter rejects:'; redis-cli get limiter_reject_count"
我在生产环境中会记录限流触发的日志,并设置告警,当限流频率异常升高时及时介入排查。
限流是系统稳定性的重要保障,选择合适的算法并合理配置参数,能让你的系统在面对流量冲击时从容不迫。希望我的这些实战经验能对你有帮助!

评论(0)