为什么你的网站该做动静分离
很多个人站点的 Nginx 配置,是把所有请求都一股脑丢给后端:无论是博客首页这种动态页面,还是 logo.png、style.css、app.js 这类永远不变的静态文件,统统 proxy_pass 给 PHP-FPM 或者 Node 进程。这种「一锅端」的写法在流量小的时候看不出问题,但一旦有爬虫、有 CDN 回源、或者被恶意刷量,静态资源的请求量会瞬间把后端打满——几百个图片请求就能占光 PHP 进程,动态页面反而没人处理,全站卡死。
动静分离的核心思想很简单:静态资源不经过后端应用,由 Nginx 直接返回,让动态请求和静态请求走两条互不影响的路径。Nginx 返回一个磁盘上的静态文件是微秒级的,而经过 PHP 处理再返回则是毫秒级起步。把静态资源从后端剥离,带来三个直接好处:后端进程数可以留给真正的动态请求,静态资源的吞吐能力提升一个数量级,同时静态资源还可以愉快地做长缓存、上 CDN,而动态页面保持实时。
更进一步的玩法是「静态资源独立域名」——用一个专门的子域名(如 static.example.com)来承载 CSS、JS、图片、字体。这不只是为了好看,它有两个实打实的技术收益:第一,独立域名下可以完全不发送 Cookie,每个静态请求都能省掉 Cookie 头的传输开销;第二,浏览器对同一域名的并发连接数有限制(HTTP/1.1 下通常是 6 个),拆分域名能突破这个限制,加快页面加载。下面就把这套架构完整落地一遍。
第一步:按扩展名分流到本地目录
先在最基础的层面做动静分离。假设站点根目录是 /home/wwwroot/www.example.com,静态资源放在 public/static/ 下。用 location 的扩展名匹配,把静态请求从后端摘出来:
server {
listen 80;
server_name www.example.com;
root /home/wwwroot/www.example.com;
# 静态资源:Nginx 直接读磁盘,绝不进后端
location ~* \.(css|js|png|jpg|jpeg|gif|webp|svg|ico|woff2?|ttf|eot|mp4)$ {
expires 30d;
add_header Cache-Control "public, max-age=2592000, immutable";
access_log off;
try_files $uri =404;
}
# 动态请求才转发给 PHP-FPM
location ~ \.php$ {
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
location / {
try_files $uri $uri/ /index.php?$query_string;
}
}几个要点。第一,location ~* 是大小写不敏感的正则匹配,比 location /static/ 这种前缀匹配更能覆盖「静态文件散落在各处」的老站点。第二,expires 30d 配合 Cache-Control immutable 告诉浏览器这个文件在 30 天内绝对不变,可以直接用本地缓存、连 304 协商都省了——前提是你的静态文件带指纹(比如 app.a1b2c3.js),否则更新后用户拿到的还是旧文件。第三,access_log off 关掉静态资源的访问日志,能省下大量磁盘 IO 和日志体积,图片多的站点尤其明显。第四,try_files $uri =404; 防止静态 location 匹配到不存在的文件后又 fallback 到首页,避免产生奇怪的 200 响应污染 SEO。
这里有个必踩的坑:location 的匹配优先级。Nginx 先匹配前缀 location(= 精确 > ^~ 前缀 > 普通前缀),记住最长前缀后,再按顺序检查正则 location,第一个匹配的正则胜出;只有正则全都不匹配时,才用之前记住的最长前缀。所以如果你有一个 location ^~ /uploads/,它就会跳过后面所有正则——这正好可以用来保证用户上传目录里的文件一定按静态处理,不会被后面的 \.php$ 正则误匹配导致「上传的图片被当成脚本执行」这种经典漏洞。
第二步:静态资源独立域名 + 无 Cookie
基础分流做完后,上独立域名。新建一个 server 块,专门服务静态资源:
server {
listen 80;
server_name static.example.com;
# 静态站点的资源实际位置(可以是同一台机器的另一个目录)
root /home/wwwroot/static.example.com;
# 关键:不给静态域名下发任何 Cookie
# 用 add_header 清掉上游可能设置的 Set-Cookie
proxy_hide_header Set-Cookie; # 仅当有代理时
location / {
expires 365d;
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Access-Control-Allow-Origin "https://www.example.com";
access_log off;
tcp_nodelay on;
try_files $uri =404;
}
}为什么要关心 Cookie?因为 HTTP/1.1 下,每个请求头里的 Cookie 都是实打实的字节。一个站点如果会话 Cookie 加上各种统计、CSRF Token,动辄几百字节到 1KB 以上;一个页面加载几十个静态资源,光是重复发送这些 Cookie 就是几十 KB 的额外流量,在弱网环境下尤其拖慢首字节时间。静态子域名天然不共享主域的 Cookie(Cookie 默认按域隔离),再配合 add_header Cache-Control,就能做到每个静态请求只有几十字节的请求头。
如果你的静态文件需要被主域通过 JavaScript 或字体加载(会受同源策略约束),记得加上 CORS 头 Access-Control-Allow-Origin,指定为你的主域。字体文件(woff2)尤其容易撞上 CORS,跨域加载会静默失败、页面字体闪一下回退到系统字体,排查时往往一头雾水。
配合前端改造,把所有静态资源的引用从 /static/app.js 改成 https://static.example.com/app.js。这一步尽量用构建工具(打包时注入的 publicPath)统一替换,别手工全局查找替换,否则漏掉几处引用就会出现 404 破图。改完先在测试环境跑一遍 Lighthouse 或浏览器控制台,确认没有 CORS 和 404 报错再上生产。
第三步:让 Nginx 高效地吐静态文件
静态资源剥离到 Nginx 后,还要把 Nginx 自身的静态文件发送性能调到位。这里最核心的是零拷贝和网络包的发送时机,配置集中在 http 块:
http {
sendfile on; # 内核态零拷贝,文件直接从页缓存送到网卡
tcp_nopush on; # 与 sendfile 配合,凑满一个 MSS 再发,减少报文数
tcp_nodelay on; # 长连接下关闭 Nagle 算法,降低小文件延迟
keepalive_timeout 65;
keepalive_requests 1000;
# 打开文件描述符缓存,减少 stat 系统调用
open_file_cache max=10000 inactive=30s;
open_file_cache_valid 60s;
open_file_cache_min_uses 2;
open_file_cache_errors on;
}sendfile on 是静态文件服务的性能命门:传统方式是内核把文件读到用户态再写回内核,零拷贝让数据在内核内部直接从页缓存流向 socket,省掉两次内存拷贝和两次上下文切换。tcp_nopush 和 tcp_nodelay 看似矛盾,其实各管一段:tcp_nopush 负责在发送响应头和文件正文时把包凑满再发(提升吞吐),tcp_nodelay 负责在长连接的空闲时刻立即发出小包(降低延迟),Nginx 会自动在不同阶段切换,两个都开即可。
open_file_cache 是另一个常被忽视的优化。默认情况下,Nginx 每次处理一个静态请求都要对文件做一次 stat 系统调用确认存在性和大小;open_file_cache 把结果缓存起来,对拥有成千上万小文件的站点(比如各种图片、图标)能显著降低系统调用开销,尤其在被小文件请求扫荡时(爬虫、404 探测)效果拔群。
第四步:接入 CDN 与版本控制
动静分离到位后,接 CDN 就顺理成章了:把 static.example.com 这个子域直接 CNAME 到 CDN,缓存 TTL 设长(比如一年,因为文件名带指纹),让绝大多数静态请求在 CDN 边缘就返回,根本不到你的源站。动态域名 www.example.com 通常不缓存或只做短缓存甚至直连。这样一来,源站的带宽和负载主要留给动态请求,静态流量全被 CDN 兜住,成本也下来了。
要接 CDN,必须在静态域名下拿到可长时间强缓存的 URL,也就是「文件指纹」。做法是:每次构建时把文件内容的哈希写进文件名,如 app.7f3a9c.js,同时生成一份映射表让 HTML 引用新文件名。文件内容一变,文件名就变,URL 也就变了,浏览器和 CDN 自然会重新拉取。这样就能放心地把 Cache-Control 设成 max-age=31536000, immutable,既不用怕用户拿到旧文件,也不用在发布后手动刷新 CDN。
反过来,如果静态文件全走同一个固定 URL(app.js 永远不变),你就永远不敢设长缓存,一发布就得等 CDN 缓存过期或手动刷,用户体验和运维都很别扭。这就是为什么「动静分离」和「文件名指纹」几乎是绑在一起谈的——分离是为了缓存,缓存的前提是 URL 可寻址。
验证与排查
配置改完,用 curl 从响应头确认静态资源真的走了本地、带了正确缓存头:
# 检查静态资源的缓存头与响应者
curl -sI "https://static.example.com/app.7f3a9c.js" | grep -iE "cache-control|expires|server|content-length"
# 期望:
# Cache-Control: public, max-age=31536000, immutable
# Expires: <一年后的日期>
# 且不应带 Set-Cookie 头确认没有 Set-Cookie 很关键——如果静态子域名还在下发 Cookie,你的「无 Cookie 域名」优化就白做了。一个常见原因是静态域名和主域名配置在同一个 server_name 下,或者上游应用对静态路径也设置了 Cookie,视场景用 proxy_hide_header Set-Cookie 或 fastcgi_hide_header 清掉。
再检查一下动静请求是不是真的分开了。开一点访问日志,分别看静态和动态路径的响应时间:静态资源的 $request_time 应该普遍在个位数毫秒,动态页面则在几十毫秒以上。如果发现静态资源的响应时间也偏高,多半是没匹配到静态 location、回落到后端去处理了,回去查 location 的匹配优先级和正则有没有写对。
最后提醒一个安全边界:动静分离不等于「静态目录可以随便写」。用户上传目录务必配成静态处理后禁止执行脚本(location ^~ /uploads/ { ... } 且不匹配 php 正则),否则攻击者上传一个伪装成图片的 .php 文件,就可能被当成脚本执行,直接拿到 webshell。分离是为了性能,但性能优化的同时守住安全底线,才算真的把这套架构用对。
小结
动静分离是个人站长性价比极高的一步优化:不需要换硬件、不需要重构应用,只靠 Nginx 配置的调整,就能把静态资源和动态请求解耦,让后端从容应对动态流量、让静态资源享受长缓存和 CDN,最终体现为更快的加载速度、更低的源站负载、更省钱的带宽。落地路径可以分三步走:先用扩展名 location 把静态请求从后端摘出来,再拆出无 Cookie 的静态子域名并配好 CORS,接着把 sendfile、open_file_cache 这些 Nginx 静态发送优化打开,最后接上 CDN 并用文件名指纹把强缓存用足。整个过程循序渐进,每一步都能独立验证、独立见效,非常适合个人站点逐步推进。