PHP网站被恶意刷接口后如何快速限制请求频率与封禁IP

服务器咆哮了!如何在PHP中构建你的“网络保镖”

各位程序员同仁,大家早上好,下午好,或者正在凌晨三点盯着黑底白字屏幕发愁的各位。

我是你们的老朋友,一个在PHP泥潭里摸爬滚打多年,头发虽然茂密但经常因熬夜而散发着“程序员特有味道”的资深工程师。今天我们不聊框架,不聊架构,也不聊如何优雅地写代码。今天,我们聊点硬核的,聊点能救命的——防御

想象一下这个场景:你正在午休,刚拿起半个没吃完的煎饼果子,或者正沉浸在“哦,这Bug真简单”的喜悦中。突然,服务器报警短信来了:“CPU 100%”,“内存溢出”,“连接数耗尽”。你跑过去一看,监控图上那条红线直冲云霄,像极了一个上吊的幽灵。

你的第一反应是什么?是不是想顺着网线爬过去给那个写爬虫的人一记“友谊拳”?冷静,兄弟,先别动手,那不礼貌,而且大概率没抓到人。

这叫“分布式拒绝服务”,或者俗称的“恶意刷接口”。这时候,你的PHP脚本就像一个贪吃的胖子,面对着成千上万个饥饿的狼,瞬间被撑爆。怎么办?限流封禁

今天,我们就来手把手教你,如何在你的PHP项目中建立起一套铜墙铁壁。别担心,我会用最接地气的比喻和最实用的代码,带你把那些想薅羊毛的脚本小子们挡在门外。


第一章:我们要的是什么?—— 防御哲学

在动手写代码之前,先搞清楚我们要解决什么问题。这不仅仅是写个计数器那么简单。

想象一下你开了一家咖啡馆。

  1. 限流:这是你的“店长”。他站在门口,规定“每秒只能进5个人”。不管你是VIP,还是刚睡醒的穷学生,进门前都得量量头围。这叫限制速率,保护你的服务器不炸锅。
  2. 封禁IP:这是你的“保安队长”。如果这个人不听话,疯狂试图闯门,或者手里拿着棒子要打人,保安就直接把他按在地上摩擦,扔出大门。这叫防御,针对特定恶意行为。

我们要做的,就是把这两个角色写进代码里。


第二章:土法炼钢——基于文件的限流(新手村方案)

首先,我们得有个地方记录谁来了。最简单的办法,就是用文件。别笑,有些老项目或者极度简单的场景,这玩意儿比Redis还快(当然,仅仅是指它不需要启动一个单独的服务)。

2.1 核心痛点:竞争条件

你可能会想,不就是写个文本文件吗?count++ 就行了。错!大错特错。如果你有10个PHP进程同时访问这个文件,他们都会读到同一个数字,然后都写入,最后的结果可能根本没增加。

这就叫“竞争条件”。这就像10个人同时往一个只有一米的桶里倒水,最后桶底穿了,谁都没喝到水,还淹了脚。

2.2 解决方案:文件锁

我们要用 flock。这就好比在桶口加了一个盖子,一个人倒完水,盖子必须关上,另一个人才能打开。

下面是一个基于文件记录IP请求次数的类。

<?php
class FileRateLimiter
{
    private $filePath;
    private $maxRequests;
    private $window;

    public function __construct($ip, $maxRequests = 10, $window = 60)
    {
        // 每个IP一个单独的文件,文件名包含IP地址,防止混淆
        $this->filePath = sys_get_temp_dir() . '/rate_limit_' . md5($ip) . '.log';
        $this->maxRequests = $maxRequests;
        $this->window = $window; // 时间窗口,单位秒
    }

    public function isAllowed()
    {
        $fp = fopen($this->filePath, 'c+'); // 'c+' 模式意味着如果文件不存在则创建,存在则打开

        // 获取独占锁(非阻塞模式 LOCK_NB)
        // 如果锁被占用,说明有其他PHP进程正在操作这个文件,我们等待一小会儿再试
        if (flock($fp, LOCK_EX | LOCK_NB)) {
            // 锁获取成功,开始操作

            // 读取文件内容
            $data = json_decode(fread($fp, 8192), true);
            if (!$data) {
                $data = [
                    'count' => 0,
                    'first_seen' => time()
                ];
            }

            $now = time();

            // 核心逻辑:如果当前时间减去首次请求时间超过了窗口期,重置计数器
            if ($now - $data['first_seen'] > $this->window) {
                $data = [
                    'count' => 1,
                    'first_seen' => $now
                ];
            } else {
                // 在窗口期内,增加计数
                $data['count']++;
            }

            // 将数据写回文件
            ftruncate($fp, 0); // 清空文件
            rewind($fp);       // 移动指针到开头
            fwrite($fp, json_encode($data));

            // 必须解锁!这是最重要的步骤,否则后续进程会一直被阻塞
            flock($fp, LOCK_UN); 

            // 关闭文件句柄
            fclose($fp);

            // 检查是否超限
            return $data['count'] <= $this->maxRequests;
        } else {
            // 获取锁失败,说明文件正在被写入
            fclose($fp);
            return true; // 为了不阻塞主流程,我们暂且放行,或者你可以在这里抛出异常
        }
    }
}

