学完这篇,你能拿到一套可直接抄进项目的 PHP 接口限流方案:Redis 计数器挡住高频刷接口、令牌桶控制突发流量,两种都有完整代码和踩坑说明。
第一步:先定限流口径,别上来就写代码
限流的 key 必须能唯一标识「谁在访问哪个接口」。常见三段式:
rl:{接口标识}:{身份标识}:{时间窗}
身份标识优先用用户 ID(已登录),没登录退化成 IP。接口标识用路由名而不是原始 URL,否则 ?page=1、?page=2 会算成不同桶,等于没限。
注意:全站统一加个前缀(如 myapp:rl:),方便和缓存、会话共用一个 Redis 实例时互不干扰,也方便运维按前缀清理。
第二步:固定窗口计数器(最简单的那个)
思路是「一分钟内第 N 次请求」。用 INCR 自增,第一次出现时设过期:
function fixedWindow(Redis $redis, string $key, int $limit, int $ttl): bool
{
$n = $redis->incr($key);
if ($n === 1) {
$redis->expire($key, $ttl);
}
return $n <= $limit;
}
调用时把时间窗拼进 key:
$win = floor(time() / 60);
$key = "myapp:rl:post_create:{$uid}:{$win}";
if (!fixedWindow($redis, $key, 30, 60)) {
// 拒绝
}
这里有两个坑必须处理。
注意:INCR 和 EXPIRE 是两次网络往返。如果第一条命令成功、第二条之前进程崩了,这个 key 就永久驻留,用户被永久限流。最省事的修法是干脆每次都 expire(多一次调用,但幂等),更严谨的做法是下面第三步的 Lua。
注意:固定窗口有临界突刺——限制 60 次/分钟,用户在 12:00:59 打 60 次、12:01:00 再打 60 次,两秒内实际过了 120 次。对普通写接口够用,对登录、发短信用滑动窗口或令牌桶。
第三步:用 Lua 把判断做成原子操作
限流逻辑只要跨多条命令,就应该塞进 Lua,由 Redis 单线程保证原子性。
-- KEYS[1]: 限流 key
-- ARGV[1]: 窗口秒数
-- ARGV[2]: 上限次数
local n = redis.call('INCR', KEYS[1])
if n == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[1])
end
if n > tonumber(ARGV[2]) then
return 0
end
return tonumber(ARGV[2]) - n + 1
PHP 侧调用:
$script = <<<'LUA'
...上面那段...
LUA;
$left = $redis->eval($script, [$key, 60, 30], 1);
if ($left === 0) {
// 超限
}
返回值我特意做成「剩余次数」而不是布尔值,这样还能顺手塞进 X-RateLimit-Remaining 响应头。
第四步:令牌桶,处理突发流量
令牌桶的核心是:桶容量 capacity 决定能扛多大的突发,补充速率 rate 决定长期平均速率。比如 capacity=10、rate=2,代表允许瞬间打 10 个,但长期平均每秒只能 2 个。
-- KEYS[1]: 桶 key
-- ARGV[1]: capacity ARGV[2]: rate(个/秒)
-- ARGV[3]: 当前时间戳(秒, 带小数) ARGV[4]: 本次消耗
local key = KEYS[1]
local cap = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local cost = tonumber(ARGV[4])
local d = redis.call('HMGET', key, 'tokens', 'ts')
local tokens = tonumber(d[1])
local ts = tonumber(d[2])
if tokens == nil then
tokens = cap
ts = now
end
-- 时钟回拨保护
if now < ts then now = ts end
tokens = math.min(cap, tokens + (now - ts) * rate)
local allowed = 0
if tokens >= cost then
tokens = tokens - cost
allowed = 1
end
redis.call('HMSET', key, 'tokens', tostring(tokens), 'ts', tostring(now))
redis.call('EXPIRE', key, math.ceil(cap / rate) + 60)
return allowed
PHP 调用:
$allowed = $redis->eval($lua, [$bucketKey, 10, 2, microtime(true), 1], 1);
if (!$allowed) {
http_response_code(429);
header('Retry-After: 1');
exit(json_encode(['code' => 429, 'msg' => '请求过于频繁']));
}
注意:时间戳一定由 PHP 传进去,别在 Lua 里用 redis.call('TIME')。多实例部署时各 Redis 节点时间不一致,桶的补充速率会飘。
注意:用了 Redis Cluster 的话,Lua 里所有 key 必须落在同一个 slot。这里只有一个 key,没问题;如果以后要做「多接口共享总配额」,记得在 key 上加 {hash tag}。
第五步:接进请求入口,并想好降级
限流必须放在业务逻辑之前,通常在框架中间件或 index.php 的路由分发前。三个细节:
- 区分读接口和写接口:读给宽松额度(如 120/分钟),发帖、登录、发短信给严格额度(如 5/分钟)。
- 响应要标准:
429 Too Many Requests + Retry-After 头,别只返回 200 加个错误文案,网关和前端都识别不了。
- Redis 挂了的策略:限流是防御手段,不是业务核心。建议 fail-open——捕获连接异常后放行并记日志,不要因为限流组件故障把整站打死。如果接口特别敏感(如短信验证码),再单独做 fail-close。
注意:$redis->eval 传参时第三个参数是 key 的数量,写错会导致 key 和参数错位,脚本返回 nil,看起来像「永远放行」。上线前先用固定并发压一遍验证。
小结
- 限流 key 用「接口标识 + 用户/IP + 时间窗」,接口标识用路由名而非原始 URL。
- 固定窗口用
INCR + EXPIRE 就够,但要解决两条命令的原子性和临界突刺问题。
- 多条命令的组合逻辑一律写进 Lua,天生原子,还省一次网络往返。
- 令牌桶用
capacity 控突发、rate 控长期均值;时间戳从 PHP 传入,并做时钟回拨保护。
- 返回 429 加
Retry-After,组件故障时 fail-open 并留日志。