PHP 应用的 DDoS 降级保护:在 Nginx/PHP-FPM 层实现基于请求频率的动态限流协议

各位同学,各位服务器界的“老司机”们,大家早上好!

欢迎来到今天的讲座,主题很沉重,但我会尽量用一种轻松的方式来聊聊。题目很直白:PHP 应用的 DDoS 降级保护:在 Nginx/PHP-FPM 层实现基于请求频率的动态限流协议

我知道,听到“DDoS”和“降级保护”,很多人的第一反应是:“哎呀,这又是那些搞安全的大佬才需要操心的事儿吧?我就是一个写 CRUD 的后端,我的代码跑得飞快,偶尔有点慢而已,怎么可能被搞挂?”

停!打住!别太自信了,朋友。

想象一下,你的 PHP 应用是一个在大排档炒菜的小哥。DDoS 攻击是什么?那就是一群穿西装的壮汉,突然冲进你的大排档,每人点了一碗面,然后还要在那儿嗑瓜子、发呆、甚至把筷子折断扔地上。你一个人忙不过来了,锅糊了,面坨了,客人都在骂娘,最后你气得掀了桌子。

这不是你代码写得不漂亮,也不是你逻辑写得不好。这就是请求洪流

今天,我们不搞虚头巴脑的理论,直接上干货。我们要教大家如何给你的 PHP 应用穿上“防弹衣”,并且这套防弹衣还得是智能的——它能看眼色行事,CPU 满了它就收一收,内存爆了它就趴一趴。这,就是动态限流

准备好了吗?把你的咖啡放下,咱们开始实操。


第一部分:静态限流的“死板”与“尴尬”

首先,我们要谈谈 Nginx。Nginx 是我们的第一道防线,它是我们的门卫大叔。在 Nginx 里,最常见的限流手段就是 limit_req_zone

咱们来看一段经典的 Nginx 配置:

# 定义一个限流区域,名字叫 my_zone,内存 10m,允许 10 个并发请求,超时 1 秒
limit_req_zone $binary_remote_addr zone=my_zone:10m rate=10r/s;

server {
    location / {
        # 对每个 IP 限制每秒 10 个请求,允许突发 5 个
        limit_req zone=my_zone burst=5 nodelay;

        fastcgi_pass   127.0.0.1:9000;
        fastcgi_index  index.php;
        fastcgi_param  SCRIPT_FILENAME  $document_root$fastcgi_script_name;
        include        fastcgi_params;
    }
}

这段代码看起来很美吧?非常标准。它把每秒超过 10 个请求的 IP 直接 503 掉。

但是!(注意这个但是,它是转折点)

如果这时候,Nginx 后面的 PHP-FPM 服务器正在处理一个极其复杂的计算任务(比如算个 3D 渲染),CPU 跑到了 90%。这时候,外界的攻击还在源源不断地涌进来。Nginx 的门卫大叔虽然拦住了 10 个,但他还得抽空告诉 PHP “这 10 个进来了”。PHP 小哥一看,CPU 都烧了,还要处理这 10 个请求,心态崩了,直接抛个 Fatal Error 或者直接卡死。

这就是静态限流的死穴:它不关心后端的状态,它只关心“频率”。

一旦后端挂了,静态限流就会变成一个“撒盐机”——不管后端死没死,前端照拦不误,最后导致数据库连接池耗尽,服务器彻底瘫痪。

所以,我们需要升级。我们需要一种动态的机制。这种机制要像人体一样:当你累了,我就慢点走;当你兴奋了,我就快点跑(或者干脆停下来歇会儿)。


第二部分:动态限流的“大脑”在哪?

要实现动态限流,我们需要一个“大脑”。在 Nginx 的世界里,这个大脑通常就是 Redis

为什么是 Redis?因为它是内存级的,读写速度极快,而且我们可以用 Lua 脚本在里面做复杂的逻辑判断。Redis 就像是那个不仅门卫,还是个脑子清楚的总指挥。

我们的目标是:根据 PHP-FPM 的负载情况,动态调整 Nginx 的限流阈值。

