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

各位同学,各位码农,各位在服务器前瑟瑟发抖的运维同仁们,大家好!

欢迎来到今天的“服务器急救与生存指南”讲座。今天我们不讲那些枯燥的算法复杂度,不讲那些让你在深夜里怀疑人生的内存泄漏。今天,我们要聊聊一个稍微带点“火药味”的话题:DDoS(分布式拒绝服务)攻击

在座的各位,谁没见过这个场景:你正在喝着咖啡,或者正在跟对象视频,突然手机震动,老板发来微信:“网站崩了?客户都在骂娘了?”你打开监控,看到 CPU 使用率直接干到了 100%,流量图表像心电图停止跳动一样变成了直线。

别慌,今天我要教大家一套绝活。我们不买昂贵的防火墙,不搞什么昂贵的云清洗,我们就用最便宜、最原生的工具——NginxPHP,玩出花来。

我们将构建一个PHP 驱动的动态熔断系统。听起来很高大上?其实就是给 Nginx 这个守门人装一个“聪明的大脑”,而 PHP 就是那个大脑。当坏人(攻击流量)来的时候,我们不用把门打开让他们进来,而是给他们一张“请回吧”的门票,然后 Nginx 负责把这张门票复印一万份发给所有人。

准备好了吗?让我们开始这场针对 DDoS 的“外科手术”。


第一部分:这帮坏蛋是谁?—— DDoS 的艺术

首先,我们要搞清楚我们在跟谁打架。

DDoS,听起来很科幻,其实特简单。想象一下,你开了一家自助餐厅。突然,门口来了 500 个肌肉猛男。他们不是来吃饭的,他们是来“吃饭”的。他们的任务就是:拿到一个汉堡,咬一口,扔地上,再拿一个,再扔。整整 500 个猛男,轮番上阵。

你的厨师(你的服务器)累得吐血,因为做汉堡的每一秒,CPU 都在处理这些垃圾请求。后面的真正顾客进不来,自助餐厅倒闭。

这就是 DDoS。攻击者利用僵尸网络(那些被种了木马的电脑)向你的服务器疯狂发送请求。

传统的解决方案有哪些?

  1. 硬防: 找安全公司买防火墙。贵!贵!贵!
  2. 高防 IP: 把流量导到别人家,别人帮你扛。贵!还是贵!
  3. Nginx 简单限流: 比如设置 limit_req_zone。这东西有个致命缺点:它是个“呆子”。它只看 IP,不看内容。比如攻击者换了 1000 个 IP,它就傻眼了。而且,如果攻击流量突然从 1000 QPS 暴涨到 100,000 QPS,你的 Nginx 配置可能还没来得及反应,就直接崩溃了。它不会“聪明”地识别出:“哦,这个 IP 在发疯,我给个 503 让他滚蛋吧。”

那我们的 PHP 动态熔断呢?
我们的目标是:基于请求频率的动态熔断。我们要让 PHP 变成那个看门大爷。它要记住:

  • “这个 IP 刚才发了 10 个请求,每秒 5 个,正常。”
  • “这个 IP 刚才 1 秒钟发了 1000 个请求,甚至没空喘气,拉黑!
  • “全网都在发疯,整个系统过载了,挂起系统,只读缓存!

这就是熔断。


第二部分:Nginx 的魔法—— FastCGI 缓存

要实现 PHP 驱动的降级,我们得利用 Nginx 强大的 fastcgi_cache

如果你觉得 fastcgi_cache 是个黑魔法,那你就错了。它其实就是 Nginx 把 PHP 输出的 HTML 字符串,临时存到了硬盘(或者内存)上。下次有人访问同一个页面,Nginx 直接把硬盘里的东西扔给浏览器,根本不经过 PHP

这意味着什么?意味着当攻击来临时,如果你的 PHP 脚本非常聪明,它知道系统快扛不住了,它就故意返回一个“服务器繁忙”的 HTML 文件。Nginx 把这个文件存起来。攻击者发来 100 万个请求,Nginx 从硬盘读 100 万次,PHP 根本不用跑! 你的 CPU 就像冬眠了一样,稳如泰山。

这可是个好东西。让我们看看怎么实现。

1. Nginx 配置基础

首先,在 nginx.conf 或者你的虚拟主机配置里,我们要开启这个魔法。

# 定义缓存目录
# levels=1:2 表示目录层级,方便分散存储
# keys_zone=ddos_cache:10m 表示给这个缓存起个名字,占 10M 内存
# max_size=1g 表示最多占 1G 磁盘空间
# inactive=60m 表示如果 60 分钟没人访问,就删掉
fastcgi_cache_path /var/cache/nginx levels=1:2 keys_zone=ddos_cache:10m max_size=1g inactive=60m use_temp_path=off;

