PHP 应用的 DDoS 降级保护:在 Nginx 负载均衡层与 PHP 业务层实现双重流量削峰平谷策略

各位同学,各位未来的架构师,还有那些觉得自己服务器最近有点“发热”的倒霉蛋们,大家好!

我是你们的老朋友,一个喜欢在代码里找乐子,在服务器崩溃前救场的资深程序员。

今天咱们不聊怎么写优雅的 ORM,也不聊怎么用 React 去粉饰那个丑陋的后端。咱们来聊点硬核的、带血腥味的——如何在 PHP 应用的 DDoS 攻击面前,建立起一道铜墙铁壁

想象一下,你正坐在工位上,手里捧着热咖啡,看着监控面板,突然发现你的 CPU 使用率瞬间飙到了 100%,磁盘 I/O 写入量像坐了火箭一样往上窜。数据库连接池满了,PHP-FPM 进程全挂了,Redis 缓存挂了,连你的 SSH 连接都断了。

这时候,你会听到屏幕对面传来一阵欢呼声:“感谢赞助商!感谢爸爸!”——恭喜你,你被 DDoS(分布式拒绝服务攻击)了。

这时候,如果你还在那儿傻乎乎地写 SELECT * FROM user WHERE ...,那你不是在救火,你是在加速服务器爆炸。所以,今天这堂课,我们就来聊聊如何构建双重防线Nginx 负载均衡层的流量削峰,以及 PHP 业务层的降级保护

准备好了吗?让我们开始这场“服务器保卫战”。


第一章:第一道防线,Nginx —— 那个冷酷无情的守门员

很多新手程序员觉得,Nginx 就是用来把请求转发给 PHP-FPM 的,就像一个快递员把包裹交给收件人。错!大错特错!

在 DDoS 战场上,Nginx 是你的第一道,也是最重要的一道防线。它是高个儿,它能扛住大部分的冲击。它的任务很简单:只要不是人,统统挡在外面

1.1 限流的艺术:拒绝“暴力拆迁”

攻击者最常用的手段是什么?就是像疯狗一样疯狂请求。比如,一秒钟发 1000 个请求。如果你的 PHP 处理不过来,那服务器就会直接卡死。

这时候,Nginx 的 limit_req_zone 模块就该上场了。它就像一个冷酷的保安,手里拿着秒表,如果你在一秒钟内闯入太多次,直接把你扔出去。

代码示例 1:Nginx 基础限流配置

http {
    # 定义一个名为 "api_limit" 的内存区域
    # 每个客户端 IP 占用 10MB 内存
    # 限制速率:每秒 10 个请求 (rate=10r/s)
    limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;

    server {
        location /api/ {
            # 应用限流区域
            # nodelay: 如果超过速率,立即拒绝,不要排队缓冲
            # burst=20: 允许瞬间突发 20 个请求(比如前 10 个来了,后面又来 10 个,先放行)
            # fallback=404: 超过限制返回 404 页面,而不是默认的 503
            limit_req zone=api_limit nodelay burst=20 fallback=404;

            # 关键:确保 Nginx 本身不把请求转给 PHP
            # 否则如果 PHP 挂了,Nginx 还在转,那就是死循环了
            location ~ .php$ {
                return 404;
            }
        }
    }
}

专家解读:
这里有个坑,一定要填上。在 location ~ .php$ 里面加了 return 404。为什么要这么做?因为如果 PHP-FPM 死了(因为 DDoS 导致资源耗尽),Nginx 还傻乎乎地尝试转发,会导致 Nginx 进程瞬间挂掉(502 Bad Gateway)。直接告诉攻击者:“我要么给你 10 个请求,要么给你个 404,别跟我扯皮。”

1.2 限制连接数:拒绝“抱团取暖”

除了每秒请求数,还有一种攻击叫“慢速攻击”或者“连接耗尽”。攻击者建立一个连接,不发送数据,也不断开,就挂着。如果 1000 个攻击者都这么做,你的 PHP-FPM 进程数很快就耗尽了,根本没资源处理正常的业务请求。

这时候,limit_conn_zone 登场。

代码示例 2:限制并发连接

# 定义一个区域,限制每个 IP 同时建立的连接数
limit_conn_zone $binary_remote_addr zone=conn_limit:10m;

server {
    location / {
        # 每个 IP 最多同时建立 5 个连接
        limit_conn conn_limit 5;

        # 如果超过限制,返回 503
        limit_conn_status 503;

        # ... PHP 配置 ...
    }
}

这时候,那些抱着连接不撒手的攻击者会发现,他们的连接被无情地拒绝了。

1.3 Geo 模块:把坏人发配到“西伯利亚”

