限流
限流在缓存上做计数,所以要先有可用的缓存驱动(file / redis / apcu)。
没有 CLI 命令
限流只有中间件和 RateLimiter 类,php bin/sr 里没有 ratelimit:* 命令。 清空计数用 Cache::flush() 或换 prefix。
固定窗口,不是滑动窗口
这点必须先说清楚,否则你会对它的精度有错误预期。
源码:
$windowStart = intdiv(time(), $decaySeconds) * $decaySeconds;
$cacheKey = $this->prefix . $key . ':' . $windowStart;窗口是对齐的:
decay_seconds = 60,
窗口在 12:00:00、12:01:00、12:02:00…… 切换
12:00:00–12:00:59 → key: xxx:1793380800
12:01:00–12:01:59 → key: xxx:1793380860每个窗口一个独立计数器。
这个设计的缺陷:边界双倍流量
限制「每分钟 60 次」时:
12:00:50 打 60 次 ← 窗口 A 用满
12:01:10 打 60 次 ← 窗口 B 用满
实际:20 秒内打了 120 次窗口交界处的实际速率可以是限制值的 2 倍。
为什么还是用它
| 方案 | 精度 | 成本 |
|---|---|---|
| 固定窗口(当前) | 边界 2 倍 | 一次 get + 一次 put |
| 滑动窗口 | 准确 | 要存时间戳列表或多次计数 |
| 令牌桶 | 准确且平滑 | 要维护令牌状态 |
对「防爆破登录」「防刷接口」这类场景,2 倍误差可以接受——攻击者要卡准窗口边界才有用,而正常用户根本感知不到。
如果确实需要精确,把窗口调小(如 10 秒限 10 次),误差的绝对值跟着变小。
三种维度
// config/ratelimit.php
'by' => 'ip',by | 计数键 | 含义 |
|---|---|---|
ip(默认) | {ip}|{route} | 每个 IP 每个路由独立配额 |
route | {route} | 整个路由全局共享一个配额 |
global | global | 所有路由共用一个配额 |
怎么选
| 想保护什么 | 用 |
|---|---|
| 防某个用户刷接口 | ip |
| 防后端服务被打挂(昂贵查询) | route——全站共用,谁打都算 |
| 全站总量控制 | global |
route 维度的一个例子:
// 报表接口,一次查询要 3 秒,全站每分钟最多 10 次
Route::get('/reports/summary', ...)->middleware(['throttle:10,60']);
// 配上 'by' => 'route',或者在这个规则里单独指定用中间件
// 每分钟 60 次(默认)
Route::get('/posts', ...)->middleware(['throttle']);
// 每分钟 300 次
Route::get('/posts', ...)->middleware(['throttle:300,1']);
// 每 5 分钟 10 次
Route::post('/login', ...)->middleware(['throttle:10,300']);参数格式:throttle:{次数},{窗口秒数}。
登录接口一定要限流
Route::post('/login', [AuthController::class, 'login'], 'auth.login')
->middleware(['throttle:5,60']);5 次/分钟。密码爆破的常见字典有上万条,限到 5 次/分钟意味着跑完要 33 小时—— 足够让攻击者放弃。
按路由名配置
不想在每个路由上写参数时,写进配置:
// config/ratelimit.php
'routes' => [
// 精确匹配
'admin.v1.auth.login' => ['max_attempts' => 5, 'decay_seconds' => 60],
// 前缀通配(多个命中时取**最长**的那条)
'admin.v1.auth.*' => ['max_attempts' => 10, 'decay_seconds' => 60],
'admin.*' => ['max_attempts' => 120, 'decay_seconds' => 60],
],匹配优先级:精确 > 长通配 > 短通配 > 默认规则。
依赖路由名
所以路由必须命名,否则 route.name 是空的,通配符一条都匹配不到。
Route::post('/login', ..., 'auth.login'); // ✅ 有名字
Route::post('/login', ...); // ❌ 名字是 null(v1.0.0-beta.2 起,未命名路由的 name 是 null 而不是退化成组前缀——见路由。)
规则里也能覆盖 by
'routes' => [
'admin.v1.reports.*' => [
'max_attempts' => 10,
'decay_seconds' => 60,
'by' => 'route', // 这个路由用全局配额
],
],响应头
每次请求都会带(不超限也带):
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 47
X-RateLimit-Reset: 1793380860超限时(429):
HTTP/1.1 429 Too Many Requests
Retry-After: 23
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1793380860
{"code":20005,"message":"请求过于频繁,请稍后再试"}Retry-After 是秒数,客户端可以直接用来算退避时间。
手动限流
不适合用中间件的场景(比如按用户 ID 而不是 IP 限流):
use SunsetReverie\RateLimit\RateLimiter;
final class PostService
{
public function __construct(private readonly RateLimiter $limiter)
{
}
public function create(int $userId): array
{
// 每个用户每分钟最多发 5 篇
$limit = $this->limiter->attempt('post:create:' . $userId, 5, 60);
if (!$limit->allowed) {
throw new HttpException(
429,
ErrorCode::TooManyRequests->value,
sprintf('发帖太快了,%d 秒后再试', $limit->retryAfter)
);
}
// ... 创建
}
}RateLimiter 的三个方法:
| 方法 | 说明 |
|---|---|
attempt($key, $max, $decay) | 计数 +1,返回 Limit |
count($key, $decay) | 只读当前计数,不增加 |
reset($key, $decay) | 清零(比如登录成功后重置失败计数) |
Limit 的四个属性:
$limit->limit; // 上限(60)
$limit->remaining; // 剩余(47)
$limit->retryAfter; // 还有几秒窗口重置
$limit->allowed; // 是否放行登录成功后重置失败计数
这是常见需求:密码错 5 次锁定,但登录成功后应该清零。
public function login(LoginRequest $request): array
{
$key = 'login:' . $request->ip();
$limit = $this->limiter->count($key, 300);
if ($limit->remaining <= 0) {
throw new HttpException(429, ErrorCode::TooManyRequests->value, '失败次数过多,请 5 分钟后再试');
}
$user = $this->users->verify($request->validated());
if ($user === null) {
$this->limiter->attempt($key, 5, 300); // 只失败才计数
throw new HttpException(401, ErrorCode::CredentialsInvalid->value, '账号或密码错误');
}
$this->limiter->reset($key, 300); // 成功则清零
return ['token' => Auth::jwt()->issue((string) $user['id'])];
}只累计失败次数,比「所有尝试都算」更友好——正常用户不会因为登录几次就被锁。
配置
// config/ratelimit.php
'enabled' => env('RATELIMIT_ENABLED', true),
'store' => env('RATELIMIT_STORE', ''), // 留空 = 用 cache.default
'prefix' => 'rl:',
'by' => 'ip',
'default' => [
'max_attempts' => 60,
'decay_seconds' => 60,
],
'routes' => [...],store 建议用 redis
限流计数是高频写。用 file 驱动时每次都要 fopen + fwrite + rename, 实测 file 比 redis 慢约 1.6 倍(见缓存)。
高并发下调 RATELIMIT_STORE=redis。
enabled = false
压测或本地开发时关掉:
RATELIMIT_ENABLED=false生产绝对不要关——enabled=false 时中间件直接放行,等于没有限流。
多域隔离
每个域有独立的 error_segment,但限流计数不按域隔离(键里没有域名)。
同一个 IP 访问 /admin/v1/login 和 /web/v1/login 时,路由名不同 (admin.v1.auth.login vs web.v1.auth.login),所以配额是分开的 ✓
但如果两个域有同名路由(比如都叫 auth.login 且没有域前缀),计数会混在一起—— 不过这种情况在框架里不会发生,因为域名前缀是自动加的。
常见错误
限流完全不生效
| 检查 | 怎么看 |
|---|---|
| 中间件挂了吗 | ->middleware(['throttle']) |
enabled 是 true 吗 | RATELIMIT_ENABLED |
| 缓存能用吗 | 换个接口测 Cache::put() |
| 请求真的超了吗 | 响应头 X-RateLimit-Remaining |
先看响应头——有没有 X-RateLimit-* 就知道中间件跑没跑。
限流太严,正常用户被拦
throttle:5,60 ← 每分钟 5 次如果你的接口被轮询(比如前端每 5 秒拉一次状态),5 次/分钟必然误伤。
改成更符合实际的值,或者换 by 维度。
多个进程的计数不一致
file 驱动在多机部署时不共享——A 机的计数 B 机看不到。
多机必须用 redis:
RATELIMIT_STORE=redis通配符规则不生效
路由没命名。检查:
php bin/sr route:list | grep login「名称」列是 - 的话,admin.v1.auth.* 匹配不到。