server {
    listen 80;
    server_name example.com;

    # 定义缓存状态页面,方便你看是不是挂了
    fastcgi_cache_status_page on;
    fastcgi_cache_status_page_path /status_cache;

    location / {
        # 开启缓存,使用上面定义的 keys_zone
        fastcgi_cache ddos_cache;

        # 缓存过期时间,这里设长一点,因为我们要靠逻辑控制何时刷新
        fastcgi_cache_valid 200 10m; 

        # 缓存键,这里用请求 URI 作为 Key
        # 注意:这里我们故意不包含 Cookie,因为 Cookie 太长了,缓存容易爆
        fastcgi_cache_key "$scheme$request_method$host$request_uri";

        # 关键配置:如果 PHP 返回 503,要不要缓存?
        # 必须要!这样熔断后,Nginx 就能守住大门了
        fastcgi_cache_use_stale error timeout invalid_header http_500 http_503;

        # 让 PHP 知道要用缓存(通过传递一个特殊 Header)
        fastcgi_cache_bypass $skip_cache;
        add_header X-Cache-Status $upstream_cache_status;

        # 转发到 PHP
        fastcgi_pass unix:/var/run/php/php8.1-fpm.sock;
        fastcgi_index index.php;
        include fastcgi_params;
    }
}

看懂了吗?这就像你给 Nginx 发了一张许可证:“只要 PHP 没给我瞎说‘服务器挂了’,我就把结果存起来,下次直接扔给用户。”


第三部分:PHP 的智慧—— 动态熔断逻辑

现在轮到 PHP 登场了。PHP 必须变得“狡猾”。

我们不要在 PHP 里做复杂的计算(比如搜索数据库),因为那会占用 CPU。我们的 PHP 逻辑要极其简单、极快。

核心思路:

  1. 请求计数: PHP 获取当前用户的 IP,在 Redis(或者文件锁)里给这个 IP 的计数器 +1。
  2. 频率判断: 检查这个 IP 的请求频率是否超过阈值(比如 10秒 100次)。
  3. 熔断触发: 如果超过,直接返回一个静态的“503 Service Unavailable”页面,并设置 X-Cache-Status: MISS(不缓存)或者返回 503 代码让 Nginx 缓存。
  4. 正常流量: 如果正常,运行你的业务逻辑,输出结果,设置 X-Cache-Status: HIT(鼓励缓存)。

为了演示,我们假设你手里没有 Redis,我们用 PHP 写一个简单的本地缓存(为了防止代码太长,实际生产请用 Redis)。

2. PHP 熔断脚本

<?php
// 文件名: index.php
// 我们要实现一个简单的滑动窗口计数器

// 配置参数
$BLOCK_THRESHOLD = 50;  // 阈值:多少个请求算多
$TIME_WINDOW = 10;      // 时间窗:多少秒内

// 获取真实 IP(防止 X-Forwarded-For 欺骗)
function getRealIp() {
    $headers = [
        'HTTP_X_FORWARDED_FOR',
        'HTTP_CLIENT_IP',
        'REMOTE_ADDR'
    ];
    foreach ($headers as $h) {
        if (!empty($_SERVER[$h])) {
            $ips = explode(',', $_SERVER[$h]);
            return trim($ips[0]);
        }
    }
    return 'UNKNOWN';
}

$ip = getRealIp();
$cacheFile = "/tmp/ban_" . md5($ip);

// 检查是否已经被熔断(这里只是简单的文件锁模拟,实际可用 Redis)
if (file_exists($cacheFile)) {
    // 如果文件存在,说明被熔断了,直接返回静态 HTML
    // 这个 HTML 会被 Nginx 缓存起来,瞬间处理百万级请求
    http_response_code(503);
    echo "<html><body><h1>Service Unavailable</h1><p>We are under heavy attack. Please come back later.</p></body></html>";
    exit;
}

// 清理过期的熔断标记(简单实现:遍历删除)
// 生产环境必须用 Redis ZSet (滑动窗口)
$files = glob("/tmp/ban_*");
foreach ($files as $file) {
    if (filemtime($file) < time() - $TIME_WINDOW * 2) {
        unlink($file);
    }
}

// --- 正常流量逻辑 ---

// 这里是你的业务逻辑
// 比如: 查数据库,计算用户信息
// 为了演示,我们 sleep 0.1秒 模拟计算
sleep(0.1); 

// 检查频率
$hitCount = 0;
if (file_exists($cacheFile)) {
    $hitCount = (int)file_get_contents($cacheFile);
}