有时候,攻击者的 IP 不是一个,而是成千上万个。你不可能一个个写黑名单。这时候,利用 Nginx 的 geo 模块,我们可以根据 IP 地址段或者国家,把流量引流到一个专门的“垃圾处理”服务器上。

代码示例 3:基于 IP 段的拦截

http {
    # 定义一个叫 bad_ip 的变量
    geo $ip $deny_access {
        # 典型的攻击来源段
        192.168.1.0/24   1;
        203.0.113.0/24   1;

        # 默认情况,允许访问
        default          0;
    }

    server {
        location / {
            # 如果 $deny_access 是 1,直接返回 444 (关闭连接,不发送任何数据)
            if ($deny_access) {
                return 444;
            }

            # 正常逻辑...
        }
    }
}

444 这个状态码很有意思,它不会在响应头里显示任何内容,客户端直接连都连不上。这招对于 UDP 反射攻击或者 SYN Flood 特别有效。


第二章:第二道防线,PHP 业务层 —— 沉默的坚守者

好,假设你的 Nginx 是个尽职的保安,它拦住了 90% 的流量,还把 5% 的可疑流量扔给了 PHP。但剩下的 5%,可能是正常用户的误操作,也可能是剩下的僵尸网络。

这时候,轮到 PHP 上场了。但 PHP 不是一个超级英雄,它很脆弱。它一旦卡死,整个应用就挂了。所以,PHP 的策略不是“硬刚”,而是降级

所谓的“降级保护”,听起来很高大上,其实就是一句话:在系统扛不住的时候,别装了,老老实实省电模式运行。

2.1 Redis 令牌桶算法:给正常用户发“入场券”

Nginx 的限流是基于 IP 的,精度不够,且容易误伤正常用户(比如一个公司用同一个出口 IP,大家都被限流了)。

在 PHP 里,我们通常使用 Redis 来实现更精细的限流,以及防止数据库被冲垮。我们要实现一个令牌桶算法

想象一下,我们有一个 Redis 槽位叫 rate_limit:ip:123.45.67.89。里面存着一个数字。每次请求进来,我们把这个数字加 1。如果这个数字超过了阈值(比如 10),直接返回错误。同时,我们设置一个过期时间,比如 60 秒。这样就形成了一个 60 秒内的滑动窗口。

代码示例 4:PHP Redis 精确限流

class RateLimiter {
    private $redis;
    private $keyPrefix = 'ddos_protection:';
    private $maxRequests = 10; // 限制:每 10 秒最多 10 次
    private $window = 10;      // 窗口:10 秒

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

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

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

        // 2. 如果这是第一次进来,设置过期时间(防止死键)
        if ($count == 1) {
            $this->redis->expire($key, $this->window);
        }

        // 3. 如果超过阈值,直接拒绝
        if ($count > $this->maxRequests) {
            return false; // 限流触发
        }

        return true; // 允许通过
    }
}

专家解读:
别急着高兴。这还不够。如果攻击者发了 1000 个请求,你的 PHP 脚本得跑 1000 次 Redis 操作。虽然 Redis 很快,但 Nginx 已经拦了一部分,剩下的这部分如果处理不好,PHP 代码一卡顿,连接数就又上去了。

所以,我们要更狠一点。直接在 PHP 代码执行前,直接把请求扼杀在摇篮里。

2.2 数据库连接的“生死时速”

在 DDoS 场景下,最怕的就是 INSERT INTOSELECT *。你的 MySQL 服务器本来就在处理几十个请求,突然 PHP 抛过来 500 个查询请求,MySQL 瞬间 CPU 100%。

降级策略: 绝对不要在业务逻辑层写复杂的 SQL。

代码示例 5:数据查询降级

public function getUserProfile($userId) {
    try {
        // 正常逻辑:查数据库
        $db = $this->getDBConnection();
        $stmt = $db->prepare("SELECT * FROM users WHERE id = ?");
        $stmt->execute([$userId]);
        $user = $stmt->fetch();

        if (!$user) return ['error' => 'User not found'];

        // 返回数据
        return ['name' => $user['name'], 'email' => $user['email']];

    } catch (Exception $e) {
        // 捕获异常,不要打印堆栈,也不要记录日志(写日志也会耗资源)
        // 直接降级返回
        return $this->gracefulDegradation($userId);
    }
}

private function gracefulDegradation($userId) {
    // 降级方案 A:返回空数据
    // return ['name' => '', 'email' => ''];

    // 降级方案 B:从缓存取(如果有的话)
    // 降级方案 C:返回一个简单的静态 JSON,假装系统正常

    // 这里我们用一个更狠的:直接拒绝
    header('HTTP/1.1 429 Too Many Requests');
    echo json_encode(['message' => 'System overloaded, please try again later.']);
    exit;
}

看到没?一旦数据库挂了(或者响应太慢),不要试图去修它,不要去记录日志。直接 exit。把资源留给那些还能跑的业务逻辑。

