Laravel 13 API 限流完全指南

API 限流是保护服务免受滥用的重要机制。本文将深入探讨 Laravel 13 中 API 限流的各种方法和最佳实践。

内置限流

路由限流

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// routes/api.php

// 基础限流
Route::middleware('throttle:60,1')->group(function () {
Route::get('/users', [UserController::class, 'index']);
});

// 按用户限流
Route::middleware('throttle:60,1,user')->group(function () {
Route::get('/profile', [ProfileController::class, 'show']);
});

// 使用配置
Route::middleware('throttle:api')->group(function () {
Route::apiResource('posts', PostController::class);
});

限流配置

1
2
3
4
5
6
7
// config/app.php
'ratelimiting' => [
'api' => [
'limit' => 60,
'window' => 1,
],
],

自定义限流器

定义限流器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// App\Providers\AppServiceProvider.php

use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;

public function boot(): void
{
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)
->by($request->user()?->id ?: $request->ip());
});

RateLimiter::for('global', function (Request $request) {
return Limit::perMinute(1000);
});

RateLimiter::for('login', function (Request $request) {
return Limit::perMinute(5)
->by($request->email . '|' . $request->ip());
});

RateLimiter::for('uploads', function (Request $request) {
return $request->user()->isPremium()
? Limit::perMinute(100)
: Limit::perMinute(10);
});
}

使用自定义限流器

1
2
3
Route::middleware(['throttle:login'])->post('/login', [AuthController::class, 'login']);

Route::middleware(['throttle:uploads'])->post('/upload', [UploadController::class, 'store']);

高级限流策略

动态限流

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
<?php

namespace App\Services;

use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Http\Request;

class DynamicRateLimiter
{
public function register(): void
{
RateLimiter::for('dynamic', function (Request $request) {
$user = $request->user();

if (!$user) {
return Limit::perMinute(30)->by($request->ip());
}

return match ($user->plan) {
'free' => Limit::perMinute(60),
'basic' => Limit::perMinute(200),
'premium' => Limit::perMinute(1000),
'enterprise' => Limit::none(),
default => Limit::perMinute(60),
};
});

RateLimiter::for('burst', function (Request $request) {
return [
Limit::perSecond(10)->by($request->ip()),
Limit::perMinute(200)->by($request->ip()),
];
});
}
}

滑动窗口限流

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
<?php

namespace App\Services;

use Illuminate\Support\Facades\Redis;

class SlidingWindowRateLimiter
{
protected string $prefix = 'rate_limit:';

public function attempt(string $key, int $maxAttempts, int $decaySeconds): bool
{
$now = microtime(true);
$windowStart = $now - $decaySeconds;

$redisKey = $this->prefix . $key;

Redis::multi();
Redis::zRemRangeByScore($redisKey, '-inf', $windowStart);
Redis::zAdd($redisKey, $now, $this->generateId());
Redis::expire($redisKey, $decaySeconds);
$result = Redis::zCard($redisKey);
Redis::exec();

return $result <= $maxAttempts;
}

public function remaining(string $key, int $maxAttempts, int $decaySeconds): int
{
$now = microtime(true);
$windowStart = $now - $decaySeconds;

$redisKey = $this->prefix . $key;

Redis::zRemRangeByScore($redisKey, '-inf', $windowStart);
$count = Redis::zCard($redisKey);

return max(0, $maxAttempts - $count);
}

protected function generateId(): string
{
return uniqid('', true);
}
}

限流中间件

自定义限流中间件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Symfony\Component\HttpFoundation\Response;

class CustomRateLimiter
{
public function handle(Request $request, Closure $next, int $maxAttempts = 60, int $decayMinutes = 1): Response
{
$key = $this->resolveKey($request);

$attempts = Cache::get($key, 0);

if ($attempts >= $maxAttempts) {
return $this->buildResponse($key, $maxAttempts, $decayMinutes);
}

Cache::put($key, $attempts + 1, $decayMinutes * 60);

$response = $next($request);

return $this->addHeaders($response, $maxAttempts, $attempts + 1);
}

protected function resolveKey(Request $request): string
{
$identifier = $request->user()?->id ?? $request->ip();
return 'rate_limit:' . sha1($identifier . '|' . $request->path());
}

protected function buildResponse(string $key, int $maxAttempts, int $decayMinutes): Response
{
$retryAfter = Cache::get("{$key}:timer", now()->addMinutes($decayMinutes)->diffInSeconds(now()));

return response()->json([
'message' => '请求过于频繁',
'retry_after' => $retryAfter,
], 429)->withHeaders([
'Retry-After' => $retryAfter,
'X-RateLimit-Limit' => $maxAttempts,
'X-RateLimit-Remaining' => 0,
]);
}

protected function addHeaders(Response $response, int $maxAttempts, int $remaining): Response
{
return $response->withHeaders([
'X-RateLimit-Limit' => $maxAttempts,
'X-RateLimit-Remaining' => max(0, $maxAttempts - $remaining),
]);
}
}

分布式限流

Redis 限流

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
<?php

namespace App\Services;

use Illuminate\Support\Facades\Redis;

