DDoS一来网站就挂?源站抗压改造清单

同样的攻击流量,有的站纹丝不动,有的一分钟就 502。差距往往不在防护设备,而在源站的单请求成本。本文用 10 个问答讲清源站抗压该改哪几处、怎么算成本、配置怎么落地。

Q:为什么同样的流量,别人扛得住你挂了?

  • 承载上限有个朴素公式:可用资源除以单请求成本。分母差三倍,能扛的流量就差三倍。
  • 以 8 核 8G 的机器为例:单请求消耗 40 毫秒 CPU,理论上两百 QPS 就顶到天花板;如果优化到 8 毫秒,同样机器能到一千 QPS。
  • 单请求成本高通常来自三件事:一次请求查了十几次数据库、每个页面都整页渲染模板、同步调用了外部接口(短信、地图、风控)。
  • 防护设备只负责把「量」压低,压不到零。流量总会有一部分落到源站,最后扛住的是源站。
  • 所以源站抗压不是锦上添花,它是防护体系的最后一段,也是唯一你能完全掌控的一段。

Q:怎么算出自己的单请求成本?

  • 两个动作:阶梯压测找到天花板,日志统计找到最贵的接口。
  • 压测要压非缓存接口,压首页得到的是缓存能力,不是源站能力。
# 阶梯加压,看每个档位的 P95 与错误率
for q in 50 100 200 400 800; do
  echo "==== concurrency $q"
  wrk -t4 -c$q -d30s --latency "https://www.example.com/api/list?page=1" \
    | grep -E "Requests/sec|Latency|Non-2xx"
done
  • 同时盯住 CPU。CPU 到 70% 的那个 QPS 就是你的实际承接上限,不是压到报错才叫上限。
  • 日志侧统计最贵的 URI(要求 log_format 末位是 $request_time):
awk '{u=$7; t=$NF; s[u]+=t; c[u]++; if(t>m[u])m[u]=t}
     END{for(k in s) printf "%-45s n=%-7d avg=%.3f max=%.3f\n", k, c[k], s[k]/c[k], m[k]}
    ' /var/log/nginx/access.log | sort -k3 -nr | head -20
  • 把 avg 和 n 相乘,就是每个接口吃掉的总时间。占用最高的那几个,就是改造清单的第一梯队。

Q:哪些接口应该最先改?

  • 排序公式:优先级 = P95 耗时 × 请求占比。不是挑最慢的,是挑「又慢又多」的。
  • 五类高危:站内搜索、导出与报表、深度翻页、动态缩图、相关推荐与实时统计。
  • 共同特征:用户点一次,源站在后台干好几秒。正常用户少点,攻击者可以连点。
  • 改法优先级从高到低:加缓存、砍请求量、限上限、异步化、最后才考虑加机器。

Q:Nginx 层有哪些低成本加固?

  • 连接数和请求数要双限。只限 QPS 挡不住慢连接,只限连接数挡不住高频短请求。
  • 超时一律收紧。默认的 60 秒超时在攻击期间会白占几十万个连接。
# http 层
limit_conn_zone $binary_remote_addr zone=conn_per_ip:10m;
limit_req_zone  $binary_remote_addr zone=req_per_ip:20m rate=20r/s;

client_header_timeout 10s;
client_body_timeout   10s;
send_timeout          10s;
keepalive_timeout     15s;
reset_timedout_connection on;

open_file_cache          max=20000 inactive=60s;
open_file_cache_valid    60s;
open_file_cache_min_uses 2;

server {
    limit_conn conn_per_ip 30;
    limit_req  zone=req_per_ip burst=40 nodelay;
    limit_req_status  429;
    limit_conn_status 429;

    # 静态资源不参与限流,否则页面直接散架
    location ~* \.(css|js|jpg|png|webp|woff2)$ {
        limit_req  off;
        limit_conn off;
        expires 30d;
        access_log off;
        try_files $uri =404;
    }

    if ($request_method !~ ^(GET|HEAD|POST)$) { return 405; }
}
  • open_file_cache 对静态文件多的站效果明显,能省掉每次请求的 stat 系统调用。
  • access_log 关掉静态资源,不是省磁盘,是省 CPU 和磁盘 IO 争抢。

Q:深度翻页和导出为什么是重灾区?

  • 翻到第一万页时,数据库要扫十万行再丢掉,耗时随页码线性上涨。攻击者只要反复请求最后一页,就能用极低成本压死数据库。
  • 解法是 keyset 分页,用上一页最后一条记录的值做条件,耗时恒定。
