各位同学,各位码农,各位在服务器前瑟瑟发抖的运维同仁们,大家好!
欢迎来到今天的“服务器急救与生存指南”讲座。今天我们不讲那些枯燥的算法复杂度,不讲那些让你在深夜里怀疑人生的内存泄漏。今天,我们要聊聊一个稍微带点“火药味”的话题:DDoS(分布式拒绝服务)攻击。
在座的各位,谁没见过这个场景:你正在喝着咖啡,或者正在跟对象视频,突然手机震动,老板发来微信:“网站崩了?客户都在骂娘了?”你打开监控,看到 CPU 使用率直接干到了 100%,流量图表像心电图停止跳动一样变成了直线。
别慌,今天我要教大家一套绝活。我们不买昂贵的防火墙,不搞什么昂贵的云清洗,我们就用最便宜、最原生的工具——Nginx 和 PHP,玩出花来。
我们将构建一个PHP 驱动的动态熔断系统。听起来很高大上?其实就是给 Nginx 这个守门人装一个“聪明的大脑”,而 PHP 就是那个大脑。当坏人(攻击流量)来的时候,我们不用把门打开让他们进来,而是给他们一张“请回吧”的门票,然后 Nginx 负责把这张门票复印一万份发给所有人。
准备好了吗?让我们开始这场针对 DDoS 的“外科手术”。
第一部分:这帮坏蛋是谁?—— DDoS 的艺术
首先,我们要搞清楚我们在跟谁打架。
DDoS,听起来很科幻,其实特简单。想象一下,你开了一家自助餐厅。突然,门口来了 500 个肌肉猛男。他们不是来吃饭的,他们是来“吃饭”的。他们的任务就是:拿到一个汉堡,咬一口,扔地上,再拿一个,再扔。整整 500 个猛男,轮番上阵。
你的厨师(你的服务器)累得吐血,因为做汉堡的每一秒,CPU 都在处理这些垃圾请求。后面的真正顾客进不来,自助餐厅倒闭。
这就是 DDoS。攻击者利用僵尸网络(那些被种了木马的电脑)向你的服务器疯狂发送请求。
传统的解决方案有哪些?
- 硬防: 找安全公司买防火墙。贵!贵!贵!
- 高防 IP: 把流量导到别人家,别人帮你扛。贵!还是贵!
- 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 逻辑要极其简单、极快。
核心思路:
- 请求计数: PHP 获取当前用户的 IP,在 Redis(或者文件锁)里给这个 IP 的计数器 +1。
- 频率判断: 检查这个 IP 的请求频率是否超过阈值(比如 10秒 100次)。
- 熔断触发: 如果超过,直接返回一个静态的“503 Service Unavailable”页面,并设置
X-Cache-Status: MISS(不缓存)或者返回 503 代码让 Nginx 缓存。 - 正常流量: 如果正常,运行你的业务逻辑,输出结果,设置
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 访问你的网站。
- 请求到达 Nginx。
- Nginx 查缓存,没找到。
- 转发给 PHP
index.php。 - PHP 计数器是 0,执行
sleep(0.1),输出内容。 - PHP 写入计数器 1。
- Nginx 收到响应,设置
X-Cache-Status: MISS,存入缓存。 - 用户 A 收到页面,显示“Hello World”。
场景 B:DDoS 攻击开始
黑客控制了 10,000 台僵尸电脑,同时访问你的网站。
- 请求疯狂涌入 Nginx。
- Nginx 发现缓存里没有(因为黑客是全新 IP),全部转发给 PHP。
- PHP 开始疯狂处理。PHP 逻辑里,每个请求都
sleep(0.1)。 - PHP 检查计数器。第一秒还好,第二秒……
- PHP 看到某个 IP 的计数器瞬间飙到了 50。
- PHP 直接返回 503 HTML,并写入
/tmp/ban_xxx文件,锁死这个 IP 10 秒钟。 - Nginx 收到 503,根据配置
fastcgi_cache_use_stale,把这个错误页面存入缓存。
场景 C:洪水滔天(熔断生效)
黑客继续发 100 万个请求。
- 请求到达 Nginx。
- Nginx 查缓存!发现刚才已经缓存过这个页面的 503 响应了!
- Nginx 直接把硬盘里的那个 HTML 扔给黑客。
- 注意了! PHP 根本没有参与!没有计算!没有
sleep! - 黑客收到 503 页面,开心地继续发请求。
- Nginx 每秒处理 10,000 个请求,全部来自本地硬盘缓存。
- 你的 CPU 使用率:5%。你的数据库:毫秒不卡。
- 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。
查询逻辑:
- 删除当前时间窗口(比如 10 秒前)的所有数据:
ZREMRANGEBYSCORE key -inf (now - 10。 - 获取剩余数量:
ZCARD key。 - 如果
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 小时。
- 购物车/下单页:必须实时,缓存策略最短(比如 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 会挂。
最佳实践总结:
- Nginx 负责“快”:利用
fastcgi_cache和proxy_cache,把不经常变的、或者是错误页面,直接怼给用户。Nginx 处理静态文件是毫秒级的。 - PHP 负责“精”:利用 PHP 轻量级的特点,在极短时间内完成 IP 识别和计数。
- Redis 负责“稳”:确保计数器的准确性。
- 熔断器模式:这是一个状态机。关 -> 开 -> 半开。不要一直死锁,要给服务器恢复的机会。
结尾:代码之外的思考
各位同学,今天我们讲了如何用 PHP 和 Nginx 构建一个 DDoS 降级保护系统。
这不仅仅是技术,这是工程思维。当流量像洪水一样冲垮你的代码时,你不想做一个手忙脚乱的建筑工人,你想做一个调度员。你告诉洪水:“左边挖个坑,右边筑个坝。”
这个方案的核心在于:不与攻击者比拼算力,而是利用缓存机制,把算力成本转移给硬盘。
如果你现在就去配置一下,你会发现你的服务器日志里,503 错误率飙升,但 CPU 使用率却稳如老狗。
最后,送大家一句话:
“在互联网的世界里,快有快的活法,慢有慢的智慧。有时候,最慢的响应(直接读缓存)恰恰是抗击风暴最快的盾牌。”
好了,今天的讲座就到这里。谁有关于 Redis 分布式锁的问题?没有?那我就当大家都听懂了。下课!