2.3 文件锁机制:最后一道防线

如果 Redis 也挂了呢?或者 Redis 连接不上怎么办?这时候,我们需要一个后备方案。

你可以使用 PHP 的文件锁来保护你的关键文件(比如写入配置,或者写入计数器)。

代码示例 6:PHP 文件锁

$fp = fopen('rate_limit.lock', 'w+');
if (flock($fp, LOCK_EX | LOCK_NB)) { // 非阻塞获取锁
    // 临界区:在这里处理业务
    // 读取当前计数...
    // 写入新计数...
    // ...
    flock($fp, LOCK_UN); // 释放锁
} else {
    // 锁获取失败,说明有其他进程正在处理,或者文件被锁住
    // 直接拒绝请求
    echo "Too busy";
}
fclose($fp);

虽然文件锁效率不如 Redis,但在极端情况下,它能防止多个 PHP 进程同时修改同一个状态变量,防止数据不一致。


第三章:双重策略的协同 —— 削峰平谷的化学反应

光有 Nginx 不行,光有 PHP 也不行。真正的降级保护,是两者的完美配合。

场景重现:

  1. Nginx 层:它像一个铁闸门。它检测到 IP 1.2.3.4 在疯狂请求。它启动 limit_req。前 10 个请求通过了,第 11 个请求被 Nginx 返回 503。
  2. PHP 层:它像一个大脑。当它收到请求时,首先调用 RateLimiter。如果 IP 超过限制,PHP 直接返回 429,不查询数据库。

关键点: Nginx 只是基于 IP 的粗略限流。如果一个正常的用户换了 IP,Nginx 可能拦不住。这时候,PHP 的精细限流就派上用场了。

架构图(脑补):

[攻击流量 (1000 QPS)]
       |
       v
[Nginx (粗限流/黑名单/Geo)] ---> [拦截 900 QPS] ---> [丢弃]
       |
       v (剩下 100 QPS)
[PHP 应用服务器集群]
       |
       +---> [Redis 令牌桶 (精细限流)] ---> [拦截 90 QPS] ---> [丢弃]
       |
       v (剩下 10 QPS)
[MySQL/数据库] (此时负载很低,安全运行)

这就是“削峰平谷”。把巨大的流量波浪,削成涓涓细流,让后端的数据库稳如老狗。


第四章:实战演练 —— 一个完整的 DDoS 防御脚本

咱们来点实际的。假设你要做一个 API 接口 api.php,你需要同时应对 Nginx 和 PHP 两层的保护。

配置文件:nginx.conf

# ... http block ...
limit_req_zone $binary_remote_addr zone=my_limit:10m rate=20r/s;
limit_conn_zone $binary_remote_addr zone=my_conn:10m rate=5conn/s;

server {
    listen 80;
    server_name api.example.com;

    # 1. 开启 Nginx 缓存,直接对静态文件(如果有)返回,不跑 PHP
    location ~* .(jpg|jpeg|png|gif|css|js|ico|txt)$ {
        root /var/www/static;
        expires 1y;
    }

    # 2. PHP 接口入口
    location /api/ {
        # 应用限流
        limit_req zone=my_limit burst=30 nodelay;
        limit_conn my_conn 2;

        # 重写规则,把所有 /api/xxx 请求转发给 index.php
        try_files $uri /index.php?$query_string;

        location ~ .php$ {
            fastcgi_pass unix:/var/run/php/php8.1-fpm.sock;
            fastcgi_index index.php;
            fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
            include fastcgi_params;

            # 增加超时时间,防止慢速攻击耗尽连接
            fastcgi_read_timeout 5s;
        }
    }
}

核心逻辑:api.php

<?php
// api.php
require_once 'config.php';

// 获取客户端 IP
$ip = $_SERVER['REMOTE_ADDR'];

// 1. Nginx 层已经做了限流,但为了保险,我们 PHP 层再做一次
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);

$key = "ddos:block:" . $ip;
$current = $redis->get($key);

// 如果 IP 被封禁,直接返回
if ($current) {
    http_response_code(429);
    echo json_encode(['code' => 429, 'msg' => 'Access Denied']);
    exit;
}

// 2. 令牌桶逻辑
$tokenBucketKey = "ddos:token:" . $ip;
$maxTokens = 10;
$tokensToAdd = 1; // 每次请求消耗 1 个 token
$refillRate = 2;  // 每秒补充 2 个 token

