我接手过不少 Nginx 调优和防盗链的需求,但印象最深的是一次凌晨两点的线上事故。客户网站流量突然暴涨,带宽被打满,服务器负载直线飙升,远程连上去看一眼 Nginx 访问日志,全是外部域名带过来的图片热链请求,几十个来源 IP 轮着刷。那一刻你就会明白,服务优化和防盗链从来不是两件独立的事——性能调得再好,也顶不住无意义的流量把出口带宽吃光。
所以这篇内容我把两件事放在一起讲:先解决 Nginx 本身怎么压榨出更好的性能,再解决怎么把不该进的流量挡在门外。文章会覆盖工作进程参数、静态资源处理、压缩策略、浏览器缓存、Referer 防盗链的完整配置和踩坑点,最后给一套可以直接抄作业的配置参考,以及上线前必做的验证方法。
1. 先把 Nginx 的工作模型调到最优——进程与连接参数
Nginx 之所以能在高并发场景下扛住压力,核心在于它的事件驱动架构和 master-worker 进程模型。但默认配置只是“能跑”,远不是“跑得好”。服务优化的第一步,就是把 worker 进程数量、连接数上限、事件处理模型这些底层参数根据你的服务器实际情况重新设定。
1.1 worker_processes 与 worker_connections 怎么配才合理
先说 worker_processes,这个参数决定 Nginx 启动几个 worker 进程。很多人直接抄网上的配置写成worker_processes 8;,如果你的服务器只有 4 核,那就白白浪费了一半进程切换的开销;反过来如果是 32 核的机器只配了 4 个 worker,CPU 又闲着不干活。
最稳妥的做法是让 worker 进程数等于服务器 CPU 核心数。可以用一条命令查出来:
grep -c processor /proc/cpuinfo有些生产环境里我也会看到worker_processes auto;这种写法,Nginx 1.2.5 以上版本支持,它会自动探测可用 CPU 核心数并启动对应数量的 worker。实测下来效果跟手动指定一致,但要注意一点:如果你打算用worker_cpu_affinity做进程绑核,就不要用 auto,因为绑核需要明确知道每个 worker 的编号。
接下来是worker_connections,这个参数定义每个 worker 进程能同时打开的最大连接数。这里有一个很多人都会算错的公式:Nginx 理论上最大并发连接数 = worker_processes × worker_connections。但如果你在配置里同时开启了反向代理,那么还要除以 2 或 4,因为代理模式下每个客户端请求会占用一个前端连接和一个后端连接。
我的经验值:如果是纯静态站点或者直接转发给 PHP-FPM,worker_connections给 1024 起步;如果服务器内存充裕、压力大,可以调到 4096 甚至 8192。但别盲目往大了调,因为每个连接都会占用内存,这个参数越大,内存开销越高。默认每个连接大约占用 2.5KB 左右的内存,你可以根据free -m看空闲内存来决定。
1.2 事件模型与内核参数:别让系统层拖后腿
Nginx 默认的事件模型在不同系统上不一样,Linux 下通常会自动选择 epoll。我建议你在events块里显式声明一下,避免某些编译版本或容器环境下回退到低效模型:
events { use epoll; worker_connections 4096; multi_accept on; }multi_accept on;这个参数很多人不理解,它表示每个 worker 进程是否一次性接受多个新连接。默认是 off,就是一次只 accept 一个,处理完再去拿下一个。在高并发短连接的场景下,打开 multi_accept 能明显降低连接排队时间。但反过来,如果是长连接为主的场景,这个参数的效果就没那么明显,开着也不会有什么副作用,我一般建议直接打开。
还有两个内核层面的参数很容易被忽略,但它们对高并发的影响很大:
net.core.somaxconn = 65535 net.ipv4.tcp_max_syn_backlog = 65535somaxconn控制 socket 监听队列的上限。Nginx 作为 Web 服务器时,如果并发连接瞬间涌入,内核的 accept 队列满了之后新连接会被直接丢弃,客户端看到的就表现为连接超时或重置。默认值 128 对生产环境来说太低,建议调高。修改/etc/sysctl.conf后执行sysctl -p生效。
1.3 keepalive 与连接复用的正确姿势
HTTP 1.1 默认支持 keepalive,也就是同一个 TCP 连接上可以发送多个 HTTP 请求。这个机制对于减少握手开销非常重要,尤其是 HTTPS 场景,一次 TLS 握手就要消耗好几个 RTT,连接复用能省掉大量延迟。
Nginx 里的 keepalive 配置涉及两个位置。一个是http块里的:
keepalive_timeout 65; keepalive_requests 1000;keepalive_timeout是连接空闲多少秒后关闭,65 是个比较中庸的值。对于图片、JS、CSS 这类小体积资源密集的页面,可以适当缩短到 30 左右,避免大量空闲连接占用文件描述符。keepalive_requests是单个连接最多处理多少个请求后关闭,默认 100 有点低,高并发页面单连接请求数很容易超过这个值,建议设到 1000 左右。
另一个位置是 upstream 后端的长连接配置,这个经常被漏掉。如果你用 Nginx 做反向代理,默认情况下 Nginx 和后端服务器之间的连接是不复用的,每个请求都会新建 TCP 连接,代理层加后端层就双重握手,延迟直接翻倍。加上这段配置,情况会好很多:
upstream backend { server 127.0.0.1:9000; keepalive 32; } server { location / { proxy_pass http://backend; proxy_http_version 1.1; proxy_set_header Connection ""; } }注意三个要点:keepalive 32是每个 worker 进程和后端建立的长连接数;proxy_http_version 1.1是必须的,因为 HTTP 1.0 没有 keepalive 的概念;proxy_set_header Connection ""用来清空请求头里的 Connection 字段,否则后端可能误判连接处理方式。这三个缺一个,upstream 长连接都是失效状态。
2. 静态资源处理与压缩:性能瓶颈经常藏在这里
对大部分网站来说,用户访问时消耗最多的不是 HTML 文档本身,而是页面里引用的图片、CSS、JavaScript、字体文件。页面里二三十个静态资源是常态,每个资源一次 HTTP 请求,如果这些资源响应慢、体积大,页面加载速度怎么可能上得去。
2.1 gzip 压缩的完整配置与常见误配
gzip 是我见过收益最高、却最容易被配错的优化项。绝大多数文本类资源经过 gzip 压缩后体积能减少 60% 到 80%,传输时间大幅缩短。但有一个非常典型的问题:很多人只写三行配置就以为够了:
gzip on; gzip_min_length 1k; gzip_types text/css;结果发现 CSS 压缩了,JS 没压缩,字体没压缩,JSON 接口返回没压缩,甚至某些已经压缩过格式又被二次压缩浪费时间。看一下我整理后的完整配置:
gzip on; gzip_min_length 1k; gzip_comp_level 5; gzip_vary on; gzip_buffers 16 8k; gzip_http_version 1.1; gzip_types text/plain text/css text/xml application/json application/javascript application/x-javascript application/xml application/xml+rss application/rss+xml image/svg+xml font/ttf font/otf font/woff;gzip_comp_level是最容易踩坑的地方,压缩级别 1 到 9,数值越大压缩率越高,但 CPU 消耗也越大。实测下来级别 5 和级别 9 的压缩率差距通常不到 3%,但 CPU 占用差距可能达到一倍以上。生产环境我建议 5,个别内网服务带宽很贵 CPU 很闲的可以考虑 6。
gzip_min_length 1k表示响应体小于 1KB 不压缩,因为压缩这类小文件消耗的时间可能比省下的传输时间还多,得不偿失。
还有一点要提的是,gzip_types里不要写text/html。不是因为不能写,而是因为 Nginx 默认就会对 text/html 做压缩,不管你有没有写进 gzip_types,所以显式写上去反而容易让后来的人误以为“没写就不压缩”,我实际排查问题时就遇到过这种误会。
2.2 浏览器缓存策略:expires 与 Cache-Control
服务端压缩解决了传输体积的问题,浏览器缓存解决了重复请求的问题。两者配合,静态资源的加载体验才会真正起来。
先看一下基础的 expires 配置:
location ~* \.(jpg|jpeg|png|gif|ico|css|js|woff|svg)$ { expires 30d; add_header Cache-Control "public, immutable"; }图片、字体这类变化频率极低的资源,缓存 30 天是合理选择。Cache-Control: immutable是给浏览器的一个强信号,表示这个资源在过期前绝对不会变,浏览器可以直接从本地加载,连重新验证的请求都不会发。
但这里有个关键的坑:只要你给了很长的缓存时间,将来更新文件时就必须改文件名。比如app.js改成app.2c3f9a.js,或者在 URL 上加版本号参数app.js?v=20250101。否则浏览器会一直用旧缓存,发布新版本等于没发布。这个思路在构建流程里就要规划好,不是运维单方面能解决的。
对于 HTML 文档本身,策略正好相反,建议no-cache,也就是每次都要回源验证,但验证通过后可以使用缓存的内容:
location ~* \.html?$ { add_header Cache-Control "no-cache, must-revalidate"; }2.3 location 匹配优先级与静态资源分离
很多人在配置静态资源时会把 location 写得特别长、特别乱。其实 Nginx 的 location 匹配规则是有优先级顺序的,理解了这个顺序,配置才能准确命中预期:
- 精确匹配
= - 前缀匹配
^~ - 正则匹配
~或~* - 普通前缀匹配
我把静态资源分离的配置写在下面,这是一个实际生产环境验证过的版本:
location = /favicon.ico { log_not_found off; access_log off; expires 30d; } location ^~ /static/ { alias /data/webapp/static/; access_log off; expires 30d; add_header Cache-Control "public, immutable"; } location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?|ttf|otf)$ { root /data/webapp/dist; expires 30d; add_header Cache-Control "public, immutable"; access_log off; }^~ /static/的优先级非常高,只要请求路径以/static/开头就会直接命中,不再继续匹配后面的正则。所以如果你的项目里静态资源都集中在某个目录下,用这个方式最省心,也让后面的正则只处理散落在其他位置的静态文件。
这里提醒一个很多人忽略的点:access_log off不是可有可无的优化,而是实打实的性能优化。静态资源请求量极大,每条请求都写日志会带来大量磁盘 IO,在高峰期占用的资源非常可观。如果确实需要统计分析静态资源的访问情况,建议单独用一份日志来记录,而不是跟业务日志混在一起。
3. 防盗链:从 Referer 校验到完整封堵方案
防盗链这件事,说到底是给资源访问加一道“门禁”。最常见的场景是:你辛苦做的图片、视频、文件被其他网站直接引用,他们网站的页面加载的是你服务器上的资源,消耗的是你的带宽和服务器资源。这在技术上叫“盗链”,本质上就是白嫖你的基础设施。
3.1 盗链是怎么发生的,Referer 校验的原理是什么
浏览器在请求一个资源时,会在 HTTP 请求头里带上Referer字段,告诉服务器“我是从哪个页面跳过来的”。比如用户访问a.com/news/1.html,这个页面里引用了img.你的域名.com/pic.jpg,那么浏览器向你的图片服务器发送请求时,Request Headers 里就会带Referer: https://a.com/news/1.html。
防盗链的基本逻辑就是:服务器检查这个 Referer,如果它指向的不是你的域名或者你信任的域名,就拒绝返回资源。Nginx 里用valid_referers模块实现,配置非常简单直接:
location ~* \.(gif|jpg|jpeg|png|bmp|swf|webp|ico)$ { valid_referers none blocked *.你的域名.com 你的域名.com; if ($invalid_referer) { return 403; } }这里解释一下valid_referers后面几个参数的含义:
none:请求头里没有 Referer 字段,直接输入网址访问或浏览器地址栏打开的场景blocked:Referer 字段存在但被代理或防火墙去掉了值,只剩一个空值的场景*.你的域名.com:匹配你的主域名及所有子域名你的域名.com:匹配根域名自身
如果实际 Referer 不在这些合法范围内,$invalid_referer变量就会变成 1,触发return 403。
3.2 完整配置示例:图片、文件、目录级别的防盗链
单纯的 403 显得有点生硬,而且对于真实的网站来说,直接返回 403 反而会让访客看到难看的错误页。更合理的做法是:校验失败时返回一张提示图片,比如一张“图片来自 XX 网站”的占位图,既阻止了盗链,又不影响自身用户体验。
location ~* \.(gif|jpg|jpeg|png|bmp|swf|webp)$ { valid_referers none blocked *.你的域名.com 你的域名.com; if ($invalid_referer) { rewrite ^/ /deny.png break; } root /data/webapp/img; expires 30d; access_log off; }注意这里我用了rewrite ^/ /deny.png break;而不是return 403。rewrite 到/deny.png后,Nginx 会重新在当前 root 目录下找这个文件返回给客户端。这样盗链者页面上的图片位置显示的是一张提示图,而不是破碎的图标,体感差别还是很大的。
如果你有视频、PDF、压缩包这类资源需要保护,配置思路基本相同,只是把文件后缀换成对应的类型:
location ~* \.(mp4|avi|mkv|pdf|zip|rar|7z|apk)$ { valid_referers none blocked *.你的域名.com 你的域名.com; if ($invalid_referer) { return 403; } root /data/webapp/files; expires 7d; }文件类资源建议直接 403,因为这类资源体积大、消耗带宽高,给盗链者一张提示图反而是浪费流量。
3.3 白名单规则:搜索引擎、CDN 和其他合作域名的放行
实际部署防盗链后,很快会遇到一个不算问题的问题:网站的收录和分享变差了。原因是很多搜索引擎的爬虫(百度、谷歌、必应)在抓取页面时,Referer 字段通常是空的或者被内部处理掉了,如果valid_referers里只配置了none blocked和自己的域名,爬虫访问图片时会被误伤。
解决办法是把搜索引擎的域名加入白名单:
valid_referers none blocked *.你的域名.com 你的域名.com *.baidu.com *.google.com *.bing.com *.sogou.com *.sm.cn;如果你用过 CDN 加速,还要考虑 CDN 回源的情况。CDN 节点回源请求的 Referer 行为取决于 CDN 服务商的实现,有些会透传原始请求的 Referer,有些会清空。如果发现 CDN 回源后被防盗链拦截,大概率就是 Referer 被清空或者替换成了 CDN 域名。此时有两种解法:一是把 CDN 服务商的域名加入白名单,二是通过 CDN 的回源鉴权机制来绕过防盗链,具体要看你的服务商支持哪种。
另外还有一个重要提醒:valid_referers本身是正则匹配,配置里放域名时不用加太多通配符,*.你的域名.com已经能匹配你的域名.com外的所有子域名,但注意它不会匹配主域名本身,所以主域名要单独写。
3.4 防盗链的边界:为什么说它“防君子不防小人”
我必须把话说透:Referer 校验是 HTTP 明文时代的手段,它只能拦截那些“没做伪装”的普通盗链者。真正有技术能力的人可以伪造 Referer 头,写几行代码就能绕过这个限制。
所以防盗链的定位应该是“提高盗链成本”而不是“完全杜绝”。通过 Referer 校验,你拦截掉了绝大多数不明所以的直接引用者,这就已经达成了 90% 的目标。如果你的资源价值很高,需要更严格的保护,就要考虑升级方案:Nginx 的secure_link模块可以做带时效的签名 URL,或者用accesskey模块做更复杂的校验,这些手段会把防盗链从“检查来源”升级为“验证身份”,防盗能力完全不在一个量级。
4. 一套可落地的完整配置参考
前面讲了很多参数和原理,这里给一份完整的配置。这份配置是从我自己维护的一台生产服务器上精简下来的,兼容性比较好,直接保存到nginx.conf里,再按自己的域名、路径、目录调整即可。
user www-data; worker_processes auto; worker_rlimit_nofile 65535; error_log /var/log/nginx/error.log warn; pid /var/run/nginx.pid; events { use epoll; worker_connections 4096; multi_accept on; } http { include /etc/nginx/mime.types; default_type application/octet-stream; log_format main '$remote_addr - $remote_user [$time_local] "$request" ' '$status $body_bytes_sent "$http_referer" ' '"$http_user_agent" "$http_x_forwarded_for" ' 'rt=$request_time uct=$upstream_connect_time'; access_log /var/log/nginx/access.log main; server_tokens off; sendfile on; tcp_nopush on; tcp_nodelay on; keepalive_timeout 30; keepalive_requests 1000; gzip on; gzip_min_length 1k; gzip_comp_level 5; gzip_vary on; gzip_types text/plain text/css text/xml application/json application/javascript application/x-javascript application/xml application/xml+rss image/svg+xml font/ttf font/otf font/woff; server { listen 80; server_name 你的域名.com www.你的域名.com; root /data/webapp/dist; index index.html; location = /favicon.ico { log_not_found off; access_log off; expires 30d; } location ^~ /static/ { alias /data/webapp/static/; access_log off; expires 30d; add_header Cache-Control "public, immutable"; } location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?|ttf|otf)$ { expires 30d; add_header Cache-Control "public, immutable"; access_log off; } location ~* \.(gif|jpg|jpeg|png|webp)$ { valid_referers none blocked *.你的域名.com 你的域名.com *.baidu.com *.google.com *.bing.com; if ($invalid_referer) { return 403; } expires 30d; access_log off; } location / { try_files $uri $uri/ /index.html; } location ~ \.php$ { fastcgi_pass 127.0.0.1:9000; fastcgi_index index.php; include fastcgi_params; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; } location ~* \.(mp4|zip|rar|pdf|apk)$ { valid_referers none blocked *.你的域名.com 你的域名.com; if ($invalid_referer) { return 403; } root /data/webapp/files; expires 7d; access_log off; } } }关于这份配置,有几个细节再强调一下:
worker_rlimit_nofile这个参数设置每个 worker 进程能打开的最大文件描述符数量。很多服务器默认只有 1024,高并发下连接数一旦上来就会报 “too many open files”,这个参数要配合系统层的ulimit -n一起调。
server_tokens off关闭版本号显示。很多扫描工具会通过 Server 响应头里的版本号寻找已知漏洞发起攻击,关闭显示可以让 Nginx 不在响应头里暴露具体版本,降低被针对性攻击的风险。
try_files $uri $uri/ /index.html是 SPA(单页应用)场景的标准写法。前端路由下的路径没有对应的物理文件,就都回退到index.html,交给前端路由处理。
PHP 那段配置是给动态接口准备的,如果你用的是前后端分离架构,后端接口走反向代理而不是 PHP-FPM,那这一段用proxy_pass替换掉即可。
5. 上线前的验证与压测,避免配置“看起来对”
配置写完了,不代表工作结束了。我见过太多配置“看起来没问题”但实际不生效的情况,所以上线前这几项验证必须要做。
5.1 语法检查与配置文件重载
修改完配置后先做语法检查,确认没有低级错误:
nginx -t这个命令会输出配置文件的语法检查结果。如果报错,会明确指出哪个文件的哪一行有问题,改完后再跑一次,直到输出syntax is ok和test is successful。
然后平滑重载配置:
nginx -s reload平滑重载的含义是:master 进程重新加载配置并启动新的 worker 进程,旧 worker 处理完手上的连接后才退出。这个过程不会中断正在进行的请求,所以完全可以白天操作。注意不要使用nginx -s stop或nginx -s quit,这两个命令是用来停服务的,不是用来改配置的。
5.2 用 curl 模拟盗链请求验证防盗链是否生效
验证防盗链,我用 curl 模拟两个请求,对比返回结果就知道配置生效没有。
模拟一个合法来源的请求:
curl -I -H "Referer: https://你的域名.com/page.html" http://你的服务器地址/image/logo.png预期返回HTTP/1.1 200 OK。
模拟一个盗链请求:
curl -I -H "Referer: https://evil.com/page.html" http://你的服务器地址/image/logo.png预期返回HTTP/1.1 403 Forbidden。
模拟一个不带 Referer 的直接访问:
curl -I http://你的服务器地址/image/logo.png如果你配置了valid_referers none,这个请求应该返回 200;如果没配置 none,会返回 403。这就要看你的业务需求了——有些图片站点希望手机端 App 等场景能直接引用图片不带 Referer,那就要保留 none;有些严格保护的场景会只允许站内引用,那就去掉 none。
5.3 压测工具与关键指标解读
配置生效之后,压测是验证性能优化成果的最后一步。我用 ab(ApacheBench)比较多,简单直接不需要额外安装:
ab -n 50000 -c 1000 http://你的服务器地址/这条命令表示 1000 个并发连接,总共发送 50000 个请求。压测时重点看几个指标:
Requests per second:每秒请求数,越高越好Time per request(mean):平均每个请求的耗时,越低越好Failed requests:失败请求数,必须为 0Transfer rate:每秒传输的字节数,可以通过这个值观察带宽消耗
我更推荐观察的其实是压测期间服务器的 CPU 和负载状态,用top看一眼 Nginx worker 进程的 CPU 占用。如果 CPU 占用很低但吞吐上不去,说明瓶颈在网络带宽或者后端服务;如果 CPU 占用很高但吞吐也不高,说明配置可能有冗余逻辑或者系统资源不足。这个判断逻辑比单纯看压测数字更有价值。
我提醒一句,压测要在低峰期做,避免影响真实用户。而且 ab 是从单机发起的,本机压本机的情况下网络开销极小,测出来的数字会比真实生产环境略好,看趋势就够了。
5.4 一个容易被忽略的验证点:确认 gzip 真的生效
配置了 gzip 之后,很多人从不验证,以为写上了就生效。其实很容易因为gzip_types写错导致某些类型的文件完全没有压缩。验证方法:
curl -I -H "Accept-Encoding: gzip" http://你的服务器地址/app.js看响应头里有没有Content-Encoding: gzip。如果没有,检查这个文件对应的 Content-Type 是否被包含在 gzip_types 里。最常见的翻车现场是:JS 文件的 Content-Type 是application/javascript,但 gzip_types 里只写了text/javascript或application/x-javascript,导致 JS 始终没有压缩。Nginx 里 MIME 类型定义以/etc/nginx/mime.types文件为准,配置前先查一下这个文件里 JS 对应的具体类型。
我在实际配置中踩过一次类似的坑,压测的时候发现静态资源传输量一直下不来,排查半天才意识到是字体文件的 MIME 类型写错了,woff2 被当成了application/octet-stream,压缩没生效。后来把font/woff2、font/woff都加进 gzip_types 才解决。这类问题用上面这条 curl 命令一验证就会暴露,所以强烈建议每改一次配置就验证一次。
最后说几句
这套方案我在自己维护的服务器和帮朋友处理的站点上反复验证过。跟那些花哨的第三方模块、付费防护方案相比,Nginx 自带的这些能力已经覆盖了绝大多数场景:进程模型调优把服务器硬件吃满,gzip 和缓存把传输量降下来,防盗链把无效流量挡在门外,这三个动作全部做完,一个小型 VPS 支撑每天几十万 PV 的问题不大。
有一点想提醒你:配置优化是一个持续的过程,不是改完就一劳永逸。网站流量涨了、业务逻辑变了,原来的参数可能就不再适合当前状态。建议每次大版本发布后重新看一眼 Nginx 的访问日志和错误日志,观察有没有异常的大流量来源,再决定要不要调整限流或防盗链策略。顺手说一下日志的时间格式,如果你发现日志里请求处理耗时普遍偏高,优先检查后端响应时间,Nginx 层面的优化空间此时已经很有限了。