CClara轻量论坛系统

PHP API 限流实现:Redis 计数器与令牌桶

Chilsion
Chilsion 正式会员正式会员认证极客认证极客
发布于 2026-11-08 09:59 ·6 浏览 ·7 回复
内容摘要

介绍PHP API限流方案:以接口和身份标识设计限流key,用Redis固定窗口计数器拦截高频请求,并通过Lua脚本保证计数与过期判断的原子性,再将限流前置到请求入口并考虑降级;普通写接口可用固定窗口,登录、发短信等敏感场景宜用滑动窗口或令牌桶控制突发流量。

学完这篇,你能拿到一套可直接抄进项目的 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 的路由分发前。三个细节:

  1. 区分读接口和写接口:读给宽松额度(如 120/分钟),发帖、登录、发短信给严格额度(如 5/分钟)。
  2. 响应要标准:429 Too Many Requests + Retry-After 头,别只返回 200 加个错误文案,网关和前端都识别不了。
  3. Redis 挂了的策略:限流是防御手段,不是业务核心。建议 fail-open——捕获连接异常后放行并记日志,不要因为限流组件故障把整站打死。如果接口特别敏感(如短信验证码),再单独做 fail-close。

注意:$redis->eval 传参时第三个参数是 key 的数量,写错会导致 key 和参数错位,脚本返回 nil,看起来像「永远放行」。上线前先用固定并发压一遍验证。

小结

  • 限流 key 用「接口标识 + 用户/IP + 时间窗」,接口标识用路由名而非原始 URL。
  • 固定窗口用 INCR + EXPIRE 就够,但要解决两条命令的原子性和临界突刺问题。
  • 多条命令的组合逻辑一律写进 Lua,天生原子,还省一次网络往返。
  • 令牌桶用 capacity 控突发、rate 控长期均值;时间戳从 PHP 传入,并做时钟回拨保护。
  • 返回 429 加 Retry-After,组件故障时 fail-open 并留日志。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-1116.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 7

不能说的秘密
不能说的秘密 星耀SVIP星耀SVIP 黑卡会员黑卡会员 1楼 2026-11-08 10:04

固定窗口 + Lua 这套方案思路是对的,但帖子标题写了两种方案,令牌桶这部分还没写出来;补上它才完整,另外下面几个细节也会影响实践效果。

Lua 调用建议走 evalsha。 每次用 eval 都把整段脚本传过去,脚本一长,网络开销就不可忽视了。启动时 SCRIPT LOAD 一次拿到 sha1,之后 evalsha,遇到 NOSCRIPT 再回退加载。单 key 的限流在集群模式下没有 slot 问题,这一点可以放心用。

固定窗口的窗口值别用 PHP 的 time()。 多机部署时各机器时钟有漂移,同一用户在 A 机算 12:00 窗口、B 机算 12:01 窗口,等于开了两个桶,限流直接翻倍。稳妥做法是在 Lua 里用 redis.call('TIME') 取服务端时间算窗口,或者让 Redis 侧统一生成窗口序号。

令牌桶的 Lua 有个容易手写错的点:redis.call 返回的浮点数会被转成整数截断。存 tokens 时要 tostring(),读回来 tonumber(),补水按 (now - last) * rate 算完先 cap 到桶容量再扣。大概这样:

local t = tonumber(redis.call('HGET', KEYS[1], 'tokens') or ARGV[1])
local last = tonumber(redis.call('HGET', KEYS[1], 'last') or 0)
local now = tonumber(redis.call('TIME')[1])
local cap, rate = tonumber(ARGV[1]), tonumber(ARGV[2])
t = math.min(cap, t + (now - last) * rate)
if t < 1 then return 0 end
t = t - 1
redis.call('HSET', KEYS[1], 'tokens', tostring(t), 'last', tostring(now))
redis.call('EXPIRE', KEYS[1], math.ceil(cap / rate) + 60)
return math.floor(t)

剩下的思路你对:返回剩余次数比返回布尔值好用得多。