点评一下:
这段代码写得很标准,使用了 flock 来解决并发问题。它维护了一个简单的滑动窗口逻辑(基于首次请求时间)。

  • 优点:不需要Redis,不需要额外的服务器进程,直接在文件系统上玩转。
  • 缺点:文件IO比内存慢。如果攻击流量达到每秒10万次,你的磁盘I/O会先于CPU先挂掉。而且,如果服务器重启,文件里的数据就没了(除非你做了持久化日志)。

第三章:进阶神装——Redis 计数器(主流方案)

别嫌弃Redis,它是现代PHP架构的灵魂。如果你连Redis都没有,那我建议你先去买个服务器,或者问问老板有没有预算。用Redis做限流,就像是用坦克去赶牛,虽然有点大材小用,但确实稳。

Redis的 INCR 命令是原子性的,这就完美解决了“竞争条件”问题。

3.1 基础 INCR 实现

我们需要一种策略:给每个IP一个唯一的Key,比如 ip:limit:192.168.1.1

class RedisRateLimiter
{
    private $redis;
    private $keyPrefix;
    private $maxRequests;
    private $window;

    public function __construct($ip, $maxRequests = 10, $window = 60)
    {
        $this->redis = new Redis();
        $this->redis->connect('127.0.0.1', 6379); // 这里最好用连接池
        $this->keyPrefix = 'rate_limit_';
        $this->maxRequests = $maxRequests;
        $this->window = $window;
    }

    public function isAllowed()
    {
        $key = $this->keyPrefix . $ip;

        // 获取当前计数
        $current = $this->redis->incr($key);

        // 如果当前计数是1(说明这是该IP第一次请求),设置过期时间
        if ($current === 1) {
            $this->redis->expire($key, $this->window);
        }

        // 如果计数超过了限制,返回 false
        return $current <= $this->maxRequests;
    }
}

关键点解析:

  1. incr:原子递增。
  2. expire:这个很关键!我们设定了60秒过期。这意味着,如果这个IP在60秒内没有新的请求,Redis会自动删除这个Key。这就自动清理了不再活跃的攻击者。
  3. 为什么这样写? 这种方法叫“固定窗口计数器”。
    • 陷阱:如果限制是10次/分钟。攻击者在59秒时发了10次,59.9秒时又发了10次。1分钟过去了,他发了20次。虽然只有两个小高峰,但你的服务器崩了。这就是“边界突刺”问题。

第四章:数学之美——滑动窗口(完美主义者的方案)

为了解决固定窗口的“边界突刺”问题,我们需要引入“滑动窗口”。这就像是用一把移动的尺子去切蛋糕,而不是用一把固定的尺子。

我们记录请求的时间戳,而不仅仅是计数。

4.1 纯PHP实现滑动窗口(慢)

如果用PHP数组来存时间戳:

// 存储格式:ip -> [timestamp1, timestamp2, ...]
$timestamps = $redis->lrange($key, 0, -1);
$now = time();
$windowStart = $now - $this->window;

// 遍历数组,移除过期的
$cleaned = array_filter($timestamps, function($t) use ($windowStart) {
    return $t > $windowStart;
});

// 重新写回Redis(或者直接在内存操作)
// 这种做法在PHP中需要频繁读写Redis,性能较差,尤其是在高并发下。

4.2 Redis + Lua 脚本(极客方案)

为了高性能,我们让Redis自己算。Lua脚本在Redis服务器端执行,原子性极强,且速度快。

-- Lua脚本开始
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])

-- 获取当前时间戳
local now = tonumber(ARGV[3])

-- 移除窗口之外的旧数据
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)

-- 获取当前窗口内的数量
local current = redis.call('ZCARD', key)

-- 如果当前数量小于限制,则添加当前时间戳并返回允许
if current < limit then
    redis.call('ZADD', key, now, now)
    redis.call('EXPIRE', key, window)
    return 1
else
    return 0
end
-- Lua脚本结束

PHP调用代码:

public function isAllowed($ip)
{
    $key = 'slide_window:' . $ip;
    $script = <<<'LUA'
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local now = tonumber(ARGV[3])

redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
local current = redis.call('ZCARD', key)

if current < limit then
    redis.call('ZADD', key, now, now)
    redis.call('EXPIRE', key, window)
    return 1
else
    return 0
end
LUA;

    $result = $this->redis->eval($script, [$key, $this->maxRequests, $this->window], 1);

    return (bool)$result;
}

