
我接手过不少 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 个 workerCPU 又闲着不干活。最稳妥的做法是让 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-FPMworker_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/xmlrss application/rssxml image/svgxml 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?v20250101。否则浏览器会一直用旧缓存发布新版本等于没发布。这个思路在构建流程里就要规划好不是运维单方面能解决的。对于 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 字段直接输入网址访问或浏览器地址栏打开的场景blockedReferer 字段存在但被代理或防火墙去掉了值只剩一个空值的场景*.你的域名.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/xmlrss image/svgxml 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 压测工具与关键指标解读配置生效之后压测是验证性能优化成果的最后一步。我用 abApacheBench比较多简单直接不需要额外安装ab -n 50000 -c 1000 http://你的服务器地址/这条命令表示 1000 个并发连接总共发送 50000 个请求。压测时重点看几个指标Requests per second每秒请求数越高越好Time per requestmean平均每个请求的耗时越低越好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 层面的优化空间此时已经很有限了。