讲座主题:当 PHP 遇上 DDoS:如何在 Nginx 负载均衡层上演“绝地求生”
各位好,各位正在屏幕前喝着咖啡(或者也许正准备给服务器加冰块)的程序员朋友们。
今天我们不讲那些花里胡哨的前端框架,也不聊什么微服务架构里的服务注册与发现。今天,我们要聊一个硬核的话题——生存。
你们有没有过这种经历:半夜两点,你正睡得像头死猪,突然,手机“叮”的一声,不是女神发来消息,是阿里云或 AWS 的短信轰炸:“尊敬的用户,您的服务器 CPU 使用率 99%,带宽跑满,请及时处理。”
你抓起手机,打开监控面板,好家伙,这不是流量,这是流量战争。你的服务器不是在处理业务,而是在被一群疯狂的 DDoS 攻击者当成了沙袋在打。这时候,传统的防火墙就像个只会说“不”的保安,看着满大街的人冲进来,除了累死自己,毫无作用。
今天,我们要学的是如何用 PHP 写一套“动态熔断策略”,挂在 Nginx 这位大力士的腰带上,在负载均衡层给它装个“大脑”,让它学会在服务器要爆炸前,哪怕自己受点委屈,也要先“捂住伤口”,拒绝那些恶意流量。
准备好了吗?我们要开始给服务器治病了。
第一部分:熔断器是什么鬼?
想象一下,你家厨房里有个水龙头,总漏水。你找了修水管的师傅,师傅修好了,滴水声没了。你高兴坏了,每天打开水龙头就洗菜、做饭、洗衣服。
突然有一天,水管又裂了。你把师傅叫来,师傅正在修,结果他越修,流出的水越多。这时候,你脑子里的开关(熔断器)会自动跳闸。
你会怎么做?你会把总阀关掉。哪怕你正在洗碗,你也必须关掉。因为继续修下去,厨房就要被淹了。
在软件世界里,熔断就是这个逻辑。
- 正常状态: 请求像流水一样穿过 Nginx,到达后端 PHP。
- 异常状态: 后端扛不住了,或者被 DDoS 摧残了,响应变慢,错误率飙升。
- 熔断状态: 熔断器打开,Nginx 不再信任后端,直接拦截请求,返回错误码,或者返回一个简单的“系统繁忙”页面。
我们的目标,就是用 PHP 写一个算法,判断“什么时候该打开这个开关”。
第二部分:为什么是 PHP?为什么是 Nginx?
听到这里,肯定有人要拍桌子了:“老铁,Nginx 不是有 limit_req 模块吗?PHP 有 max_execution_time 限制,用 PHP 做熔断器,是不是有点杀鸡用牛刀?”
别急,咱们来聊聊架构。
- Nginx 的局限性: Nginx 是 C 语言写的,虽然性能强悍,但它主要是做“转发”和“路由”。它的逻辑层(Lua)虽然灵活,但学习曲线陡峭,对于大多数使用 PHP 构建业务的团队来说,部署 Lua 模块就像是请个外国大厨来炒青菜,虽然好吃,但备菜太麻烦。
- 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 熔断系统。
- 定位: 它不是防火墙,它是熔断器。防火墙防的是“别人”,熔断器保的是“己”。
- 实现: 利用 Nginx 作为入口,PHP 脚本作为逻辑控制器,Redis 作为分布式计数器。
- 动态性: 熔断策略不是写死的数字,而是可以根据时间、错误率、流量波动动态调整的算法。
- 哲学: 在系统崩溃边缘,果断切断连接比试图修复它是更明智的选择。
记住,作为一名程序员,你的目标不是写出最完美的代码,而是写出最不容易崩的代码。当 DDoS 攻击来临,你的 PHP 熔断脚本就像那个在火海中关掉总阀的男人一样,虽然你可能会被用户骂“怎么网站打不开”,但你的服务器保住了,你的 KPI 也就保住了。
好了,今天的讲座到此结束。现在,去检查一下你的 php.ini,看看是不是把 max_execution_time 设得太长了。如果 Redis 挂了,你的 PHP 脚本可能会在那儿转圈圈,直到你把服务器电源拔了。
祝大家编码愉快,睡个好觉!