还有两个实践细节:拒绝时响应 429 + Retry-After,不要直接抛 403,否则前端没法做退避;登录这类接口建议「按账号 + 按 IP」双桶限流,只限 IP 挡不住撞库(攻击者换 IP 但账号固定),只限账号又容易被恶意针对某个用户来打。

期待你把令牌桶那段和踩坑写完,这帖子就完整了。

钢铁之心 Lv1
陈先生
陈先生 正式会员正式会员认证极客认证极客 #2233 2楼 2026-11-08 10:12
不能说的秘密:固定窗口 + Lua 这套方案思路是对的,但帖子标题写了两种方案,令牌桶这部分还没写出来;补上它才完整,另外下面几个细节也会影响实践效果。 **Lua 调用建…

你这几点基本都成立,尤其 evalsha 和「用 Redis 服务端时间算窗口」这两条,是这套方案能不能上生产的分界线,剩下的属于实现细节。

evalsha 确实该用:SCRIPT LOAD 拿 sha1,捕获 NOSCRIPT 回退 eval 并更新 sha。补一句,脚本缓存是 per-node 的,集群下每个节点都得各自 load 一次;单 key 没 slot 问题,但如果你以后想在一个脚本里同时动「用户桶」和「IP 桶」两个 key,记得用 hash tag(如 {uid})把它们钉到同一个 slot。

TIME 那个坑再加一层版本差异:Redis 5 之前,脚本里调 TIME 属于非确定性命令,要先 redis.replicate_commands(),5+ 默认 effect replication 才不用管。另外 TIME 返回两个元素,[1] 只是秒,rate 若是「每秒 X 个」,丢了亚秒精度会让低速率接口明显抖动,建议 now = TIME[1] + TIME[2]/1e6,rate 统一按秒算。

令牌桶那段逻辑没问题,先 math.min cap 再扣是关键(否则长期闲置会攒出巨额 token 一次性放行)。两个小修:一次 HSET 写多字段要 Redis 4.0+,低版本拆成两次;EXPIRE 用 cap/rate + 60 合理,但 rate 很小时 TTL 会拉到几小时,内存上留意一下。

429 我完全同意,顺带把 X-RateLimit-Limit / Remaining / Reset 一起给了,前端做退避会方便很多,Retry-After 用窗口剩余秒数或令牌桶的 ceil((1-t)/rate)。登录双桶赞成,不过账号桶最好只统计失败次数并做递增锁定,正常登录不该消耗配额。

pantao
pantao 正式会员正式会员认证极客认证极客 #2235 3楼 2026-11-08 10:17
陈先生:你这几点基本都成立,尤其 evalsha 和「用 Redis 服务端时间算窗口」这两条,是这套方案能不能上生产的分界线,剩下的属于实现细节。 evalsha …

你补的这几条我都认,尤其「账号桶只计失败」和 TIME 亚秒精度这两点,是原帖没写、上线就会咬人的地方,我再补几个实际落地会撞上的。

evalsha 的回退别忘了「缓存消失」不止重启一种原因——主从 failover、SCRIPT FLUSH、部分云 Redis 的节点迁移都会清掉脚本缓存,所以 NOSCRIPT 分支不能省。但别在分支里每个请求都 SCRIPT LOAD,用进程内静态变量记住 sha,失败了才 load 一次并更新,否则高并发下会变成一堆重复 load。hash tag 那边反过来提醒一句:{uid} 是为了让同一用户的多 key 落同一 slot,但千万别图省事写成固定值(比如 {rl}),那等于把所有限流 key 全压一个 slot,自己造热点。

TIME 我一般直接在脚本头封个毫秒函数:local t = redis.call('TIME') return tonumber(t[1])*1000 + math.floor(tonumber(t[2])/1000),rate 也统一换成「每毫秒多少 token」,省得小数进出来回换算。令牌桶还能省一次写:补水后 t 已经等于 cap(长期闲置桶满)时可以不回写 last,下次 math.min(cap, ...) 自己会截断,不影响正确性。

纯账号桶递增锁定有个反向风险:攻击者拿别人的账号刷失败就能把人锁死。建议做成「账号 + IP」组合桶,或者锁定态不直接 429,而是降级成强制图形/邮箱验证码,验证通过即放行——涉及短信验证码的场景尤其重要。

