CC防护开了网站变慢?性能开销与调优实战

很多站长上完CC防护后发现一个怪现象:攻击确实被打下去了,可网站访问速度明显变慢,接口偶发超时。本文用 10 个问答把CC防护的性能开销拆开算,讲清慢在哪一层、哪些参数可以调、哪些改动纯属自己给自己加负担。

Q:开了CC防护之后网站变慢,一定是防护的问题吗?

  • 先分清两种慢。一种是攻击还没停,防护只是把请求挡在了更前面,源站仍然在承压;另一种是攻击已经停了,站还是慢,那才是防护引入的开销。
  • 最简单的判定方法:把请求绕开防护直连源站(内网、hosts 回源、或临时关闭防护层的某条规则),对比两次的首字节时间。差值就是防护本身的成本。
  • 如果差值在 1~5 毫秒,属于正常范围,用户端感知不到;如果差值到几十毫秒甚至上百毫秒,一定是配置方式出了问题,而不是「防护天生就慢」。
  • 常见元凶集中在四个地方:规则全开、日志同步写、限流 zone 太小、每请求都走挑战页——这些大多是自己配出来的,不是必须付的代价。

Q:CC防护的开销具体花在哪几层?

  • 连接层:TLS 握手要做密钥交换和证书链校验,每个新连接都要来一遍。并发一高,握手比业务逻辑还贵。
  • 规则层:每条正则都要对 URI、header、body 扫一遍,CPU 开销约等于「规则数 × 请求数」。规则写得不收敛还会出现回溯爆炸。
  • 计数层:limit_req / limit_conn 走共享内存,key 太复杂、zone 太小都会造成锁争用,表现为某些 worker 特别忙。
  • IO 层:access_log 默认每个请求写一次,磁盘一慢,worker 就被阻塞在 write 上,表现为「CPU 不高但并发上不去」。
  • 应用层:挑战页、验证码、缓存未命中导致的回源。防护把请求放大成更多回源请求,是最容易被忽略的一层。
  • 一句话总结:慢通常慢在 IO 和正则,而不是慢在「拦截」这个判断本身。

Q:为什么平均延迟正常,P99 却翻了一倍?

  • 因为慢的永远是少数请求,被平均值稀释掉了。平均值好看、P99 难看,是防护开销最典型的表现。
  • 长尾来源:大 body 请求走完最贵的规则、正则回溯、共享内存锁等待、日志刷盘抖动、挑战页的额外首跳。
  • 线上定位看两个变量的差值就够了:$request_time 减去 $upstream_response_time。差值大说明时间花在 Nginx 自己身上(规则、限流、日志);差值小而上游时间大,说明是后端慢,跟防护无关。
  • 把耗时分布拉出来看一眼,比猜半天都管用:
# 从访问日志里取第 9 列(request_time)算分位
awk '{print $9}' /var/log/nginx/access.log | sort -n | \
awk '{a[NR]=$1} END {print "P50",a[int(NR*0.5)],"P95",a[int(NR*0.95)],"P99",a[int(NR*0.99)]}'

# Nginx 自身耗时 = 总耗时 - 上游耗时,大于 0.1 秒的挑出来
awk '$9-$10 > 0.1 {print $7, $9, $10}' /var/log/nginx/access.log | head -50

Q:Nginx 限流本身会不会成为瓶颈?怎么配才不拖慢?

  • 会,但原因基本是两个:zone 给太小、key 写太复杂。
  • zone 的经验值是 10m 约可存 16 万个状态(按 IP 做 key 时)。被攻击时活跃 IP 动辄几十万,zone 不够会不断淘汰旧记录,限流形同虚设。
  • key 优先用 $binary_remote_addr,它比 $remote_addr 省内存也快;不要在 key 里拼接多个变量做字符串哈希。
  • 只对需要的 location 开,别全站一个速率。静态资源不需要限流,登录、搜索、下单接口才需要。
  • 超限返回 429 而不是 503,429 语义是「请求太多」,不会被误判成服务器故障。
  • 配置骨架可以直接拿去用:
http {
    # 两个档位:接口严、动态页宽
    limit_req_zone  $binary_remote_addr zone=cc_api:20m rate=5r/s;
    limit_req_zone  $binary_remote_addr zone=cc_dyn:20m rate=2r/s;
    limit_conn_zone $binary_remote_addr zone=conn_ip:10m;
    limit_req_status 429;
    limit_conn_status 429;

    server {
        # 登录、搜索、下单这类接口
        location ^~ /api/ {
            limit_req  zone=cc_api burst=10 nodelay;
            limit_conn conn_ip 20;
        }
        # 动态页面
        location ~ \.php$ {
            limit_req  zone=cc_dyn burst=20 nodelay;
        }
        # 静态资源完全免检,省掉无意义的判断
        location ~* \.(js|css|png|jpg|woff2)$ {
            expires 30d;
        }
    }
}
  • burst 要留连点余量:设成 1 会让真实用户点两下就被限;nodelay 让突发请求立刻放行,而不是排队等,排队反而抬高延迟。