-- 坏:越翻越慢
SELECT id,title FROM article ORDER BY id DESC LIMIT 20 OFFSET 100000;

-- 好:keyset 分页,始终走索引
SELECT id,title FROM article WHERE id < 900000 ORDER BY id DESC LIMIT 20;
  • 再补两道硬限制:页码超过 100 直接返回 400,导出超过 5 万行改成异步任务。
  • 导出走队列:请求只负责入队并返回任务号,后台生成好再通知下载。导出接口是 CC 攻击最爱的目标之一,因为它天然又慢又重。

Q:缓存怎么用才真的减负?

  • 优先片段缓存而不是整页缓存。整页缓存遇到登录态、购物车就废,片段缓存可以只缓存不随用户变化的部分。
  • 热点数据放 Redis,同时做三件事:空值也缓存(防穿透)、同一 key 只放一个请求去回源(防击穿)、过期时间加随机偏移(防雪崩)。
  • 反向代理层的缓存只对匿名 GET 生效,带登录 Cookie 的请求必须绕过,否则会把别人的页面发给下一个人。
proxy_cache_path /data/cache levels=1:2 keys_zone=page:200m inactive=10m max_size=8g;

map $http_cookie $skip_cache {
    default 0;
    "~*(PHPSESSID|wordpress_logged_in|token)" 1;
}
map $request_method $skip_method {
    GET     0;
    default 1;
}

location / {
    proxy_cache page;
    proxy_cache_valid 200 5m;
    proxy_cache_bypass $skip_cache $skip_method;
    proxy_no_cache     $skip_cache $skip_method;
    proxy_pass http://backend;
}
  • 缓存的价值要量化:改造后回源 QPS 占比下降多少。占比不降,说明缓存键设计有问题。

Q:数据库连接池和进程数怎么设才不出事?

  • 估算公式:连接数约等于峰值 QPS 乘平均查询耗时(秒)。一千 QPS、单查询 10 毫秒,理论上十个连接就够,留余量给二十到三十。
  • 最常见的坑是乘法陷阱:PHP 进程数设 200,每个进程一个数据库连接,数据库只给 151 个连接上限,攻击一来立刻大面积「连接数超限」。
; php-fpm:按内存算,不是按 CPU 核数算
; max_children = 可用内存 / 单进程常驻内存
pm = dynamic
pm.max_children = 60
pm.start_servers = 10
pm.max_spare_servers = 20
pm.max_requests = 1000
-- MySQL:连接上限与空闲回收
SHOW VARIABLES LIKE 'max_connections';
SET GLOBAL max_connections = 300;
SET GLOBAL wait_timeout = 60;
  • 三层要满足一条不等式:应用进程数 × 每进程连接数 ≤ 数据库连接上限的 80%。

Q:慢请求会不会拖死整个站?

  • 会,而且比流量更致命。超时必须逐层递减,外层永远比内层宽松。
  • 推荐链路:客户端 60 秒,CDN 30 秒,Nginx 20 秒,应用 15 秒,数据库 10 秒。
  • 顺序反了就会出现「上游已经超时放弃,下游还在拼命算」,等于白烧资源。
proxy_connect_timeout 5s;
proxy_send_timeout    20s;
proxy_read_timeout    20s;
fastcgi_connect_timeout 5s;
fastcgi_read_timeout    20s;
  • 再加一道熔断:某个下游连续错误就快速失败,别再排队。max_fails 加 fail_timeout 就是最轻量的版本。

Q:改造完怎么验证真的有效?

  • 用同一个压测脚本、同一个 URL、同一个并发做前后对比,只改一个变量。
  • 看四个数:同 QPS 下的 CPU 占用、P95 延迟、错误率、数据库 QPS。
  • 达标线可以定成:承接上限至少翻倍,或者同 QPS 下 CPU 占用降一半。
  • 别压首页交差。首页有缓存,压出来漂亮,攻击来了照样挂。要压的是登录、搜索、列表这些真正吃资源的路径。

Q:还有什么补充建议?

  • 给非核心功能做一键开关:推荐、评论、实时统计、在线客服,攻击期间先关掉,把资源全留给主流程。
  • 图片走对象存储加 CDN,源站不要动态产图。缩图接口是典型的「一个小请求换几秒 CPU」。
  • 首页和栏目页定时静态化,攻击期间切静态兜底,这是最便宜的降级手段。
  • 日志单独落盘或直接推走,别和网站数据抢同一块盘。
  • 最后一句:防护决定你能不能扛住第一波,源站决定你能扛多久。两条腿都要有,缺一条都站不住。