这就好比:

  • 正常情况(负载 < 50%): Nginx 每秒允许 100 个请求进 PHP-FPM。
  • 紧张情况(负载 > 70%): Nginx 立刻收紧,每秒只允许 10 个请求进 PHP-FPM。
  • 危急情况(负载 > 90%): Nginx 停止接受新请求,直接返回 503,或者只允许 POST 请求(为了紧急修复),拒绝 GET 请求(为了保命)。

这听起来很酷,对吧?咱们怎么实现呢?这就涉及到 OpenResty 或者 Nginx + LuaJIT 的世界了。


第三部分:代码实战——Lua 脚本实现动态限流

让我们假设你使用的是 OpenResty(Nginx + LuaJIT),这是实现高性能限流的黄金标准。

1. Lua 逻辑设计

我们需要写一个 Lua 脚本。这个脚本要干两件事:

  1. 检查 Redis 里有没有当前 IP 的计数器。
  2. 去读一下服务器当前的负载情况。

2. Redis + Lua 脚本核心代码

注意,这是要在 Nginx 配置文件里 content_by_lua 调用的。

-- 这是一个本地缓存,避免每次请求都去读文件系统获取负载,提升性能
local sysconf = require "resty.core.sysconf"

local redis = require "resty.redis"
local red = redis:new()

-- 连接池复用
red:set_timeout(1000) -- 1 sec

-- 1. 获取服务器负载 (Linux 常用 /proc/loadavg)
-- load1 是 1分钟内的平均负载
local handle = io.open("/proc/loadavg", "r")
local load_text = handle:read("*a")
handle:close()

-- 解析 load1 (简单粗暴的正则)
local load1 = load_text:match("([^%s]+)/")

if not load1 then load1 = "0.0" end

-- 2. 根据负载计算动态限流阈值
-- 假设 3.0 是满载,我们设定一个降级曲线
-- 如果负载在 0.5 以下,全速(每秒100请求)
-- 如果负载在 2.0 以上,极速降级(每秒10请求)
local max_rate = 100
if tonumber(load1) > 2.0 then
    max_rate = 10
elseif tonumber(load1) > 1.0 then
    max_rate = 30
elseif tonumber(load1) > 0.5 then
    max_rate = 50
end

-- 3. Redis 限流逻辑 (令牌桶算法简化版)
local key = "limit_ip_" .. ngx.var.remote_addr
local current = red:incr(key)

if current == 1 then
    -- 如果是第一次访问,设置过期时间
    red:expire(key, 1)
end

-- 检查是否超限
if current > max_rate then
    -- 拒绝请求
    return ngx.exit(503, "服务繁忙,请稍后再试 (动态限流触发)")
end

-- 4. 执行下游逻辑
-- 这里放你的 fastcgi_pass 或者其他处理逻辑
ngx.say("Welcome! Rate limit is dynamic based on load: ", max_rate)

-- 保持 Redis 连接在连接池中
local ok, err = red:set_keepalive(0, 100)
if not ok then
    ngx.log(ngx.ERR, "redis set_keepalive failed: ", err)
end

这段代码的精髓在哪?

你看 max_rate 这个变量。它不是写死在 Nginx 配置里的,而是通过 Lua 实时算出来的。当 /proc/loadavg 里的数字爆表时,max_rate 瞬间变成 10。这时候,Redis 的 incr 操作就会疯狂拒绝请求。

这就像是你的 Nginx 门卫大叔,手里拿着一个能变色的小本本。看到 CPU 指示灯变红,他立马就把大门锁死,只留一条缝放急救车(关键业务)进去。


第四部分:PHP 层面的“最后的防线”

虽然我们在 Nginx 层做了动态限流,但 PHP 代码里也不能掉以轻心。为什么?因为有时候 Nginx 配置搞错了,或者 Nginx 被绕过了(比如有人用 WebSocket 直接连 PHP 后端)。

在 PHP 里,我们也要实现一个自适应限流器

1. Redis 基础限流

这是最简单的“漏桶”实现:

<?php
class DynamicRateLimiter {
    private $redis;
    private $keyPrefix = 'rate_limit:';
    // 基础限制,默认每秒 50 个请求
    private $baseLimit = 50; 
    // 负载感知限制倍率,最高降级到 10
    private $minRate = 10; 

