不能只靠$_SERVER['REMOTE_ADDR']限流,因其在代理、CDN、NAT或移动端IP频繁变化时失效;真实业务需基于认证后的用户身份(如$user_id)和用户组(如$group_name)进行分组滑动窗口限流,Redis+Lua原子脚本保障计数与过期一致性,并通过鉴权中间件提前注入用户组属性供限流中间件消费。

限流策略为什么不能只靠 $_SERVER['REMOTE_ADDR']
直接用 IP 做限流主体,在真实业务中基本不可行:用户走代理、NAT、CDN 后,$_SERVER['REMOTE_ADDR'] 可能是 CDN 节点或网关地址;移动端频繁切换基站/WiFi 也会导致 IP 骤变。更关键的是,你真正想控制的是「某 VIP 用户组每分钟最多调 500 次」,不是「某个 IP 最多 50 次」。
所以必须把认证后的用户身份(如 $user_id)和其所属组(如 $group_name)带入限流逻辑。常见做法是在 JWT 或 session 中解析出 group 字段,再查配置表或缓存获取对应阈值。
用 Redis 实现分组滑动窗口限流的最小可行代码
PHP 自带的 file_put_contents 或 APCu 不适合生产限流——它们不跨进程、无原子计数、无法设置过期时间。Redis 的 INCR + EXPIRE 组合才是可靠选择。注意:必须用 Lua 脚本保证「计数+过期」原子性,否则高并发下会超限。
- 假设用户组
"vip"阈值为 500 次/60 秒,"basic"为 100 次/60 秒 - key 格式建议为
"rate_limit:{$group_name}:{$user_id}:{$timestamp_floor}",其中$timestamp_floor = floor($_SERVER['REQUEST_TIME']) - (floor($_SERVER['REQUEST_TIME']) % 60) - 使用
eval执行 Lua 脚本,避免多次网络往返
lua
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local expire = tonumber(ARGV[2])
local current = tonumber(redis.call('INCR', key))
if current == 1 then
redis.call('EXPIRE', key, expire)
end
return current <= limit
RateLimitMiddleware 在 Slim/FastRoute 中如何注入用户组
中间件本身不负责鉴权,它只消费已解析好的上下文。别在限流逻辑里重复解析 JWT 或查数据库——这会拖慢所有请求。正确顺序是:鉴权中间件 → 写入 $request->getAttribute('user_group') → 限流中间件读取该属性。
立即学习“PHP免费学习笔记(深入)”;
- 若用 Slim v4,确保在路由前注册鉴权中间件:
$app->add(new AuthMiddleware()) - 限流中间件内通过
$request->getAttribute('user_group', 'basic')获取分组,fallback 到默认组 - 不要硬编码阈值,从配置数组或
getenv('RATE_LIMIT_VIP')动态读取 - 触发限流时返回标准 HTTP 状态码
429 Too Many Requests,并带上X-RateLimit-Limit和X-RateLimit-Remaining头
为什么 sleep() 或 usleep() 不能用于“匀速限流”
有人试图用 sleep(0.1) 把请求压到每秒 10 次,这是危险操作:PHP 进程被阻塞,无法响应其他请求,连接数迅速耗尽,Nginx 很快返回 504。真正的匀速限流(leaky bucket / token bucket)必须由 Redis 或专用服务(如 Envoy)实现,PHP 层只做“是否放行”的判断。
另外,Redis 的 TIMEOUT 参数容易被忽略:如果网络抖动导致 eval 超时,PHP 可能收不到返回,此时应记录告警而非静默失败。生产环境务必设置 redis->setOption(Redis::OPT_READ_TIMEOUT, 0.1) 并捕获 RedisException。



