点评:
这代码写得,看着就很“硬核”。ZSET(有序集合)非常适合处理这种基于时间戳的滑动窗口。它保证了精度,没有边界突刺,且性能极高。


第五章:扬汤止沸——Nginx 层面的拦截(核武器)

回到开头那个场景,PHP代码跑得再好,如果Nginx把CPU都吃光了,PHP也跑不起来。所以,第一道防线必须在Nginx那里。

Nginx 有一个非常强大的模块叫做 ngx_http_limit_req_module。它可以在请求到达PHP进程之前,直接在连接层就把多余的请求拒绝了。这比PHP代码拦截快了几个数量级。

5.1 Nginx 配置实战

在你的 Nginx 配置文件(通常是 conf.d/limit.conf 或者直接写在 nginx.confhttp 块里)中:

http {
    # 1. 定义一个区域,名为 "my_limit_zone"
    # $binary_remote_addr 是访问者的二进制IP地址,比 $remote_addr 省内存
    # $binary_remote_addr:10MB
    # rate=1r/s 表示限制每秒1个请求
    limit_req_zone $binary_remote_addr zone=my_limit_zone:10m rate=1r/s;

    # 2. 定义一个区域用于限制连接数(防止DDOS攻击连接)
    limit_conn_zone $binary_remote_addr zone=conn_limit_zone:10m;

    server {
        location /api/ {
            # zone=my_limit_zone: 指定使用上面定义的区域
            # burst=5: 允许突发流量,例如在1秒内来了10个请求,Nginx允许缓存5个,剩下的直接拒绝并返回503
            # nodelay: 不延迟处理,直接拒绝多余的,这样能快速释放资源

            # combined 这种日志格式可以记录下被限流的请求,方便排查
            limit_req zone=my_limit_zone burst=5 nodelay;
            limit_conn conn_limit_zone 1;

            # 代理到PHP-FPM
            proxy_pass http://php_backend;

            # 关键:被限流时,返回的状态码
            # 503 服务暂时不可用 是个比较优雅的代码
            proxy_intercept_errors on;
            error_page 503 = @limiting;
        }

        # 自定义返回给被限流用户的页面
        location @limiting {
            return 503 '{"code": 503, "msg": "请求过于频繁,请稍后再试"}';
        }
    }
}

这段配置的威力:
当有人疯狂刷你的 /api/login 接口时,Nginx 会迅速把多余的请求丢进“黑洞”。PHP-FPM 根本不会收到这些请求,CPU 不会飙升,服务器稳如老狗。


第六章:驱逐出境——IP 封禁机制

仅仅限流有时候不够,因为有些脚本小子就是想搞你。当检测到某个IP在短时间内触发了多少次限流(比如10次),或者触发了某种特定的恶意行为(比如SQL注入尝试),我们就该把他踢出局了。

我们要建立一个“黑名单”。

6.1 黑名单的数据结构

黑名单不需要复杂的窗口期,因为一旦进去,就没必要再放出来了(除非你信任他们)。
我们可以用 Redis 的 Set 或者 Hash。

最简单的方法:直接把 IP 写进 nginx 的黑名单配置里?不,那太慢了,改配置文件重启Nginx太折腾。

我们用 Redis 的 Set 来做,配合一个超长的时间(或者永不过期)。

class IpBanManager
{
    private $redis;

    public function __construct()
    {
        $this->redis = new Redis();
        $this->redis->connect('127.0.0.1', 6379);
    }

    /**
     * 封禁 IP
     */
    public function banIp($ip, $seconds = 3600)
    {
        // 加入黑名单集合
        $this->redis->sadd('blacklisted_ips', $ip);

        // 如果设置了过期时间(例如临时封禁),可以单独存一个带时间的Key
        // 这里为了演示简单,假设永久封禁直到管理员手动移除
    }

    /**
     * 检查 IP 是否被封禁
     */
    public function isBanned($ip)
    {
        return $this->redis->sismember('blacklisted_ips', $ip);
    }

    /**
     * 解封 IP
     */
    public function unbanIp($ip)
    {
        $this->redis->srem('blacklisted_ips', $ip);
    }
}

6.2 在业务逻辑中集成

现在,我们在前面写的 RedisRateLimiter 里加一把锁。