    public function __construct($redis) {
        $this->redis = $redis;
    }

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

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

        // 2. 如果是第一个请求,设置过期时间为 1 秒
        if ($count == 1) {
            $this->redis->expire($key, 1);
        }

        // 3. 动态调整阈值 (模拟读取系统负载)
        // 在实际生产中,你应该通过 HTTP 请求去调用监控接口,或者共享一个内存变量
        $loadFactor = $this->getSystemLoadFactor(); // 假设这个函数返回 0.2 到 1.0 之间的数

        $currentLimit = (int)($this->baseLimit * $loadFactor);

        // 4. 判定
        if ($count > $currentLimit) {
            // 记录日志,防止日志本身也被攻击导致磁盘写满
            // $this->logBlockedRequest($ip);
            return false; // 拒绝
        }

        return true; // 允许
    }

    private function getSystemLoadFactor() {
        // 实际代码中,这里应该读取 Nginx 的 status 模块数据,或者系统的 load average
        // 为了演示,我们随机返回一个值
        return 0.2; 
    }
}

// 使用示例
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);

$limiter = new DynamicRateLimiter($redis);

if ($limiter->allowRequest($_SERVER['REMOTE_ADDR'])) {
    // 正常业务逻辑
    echo "Access Granted";
} else {
    header("HTTP/1.1 429 Too Many Requests");
    echo "Too many requests. Please wait.";
}
?>

2. PHP 降级策略:当限流失效时

如果 PHP 接到了请求,处理不过来了,怎么办?这里有一个高级技巧:降级输出

不要让 PHP 线程一直阻塞等待数据库查询。如果数据库连接数满了,PHP 应该立即返回,而不是等待超时。

public function handleRequest() {
    // 检查数据库连接池状态
    if ($this->db->ping() === false) {
        // 数据库挂了,进入只读模式或返回缓存
        return $this->getFromCache($this->request);
    }

    // 检查缓存
    $cacheKey = md5($this->request);
    $data = $this->cache->get($cacheKey);
    if ($data) {
        return $data;
    }

    // 如果没缓存,尝试写 DB
    // 关键点:使用 try-catch 和超时控制
    try {
        $this->db->beginTransaction();

        // 执行耗时操作
        $result = $this->heavyProcessing();

        $this->db->commit();
        $this->cache->set($cacheKey, $result, 60);
        return $result;
    } catch (Exception $e) {
        $this->db->rollBack();
        // 降级:如果 DB 挂了,返回一个假的“系统升级中”页面,而不是白屏
        return $this->getFallbackPage();
    }
}

第五部分:DDoS 降级协议的“仪式感”

单纯的限流是不够的,我们还需要定义一套协议。这听起来像是在写 HTTP 协议,不,我们在写“服务器保命协议”。

我们要定义几个状态码和响应头,让前端和客户端知道服务器现在的身体状况。

1. 状态码定义:

  • 200 OK: 系统健康,全速运行。
  • 206 Partial Content (或者自定义 429 Service Unavailable): 系统繁忙,请求被限流。
  • 503 Service Unavailable: 系统过载,拒绝所有新请求,仅保留核心接口(如健康检查接口)。

2. 响应头定义:

我们需要告诉客户端:“嘿,我现在很累,你自己慢点发,别再给我加压了。”

# 在 Nginx 返回 503 的时候,加上这个头
add_header X-Server-Status "LOAD_HIGH";
add_header Retry-After "10"; # 告诉客户端,10秒后再试

3. “蓝屏”时刻的应对

如果 Nginx 处理不过来了,怎么办?这时候我们不能让 PHP-FPM 进程被杀掉(OOM Killer 会杀进程,而且很暴力)。

我们需要利用 PHP-FPM 的 pm.max_requestsslowlog

  • 动态调整 PHP-FPM 进程数:
    在高负载时,不要增加 PHP 进程数,因为线程多了,上下文切换太厉害,反而更慢。要减少进程数,保持少量线程运行。

  • 拒绝慢请求:
    配置 request_slowlog_timeout。如果请求超过 2 秒还没处理完,直接砍掉,写入慢日志,然后继续下一个。不要让一个慢请求拖死整个队列。

