PHP 驱动的 DDoS 降级保护:在 Nginx 负载均衡层实现基于请求频率的 PHP 动态熔断策略

讲座主题:当 PHP 遇上 DDoS:如何在 Nginx 负载均衡层上演“绝地求生”

各位好,各位正在屏幕前喝着咖啡(或者也许正准备给服务器加冰块)的程序员朋友们。

今天我们不讲那些花里胡哨的前端框架,也不聊什么微服务架构里的服务注册与发现。今天,我们要聊一个硬核的话题——生存

你们有没有过这种经历:半夜两点,你正睡得像头死猪,突然,手机“叮”的一声,不是女神发来消息,是阿里云或 AWS 的短信轰炸:“尊敬的用户,您的服务器 CPU 使用率 99%,带宽跑满,请及时处理。”

你抓起手机,打开监控面板,好家伙,这不是流量,这是流量战争。你的服务器不是在处理业务,而是在被一群疯狂的 DDoS 攻击者当成了沙袋在打。这时候,传统的防火墙就像个只会说“不”的保安,看着满大街的人冲进来,除了累死自己,毫无作用。

今天,我们要学的是如何用 PHP 写一套“动态熔断策略”,挂在 Nginx 这位大力士的腰带上,在负载均衡层给它装个“大脑”,让它学会在服务器要爆炸前,哪怕自己受点委屈,也要先“捂住伤口”,拒绝那些恶意流量。

准备好了吗?我们要开始给服务器治病了。


第一部分:熔断器是什么鬼?

想象一下,你家厨房里有个水龙头,总漏水。你找了修水管的师傅,师傅修好了,滴水声没了。你高兴坏了,每天打开水龙头就洗菜、做饭、洗衣服。

突然有一天,水管又裂了。你把师傅叫来,师傅正在修,结果他越修,流出的水越多。这时候,你脑子里的开关(熔断器)会自动跳闸。

你会怎么做?你会把总阀关掉。哪怕你正在洗碗,你也必须关掉。因为继续修下去,厨房就要被淹了。

在软件世界里,熔断就是这个逻辑。

  • 正常状态: 请求像流水一样穿过 Nginx,到达后端 PHP。
  • 异常状态: 后端扛不住了,或者被 DDoS 摧残了,响应变慢,错误率飙升。
  • 熔断状态: 熔断器打开,Nginx 不再信任后端,直接拦截请求,返回错误码,或者返回一个简单的“系统繁忙”页面。

我们的目标,就是用 PHP 写一个算法,判断“什么时候该打开这个开关”。


第二部分:为什么是 PHP?为什么是 Nginx?

听到这里,肯定有人要拍桌子了:“老铁,Nginx 不是有 limit_req 模块吗?PHP 有 max_execution_time 限制,用 PHP 做熔断器,是不是有点杀鸡用牛刀?”

别急,咱们来聊聊架构。

  1. Nginx 的局限性: Nginx 是 C 语言写的,虽然性能强悍,但它主要是做“转发”和“路由”。它的逻辑层(Lua)虽然灵活,但学习曲线陡峭,对于大多数使用 PHP 构建业务的团队来说,部署 Lua 模块就像是请个外国大厨来炒青菜,虽然好吃,但备菜太麻烦。
  2. PHP 的万能性: PHP 是世界上最“脏”但也最“强”的语言。哪里有业务逻辑,哪里就有 PHP。我们在后端数据库用 PHP,API 接口用 PHP,现在,我们让 PHP 来当“守门人”。

策略: 我们不直接在 Nginx 层写死逻辑,而是通过 fastcgi_pass 将请求“转交”给 PHP 脚本。这个 PHP 脚本就是我们的“守门员”。它不看源代码,只看统计数据。


第三部分:核心数据结构——Redis 里的计数器

为了知道谁在攻击,我们需要记忆。内存是不够的(多台服务器之间怎么同步?),所以我们必须依靠 Redis

Redis 是分布式系统的“瑞士军刀”,而我们的场景,需要它当“计数器”。

我们需要定义几个关键键:

  • ratelimit:user:ip:{client_ip}:记录该 IP 在最近 60 秒内的请求数。
  • circuit_breaker:status:记录熔断器当前的状态(开启/关闭)。
  • circuit_breaker:last_error_time:记录最后一次异常的时间。