class RedisRateLimiter
{
protected string $prefix = 'rate_limit:';

public function tooManyAttempts(string $key, int $maxAttempts): bool
{
$redisKey = $this->prefix . $key;

$attempts = Redis::incr($redisKey);

if ($attempts === 1) {
Redis::expire($redisKey, 60);
}

return $attempts > $maxAttempts;
}

public function hit(string $key, int $decaySeconds = 60): int
{
$redisKey = $this->prefix . $key;

$attempts = Redis::incr($redisKey);

if ($attempts === 1) {
Redis::expire($redisKey, $decaySeconds);
}

return $attempts;
}

public function attempts(string $key): int
{
return (int) Redis::get($this->prefix . $key) ?? 0;
}

public function resetAttempts(string $key): void
{
Redis::del($this->prefix . $key);
}

public function availableIn(string $key): int
{
return Redis::ttl($this->prefix . $key);
}

public function remaining(string $key, int $maxAttempts): int
{
$attempts = $this->attempts($key);
return max(0, $maxAttempts - $attempts);
}
}

令牌桶限流

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
<?php

namespace App\Services;

use Illuminate\Support\Facades\Redis;

class TokenBucketRateLimiter
{
protected string $prefix = 'token_bucket:';

public function __construct(
protected int $capacity = 100,
protected int $refillRate = 10,
protected int $refillTime = 1
) {}

public function attempt(string $key, int $tokens = 1): bool
{
$redisKey = $this->prefix . $key;
$now = microtime(true);

$script = <<<'LUA'
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local tokens = tonumber(ARGV[4])

local bucket = redis.call('HMGET', key, 'tokens', 'last_refill')
local current_tokens = tonumber(bucket[1]) or capacity
local last_refill = tonumber(bucket[2]) or now

local elapsed = now - last_refill
local refill = math.floor(elapsed * rate)

current_tokens = math.min(capacity, current_tokens + refill)

if current_tokens >= tokens then
current_tokens = current_tokens - tokens
redis.call('HMSET', key, 'tokens', current_tokens, 'last_refill', now)
redis.call('EXPIRE', key, 3600)
return {1, current_tokens}
else
return {0, current_tokens}
end
LUA;

$result = Redis::eval($script, 1, $redisKey, $this->capacity, $this->refillRate, $now, $tokens);

return (bool) $result[0];
}

public function getTokens(string $key): int
{
$tokens = Redis::hget($this->prefix . $key, 'tokens');
return (int) $tokens ?? $this->capacity;
}
}

限流监控

限流统计

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
<?php

namespace App\Services;

use Illuminate\Support\Facades\Cache;

class RateLimitMonitor
{
protected string $statsKey = 'rate_limit_stats:';

public function recordHit(string $key, bool $limited = false): void
{
$statsKey = $this->statsKey . $key;
$date = now()->format('Y-m-d');

Cache::increment("{$statsKey}:{$date}:total");

if ($limited) {
Cache::increment("{$statsKey}:{$date}:limited");
}
}

public function getStats(string $key, ?string $date = null): array
{
$date = $date ?? now()->format('Y-m-d');
$statsKey = $this->statsKey . $key;

return [
'total' => Cache::get("{$statsKey}:{$date}:total", 0),
'limited' => Cache::get("{$statsKey}:{$date}:limited", 0),
];
}

public function getTopLimited(int $limit = 10): array
{
// 获取被限流最多的 IP/用户
return Cache::get('top_limited', []);
}
}

限流响应

自定义限流响应

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Cache\RateLimiter;
use Symfony\Component\HttpFoundation\Response;

class ThrottleRequests
{
public function __construct(
protected RateLimiter $limiter
) {}

public function handle(Request $request, Closure $next, int $maxAttempts = 60, int $decayMinutes = 1, string $prefix = ''): Response
{
$key = $prefix . $this->resolveRequestSignature($request);

if ($this->limiter->tooManyAttempts($key, $maxAttempts)) {
return $this->buildResponse($key, $maxAttempts);
}

$this->limiter->hit($key, $decayMinutes * 60);

$response = $next($request);

return $this->addHeaders(
$response,
$maxAttempts,
$this->calculateRemainingAttempts($key, $maxAttempts)
);
}

protected function buildResponse(string $key, int $maxAttempts): Response
{
$retryAfter = $this->limiter->availableIn($key);

return response()->json([
'success' => false,
'message' => '请求次数已达上限',
'retry_after' => $retryAfter,
'limit' => $maxAttempts,
], 429)->withHeaders([
'Retry-After' => $retryAfter,
'X-RateLimit-Limit' => $maxAttempts,
'X-RateLimit-Remaining' => 0,
'X-RateLimit-Reset' => now()->addSeconds($retryAfter)->timestamp,
]);
}

protected function resolveRequestSignature(Request $request): string
{
return sha1(
($request->user()?->id ?: $request->ip()) . '|' . $request->path()
);
}

protected function calculateRemainingAttempts(string $key, int $maxAttempts): int
{
return max(0, $maxAttempts - $this->limiter->attempts($key) + 1);
}

protected function addHeaders(Response $response, int $maxAttempts, int $remainingAttempts): Response
{
return $response->withHeaders([
'X-RateLimit-Limit' => $maxAttempts,
'X-RateLimit-Remaining' => $remainingAttempts,
]);
}
}

总结

Laravel 13 的 API 限流提供了:

  • 内置限流中间件
  • 自定义限流器定义
  • 动态限流策略
  • 滑动窗口算法
  • 令牌桶算法
  • 分布式 Redis 限流
  • 限流监控统计

合理使用限流可以有效保护 API 免受滥用。