Q:WAF 规则是不是越多越安全?

  • 不是。规则数与 CPU、误报率、排障难度是同时上升的。小站把一套两三千条的通用规则全集打开,收益远小于代价。
  • 三条精简原则:按 location 分级(静态资源免检,只有接口和表单走完整规则)、关掉用不到的规则族(不是 Java 站就别留着大量反序列化规则)、先用观察模式跑一周再决定要不要拦。
  • 规则集的宽松等级(paranoia level)别一上来就拉满,PL1 覆盖了绝大多数真实攻击,PL2 以上误报会明显增加。
  • 有个反直觉的结论:拦掉三成垃圾请求带来的源站减负,通常大于规则本身的算力消耗。所以要做的是精调,不是关掉。
# ModSecurity 观察模式,只记日志不拦,用来评估规则收益
SecRuleEngine DetectionOnly
SecAuditEngine RelevantOnly
SecAuditLog /var/log/modsec/audit.log

# 单请求体大小与正则限制,防止大 body 拖死 worker
SecRequestBodyLimit 13107200
SecRequestBodyNoFilesLimit 131072
SecPcreMatchLimit 10000
SecPcreMatchLimitRecursion 10000

Q:日志和统计会不会把磁盘、CPU 吃满?

  • 会。默认 access_log 每请求写一次,磁盘一慢整个 worker 都被拖住。
  • 三个立刻见效的改法:加 buffer 批量写、只记需要的字段、只记需要记的请求。
  • 代价要提前知道:buffer 模式下进程崩溃可能丢掉最后几秒日志。安全审计要求严格的场景就别开大 buffer,或者把审计日志单独走一条同步链路。
log_format sec '$remote_addr $time_iso8601 "$request_method $uri" $status '
                '$request_time $upstream_response_time $body_bytes_sent';

# 只落 3xx/4xx/5xx 与慢请求,2xx 正常请求不进日志
map $status $loggable {
    ~^[23]  0;
    default 1;
}

access_log /var/log/nginx/access.log sec buffer=64k flush=5s if=$loggable;
open_log_file_cache max=1000 inactive=60s valid=60s;
  • 另外两个细节:动态响应上不要开 gzip,log_format 里绝对不要放请求体。

Q:挑战页、人机验证怎么开才不影响体验?

  • 挑战页的成本很实在:首次访问多一个往返,再加载几十到几百 KB 脚本,弱网下感受明显。
  • 所以只对可疑路径或可疑会话开,不要全站默认挑战。全站挑战相当于让所有真实用户替攻击者买单。
  • 通过后发一个短有效期的凭证 cookie(比如 30 分钟),避免同一用户反复被挑战。
  • 挑战页本身静态化并走 CDN,别每次动态生成。返回 403 或 429 时要配好 error_page,避免 302 死循环。
  • 盯一个指标:挑战通过率。正常应在 95% 以上,掉到 90% 以下说明规则太严,误伤了真实用户。

Q:连接和 TLS 层面还能省出多少?

  • 这些都是「配一次长期受益」的项,能省出不少。
  • 开 session 复用(session cache 或 ticket),让回访用户不必重新完整握手;开 OCSP stapling,省掉客户端自己去查证书状态的那一跳。
  • 到上游用 keepalive 连接池,否则每个请求都新建一次 TCP,开销比想象中大。
  • worker 进程数对齐 CPU 核数,worker_connections 与 ulimit 一起调,只改一边等于没改。
upstream backend {
    server 127.0.0.1:8080;
    keepalive 64;              # 到上游的长连接池
}
server {
    ssl_session_cache   shared:SSL:50m;
    ssl_session_timeout 1h;
    ssl_stapling        on;
    ssl_stapling_verify on;
    ssl_buffer_size     4k;    # 小响应首字节更快
    location / {
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_pass http://backend;
    }
}

Q:怎么量化验证防护到底带来多少开销?

  • 做 A/B 对比,别凭感觉。同一个 URL、同样并发,规则开与关各压一次,看 RPS 和 P99 的差值。
  • 线上做灰度更准:抽 10% 流量走新规则,对比两组的 P99、5xx 比例、限流命中率。
  • 压测时同时看三个系统指标:worker 线程 CPU(top -H)、软中断(mpstat)、磁盘 await(iostat)。哪个先到瓶颈,优化就做在那里。
wrk -t4 -c200 -d30s --latency https://www.example.com/api/list
# 关注 Latency Distribution 的 99% 那一行

mpstat -P ALL 1 5          # 看软中断是否集中在某个核
iostat -x 1 5              # 看 %util 与 await
pidstat -w -p $(cat /run/nginx.pid) 1   # 看上下文切换是否异常

Q:还有什么补充建议?

  • 调优顺序别搞反:先定位哪一层慢(绕开防护对比)→ 精简规则 → 调 IO 与参数 → 最后才加机器。上来就加配置,多半把钱花在了错的地方。
  • 不要为了「快」把防护整个关掉。攻击期间源站被打挂的损失,比几毫秒延迟大几个数量级。
  • 四个常看的数:限流命中率、429 比例、源站 5xx、P99。这四个指标稳定,说明防护的开销是可控的。
  • 最后一句:防护开销应该是可解释的成本,而不是一个说不清的黑箱。说不清的时候,通常是配置没读懂,不是产品不行。