这里的“最近 60 秒”,就是我们算法的核心。

为什么是滑动窗口?

很多人喜欢用固定窗口(比如每秒最多 100 个请求)。但这有个巨大的漏洞:如果流量是 90, 90, 90, 90, 90… 固定窗口可能会在一瞬间秒杀 500 个请求,然后空转一秒。

我们要用滑动窗口。想象一个钟表,指针每秒转动一次。我们记录过去 60 秒内所有“滴答声”。只有当“滴答声”的总和超过了阈值,才触发熔断。


第四部分:实战代码——PHP 熔断脚本

好,废话不多说,我们直接上代码。假设我们的 Nginx 配置了 fastcgi_pass 指向这个脚本。

<?php
// protect.php - 你的 Nginx 后端守护神

// 1. 获取客户端 IP(注意:如果是代理进来的,要读取 HTTP_X_FORWARDED_FOR,这里简化处理)
$clientIp = $_SERVER['REMOTE_ADDR'] ?? 'unknown';

// 2. 配置参数 - 这些参数可以做成从数据库读取,实现“动态”配置
$thresholdPerSecond = 50;      // 每秒允许的请求数阈值
$circuitOpenDuration = 300;    // 熔断开启时长(秒),比如 5 分钟
$blockResponse = 503;          // 熔断时的 HTTP 状态码
$blockBody = '{"error": "系统繁忙,请稍后再试"}';

// 3. Redis 连接(使用 pconnect 保持长连接,别每次请求都握手,那是自杀)
$redis = new Redis();
$redis->connect('127.0.0.1', 6379, 1); // timeout 设为 1 秒,防止 Redis 挂了拖死 PHP

// 4. 检查熔断器状态
$circuitKey = 'circuit_breaker:status';
$circuitOpenUntil = $redis->get($circuitKey);

// 如果熔断器开启了,且当前时间还没到恢复时间
if ($circuitOpenUntil && time() < $circuitOpenUntil) {
    header("HTTP/1.1 $blockResponse Service Unavailable");
    header("Content-Type: application/json");
    echo $blockBody;
    exit;
}

// 5. 计算滑动窗口内的请求数
// 我们使用 Redis 的 ZSET (有序集合) 来实现滑动窗口
// member: 当前时间戳微秒
// score: 当前时间戳微秒
$now = microtime(true);
$windowStart = $now - 1; // 统计最近 1 秒

// 删除窗口外的数据(清理垃圾)
$redis->zRemRangeByScore($circuitKey, 0, $windowStart);

// 添加当前请求
$currentCount = $redis->zCard($circuitKey);
$redis->zAdd($circuitKey, $now, uniqid('', true));

// 6. 判断是否超过阈值
if ($currentCount > $thresholdPerSecond) {
    // 超过阈值!必须熔断!

    // 计算新的熔断结束时间
    $newCircuitOpenUntil = time() + $circuitOpenDuration;

    // 在 Redis 中设置熔断状态
    $redis->setex($circuitKey, $circuitOpenDuration, $newCircuitOpenUntil);

    // 记录日志(千万别用 file_put_write 每秒几千次,用 Redis 写日志或 syslog)
    error_log("[DDoS Attack Detected] IP: $clientIp blocked. Count: $currentCount");

    // 响应客户端
    header("HTTP/1.1 $blockResponse Service Unavailable");
    header("Content-Type: application/json");
    echo $blockBody;
    exit;
}

// 7. 正常放行
// 注意:这里有一个致命的性能陷阱!
// 每个请求都连接 Redis 会把 Nginx 压垮。但在 PHP 单进程模式下,我们暂时接受它。
// 下一步我们会优化。

// 继续执行你的业务逻辑
// ...
// require_once 'index.php'; 
// ...
echo "Hello World";

这段代码的问题在哪里?

注意看第 7 步。在 Nginx 处理高并发时,PHP-FPM 的每个 worker 进程都要去连 Redis。如果来了 1000 个请求,Redis 服务器就得处理 1000 次握手。这在压测时会瞬间把 Redis 磨盘。