public function isAllowed($ip)
{
    // 1. 首先检查黑名单
    if ($this->banManager->isBanned($ip)) {
        return false; 
    }

    // 2. 正常限流逻辑 (使用上一节的滑动窗口脚本)
    $key = 'slide_window:' . $ip;
    $script = <<<'LUA'
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local now = tonumber(ARGV[3])

redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
local current = redis.call('ZCARD', key)

if current < limit then
    redis.call('ZADD', key, now, now)
    redis.call('EXPIRE', key, window)
    return 1
else
    return 0
end
LUA;

    $result = $this->redis->eval($script, [$key, $this->maxRequests, $this->window], 1);

    // 3. 如果超限了,执行封禁逻辑
    if ($result === 0) {
        // 触发自动封禁!
        $this->banManager->banIp($ip, 3600 * 24); // 封禁24小时
        // 记录日志:warn("Detected abuse from $ip, banned for 24h");
    }

    return (bool)$result;
}

第七章:架构师的视角——中间件封装(优雅的代码)

如果每次写代码都要手写 Redis 判断,那程序员会疯掉的。我们要封装。这里以 Laravel (PHP最流行框架) 的中间件为例,如果你用的是原生框架,原理完全一样,只需要写一个基类。

class RateLimitMiddleware
{
    protected $redis;
    protected $maxRequests;
    protected $window;

    public function __construct($maxRequests = 10, $window = 60)
    {
        $this->redis = app('redis')->connection();
        $this->maxRequests = $maxRequests;
        $this->window = $window;
    }

    public function handle($request, Closure $next)
    {
        $ip = $request->ip(); // 获取客户端IP

        // 假设我们使用了简单的 INCR 模式
        $key = "limit:{$this->window}:{$ip}";

        $current = $this->redis->incr($key);

        if ($current === 1) {
            $this->redis->expire($key, $this->window);
        }

        if ($current > $this->maxRequests) {
            // 返回 429 Too Many Requests
            return response()->json([
                'error' => 'Too Many Requests',
                'retry_after' => $this->redis->ttl($key)
            ], 429);
        }

        return $next($request);
    }
}

用法:

// 在路由中使用
Route::get('/login', [LoginController::class, 'index'])->middleware(RateLimitMiddleware::class);

这样,你的业务代码变得非常干净。Controller里不需要关心“限流”逻辑,只管业务逻辑。这叫关注点分离。


第八章:防人之心不可无——日志与监控

你以为你写了代码就安全了吗?别忘了,脚本小子也在进化。他们会用代理池、他们会伪造User-Agent、他们会睡觉醒来继续刷。

你必须知道你的防御是否有效。

8.1 记录被拦截的请求

在你的中间件或拦截器里,一定要记录日志。

if ($current > $this->maxRequests) {
    Log::warning('Rate limit exceeded', [
        'ip' => $ip,
        'url' => $request->fullUrl(),
        'user_agent' => $request->header('User-Agent'),
        'timestamp' => now()
    ]);

    return response()->json(['msg' => 'Too fast'], 429);
}

8.2 监控大屏

当你看到日志里全是 Rate limit exceeded,说明你的防御起作用了。如果你的CPU依然是100%,那说明你的限流代码在Nginx层没生效,或者你的限流阈值设置得太高了(比如把正常用户也拦了)。

这时候你需要一个简单的监控脚本,跑在服务器上:

# 检查 PHP-FPM 进程数
ps aux | grep php-fpm | wc -l

# 检查 Nginx 错误日志里有没有 503
grep "limiting requests" /var/log/nginx/error.log | tail -f

第九章:终极奥义——Web应用防火墙(WAF)

如果你面对的是国家级黑客,或者是专门针对你的勒索软件,PHP代码和Nginx配置可能都救不了你。这时候你需要云厂商的武器。

阿里云WAF、腾讯云WAF、Cloudflare。
这些东西会屏蔽掉几十个G的流量,它比你写的一行 if ($count > 10) 强大无数倍。它们基于规则库和机器学习,能识别CC攻击、SQL注入、XSS攻击。

虽然这需要花钱,但当你服务器被攻陷、数据被加密勒索时,你会发现几千块的WAF费用简直就是“续命钱”。


结语:时刻保持警惕

好了,各位,今天的讲座就到这里。

我们今天从最原始的文件锁,聊到了高性能的Redis Lua脚本;从PHP的业务逻辑拦截,聊到了Nginx内核层的拒绝服务;从简单的计数器,聊到了复杂的滑动窗口。

记住,网络世界没有永远的和平。写代码只是为了让坏人少一点,而不是为了绝对的安全。

给你的建议清单:

  1. Nginx 必须开 limit_req,这是第一道防线。
  2. Redis 必须用 来做计数,别用文件,别用数据库。
  3. 封禁机制要有,对于那些屡教不改的IP,直接拉黑。
  4. 日志要记好,不然你都不知道你的服务器是不是在被攻击。

现在的你,应该已经掌握了防御恶意刷接口的核心技能。去你的代码里加一把锁吧,别让那些脚本小子再打扰你写代码了。如果他们还在,那就给他们返回一个 429,让他们喝杯咖啡冷静一下。

祝你们的服务器稳如老狗,代码写得飞起!

(完)

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注