在开放的API场景中,如果没有保护机制,恶意请求或程序错误可能会在短时间内发起大量调用,导致服务器负载飙升甚至服务中断。接口限流(Rate Limiting)是保障系统稳定性的重要手段。ThinkPHP 8结合强大的中间件系统和Redis,可以非常方便地实现各种限流策略。本文将详细讲解令牌桶算法和滑动窗口算法,并手把手教你编写可复用的限流中间件,保护核心接口免遭滥用。
为什么需要接口限流?常见策略概览
接口限流主要用于以下场景:
- 防止恶意爬虫:限制同一IP或用户短时间内访问次数。
- 保护登录/注册接口:防止暴力破解和短信轰炸。
- 平滑突发流量:在秒杀、抢票等高并发场景下保护后端服务。
- 遵守第三方API调用限制:控制出站请求频率。
常见的限流算法包括:
- 固定窗口计数器
- 在固定时间窗口(如1分钟)内计数,超过阈值则拒绝。缺点是边界突发问题。
- 滑动窗口
- 将时间窗口划分为多个小格子,随时间滑动,精度更高。
- 令牌桶
- 系统以恒定速率向桶中放入令牌,请求需获取令牌才被处理,可容忍一定突发。
- 漏桶
- 请求像水一样进入桶中,以固定速率流出,强制平滑流量。
ThinkPHP 8 的中间件让我们可以在请求到达控制器之前执行限流判断,逻辑清晰且不侵入业务代码。下面将重点实现令牌桶和滑动窗口两种方法,它们分别适用于需要允许一定突发的场景和需要严格控制平均速率的场景。
环境准备:Redis配置与场景设计
限流中间件通常依赖高性能的计数器存储,Redis提供了原子操作和过期机制,是理想的选型。首先确保PHP安装了Redis扩展,并在ThinkPHP 8中配置Redis连接。
修改config/cache.php或.env文件:
# .env
[REDIS]
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=
在config/cache.php中确保default驱动为redis,或者直接使用门面Cache时指定Redis连接。为方便操作Redis原子命令,我们会在中间件中直接使用thinkfacadeCache,因为TP8的缓存抽象层已支持increment、expire等方法,但令牌桶需要更精细的操作(如执行Lua脚本保证原子性)。因此我们采用thinkfacadeRedis门面直接调用Redis原生方法。
假设我们有一个小说阅读API,需要限制每个用户每分钟最多请求30次,同时允许一定突发(令牌桶);而登录接口需要严格限制每个IP每5分钟最多尝试5次(滑动窗口)。
令牌桶算法原理与中间件实现
令牌桶算法维护一个桶,以固定速率(如每秒10个)添加令牌,桶有最大容量(如30个)。请求到来时尝试消费一个令牌,消费成功则放行,否则拒绝。这允许在令牌积累满后突发处理多个请求。
在ThinkPHP 8中,我们创建一个中间件app/middleware/TokenBucket.php,使用Redis的SORTED SET或HASH来存储桶状态。但更简单且精确的方法是使用Lua脚本原子化执行令牌桶逻辑。下面给出一个基于Redis HASH + 时间戳计算的PHP实现(利用Cache的inc与过期时间,但令牌桶复杂,此处采用原文计算):
// app/middleware/TokenBucket.php
namespace appmiddleware;
use thinkfacadeRedis;
use Closure;
class TokenBucket
{
public function handle($request, Closure $next, int $rate = 10, int $capacity = 30)
{
$key = 'rate_limit:token_bucket:' . ($request->ip() ?: 'unknown');
$now = time();
// 从Redis读取桶的状态
$bucket = Redis::hGetAll($key);
$tokens = isset($bucket['tokens']) ? (float)$bucket['tokens'] : (float)$capacity;
$lastTime = isset($bucket['last_time']) ? (int)$bucket['last_time'] : $now;
// 计算新增令牌
$elapsed = $now - $lastTime;
$addedTokens = $elapsed * $rate;
$tokens = min($capacity, $tokens + $addedTokens);
// 尝试消费一个令牌
if ($tokens >= 1) {
$tokens -= 1;
// 保存新状态,设置过期时间(防止冷数据占满内存)
Redis::hMSet($key, [
'tokens' => $tokens,
'last_time' => $now,
]);
Redis::expire($key, 3600);
return $next($request);
} else {
// 无令牌可用,返回429
return response(json_encode([
'code' => 429,
'msg' => '请求过于频繁,请稍后再试'
]), 429)->header([
'Content-Type' => 'application/json',
'Retry-After' => ceil(1 / $rate)
]);
}
}
}
该中间件默认速率为每秒10个令牌,桶容量为30。你可以通过参数动态调整(将在后面章节介绍)。注意这里直接使用了Redis门面,你可以将其替换为Cache门面配合同步锁,但Redis哈希操作已经足够快,并发下可能存在微小竞争,但对精确度要求不极端的场景完全可用。如果追求极致原子性,可编写Lua脚本。
滑动窗口算法原理与中间件实现
滑动窗口将时间窗口划分为多个子窗口(例如1分钟窗口划分为6个10秒格子)。每个请求记录在当前子网格中,判断时统计最近几个格子中的请求总数是否超限。实现上可以使用Redis的有序集合(ZSET),以请求时间戳为score,每次请求时移除窗口外的记录,然后计数。
创建中间件app/middleware/SlidingWindow.php:
// app/middleware/SlidingWindow.php
namespace appmiddleware;
use thinkfacadeRedis;
use Closure;
class SlidingWindow
{
public function handle($request, Closure $next, int $limit = 10, int $window = 60)
{
$ip = $request->ip() ?: 'unknown';
$key = 'rate_limit:sliding:' . $ip;
$now = microtime(true) * 1000; // 毫秒时间戳
// 移除窗口外的记录
$windowStart = $now - $window * 1000;
Redis::zRemRangeByScore($key, 0, $windowStart);
// 获取当前窗口内的请求数
$count = Redis::zCount($key, $windowStart, $now);
if ($count >= $limit) {
return response(json_encode([
'code' => 429,
'msg' => '操作过于频繁,请稍后再试'
]), 429)->header([
'Content-Type' => 'application/json',
'Retry-After' => $window
]);
}
// 记录本次请求
Redis::zAdd($key, $now, $now);
Redis::expire($key, $window * 2); // 保留两倍窗口时间
return $next($request);
}
}
此处使用有序集合的score存储毫秒时间戳,每次查询窗口内的请求数,超过限制则拒绝。这种方式精确控制了在滑动时间窗口内的请求总数,不存在固定窗口的边界突变问题,特别适合登录、短信发送等需要严格限制的场景。
动态参数:为不同接口配置独立限流规则
中间件支持接收额外参数,这让我们可以在路由中为不同接口指定不同的速率和容量。例如,为“发送验证码”接口设置较低的限额。在中间件类中,handle方法的第三个参数开始即是路由传入的参数。我们已经在前面的中间件中定义了$rate和$capacity(令牌桶),以及$limit和$window(滑动窗口)。
现在,在路由中使用中间件时,只需通过冒号分隔传递参数。例如:
// route/app.php
use appmiddlewareTokenBucket;
use appmiddlewareSlidingWindow;
// 令牌桶:每秒2个令牌,桶容量10
Route::post('api/order/create', 'api/Order/create')
->middleware(TokenBucket::class . ':2,10');
// 滑动窗口:每60秒最多5次
Route::post('api/sms/send', 'api/Sms/send')
->middleware(SlidingWindow::class . ':5,60');
这样,不同接口的限流规则互不干扰,配置清晰且易于调整。
路由注解应用限流中间件
如果你使用注解路由,同样可以为方法赋予限流中间件。例如:
// app/controller/api/Sms.php
namespace appcontrollerapi;
use thinkannotationRoute;
use appmiddlewareSlidingWindow;
class Sms
{
#[Route('POST', '/api/sms/send')]
#[SlidingWindow(limit: 5, window: 60)]
public function send()
{
// 发送验证码逻辑
return json(['code' => 200, 'msg' => '验证码已发送']);
}
}
注意,注解方式需要中间件类支持通过属性参数注入,你可以在中间件类中定义可公开访问的属性,并在handle中读取$this->limit等。简洁起见,我们也可以在handle方法中直接访问方法参数(通过闭包),但注解方式需要中间件类实现thinkmiddlewareInteractsWithParameters接口或通过__construct接收。上面SlidingWindow中间件使用handle参数,注解方式则需要封装一下。最常见的做法是使用别名注册时传递参数,前面已经展示。
测试与效果验证
可以使用压力测试工具(如Apache Bench、wrk)或编写简单的脚本验证限流效果。例如使用curl循环请求登录接口:
for i in {1..10}; do
curl -X POST http://127.0.0.1:8000/api/sms/send -H "Content-Type: application/json" -d '{}';
echo;
done
如果滑动窗口中间件配置为5,60,那么前5次请求会得到正常响应,从第6次开始返回429状态码和错误信息。同时,Redis中可以看到有序集合key的存在和过期时间。
令牌桶测试更直观:使用高并发工具模拟突发请求,观察在前几个请求顺利通过后,后续请求被限流,直到令牌重新生成。
最佳实践与生产建议
在实际生产环境中部署限流中间件时,请注意以下几点:
- 使用统一的限流Key标识:除了IP地址,还可以加入用户ID、设备指纹等,以精确控制。但注意避免生成过多Redis键导致内存浪费。
- 设置合理的Redis过期时间:限流键应设置过期时间,防止僵尸数据常驻内存。
- 返回必要的HTTP头:像
X-RateLimit-Limit、X-RateLimit-Remaining、Retry-After等,便于客户端处理。 - 降级方案:当Redis不可用时,中间件应能自动降级(放行或使用本地内存计数器),避免全站不可用。
- 监控与告警:将限流拒绝次数上报到监控系统,及时发现异常流量。
- 结合业务场景选择算法:对于要求严格限制的场景(如短信验证码)使用滑动窗口;对于需要容忍突发流量的场景(如API查询)使用令牌桶。
你还可以扩展中间件,支持按用户等级(VIP用户更高速率)动态调整参数,构建更精细化的流量控制体系。
总结
通过本文的实战,我们掌握了在ThinkPHP 8中实现令牌桶和滑动窗口两种限流算法的方法,并将其封装为可复用的中间件。搭配路由参数传递,不同接口可以拥有独立的流控规则,极大增强了API的安全性和稳定性。限流是构建健壮Web服务的基石之一,现在就为你的接口添加这层保护吧。