解决方案:
我们不要“请求一次检查一次”。我们要用 Redis 的 Lua 脚本 或者 共享内存。但为了保持 PHP 的纯粹性,我们可以在 Nginx 层加一层简单的 limit_req,或者让这个 PHP 脚本只返回 JSON,Nginx 负责拦截。

但既然我们要“PHP 驱动的动态熔断”,那我们就得硬着头皮优化 PHP 脚本。

第五部分:Nginx 配置——如何把 PHP 变成“守门员”

现在,我们有了一个 PHP 脚本 check.php。我们需要告诉 Nginx:“别急着把请求传给 PHP 业务代码,先让 check.php 检查一下,如果不通过,直接回退,别浪费资源。”

这是 nginx.conf 的核心配置段:

http {
    # 1. 定义一个 fastcgi 缓存,用来缓存 PHP 的响应
    # 如果 PHP 返回 200,我们缓存 1 秒;如果返回 503,我们也缓存 1 秒
    fastcgi_cache_path /var/cache/nginx levels=1:2 keys_zone=ddos_cache:10m inactive=1m;

    server {
        location / {
            # 2. 增加连接数限制,防止 Nginx 自己也被拖死
            limit_conn_zone $binary_remote_addr zone=addr:10m;

            # 3. 动态检查逻辑
            # 我们不直接调用业务入口 index.php,而是先调用 check.php
            if ($request_method = GET) {
                # 调用我们的 PHP 检查脚本
                # 这里有个技巧:使用 fastcgi_pass_pass
                include fastcgi_params;
                fastcgi_pass 127.0.0.1:9000; # 你的 PHP-FPM 监听地址
                fastcgi_param SCRIPT_FILENAME $document_root/check.php;

                # 强制执行这个逻辑,并且必须获取到结果才能往下走
                fastcgi_intercept_errors on;

                # 调用这个脚本,然后根据脚本返回的状态码决定是放行还是拦截
                error_page 418 = @bypass_check;
                # 如果 check.php 返回 418 (自定义状态码),则跳转到 @bypass_check
                # 如果 check.php 返回 200,Nginx 会自动把请求交给下面的逻辑
                return 418;
            }

            location @bypass_check {
                # 放行请求,执行正常的业务逻辑
                include fastcgi_params;
                fastcgi_pass 127.0.0.1:9000;
                fastcgi_param SCRIPT_FILENAME $document_root/index.php;

                # 这里也可以加上 fastcgi_cache,如果是 200 的响应
                fastcgi_cache ddos_cache;
                fastcgi_cache_valid 200 1s;
            }
        }
    }
}

这里面的玄机:
我们利用 Nginx 的 error_page 和自定义状态码 418。我们将 PHP 检查逻辑插入到了请求处理的最前端。如果 PHP 发现 IP 在撒野,它返回 503。Nginx 收到 503,直接返回给用户,不再调用业务代码。


第六部分:进阶——动态熔断策略

上面的代码是静态的(阈值 50)。但真实的 DDoS 攻击是波动的。有时候流量大,有时候流量小。我们要让 PHP 的逻辑更像一个“智能系统”。

策略 A:基于正弦波的动态调整

我们可以让熔断器的阈值随着时间自动波动。

  • 深夜:阈值 100。
  • 高峰期:阈值 1000。

在 PHP 中,我们可以计算当前时间戳,得出一个正弦波值。

$hour = date('H');
// 模拟正弦波波动,凌晨 0 点到 4 点最低,中午 12 点最高
$sensitivity = 100 + 500 * sin($hour / 24 * 2 * M_PI); 

// 在判断阈值时使用这个 $sensitivity
if ($currentCount > $sensitivity) {
    // 触发熔断
}

策略 B:基于错误率的熔断

有时候流量正常,但后端服务挂了(比如数据库连不上了)。这时候正常的请求也会报错。

我们需要维护一个“错误率”键:circuit_breaker:error_rate
如果最近 1 分钟的错误率超过 50%,直接熔断,不管流量大小。

// 简化逻辑
$errorKey = 'circuit_breaker:error_rate';
$errorCount = $redis->incr($errorKey);
if ($errorCount == 1) $redis->expire($errorKey, 60); // 1分钟过期

if ($errorCount > 50) {
    // 错误太多了,熔断!
    triggerCircuitBreaker();
}

