各位同学,各位服务器界的“老司机”们,大家早上好!
欢迎来到今天的讲座,主题很沉重,但我会尽量用一种轻松的方式来聊聊。题目很直白: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 脚本。这个脚本要干两件事:
- 检查 Redis 里有没有当前 IP 的计数器。
- 去读一下服务器当前的负载情况。
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_requests 和 slowlog。
-
动态调整 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 (为了保命)。
- 结果:虽然保护力度减弱了,但至少服务器还能响应几个请求。
第八部分:运维与监控
最后,光有代码是不够的。你需要看着它工作。
- 看 Nginx Status: 一定要配置
stub_status模块。这样你可以实时看到 Nginx 现在正在处理多少个连接,等待多少个请求。location /nginx_status { stub_status on; allow 127.0.0.1; deny all; } - 看 PHP-FPM Pool Status: 确保
/status页面是可访问的,监控它的active processes和idle processes。
终极奥义:
DDS 攻击的本质是资源耗尽。动态限流的核心思想是资源守恒。
当你发现系统负载高时,你不仅要“挡”,还要“省”。不要试图处理每一个请求,有时候拒绝一个请求比处理一个请求成本更低。你的目标不是让所有 100 万用户都开心,而是确保那 1 万个正常用户不感到卡顿,同时挡住那 99 万个捣乱分子。
好了,今天的讲座就到这里。希望大家回去后,能拿起你们的编辑器,去修改那个死板的 Nginx 配置文件。
记住:保护系统最好的方式,不是让它无所不能,而是让它知道什么时候该“闭嘴”。
谢谢大家!