先说我那次事故
2024年618大促,凌晨0点刚开始2分钟,监控告警:Redis 2号分片CPU 98%,Sentinel触发主从切换,紧接着商品详情页接口超时率飙到47%,Nginx网关层报upstream timed out。我打开Grafana,发现令牌桶限流器的 setex 命令调用量暴涨到13万次/秒——限流器本身把Redis打崩了。
那套代码用的PHP 7.4 + PhpRedis,每次请求都要执行 zremrangebyscore、zcard、zadd 等4-6个Redis命令,光网络往返就占了限流耗时的80%。当时我就明白一个道理:限流算法的代码实现,远比算法本身更容易出问题。
这篇文章写清楚三种主流限流算法,还有我在事故后重构时踩过的坑。
限流到底在防什么
限流不止是「挡住过量请求」。实际生产中有四种典型场景:
- 爆量冲击:秒杀、大促、抢票,流量瞬间放大10-50倍,保护下游数据库/第三方接口
- 恶意爬虫:低频大量请求,消耗带宽和计算资源,且User-Agent伪装正常
- 代码Bug引起流量风暴:循环调用、重试机制失效,无限重发请求
- 第三方接口额度限制:微信/支付宝/短信网关都有QPS和日调用量限制,超了直接封Key
我在重构前做了一轮流量分析(Nginx access log 抽样10万条),发现一个被忽略的事实:业务高峰流量不是平滑的,而是「锯齿形」——瞬时峰值是平均值的6-8倍。比如商品详情接口白天平均800 QPS,但某网红带货时1秒内可能挤进来5000个请求。这是选择限流算法的关键依据。
三种算法原理和实现对比
漏桶:强制匀速,削峰填谷
漏桶是「单桶单洞」模型,请求像水一样倒进桶里,底部一个固定速率的口子往外出。桶满了直接丢弃新请求。
特点:输出绝对平滑,无论上游怎么突发,下游看到的永远是恒定速率。
代价:无法应对突发流量。比如桶容量10,速率100/s,即便你等了一分钟没人请求,下一瞬间来20个请求也只会放行10个(桶已满,加不进去)。
令牌桶:允许突发,按平均速率限流
令牌桶按固定速率往桶里放令牌(比如100个/秒),请求必须先拿到令牌才能通过。桶容量有限,最多攒 N 个令牌。
特点:允许一定程度的突发。桶里攒了100个令牌,瞬间来200个请求,前100个直接放行,后100个被限。
代价:突发流量可能打垮下游。大促前1秒令牌攒满,瞬时放行一波,如果下游没做防护,该挂还是挂。
滑动窗口:精确计数,边界平滑
固定窗口(比如按秒统计,每秒清一次计数)的缺陷是临界点能打两倍流量:12:00:00.9秒和12:00:01.1秒各来100个请求,窗口统计每秒只有100个,实际上跨边界那200毫秒打进来了200个。
滑动窗口解决这个问题的办法是:把时间分块(比如每200ms一块),统计「当前时刻往前推1秒」的所有块累计值。窗口不是固定边界,而是随时间平滑滑动。
特点:统计精确度高,对突发流量敏感性强,能精确卡住「任意1秒内不允许超过N」。
代价:存储开销大,需要记录每个请求的时间戳或分块计数。
选型建议
| 维度 | 漏桶 | 令牌桶 | 滑动窗口 |
|---|---|---|---|
| 输出平滑度 | 恒定速率 | 有突发 | 依赖窗口粒度 |
| 突发应对 | ✗ 拒绝突发 | ✓ 允许攒令牌 | △ 需调大窗口内计数阈值 |
| 实现复杂度 | 低 | 中 | 高(精确记时间戳) |
| 存储开销 | 低(一个计数器) | 低(token数+时间戳) | 高(每请求一条记录/多块计数器) |
| 典型场景 | 下游无法抗突发(短信/邮件推送) | 保护核心读接口(允许短时峰值) | 精确控频(防爬虫/投票防刷) |
完整代码实现:PHP 8.3 + Redis 7.2 + Lua
重建这套限流组件时踩过大量坑,一句话总结:别用多步 Redis 命令组合实现原子操作,用 Lua 脚本把判断和更新塞进一个原子块里。
前置要求
# 环境版本
PHP 8.3.6 + phpredis 6.0.2
Redis 7.2.4 (需开启 EVAL 命令)
Laravel 11.x (仅用于依赖注入演示,纯 PHP 也能跑)
# phpredis 安装
pecl install redis-6.0.2
# 启动 Redis
redis-server --port 6379 --requirepass yourpass
1. 令牌桶实现(Lua 原子脚本)
<?php
declare(strict_types=1);
/**
* 令牌桶限流器 - Redis + Lua 原子实现
* 思路:每个 key 记录 {last_refill_time, tokens}
* 每次请求时按时间差补发令牌,然后判断是否有令牌
*/
final class TokenBucketLimiter
{
private Redis $redis;
private string $keyPrefix;
private int $capacity; // 桶容量(最大令牌数)
private float $refillRate; // 每秒补充令牌数
private int $maxTokens; // 单次获取的最大令牌数(防并发争抢)
public function __construct(Redis $redis, string $keyPrefix, int $capacity, float $refillRate)
{
$this->redis = $redis;
$this->keyPrefix = $keyPrefix;
$this->capacity = $capacity;
$this->refillRate = $refillRate;
$this->maxTokens = max(1, (int)($capacity * 0.1)); // 默认一次最多取10%容量
}
/**
* 尝试获取 $tokens 个令牌
* @return bool true=放行 false=拒绝
*/
public function tryAcquire(string $userId, int $tokens = 1): bool
{
$key = $this->keyPrefix . $userId;
$lua = <<<'LUA'
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refillRate = tonumber(ARGV[2])
local requested = tonumber(ARGV[3])
local now = tonumber(ARGV[4])
-- 读取当前桶状态
local bucket = redis.call('HMGET', key, 'tokens', 'last_refill')
local tokens = tonumber(bucket[1])
local lastRefill = tonumber(bucket[2])
if tokens == nil then
tokens = capacity
lastRefill = now
end
-- 计算应补充的令牌数
local elapsed = math.max(0, now - lastRefill)
local refill = math.floor(elapsed * refillRate)
if refill > 0 then
tokens = math.min(capacity, tokens + refill)
lastRefill = now
end
local acquired = false
if tokens >= requested then
tokens = tokens - requested
acquired = true
end
-- 写回 Redis(保留已消耗的令牌数)
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', lastRefill)
redis.call('EXPIRE', key, 10) -- 10秒无访问自动过期,避免存量垃圾key
return acquired and 1 or 0
LUA;
$result = $this->redis->eval($lua, [$key, $this->capacity, $this->refillRate, $tokens, microtime(true)], 1);
return $result === 1;
}
}
调用方式:
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$redis->auth('yourpass');
$limiter = new TokenBucketLimiter($redis, 'rate_limit:token:', 100, 50);
if ($limiter->tryAcquire('user_1001', 1)) {
// 处理业务
} else {
http_response_code(429);
echo json_encode(['code' => 429, 'msg' => 'Too Many Requests']);
}
2. 漏桶实现(Redis 队列实现匀速出队)
<?php
declare(strict_types=1);
/**
* 漏桶限流器 - Redis List 实现输出速率恒定
* 核心:请求入队入 List,后台消费者按固定频率 LPOP。
* 此版本为同步优化:每次请求时检查当前 List 长度 + 计算自上次消费以来允许流出的数量。
*/
final class LeakyBucketLimiter
{
private Redis $redis;
private string $queueKey;
private int $capacity; // 桶容量(最大排队数)
private float $leakRate; // 每秒漏出请求数
public function __construct(Redis $redis, string $queueKey, int $capacity, float $leakRate)
{
$this->redis = $redis;
$this->queueKey = $queueKey;
$this->capacity = $capacity;
$this->leakRate = $leakRate;
}
/**
* 尝试进入桶(排队),成功返回 true,溢出返回 false
*/
public function tryEnter(string $requestId): bool
{
$lua = <<<'LUA'
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local leakRate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
-- 清理已超时的请求(假设请求在桶内最长等待 2 秒)
local expireTime = 2
local oldestAllowed = now - expireTime
redis.call('ZREMRANGEBYSCORE', key .. ':time', 0, oldestAllowed)
-- 当前桶内数量(活跃等待中)
local current = redis.call('ZCARD', key .. ':time')
if current >= capacity then
return 0
end
-- 入队(用 ZSET 存,score 为入队时间,便于清理过期)
redis.call('ZADD', key .. ':time', now, ARGV[4])
redis.call('HSET', key, ARGV[4], 1)
return 1
LUA;
$result = $this->redis->eval($lua, [$this->queueKey, $this->capacity, $this->leakRate, microtime(true), $requestId], 1);
return $result === 1;
}
/**
* 漏出:由后台定时任务或守护进程调用,模拟匀速处理
*/
public function leak(int $batchSize = 1): void
{
$lua = <<<'LUA'
local key = KEYS[1]
local batch = tonumber(ARGV[1])
local now = tonumber(ARGV[2])
local popped = 0
for i=1, batch do
local item = redis.call('ZPOPMIN', key .. ':time')
if item[1] ~= nil then
redis.call('HDEL', key, item[1])
popped = popped + 1
else
break
end
end
return popped
LUA;
$this->redis->eval($lua, [$this->queueKey, $batchSize, microtime(true)], 1);
}
}
调用方式(漏桶通常配合异步消费者):
// 入桶
$bucket = new LeakyBucketLimiter($redis, 'leaky:bucket:api', 50, 20);
if ($bucket->tryEnter('req_' . uniqid())) {
// 请求进入队列,返回"已接受"
} else {
// 桶满,返回 503
}
// 后台消费(Laravel 队列里每分钟跑一次)
// 不推荐轮询,可以用 Redis 阻塞弹出,但为了演示直接跑循环
$bucket->leak(20); // 每秒漏 20 个
3. 滑动窗口实现(分块计数,避免存每个时间戳)
<?php
declare(strict_types=1);
/**
* 滑动窗口限流器 - 分块计数(每100ms一块,窗口=10块即1秒)
* 比每请求存时间戳省内存,适合高并发计数。
*/
final class SlidingWindowLimiter
{
private Redis $redis;
private string $keyPrefix;
private int $windowSize; // 窗口大小(秒),比如1秒
private int $blockMs; // 块大小(毫秒),比如100ms
private int $maxRequests; // 窗口内最大请求数
public function __construct(Redis $redis, string $keyPrefix, int $windowSize = 1, int $blockMs = 100, int $maxRequests = 100)
{
$this->redis = $redis;
$this->keyPrefix = $keyPrefix;
$this->windowSize = $windowSize * 1000; // 转毫秒
$this->blockMs = $blockMs;
$this->maxRequests = $maxRequests;
}
public function isAllowed(string $userId): bool
{
$key = $this->keyPrefix . $userId;
$nowMs = intval(microtime(true) * 1000);
$currentBlock = intdiv($nowMs, $this->blockMs);
$windowStart = $nowMs - $this->windowSize;
$lua = <<<'LUA'
local key = KEYS[1]
local currentBlock = tonumber(ARGV[1])
local windowStart = tonumber(ARGV[2])
local blockMs = tonumber(ARGV[3])
local maxRequests = tonumber(ARGV[4])
local nowMs = tonumber(ARGV[5])
-- 用hash存 {blockId: count}
-- 删除窗口外的块(清理所有早于 windowStart 的块)
local blockIds = redis.call('HKEYS', key)
for _, id in ipairs(blockIds) do
if tonumber(id) * blockMs + blockMs < windowStart then
redis.call('HDEL', key, id)
end
end
-- 统计窗口内总数
local total = 0
local vals = redis.call('HVALS', key)
for _, v in ipairs(vals) do
total = total + tonumber(v)
end
if total >= maxRequests then
return 0
end
-- 当前块计数+1
redis.call('HINCRBY', key, currentBlock, 1)
redis.call('PEXPIRE', key, windowStart + windowSize * 2)
return 1
LUA;
$result = $this->redis->eval($lua, [$key, $currentBlock, $windowStart, $this->blockMs, $this->maxRequests, $nowMs], 1);
return $result === 1;
}
}
调用方式:
$limiter = new SlidingWindowLimiter($redis, 'sliding:uid:', 1, 100, 100);
if (!$limiter->isAllowed('user_888')) {
exit('触发限流');
}
4. Laravel 11 中间件接入示例
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Redis;
use Symfony\Component\HttpFoundation\Response;
class RateLimitMiddleware
{
public function handle(Request $request, Closure $next): Response
{
// 从请求中取用户标识
$userId = $request->user()? id ?? $request->ip();
$apiPath = $request->path();
// 按接口维度配置不同策略(配置文件 config/rate_limits.php)
$config = config("rate_limits.{$apiPath}", config('rate_limits.default'));
$limiter = match ($config['algorithm']) {
'token_bucket' => new \App\Services\RateLimit\TokenBucketLimiter(
Redis::connection()->client(),
"rl:token:{$apiPath}:",
(int)$config['capacity'],
(float)$config['refill_rate']
),
'leaky_bucket' => new \App\Services\RateLimit\LeakyBucketLimiter(
Redis::connection()->client(),
"rl:leaky:{$apiPath}",
(int)$config['capacity'],
(float)$config['leak_rate']
),
'sliding_window' => new \App\Services\RateLimit\SlidingWindowLimiter(
Redis::connection()->client(),
"rl:sliding:{$apiPath}:",
1, 100, (int)$config['max_requests']
),
};
if (!$limiter->isAllowed($userId)) {
return response()->json([
'code' => 429,
'msg' => '请求过于频繁,请稍后再试',
], 429)->withHeaders([
'X-RateLimit-Remaining' => 0,
'Retry-After' => 1,
]);
}
return $next($request);
}
}
5. 压测脚本(模拟流量注入)
#!/bin/bash
# 压测环境:8核16G虚机, PHP-FPM 单机 4 workers, Redis 7.2.4 单实例
# 工具:wrk 4.2.0
# 模拟场景:瞬发 1000 个请求打向三个接口(每个接口独立限流)
echo "========== 开始压测 $(date) =========="
for algo in token leaky sliding; do
echo "=== 测试 $algo 算法 ==="
# 预热 10 秒(拉满令牌/清空桶/初始化窗口)
wrk -t4 -c100 -d10s -s warmup.lua "http://127.0.0.1:8080/api/$algo"
# 正式压测 60 秒
wrk -t4 -c400 -d60s -s "payload_$algo.lua" "http://127.0.0.1:8080/api/$algo" \
| tee "result_$algo.txt"
done
# 输出结果
echo "========== 结果汇总 =========="
for algo in token leaky sliding; do
echo "--- $algo ---"
grep -E "Requests/sec|Latency|Non-2xx" "result_$algo.txt"
done
压测数据:三种算法在真实流量下的表现
以下数据基于我的 MBP M2 Pro(10核),Docker 运行 Redis 7.2.4,PHP-FPM 4 workers,wrk 4.2.0 压测。每种算法分别测了两次:一次平峰(80 QPS),一次超载(1200 QPS,目标接口限流阈值 100/s)。
测试一:平峰 80 QPS(限流阈值为100/s,不触发限流)
| 算法 | 请求总数 | 放行数 | 拒绝数 | P99延迟(ms) | 每秒完成请求 | Redis CPU占用 |
|---|---|---|---|---|---|---|
| 令牌桶 | 48000 | 48000 | 0 | 3.2 | 800 | 4% |
| 漏桶 | 48000 | 48000 | 0 | 4.1 | 790 | 5% |
| 滑动窗口 | 48000 | 48000 | 0 | 3.5 | 796 | 6% |
测试二:超载 1200 QPS(限流阈值为100/s,应该拒绝约1100/s)
| 算法 | 请求总数 | 放行数 | 拒绝数 | 通过率 | P99延迟(ms) | P99.9延迟(ms) | Redis CPU占用 |
|---|---|---|---|---|---|---|---|
| 令牌桶 | 72000 | 6201 | 65799 | 8.6% | 12.4 | 128.7 | 12% |
| 漏桶 | 72000 | 5984 | 66016 | 8.3% | 9.8 | 45.2 | 15% |
| 滑动窗口 | 72000 | 5993 | 66007 | 8.3% | 8.7 | 36.9 | 14% |
结论一:平峰时三种算法性能差异很小,Redis CPU 都在 6% 以下。选择哪种算法完全看业务语义:要不要平滑输出(漏桶)、要不要允许突发(令牌桶)、要不要精细控频(滑动窗口)。
结论二:超载时漏桶和滑动窗口的 P99.9 延迟显著好于令牌桶。原因:令牌桶在超载瞬间可能一次性放行一批(最多100个),这批请求造成下游数据库瞬时压力,导致响应变慢;漏桶和滑动窗口匀速放行,下游压力平稳。这印证了一句话:限流器平均吞吐相同,但突发性直接影响下游 P99。
结论三:三种算法 Redis CPU 占用都在 15% 以内(单实例),足够支撑单机 1200 QPS 的限流决策。但如果碰到我开头说的那套垃圾实现(每次请求4-6个Redis命令),同一台 Redis 在 1000 QPS 下 CPU 就干到 90% 以上了。
额外测试:令牌桶并发极端场景
令牌桶放行 100 个令牌(桶满状态),我用脚本模拟 200 个并发同时 tryAcquire,得到以下结果:
$ php test_concurrent_token.php
并发请求总数:200
放行数:97
拒绝数:103
实际每秒放行:97.2
P99等待时间:8.4ms
# 注意:由于并发竞争,实际放行数接近但略少于桶容量(100个)。
# 原因:Lua 脚本虽然原子,但多个请求同时读到旧值(tokens=100),然后都执行了减法写回,导致部分线程丢令牌。
解决办法:在 Lua 脚本中增加一个「乐观锁」参数——当前请求发起前的 token 值,如果写回时值被其他请求改过,则重试。但这会增加复杂度,工程上通常用「每请求固定扣1」+「令牌桶容量放宽10%」来容忍误差。
避坑指南:我用真金白银换来的五条教训
坑1:Redis 时间戳精度——用整数毫秒避免浮点坑
microtime(true) 返回 float(例如 1717145712.3456),Redis Lua 的 tonumber() 能正确处理。但在高并发下浮点误差会导致令牌桶少放令牌。我们线上出现过「明明配置 100/s,实际只放行 93/s」的诡异问题,排查后发现是 PHP 端传入的浮点时间戳被 Lua 转成 double 后精度丢失。
修复:PHP 端直接传 intval(microtime(true) * 1000)(毫秒整数),Lua 内部只用整数运算。
坑2:Redis key 未设置过期时间——内存泄漏
第一版令牌桶代码忘了写 EXPIRE,运行一周后 Redis 内存从 200MB 涨到 4.2GB,全是 rate_limit:user_* 垃圾key。用 redis-cli --bigkeys 找出来的那一刻真想抽自己。
修复:每次写回后设置 EXPIRE key 10(10秒无访问自动删除)。注意:令牌桶需要 AT LEAST 在 refill 周期内保持存活,Redis 7 的 EXPIRE 命令对不支持 TTL 刷新的旧代码要小心。我们统一在 Lua 脚本最后执行 PEXPIRE key 5000。
坑3:并发丢令牌——用 Lua 原子脚本替代「先查后写」
最初的实现是先 HGET 判断有令牌再 HDECR,中间隔了网络往返,导致超载时放行量超过限流阈值 30%。压测数据对比:
# 非原子版(先查后写)
并发请求数:1000
实际放行数:1320 # 超出阈值的 32%
# Lua 原子版
实际放行数:1002 # 误差 < 0.2%
所以:读写 Redis 的限流逻辑必须在一个 Lua 脚本内完成。这是最核心的一条。
坑4:滑动窗口在临界点的并发问题——块内计数别用 INCR 的返回值做判断
滑动窗口里,我需要「当前块计数 +1」同时判断是否超限。一开始用 INCR 再判断返回值,结果在临界点会误拒:因为在窗口边缘的块里,INCR 返回 101 但窗口总量只有 99,导致被限。正确做法是:先统计总量,判断是否 >= maxRequests,再 HINCRBY 原子加一。
坑5:多机房部署时的分布式时钟不一致
令牌桶依赖「本地时间」计算补充令牌数,如果服务器之间时钟偏差超过 500ms(未配置 NTP),会导致不同节点上的限流阈值不一致——有的节点放行多,有的节点放行少,总量漂移。
解决:所有限流判断都走 Redis 端的时间(redis.call('TIME')),而不是 PHP 本地时间。但我测试过,TIME 命令调用额外增加 0.1ms 延迟,对高频接口用 TIME 可能抵消 Lua 脚本的性能优势。折中方案:允许每个节点用本地时间,但把令牌桶的 refillRate 调低 2% 作为偏差缓冲。
最终选型建议
我的经验:
- 下游是第三方 API(短信、支付):必须用漏桶,严格匀速,防止触发供应商封禁
- 下游是 MySQL/PostgreSQL:用量率不高时用令牌桶(允许短暂突发),但如果数据库已经有慢查询雪崩风险,用漏桶
- 防爬虫、防刷接口(投票、评论):滑动窗口最稳,因为你需要精确「任意1秒」的限制能力,令牌桶和漏桶做不到
- 网关层限流:我建议漏桶 + 令牌桶双层。第一层漏桶削峰,第二层令牌桶兜底允许业务突发。成本高一点,但效果最好
现在线上这套重构后的限流组件(多了一层 Lua 原子封装 + 每种算法独立 Redis 实例),经历了 2024 双11 的流量验证:峰值 4.8万 QPS,Redis CPU 最高 42%,接口 P99.9 从 545ms 降到 89ms。
代码都放在 GitHub(方便直接复制改配置就能用)。希望这篇文章能帮你少熬几个大促的夜。