策略 C:熔断后的“自愈”机制

当熔断器打开后,我们不是直接关了就不管了。我们要在熔断时间结束时,尝试“试探”。

check.php 中,我们可以加一个逻辑:当熔断快结束前,我们放行 10 个请求给后端,看看后端是否恢复健康。如果这 10 个请求成功,我们自动关闭熔断器;如果还是失败,说明后端还没好,继续熔断。

这就是熔断器模式中的“半开状态”。

// 获取剩余熔断时间
$remainingTime = $circuitOpenUntil - time();

// 如果剩余时间小于 10 秒,进入半开状态
if ($remainingTime < 10) {
    // 10% 的概率放行一个请求进行健康检查
    if (mt_rand(1, 100) < 10) {
        // 假设我们调用后端 API 做个测试
        // ...
        // 如果健康,关闭熔断器
        $redis->del('circuit_breaker:status');
    }
}

第七部分:性能优化与避坑指南

讲了这么多理论,最后我们得谈谈现实。PHP 是单线程的,Nginx 是多进程的。如果你让每个 PHP-FPM 进程都去连 Redis,你的 PHP-FPM 进程很快就会变成“僵尸进程”,CPU 飙升到 100%。

优化方案一:利用 Redis 批处理

不要在请求循环里 INCR。我们要利用 Redis 的 Pipeline(管道)或者 Lua 脚本。

// Lua 脚本:原子性执行检查和计数
$luaScript = <<<LUA
local key = KEYS[1]
local threshold = tonumber(ARGV[1])
local current = redis.call('ZCARD', key)
if current > threshold then
    return 0 -- 拒绝
end
redis.call('ZADD', key, ARGV[2], ARGV[2])
redis.call('EXPIRE', key, 1)
return 1 -- 放行
LUA;

$redis->eval($luaScript, 1, 'ratelimit:user:ip:' . $clientIp, 50, microtime(true));

使用 Lua 脚本,Redis 只需要执行一次网络往返,极大降低了延迟。

优化方案二:Nginx 层面的轻量级检查

如果 Nginx 带宽扛不住,不要让 PHP 去计算。你可以用 Nginx 的 limit_req 做一个粗略的筛子。

# 先用 Nginx 挡住 90% 的垃圾流量
limit_req zone=general burst=10 nodelay;

只有通过 Nginx 这个筛子的流量,才会到达我们的 PHP 熔断脚本。这叫“两阶段防护”。

优化方案三:缓存“放行”结果

在我们的代码示例中,每次请求都去 Redis 计数。这在流量小时没问题,流量大时 Redis 就是瓶颈。

更高级的做法是 Leaky Bucket(漏桶算法)
在 Redis 中,我们维护一个计数器。每次请求,DECR 计数器。
如果计数器 >= 0,说明桶里有水,放行。
如果计数器 < 0,说明桶空了,拒绝。
这就完全不需要 ZCARD 这种耗时操作。


第八部分:总结——做一个冷酷的架构师

今天我们构建了一个基于 PHP 的 DDoS 熔断系统。

  1. 定位: 它不是防火墙,它是熔断器。防火墙防的是“别人”,熔断器保的是“己”。
  2. 实现: 利用 Nginx 作为入口,PHP 脚本作为逻辑控制器,Redis 作为分布式计数器。
  3. 动态性: 熔断策略不是写死的数字,而是可以根据时间、错误率、流量波动动态调整的算法。
  4. 哲学: 在系统崩溃边缘,果断切断连接比试图修复它是更明智的选择。

记住,作为一名程序员,你的目标不是写出最完美的代码,而是写出最不容易崩的代码。当 DDoS 攻击来临,你的 PHP 熔断脚本就像那个在火海中关掉总阀的男人一样,虽然你可能会被用户骂“怎么网站打不开”,但你的服务器保住了,你的 KPI 也就保住了。

好了,今天的讲座到此结束。现在,去检查一下你的 php.ini,看看是不是把 max_execution_time 设得太长了。如果 Redis 挂了,你的 PHP 脚本可能会在那儿转圈圈,直到你把服务器电源拔了。

祝大家编码愉快,睡个好觉!

发表回复

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