最直接的IP限流是用$_SERVER['REMOTE_ADDR']配合文件缓存实现,需用flock()防并发写崩、时间戳对齐窗口、JSON存最近请求;Redis推荐Lua脚本原子执行INCR+EXPIRE;响应需返回X-RateLimit-Limit/Remaining和动态Retry-After头;限流应按需装饰而非全局启用。

用 $_SERVER['REMOTE_ADDR'] 做基础 IP 限流最直接
限流本质是「记录 + 判断」,PHP 没有原生限流组件,得自己搭骨架。最轻量的做法就是按客户端 IP 统计请求次数,配合 file_get_contents() 和 file_put_contents() 做本地文件缓存。
关键不是“能不能做”,而是“怎么避免写崩”:文件并发写入会丢数据,必须加 flock();时间窗口要用时间戳对齐(比如每分钟从整点开始算),不能每次请求都 time() - 60 算——否则窗口漂移,实际限流宽松得多。
- 把 IP 和最近 10 次请求时间戳存成 JSON 数组,每次只保留 60 秒内的记录
- 用
fopen($file, 'c')打开文件,再flock($fp, LOCK_EX)加写锁 - 读完立刻
flock($fp, LOCK_UN)解锁,别等fclose()——锁持有时间越短越好 - 别用
file_put_contents($file, $data, FILE_APPEND),它不保证原子性,会覆盖或截断
Redis 实现滑动窗口限流更准但要注意连接和 key 过期
如果已有 Redis,INCR + EXPIRE 是最常用组合,但直接 INCR key 然后 EXPIRE key 60 有竞态:万一 INCR 成功但 EXPIRE 失败,key 就永久存在了。
正确做法是用 Lua 脚本一次性完成计数、过期设置和判断:
立即学习“PHP免费学习笔记(深入)”;
local current = redis.call("INCR", KEYS[1])
if current == 1 then
redis.call("EXPIRE", KEYS[1], ARGV[1])
end
return currentPHP 里调用:$redis->eval($lua_script, [$key, $window], 1)。注意两点:
-
$key最好包含接口路径和 IP,比如"rate:api/v1/user:192.168.1.100",避免不同接口共用一个计数器 - 别用长连接池管理 Redis 连接——PHP-FPM 每个 worker 是独立进程,连接复用意义不大,反而容易因超时断连导致异常
- 滑动窗口精度高,但 Redis 调用本身有网络开销,QPS 上万时得压测延迟是否可接受
用 header() 返回标准限流响应头,别只靠状态码
限流被触发时,光返回 429 Too Many Requests 不够。客户端需要知道「还能等多久」,不然只能盲等或退避策略失效。
HTTP 标准推荐三个响应头:
-
X-RateLimit-Limit:当前窗口最大请求数(如100) -
X-RateLimit-Remaining:剩余可用次数(如0) -
Retry-After:建议重试秒数(如60),这个比自定义头更易被前端库识别
注意:Retry-After 的值不是固定 60,而应是窗口结束时间戳减去当前 time(),向下取整。如果窗口是「每分钟」,且当前是 15:23:47,那下个窗口从 15:24:00 开始,Retry-After 就该是 13。
别在 __construct() 或全局 include 里初始化限流逻辑
限流不是所有接口都需要,硬塞到基类或中间件里,等于给每个请求加 Redis 或文件 I/O 开销。实际项目中,往往只有登录、发短信、下单等少数高危接口需要限流。
更合理的做法是「按需装饰」:
- 在路由定义时显式绑定限流策略,比如
['rate_limit' => ['limit' => 5, 'window' => 60]] - 或者用注解(PHP 8+):在控制器方法上写
#[RateLimit(limit: 10, window: 300)],运行时反射读取 - 绝对不要在
index.php开头就file_get_contents('rate.json')——没走限流逻辑的请求也得读一次磁盘
真正难的不是写通逻辑,而是想清楚「哪些请求值得限」「窗口粒度要不要动态调整」「失败日志记不记 IP 和 User-Agent」——这些决策比代码本身影响更大。



