if ($hitCount > $BLOCK_THRESHOLD) {
    // 熔断!记录熔断标记,有效期 10秒
    file_put_contents($cacheFile, time(), LOCK_EX);

    // 返回 503,Nginx 会根据 fastcgi_cache_use_stale 把这个错误页面缓存起来
    http_response_code(503);
    echo "<html><body><h1>Too Many Requests</h1><p>Your IP is being rate-limited to protect the server.</p></body></html>";
} else {
    // 正常,增加计数,写入内容
    file_put_contents($cacheFile, $hitCount + 1, LOCK_EX);

    // 正常的业务输出
    echo "<html><body><h1>Hello World</h1><p>Your IP is: $ip</p><p>Request Count: " . ($hitCount + 1) . "</p></body></html>";
}

?>

这代码看起来是不是有点“土”? 没错,这就是我喜欢的风格。没有花哨的框架,只有最原始的文件操作。

第四部分:实战演练—— 场景模拟

现在,让我们想象一下攻击发生了。

场景 A:正常流量
用户 A 访问你的网站。

  1. 请求到达 Nginx。
  2. Nginx 查缓存,没找到。
  3. 转发给 PHP index.php
  4. PHP 计数器是 0,执行 sleep(0.1),输出内容。
  5. PHP 写入计数器 1。
  6. Nginx 收到响应,设置 X-Cache-Status: MISS,存入缓存。
  7. 用户 A 收到页面,显示“Hello World”。

场景 B:DDoS 攻击开始
黑客控制了 10,000 台僵尸电脑,同时访问你的网站。

  1. 请求疯狂涌入 Nginx。
  2. Nginx 发现缓存里没有(因为黑客是全新 IP),全部转发给 PHP。
  3. PHP 开始疯狂处理。PHP 逻辑里,每个请求都 sleep(0.1)
  4. PHP 检查计数器。第一秒还好,第二秒……
  5. PHP 看到某个 IP 的计数器瞬间飙到了 50。
  6. PHP 直接返回 503 HTML,并写入 /tmp/ban_xxx 文件,锁死这个 IP 10 秒钟。
  7. Nginx 收到 503,根据配置 fastcgi_cache_use_stale,把这个错误页面存入缓存。

场景 C:洪水滔天(熔断生效)
黑客继续发 100 万个请求。

  1. 请求到达 Nginx。
  2. Nginx 查缓存!发现刚才已经缓存过这个页面的 503 响应了!
  3. Nginx 直接把硬盘里的那个 HTML 扔给黑客
  4. 注意了! PHP 根本没有参与!没有计算!没有 sleep
  5. 黑客收到 503 页面,开心地继续发请求。
  6. Nginx 每秒处理 10,000 个请求,全部来自本地硬盘缓存。
  7. 你的 CPU 使用率:5%。你的数据库:毫秒不卡。
  8. PHP 里的计数器可能已经爆表了,但因为 Nginx 直接给了缓存,没人理它。

这招叫什么?这叫“以彼之道,还施彼身”。既然你让我跑得慢,那我就让你访问得快(访问静态缓存)。


第五部分:高级进阶—— 从“土法炼钢”到“工业标准”

上面的代码能用,能保命,但有点像用破铜烂铁盖房子。在生产环境,我们需要解决几个问题:

1. 分布式锁(Redis 的重要性)

你看到上面的代码了吗?它依赖 file_put_contents。如果你的 Nginx 有 10 个进程,或者你的 PHP-FPM 有 50 个进程,它们可能会同时操作 /tmp/ban_xxx 这个文件。这就是竞态条件。
如果两个人同时到达,一个加 1,一个加 1,结果计数器没变,熔断失效。

修正方案: 使用 Redis。

$redis = new Redis();
$redis->connect('127.0.0.1', 6379);

$ip = getRealIp();
$key = "rate_limit:$ip";
$now = time();

// 使用 Lua 脚本保证原子性
// 逻辑:1. 获取当前计数。2. 如果小于阈值,+1,返回 true。3. 如果 >= 阈值,不操作。
$lua = "
local current = redis.call('get', KEYS[1])
if current and tonumber(current) > tonumber(ARGV[1]) then
    return 0
end
return redis.call('incr', KEYS[1])
";
$redis->script('load', $lua);

// 如果计数结果为 0,说明被封了;否则继续业务逻辑

有了 Redis,你的熔断器就是全网同步的。黑客换了 100 个 IP,每个 IP 都会独立被熔断,但不会出现计数器错乱。

2. 滑动窗口算法

上面的代码是“固定窗口”(比如 10 秒内,如果超过 50 个就封)。这有个坑:如果在第 9 秒来了 49 个,第 10 秒又来了 49 个,总共 98 个,瞬间被封。但实际上这 98 个请求是均匀分布的,完全可以接受。