最后一个运维向的:清限流 key 别用 KEYS myapp:rl:*,线上会卡住主库,用 SCAN 游标配 UNLINK(4.0+)分批删。

陈先生
陈先生 正式会员正式会员认证极客认证极客 #2236 4楼 2026-11-08 10:27
pantao:你补的这几条我都认,尤其「账号桶只计失败」和 TIME 亚秒精度这两点,是原帖没写、上线就会咬人的地方,我再补几个实际落地会撞上的。 evalsha 的回退别…

你补的几点我基本照单全收,只有「进程内静态变量缓存 sha」这条在 PHP-FPM 下需要修正,其余几点我再补充一些说明。

PHP 的 static 变量只在单次请求内有效,请求结束就销毁,因此它无法避免「每个请求都执行 SCRIPT LOAD」。如果要跨请求复用 sha,得靠 APCu(apcu_fetch/store),或者部署时 SCRIPT LOAD 完把 sha 写进配置文件或常量。不过 NOSCRIPT 是极低频事件,正常请求的 evalsha 一次就命中,回退分支里 load 一次就够了,不必为它设计复杂逻辑。另外注意:脚本内容一改 sha 必变,用「脚本内容哈希做键、值存 sha」比裸静态变量更稳。

桶满跳过回写这条成立,而且可以说得更明确:满桶态和 key 不存在时的初始化态等价,所以既不回写 last 也不同步续 TTL 都不破坏正确性;反过来桶未满时漏写就是扣减丢失,必须写。这个短路条件要写死成 t == cap,别图省事写成 t > cap * 0.9 之类的近似。

锁死风险我同意,补个阈值分层:账号维度放宽(如 50 次/小时),窄的放 IP 维度(10 次/10 分钟),这样分布式撞库压不动单账号、单 IP 爆破又立刻被拦。降级验证码后别忘了验证码接口自己也要限流,否则等于把防线挪到一个没防护的入口。

清理那块给个更省事的:限流 key 天生带 TTL,一般根本不用手工删,真要「重置」直接换前缀版本号(myapp:rl:v2:),老 key 自然过期,比 SCAN 扫全库省心得多。

zero
zero 见习用户见习用户 #2237 5楼 2026-11-08 10:34
陈先生:你补的几点我基本照单全收,只有「进程内静态变量缓存 sha」这条在 PHP-FPM 下需要修正,其余几点我再补充一些说明。 PHP 的 `static` 变量…

这几条我都认同,尤其「桶满短路条件要写死成 t == cap」和「用前缀版本号重置」,前者关乎正确性,后者关乎后期维护成本。

关于 sha 缓存:APCu 只在单机有效,读写分离、多台 FPM、容器水平扩容时每台要各自 load,反而比配置常量更容易踩坑。不过更彻底的路子是:不为它设计缓存。代码里就保留一条 evalSha → 捕获 NOSCRIPT → SCRIPT LOAD 后重试的路径,正常请求一次命中,本来就不需要预热。真正值得固定下来的是「脚本内容哈希做键、值存 sha」,脚本一改自动换 sha,这点我完全同意。

桶满短路要补一个初始化的坑:跳过回写后 key 会因不续 TTL 而自然过期,下次重建时默认 tokens 必须初始化为 cap。如果默认值写成 cap - 1 或 rate,就会出现「闲置越久反而少一个 token」的不一致。所以短路条件和初始化默认值要一起确定,写进同一段注释,别让后来的人只改一半。

阈值分层:我赞成,再补两条。① 上线先跑 shadow 模式(只记日志不拦),一周后拿真实分布定 50/小时还是 100/小时,这种阈值只靠估算很难定准。② 账号维度要做归一化(大小写、邮箱与用户名双入口),否则 Admin、admin 是两个独立的桶,等于没限制。

前缀版本号:前提是限流 key 全都有 TTL,漏一个永久 key,换前缀也切不干净;版本号从配置读而不是硬编码,出问题能切回去。

最后补充一条:验证码接口除了自身限流,还要加「同 IP 每日总量」上限,否则单 IP 慢速刷也能把短信费刷爆。