# Nginx 配置示例
location ~ .php$ {
    # ... fastcgi 配置 ...

    # 如果请求时间超过 2 秒,直接返回 504 Gateway Timeout
    fastcgi_read_timeout 2s;

    # PHP-FPM 配置:如果脚本执行超过 2 秒,杀掉进程
    # php_admin_value[max_execution_time] = 2
    # php_admin_value[request_terminate_timeout] = 2
}

第六部分:故障转移与“盲点”

这里有一个非常关键的问题,也是很多新手容易踩的坑:如果 Redis 挂了怎么办?

如果你把限流逻辑完全依赖于 Redis,那么当 DDoS 攻击波及 Redis(例如 Redis 本身也扛不住了,或者网络不通),你的 PHP 应用将没有任何保护,直接裸奔。

解决方案:本地缓存降级。

我们需要在应用服务器本地也保留一份限流计数器。当 Redis 挂了,Nginx/Lua 可以退回到一个宽松模式

-- 伪代码逻辑
local allowed = false
if redis_is_ok then
    allowed = check_redis_limit()
else
    -- Redis 挂了,使用本地计数器
    allowed = check_local_limit()

    if allowed then
        -- 这是一个降级时刻,我们可以记录一条日志,或者给客户端一个特殊的状态头
        ngx.header["X-Fallback"] = "true";
    end
end

这就好比你的门卫大叔有个笔记本,虽然比不上电脑快,但总比没有强。而且,当 Redis 恢复后,我们可以让本地计数器慢慢追上 Redis 的状态,或者直接丢弃本地缓存,重新开始。


第七部分:实战演练——模拟一场攻击

假设我们现在正在维护一个电商网站。现在是双十一,流量巨大。

阶段一:平稳期

  • 负载:0.5
  • 策略:Nginx 允许 100 r/s,PHP 处理一切。
  • 体验:飞快。

阶段二:攻击开始

  • 负载:3.0 (爆表!)
  • 策略:Lua 脚本检测到 loadavg > 2.0,动态将阈值降低到 10 r/s。
  • 结果:Nginx 疯狂返回 503。外部流量被遏制。PHP-FPM 的工作量从 1000 TPS 降到了 10 TPS。
  • 体验:用户觉得网站卡了,但网站没挂。

阶段三:攻击升级

  • 负载:4.0 (CPU 飙升)
  • 策略:阈值降低到 2 r/s。
  • 结果:只有极少量的请求能进来。
  • 降级:PHP 代码检测到数据库连接数减少,自动切换到只读模式,不从数据库写数据,只读缓存。

阶段四:攻击波及基础设施

  • 负载:5.0 (甚至 Redis 也卡了)
  • 策略:Lua 检测到 Redis 超时,退回到本地计数器,且阈值放宽到 50 r/s (为了保命)。
  • 结果:虽然保护力度减弱了,但至少服务器还能响应几个请求。

第八部分:运维与监控

最后,光有代码是不够的。你需要看着它工作。

  1. 看 Nginx Status: 一定要配置 stub_status 模块。这样你可以实时看到 Nginx 现在正在处理多少个连接,等待多少个请求。
    location /nginx_status {
        stub_status on;
        allow 127.0.0.1;
        deny all;
    }
  2. 看 PHP-FPM Pool Status: 确保 /status 页面是可访问的,监控它的 active processesidle processes

终极奥义:

DDS 攻击的本质是资源耗尽。动态限流的核心思想是资源守恒

当你发现系统负载高时,你不仅要“挡”,还要“省”。不要试图处理每一个请求,有时候拒绝一个请求比处理一个请求成本更低。你的目标不是让所有 100 万用户都开心,而是确保那 1 万个正常用户不感到卡顿,同时挡住那 99 万个捣乱分子。

好了,今天的讲座就到这里。希望大家回去后,能拿起你们的编辑器,去修改那个死板的 Nginx 配置文件。

记住:保护系统最好的方式,不是让它无所不能,而是让它知道什么时候该“闭嘴”。

谢谢大家!

发表回复

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