// 如果 key 不存在,初始化
if (!$redis->exists($tokenBucketKey)) {
    $redis->set($tokenBucketKey, $maxTokens);
    $redis->expire($tokenBucketKey, 1); // 1秒过期
    $redis->set("ddos:block:" . $ip, 1, 60); // 初始封禁 60 秒
    $hasAccess = true;
} else {
    // 尝试获取 token
    $hasAccess = $redis->decrBy($tokenBucketKey, $tokensToAdd);

    // 如果 token 不足,说明超过速率
    if ($hasAccess < 0) {
        // 拒绝请求,并增加封禁时间(累加)
        $redis->incr("ddos:block:" . $ip);
        $redis->expire("ddos:block:" . $ip, 60); // 保持 60 秒封禁

        http_response_code(429);
        echo json_encode(['code' => 429, 'msg' => 'Rate Limit Exceeded']);
        exit;
    } else {
        // 获取成功,暂时解除封禁(如果之前被封的话)
        $redis->set("ddos:block:" . $ip, 0);
    }
}

// 3. 令牌桶补充逻辑 (在每次成功请求后执行)
$tokens = $redis->get($tokenBucketKey);
if ($tokens < $maxTokens) {
    $redis->incrBy($tokenBucketKey, $refillRate);
}

// 4. 业务逻辑执行 (假设这里很耗时)
try {
    // 模拟业务处理
    sleep(0.1); 

    $data = ['status' => 'ok', 'data' => 'Hello World'];
    echo json_encode($data);
} catch (Exception $e) {
    // 业务异常也要记录,但不要死循环
    error_log("API Error: " . $e->getMessage());
    http_response_code(500);
    echo json_encode(['code' => 500, 'msg' => 'Internal Server Error']);
}
?>

代码解析:

  1. 双重检查:代码里先检查 Redis 里的 ddos:block,如果有,直接送客。这比等 PHP 代码跑完检查 token 要快得多。
  2. Token Bucket (令牌桶):这个逻辑的核心是 decrBy。每次请求减 1。如果减到负数,说明桶空了,攻击者必须等待桶里慢慢补满 token(refillRate)。
  3. 防刷机制:如果 IP 超过速率,我们在 Redis 里设置一个 ddos:block,把 IP 暂时封禁。这比单纯的限流更有效,因为攻击者发现怎么请求都返回 429,通常会停止尝试。

第五章:降级的艺术 —— 什么时候该放弃治疗?

在 DDoS 防御中,有一个词叫“部分服务降级”。

如果你的网站是电商,用户无法下单,那是灾难。但如果你是论坛,用户无法发帖,还能看帖子,那问题不大。

实战策略:

  1. 核心链路优先:确保支付、登录、核心搜索功能绝对畅通。
  2. 非核心功能静默:当系统负载超过 70% 时,自动关闭评论区、点赞功能、上传图片功能。
  3. 返回静态 HTML:这是终极降级。与其写 PHP 去查询数据库然后渲染 HTML,不如直接 return file_get_contents('static_ugly_page.html')。这个静态页面上可能写着:“系统繁忙,请稍后再试”。这比一个报错的页面要友好得多,也能节省服务器 90% 的算力。

代码示例 7:自动降级开关

$load = $this->getServerLoad(); // 获取系统负载

if ($load > 0.8) {
    // 进入降级模式
    header('Content-Type: text/html');
    echo file_get_contents('/var/www/html/maintenance.html');
    exit;
}

总结与进阶

好了,讲了这么多,咱们来总结一下这“双重盾牌”的威力。

Nginx 层就像是高速公路的收费站。它靠的是硬件性能和简单的规则,粗暴地拦截绝大多数无效流量。它不需要理解你的业务逻辑,它只认 IP 和规则。

PHP 层就像是收费站里的收费员。当车流(流量)通过收费站(Nginx)到达收费员(PHP)面前时,收费员需要根据车型(业务类型)和通行证(Token Bucket)来决定是否放行。如果车太多,收费员直接停止工作,把路封死,告诉司机“前方施工”。

如何应对 DDoS?

  1. 冷启动(预热):不要在凌晨 3 点搞大促。提前半小时把 Nginx 和 Redis 预热起来。
  2. 弹性伸缩:如果你的云服务器支持,配置自动扩容。流量一上来,自动加机器。Nginx 是无状态的,加一台就是加倍处理能力。
  3. CDN 加持:这是大招。把图片、CSS、JS 放到 CDN 上。把大部分的静态请求都挡在源站外面。

最后,我要提醒大家:没有绝对完美的防御。总有一天,攻击者的带宽比你的服务器还大。这时候,我们要做的不是死磕,而是止损

当你发现你的 PHP 代码还在跑,但数据库连不上了,别慌。看看 Nginx 是否还在转发请求?如果是,赶紧在 Nginx 里加一行 return 503;。让 Nginx 告诉全世界:你的服务器今天不想说话。

这就是架构的韧性。祝大家的代码都能跑在跑车上,而不是拖拉机上!

(课程结束,请各位散会,记得给自己的服务器备份数据库!)

发表回复

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