上周三同事甩过来一张监控截图。短信验证码接口,配置是”每分钟最多 10 次”,可后台日志里 12:00:59 走了 10 条,12:01:00 又走了 10 条。两秒钟之内实际发出了 20 条短信,老板那边复现了一下,手机震了快半分钟。
项目里用的限流写法很朴素,Redis 的 INCR 加 EXPIRE,键名里带上当前这一分钟的时间戳。逻辑没写错,错在算法本身:固定窗口天然就有边界突刺。窗口切换的那一瞬间,上一分钟的余额还没消化完,下一分钟的额度已经全部解锁,两拨流量叠在一起就冲破了阈值。
下面把改造成滑动窗口的完整过程整理一遍。ThinkPHP 8 + phpredis,一个中间件把限流这件事收口,源码可以直接拿去用。
一、先想清楚数据结构该长什么样
滑动窗口的意思是:每次请求进来的时候,不看”现在是第几分钟”,而是看”从此刻往前推 60 秒”这个区间里已经发生了多少次。这个区间是跟着当前时间滑动的,不存在硬边界,所以也就没有突刺。
要在 Redis 里表示这个区间,有序集合是最合适的选择。
- 集合里的每个成员代表一次请求,分数(score)就是这次请求发生的时间戳。
- 新请求进来时,先按分数把 60 秒之前的成员全部删掉,剩下的就是当前窗口内的记录。
- 用
ZCARD数一下还剩几个,跟阈值比一比。 - 没超就
ZADD把自己加进去,超了就返回 429。
听起来很简单。但真写起来,第一步就有坑:删除、计数、写入这三步之间如果有其他请求插进来,计数就不准了。
比如当前计数是 9,阈值是 10。请求 A 读完得到 9,还没来得及写入,请求 B 也读到了 9。A 写入变成 10,B 也跟着写入变成 11。超发了。
解决办法有两个:加分布式锁,或者把这三步压成一个原子操作。前者的性能代价太大,高 QPS 场景下锁本身就会变成瓶颈。所以选后者——用 Lua 脚本。
二、Lua 脚本:把”读-判-写”焊成一个整体
Redis 执行 Lua 脚本是单线程的,脚本跑起来之后不会有其他命令插队。这就保证了三步操作的原子性,还省掉了锁的开销。
另外一个容易被忽略的好处是:时间从 Redis 取,而不是从 PHP 取。
假设接口部署在三台机器上,其中一台的系统时间比另外两台快了 800 毫秒。用它作为分数写入的记录,在老老实实从 PHP 取时间的节点看来”来自未来”,计算窗口时就会被算错位置。轻则统计偏差,重则窗口永远清理不干净,集合越堆越大。
Redis 的 TIME 命令返回的是 Redis 服务器自己的时间,所有应用节点都向它对齐,这个问题就不存在了。
-- 滑动窗口限流
-- KEYS[1] 限流用的 zset 键
-- ARGV[1] 窗口内允许的最大请求数
-- ARGV[2] 窗口长度(毫秒)
-- ARGV[3] 本次请求的唯一后缀
--
-- 返回 { 是否放行, 剩余额度, 建议等待毫秒数 }
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local tag = ARGV[3]
-- 用 Redis 服务器时间,避免多节点时钟漂移
local t = redis.call('TIME')
local now = tonumber(t[1]) * 1000 + math.floor(tonumber(t[2]) / 1000)
-- 清掉已经滑出窗口的记录
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
local count = redis.call('ZCARD', key)
if count < limit then
-- member 必须唯一,不能只用 now
redis.call('ZADD', key, now, now .. ':' .. tag)
redis.call('PEXPIRE', key, window)
return {1, limit - count - 1, 0}
end
-- 被限流了,算一下最早那条记录还有多久滑出去
local first = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')
local retry = window
if first[2] then
retry = math.ceil(window - (now - tonumber(first[2])))
end
return {0, 0, retry}
脚本里有三处细节值得单独拎出来说。
第一,成员名用了 now .. ':' .. tag,而不是直接写 now。有序集合的成员是去重的。如果同一毫秒里有三个请求进来,德全部用 now 当成员名,ZADD 只会保留一个,ZCARD 也只返回 1。三次请求被记成了一次,限流形同虚设。加上随机后缀之后,同一毫秒的并发请求各自独立,计数才算准确。
第二,每次写入都执行 PEXPIRE。它保证键在最后一个请求之后的窗口长度内自动销毁。少了这行,如果 Redis 的淘汰策略是 noeviction,冷门接口的 zset 会一直躺在内存里,日积月累能把内存吃光。
第三,被限流时用 ZRANGE key 0 0 WITHSCORES 取出最早那条记录来计算等待时间。返回值里 first[1] 是成员名,first[2] 才是分数。这个顺序别搞反了,WITHSCORES 返回的是扁平的交替数组。
三、限流中间件完整实现
先建配置文件,把不同接口的策略拆开写,路由里按名字引用。
// config/ratelimit.php
return [
// 全局兜底策略
'default' => [
'enable' => true,
'window' => 60, // 窗口长度,单位秒
'limit' => 120, // 窗口内最大请求数
'key' => 'ip',
],
// 登录、验证码这类接口要卡紧
'login' => [
'enable' => true,
'window' => 300,
'limit' => 10,
'key' => 'ip',
],
// 已登录用户走 uid 维度,避免同一出口 IP 下的员工互相拖累
'user' => [
'enable' => true,
'window' => 60,
'limit' => 300,
'key' => 'uid',
],
// 白名单,压测机、内部服务账号放这里
'whitelist' => [
'127.0.0.1',
'10.0.0.15',
],
];
然后是中间件本体。为了少贴几遍脚本,我把 Lua 放在一个独立文件里读,同时用 SCRIPT LOAD + EVALSHA 的方式减少网络传输量。
<?php
declare(strict_types=1);
namespace appmiddleware;
use Closure;
use thinkfacadeCache;
use thinkfacadeConfig;
use thinkfacadeLog;
use thinkRequest;
use thinkResponse;
use Throwable;
class RateLimit
{
/**
* 脚本 sha1 跨请求复用。
* 中间件实例是每个请求 new 一个的,所以这里必须用静态属性。
*/
protected static ?string $sha1 = null;
/** @var Redis|null */
protected $redis;
protected string $lua;
public function __construct()
{
$this->redis = $this->connect();
$this->lua = (string) file_get_contents(app()->getRootPath() . 'extend/lua/ratelimit.lua');
}
public function handle(Request $request, Closure $next, string $rule = 'default')
{
$conf = Config::get('ratelimit.' . $rule, Config::get('ratelimit.default'));
// Redis 不可用或者策略被手动关掉,直接放行
if (!$this->redis || empty($conf['enable'])) {
return $next($request);
}
$identifier = $this->resolveIdentifier($request, $conf['key'] ?? 'ip');
// 拿不到限流主体(比如未登录却按 uid 限流),交给后续中间件处理
if ($identifier === null) {
return $next($request);
}
if ($this->inWhitelist($identifier)) {
return $next($request);
}
$key = 'rl:' . $rule . ':' . md5($identifier);
$limit = (int) $conf['limit'];
$window = (int) $conf['window'] * 1000;
[$allowed, $remaining, $retryAfter] = $this->execScript($key, $limit, $window);
if (!$allowed) {
return $this->reject($request, $limit, $retryAfter);
}
$response = $next($request);
return $this->attachHeaders($response, $limit, $remaining, $conf['window']);
}
/**
* 返回 [是否放行, 剩余额度, 建议等待毫秒]
*/
protected function execScript(string $key, int $limit, int $window): array
{
$tag = bin2hex(random_bytes(4));
try {
$sha = $this->loadScript();
$raw = $this->redis->evalSha($sha, [$key, $limit, $window, $tag], 1);
// Redis 重启后脚本缓存会丢,需要重新装载
if ($raw === false && stripos((string) $this->redis->getLastError(), 'NOSCRIPT') !== false) {
self::$sha1 = null;
$sha = $this->loadScript();
$raw = $this->redis->evalSha($sha, [$key, $limit, $window, $tag], 1);
}
if (!is_array($raw) || count($raw) < 3) {
return [true, $limit, 0];
}
} catch (Throwable $e) {
// 限流组件本身出问题,不应该拖垮业务,宁可放过
Log::error('[ratelimit] ' . $e->getMessage());
return [true, $limit, 0];
}
return [(bool) $raw[0], (int) $raw[1], (int) $raw[2]];
}
protected function loadScript(): string
{
if (self::$sha1 === null) {
self::$sha1 = $this->redis->script('load', $this->lua);
}
return self::$sha1;
}
protected function connect()
{
try {
return Cache::store('redis')->handler();
} catch (Throwable $e) {
Log::warning('[ratelimit] redis connect failed: ' . $e->getMessage());
return null;
}
}
protected function resolveIdentifier(Request $request, string $type): ?string
{
switch ($type) {
case 'ip':
return $request->ip();
case 'uid':
$uid = $request->uid ?? null;
return $uid ? (string) $uid : null;
case 'ip_route':
return $request->ip() . ':' . $request->method() . ':' . $request->pathinfo();
case 'global':
return 'global';
default:
return null;
}
}
protected function inWhitelist(string $identifier): bool
{
$list = Config::get('ratelimit.whitelist', []);
if (empty($list)) {
return false;
}
// identifier 可能是 "ip:method:path" 这种复合形式,只比 IP 部分
$ip = strpos($identifier, ':') === false
? $identifier
: explode(':', $identifier, 2)[0];
return in_array($ip, $list, true);
}
protected function reject(Request $request, int $limit, int $retryAfter): Response
{
$seconds = (int) ceil($retryAfter / 1000);
if ($request->isJson() || $request->isAjax()) {
$response = json([
'code' => 429,
'msg' => '操作太频繁了,请 ' . $seconds . ' 秒后再试',
'data' => null,
], 429);
} else {
$response = response('Too Many Requests', 429);
}
return $response->header([
'X-RateLimit-Limit' => (string) $limit,
'X-RateLimit-Remaining' => '0',
'Retry-After' => (string) $seconds,
]);
}
protected function attachHeaders(Response $response, int $limit, int $remaining, int $window): Response
{
return $response->header([
'X-RateLimit-Limit' => (string) $limit,
'X-RateLimit-Remaining' => (string) max(0, $remaining),
'X-RateLimit-Reset' => (string) (time() + $window),
]);
}
}
有几个地方是我改了几版才定下来的写法。
$sha1 用 static 修饰。中间件实例在 ThinkPHP 里是每个请求都会重新构造的,如果用普通属性缓存 sha1,那每次请求都要重新 SCRIPT LOAD 一遍,EVALSHA 的优化等于白做。静态属性让脚本指纹在整个 PHP 进程生命周期内复用,而进程重启时 SHA 失效,脚本里的 NOSCRIPT 分支会自动兜住。
evalSha 返回 false 的时候不要直接当失败处理。getLastError() 里如果没有 NOSCRIPT 字样,说明是别的错误,这时候应该走降级放行,而不是把用户的请求拦下来。
整个 execScript 都包在 try 里,异常时返回”放行”。限流是保护性设施,它本身出问题的时候,正确的姿势是让业务继续跑,同时把告警打出去,而不是让所有接口都 500。
四、挂到路由上
中间件写完了,接入只需要一行。
// route/app.php
use appmiddlewareRateLimit;
Route::group('api', function () {
// 登录接口单独卡紧,5 分钟 10 次
Route::post('login', 'Auth/login')
->middleware(RateLimit::class, 'login');
Route::post('sms/code', 'Sms/send')
->middleware(RateLimit::class, 'login');
// 需要登录的接口,先过鉴权拿到 uid,再按 uid 限流
Route::group(function () {
Route::get('user/profile', 'User/profile');
Route::post('order/create', 'Order/create');
})
->middleware('auth')
->middleware(RateLimit::class, 'user');
})
// 整个分组再套一层 IP 维度的兜底
->middleware(RateLimit::class, 'default');
这里中间件的顺序不能随便调。鉴权中间件必须排在按 uid 限流之前,否则 $request->uid 还是空的,限流会直接跳过——它拿到 null 就放行了。上线前一定要用未登录的 token 打一下,确认返回的是 401 而不是 429,也不是 200。
五、验证:跑一次压测看曲线
写完不能只跑一个请求看着返回 200 就算完。用 ab 压一下,重点看被拦截的那部分是不是均匀分布在时间轴上,而不是集中爆发。
ab -n 500 -c 50 -H "Accept: application/json" http://127.0.0.1:8000/api/ping
我这次用的策略是 window=10、limit=100。500 个并发请求打进去,预期是 100 个 200、400 个 429。
实际的 ab 输出:
Concurrency Level: 50
Time taken for tests: 0.821 seconds
Complete requests: 500
Failed requests: 0
Non-2xx responses: 400
Requests per second: 609.02 [#/sec] (mean)
Time per request: 82.10 [ms] (mean)
这里有个前提要说明:ab 的 -c 50 只是并发连接数,压测机自己也得扛得住。如果本机跑 Redis 又跑 PHP 又跑 ab,CPU 打满了以后数字会失真,所以关键指标要看 Non-2xx 的数值对不对,而不是看 QPS。
接着换一个方式验证边界。把 window 调成 10 秒、limit 调成 100,然后用脚本模拟”第 9 秒发 100 个,第 11 秒再发 100 个”。
如果是旧的固定窗口实现,这两批会全部通过,因为它们在两个不同的 10 秒周期里。滑动窗口则会在第二批时发现”往前 10 秒内已经有 100 条”,直接拦截。这一条实测下来最能说明问题,改完之后监控里那个突刺尖峰就平下去了。
六、四个真正踩过的坑
坑一:白名单里混进了带端口的 IP。
$request->ip() 在代理层配置不当的时候,返回的可能是 10.0.0.15:53214 这种带端口的形式。这时候它和配置里写死的 10.0.0.15 比不相等,白名单就形同不存在。我在 inWhitelist 里做了截断处理,但更稳妥的做法是把代理层的 X-Forwarded-For 格式固定下来,从源头保证 IP 是干净的。
坑二:Redis 内存增长。
每个被限流过的键都是一个 zset,窗口内最多有 limit 个成员。听起来不多,但如果 limit 配成了 10000,窗口又是 600 秒,那么一个攻击者用不同的 IP 刷,每个 IP 都能撑起一个一万成员的集合。内存会在几分钟内被吃光。
两个防守手段:一是给 ratelimit 单独分一个 Redis DB 或者实例,不要让业务缓存和它共享内存;二是把 limit 的上限在配置里做一次校验,超过某个数值直接抛异常,避免有人手一抖填了六位数。
坑三:EVALSHA 在集群模式下失效。
如果 Redis 是集群部署,脚本里的所有键必须落在同一个 slot 上。上面的实现里只有一个 KEYS[1],天然满足。但如果以后想扩展成”多键联合限流”,比如同时统计 IP 和接口维度,那就得用 hash tag 把它们强制绑到同一个 slot,比如 rl:{user:100}:minute 和 rl:{user:100}:hour。
另外集群模式下 SCRIPT LOAD 只作用于执行它的那个节点。虽然 NOSCRIPT 的回退机制能兜住,但每次回退都要多一次往返。高并发下这个开销不能忽略,值得在连接初始化的时候给每个节点预热一遍脚本。
坑四:Retry-After 和前端不对齐。
后端返回的 Retry-After 是秒数,但有些前端拿到之后直接 setTimeout 加这个数值就重试,结果因为网络延迟又撞在窗口边缘。更稳的做法是把 X-RateLimit-Reset 也带上,让前端能算出一个绝对的重试时间点。我现在前端那边是按 Retry-After + 200ms 来排的,多给一点点缓冲,命中率明显好很多。
七、还可以往下走的方向
这套实现目前是纯内存的,够用,但有两个地方可以继续加压。
一是分级限流。可以同时挂两个策略,先过”每秒 5 次”的短窗口,再过”每分钟 100 次”的长窗口,任一超限就拒绝。短窗口挡住瞬时爆发,长窗口控制总量,比单一阈值更贴合真实流量。
二是令牌桶。滑动窗口解决的是”平均速率”问题,但挡不住”允许突发到某个上限、之后匀速恢复”这种场景。如果业务上希望用户攒了一阵子额度之后能一次性用掉,那令牌桶更合适。Redis 里用 hash 存令牌数和上次补充时间,同样是 Lua 脚本一把梭。
先把这一版跑稳,观察几天的拦截曲线再决定要不要动。很多项目在没被刷之前根本不需要那么复杂的限流,先解决边界突刺,性价比是最高的。