更好的方案: Redis Sorted Set(ZSET)。
我们将每个请求的时间戳作为分数,IP 作为值存入 ZSET。
查询逻辑:

  1. 删除当前时间窗口(比如 10 秒前)的所有数据:ZREMRANGEBYSCORE key -inf (now - 10
  2. 获取剩余数量:ZCARD key
  3. 如果 ZCARD key > 50,熔断。

这样,你的熔断器就像一个流动的水龙头,更加精准。

3. 全局降级(系统级熔断)

刚才的代码是针对单个 IP 的。但如果黑客攻击你的首页,你的首页缓存满了,其他页面的缓存是空的,黑客去攻击“联系我们”页面,你的服务器还是得跑 PHP。

我们需要一个全局开关
在 PHP 逻辑的最顶端,先检查一个全局计数器(比如 global_requests_in_second)。
如果每秒总请求数超过了 5000,无论单个 IP 是不是好人,全部直接返回 503 静态页面
这个静态页面是 Nginx 缓存的,所以即使黑客在每秒发 50000 个请求,Nginx 也能扛住。

// 在 PHP 脚本开头
$globalKey = "global_rate_limit";
$globalCount = $redis->incr($globalKey);
if ($globalCount == 1) {
    $redis->expire($globalKey, 1); // 1秒过期
}

if ($globalCount > 5000) {
    // 全局熔断
    http_response_code(503);
    echo "System Overload";
    exit;
}

第六部分:架构师的视角—— 降级与兜底

我们今天实现的是一种降级
降级是什么意思?降级就是:为了保住核心业务,牺牲非核心业务。

比如你的电商网站,双十一来了。

  1. 首页:为了快,展示大量图片,缓存策略激进,甚至可以关掉评论功能(降级)。
  2. 商品详情页:允许缓存 1 小时。
  3. 购物车/下单页:必须实时,缓存策略最短(比如 10 秒)。

如果你在 DDoS 保护里实现了动态熔断,你实际上是在实现一种分级保护
你可以定义不同的 location 块,给不同的页面配置不同的缓存时间。

# 首页:激进缓存
location / {
    fastcgi_cache ddos_cache;
    fastcgi_cache_valid 200 5m; # 缓存 5 分钟
}

# 购物车:保守缓存
location /cart/ {
    fastcgi_cache ddos_cache;
    fastcgi_cache_valid 200 10s; # 缓存 10 秒
    # 购物车变动时必须清除缓存
    fastcgi_cache_bypass $skip_cart_cache;
}

# API 接口:不缓存,实时限流
location /api/ {
    fastcgi_pass php_backend;
    # API 接口直接拒绝,不走 Nginx 缓存逻辑
    deny all; 
}

第七部分:当你的代码被嘲笑时

作为专家,我要提醒你一点:性能是有代价的。

如果你把 Nginx 缓存开得太大(比如 max_size=10g),你的磁盘 I/O 会变成瓶颈。当你把所有的请求都扔给 PHP 去判断熔断逻辑时,PHP-FPM 会挂。

最佳实践总结:

  1. Nginx 负责“快”:利用 fastcgi_cacheproxy_cache,把不经常变的、或者是错误页面,直接怼给用户。Nginx 处理静态文件是毫秒级的。
  2. PHP 负责“精”:利用 PHP 轻量级的特点,在极短时间内完成 IP 识别和计数。
  3. Redis 负责“稳”:确保计数器的准确性。
  4. 熔断器模式:这是一个状态机。关 -> 开 -> 半开。不要一直死锁,要给服务器恢复的机会。

结尾:代码之外的思考

各位同学,今天我们讲了如何用 PHP 和 Nginx 构建一个 DDoS 降级保护系统。

这不仅仅是技术,这是工程思维。当流量像洪水一样冲垮你的代码时,你不想做一个手忙脚乱的建筑工人,你想做一个调度员。你告诉洪水:“左边挖个坑,右边筑个坝。”

这个方案的核心在于:不与攻击者比拼算力,而是利用缓存机制,把算力成本转移给硬盘。

如果你现在就去配置一下,你会发现你的服务器日志里,503 错误率飙升,但 CPU 使用率却稳如老狗。

最后,送大家一句话:
“在互联网的世界里,快有快的活法,慢有慢的智慧。有时候,最慢的响应(直接读缓存)恰恰是抗击风暴最快的盾牌。”

好了,今天的讲座就到这里。谁有关于 Redis 分布式锁的问题?没有?那我就当大家都听懂了。下课!

发表回复

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