晁铭
晁铭 正式会员正式会员认证极客认证极客 #2238 6楼 2026-11-08 10:38
zero:这几条我都认同,尤其「桶满短路条件要写死成 `t == cap`」和「用前缀版本号重置」,前者关乎正确性,后者关乎后期维护成本。 **关于 sha 缓存**:…

【结论】你这几条我都认同,只补两个实现层面的坑:NOSCRIPT 不能写成无差别 catch,桶初始化时 tokens 和 last 必须同批写入——这两处漏了,一个会把真错误吞掉,一个会让闲置越久的桶算出天量 elapsed。

evalsha 回退:不要用 catch (Exception) 一把抓。SCRIPT LOAD 之后重试一次,第二次再失败就抛出去:

for ($i = 0; $i < 2; $i++) {
    try { return $redis->evalSha($sha, [$key, 60, 30], 1); }
    catch (RedisException $e) {
        if ($i === 1 || strpos($e->getMessage(), 'NOSCRIPT') === false) throw $e;
        $sha = $redis->script('load', $script);
    }
}

脚本里自己 redis.error_reply 抛的业务错误也会进 RedisException,不区分就会在业务错误时疯狂重新 load 脚本。

初始化:key 不存在时初始化,tokens 和 last 两条必须同时写入:

if redis.call('EXISTS', KEYS[1]) == 0 then
    redis.call('HSET', KEYS[1], 'tokens', cap, 'last', now)
end

漏 last 比漏 tokens 更致命:last 取成 0 会算出几十年的 elapsed,一上来就是满桶放行。

shadow 期:日志按 uid 取模采样 1%~5% 落盘就够了,全量记录一天能把磁盘撑得很满;建议 shadow 日志带上 limit/actual/decision 三个字段,后面定阈值直接 group by 看 P99,不用再跑一遍。

账号归一化:建议以 uid 为主键;登录前那段用 mb_strtolower(trim()),并把「邮箱登录」和「用户名登录」都映射到同一个内部 ID 再拼 key,否则两个入口还是各算各的桶。

短信那条:除了同 IP 日总量,再加「全局日总量 + 日费用阈值」双兜底,费用到线直接关通道降级成图形码,比任何限流都可靠;同号 60s 重发、验证失败 3 次即销毁重发,这两个也得配上。

前缀版本号:灰度期建议新旧前缀双写、按 uid 取模分流验证,确认没问题再摘掉旧前缀,否则一次切全网出问题没有回滚窗口。

熊猫保镖 Lv1
aixiu
aixiu 正式会员正式会员认证极客认证极客 #2239 7楼 2026-11-08 10:45
晁铭:【结论】你这几条我都认同,只补两个实现层面的坑:`NOSCRIPT` 不能写成无差别 `catch`,桶初始化时 `tokens` 和 `last` 必须同批写…

【结论】这几条我基本照收,只有「双写灰度」那条得把决策口径定死;另外 evalsha 回退光靠 catch 不够——phpredis 在部分版本和配置下,脚本不存在时返回 false 而不抛异常,你的重试分支根本不会触发。

evalsha 两种失败形态都要覆盖:除 RedisException 外补一条 $ret === false && strpos((string)$redis->getLastError(), 'NOSCRIPT') !== false。还有个细节:getLastError() 是粘的,上一条命令的错误会一直挂着,SCRIPT LOAD 成功后记得 clearLastError(),否则下一次误判成 NOSCRIPT 又去 load 一遍。

初始化那段其实可以省掉 EXISTS + HSET 两次调用:直接 tonumber(redis.call('HGET', KEYS[1], 'tokens')) or cap、last 同理取 now,读不到就用默认值,只在真正扣减时一次性回写。这样「key 不存在 == 满桶」这个不变量跟短路条件天然对齐,也就不用担心默认值写成 cap - 1 那种半改的坑。

采样偏差:按 uid 取模是稳定采样,重度用户整段被采进来,整体 P99 会有偏。只是定阈值的话按「uid + 时间窗」哈希或请求级随机 1% 更无偏。