ThinkPHP 8 接口限流实战:用 Redis+Lua 写一个滑动窗口中间件,告别整点误伤

2026-09-16 0 370

上周三同事甩过来一张监控截图。短信验证码接口,配置是”每分钟最多 10 次”,可后台日志里 12:00:59 走了 10 条,12:01:00 又走了 10 条。两秒钟之内实际发出了 20 条短信,老板那边复现了一下,手机震了快半分钟。

项目里用的限流写法很朴素,Redis 的 INCREXPIRE,键名里带上当前这一分钟的时间戳。逻辑没写错,错在算法本身:固定窗口天然就有边界突刺。窗口切换的那一瞬间,上一分钟的余额还没消化完,下一分钟的额度已经全部解锁,两拨流量叠在一起就冲破了阈值。

下面把改造成滑动窗口的完整过程整理一遍。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),
        ]);
    }
}

有几个地方是我改了几版才定下来的写法。

$sha1static 修饰。中间件实例在 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=10limit=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}:minuterl:{user:100}:hour

另外集群模式下 SCRIPT LOAD 只作用于执行它的那个节点。虽然 NOSCRIPT 的回退机制能兜住,但每次回退都要多一次往返。高并发下这个开销不能忽略,值得在连接初始化的时候给每个节点预热一遍脚本。

坑四:Retry-After 和前端不对齐。

后端返回的 Retry-After 是秒数,但有些前端拿到之后直接 setTimeout 加这个数值就重试,结果因为网络延迟又撞在窗口边缘。更稳的做法是把 X-RateLimit-Reset 也带上,让前端能算出一个绝对的重试时间点。我现在前端那边是按 Retry-After + 200ms 来排的,多给一点点缓冲,命中率明显好很多。

七、还可以往下走的方向

这套实现目前是纯内存的,够用,但有两个地方可以继续加压。

一是分级限流。可以同时挂两个策略,先过”每秒 5 次”的短窗口,再过”每分钟 100 次”的长窗口,任一超限就拒绝。短窗口挡住瞬时爆发,长窗口控制总量,比单一阈值更贴合真实流量。

二是令牌桶。滑动窗口解决的是”平均速率”问题,但挡不住”允许突发到某个上限、之后匀速恢复”这种场景。如果业务上希望用户攒了一阵子额度之后能一次性用掉,那令牌桶更合适。Redis 里用 hash 存令牌数和上次补充时间,同样是 Lua 脚本一把梭。

先把这一版跑稳,观察几天的拦截曲线再决定要不要动。很多项目在没被刷之前根本不需要那么复杂的限流,先解决边界突刺,性价比是最高的。

ThinkPHP 8 接口限流实战:用 Redis+Lua 写一个滑动窗口中间件,告别整点误伤
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 thinkphp ThinkPHP 8 接口限流实战:用 Redis+Lua 写一个滑动窗口中间件,告别整点误伤 https://www.taomawang.com/server/thinkphp/2762.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务