多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Nginx动静分离实战:从压测事故到QPS翻倍的调优指南

Nginx动静分离实战:从压测事故到QPS翻倍的调优指南 接了个压测上不去的项目后端是Spring Boot前端资源全部丢在Tomcat的webapps目录里。10个并发线程压下去接口响应直接飙到几秒监控里全是一堆Tomcat线程在排队可那会儿访问的明明只是几个大图和JS文件。后来把静态资源全部拨给Nginx托管动态接口才回到Tomcat同样压测QPS翻了三倍还多。这就是动静分离最直接的价值——把正确的工作交给正确的进程。这篇文章不打算讲太虚的架构概念就从我在这个项目里的完整实践出发先说清楚动静分离为什么能立竿见影再拆开location配置里最容易踩的规则细节然后是几个真实复现过的坑最后聊配置验证、监控和容量评估。无论你是刚接触Nginx的新人还是已经在用Nginx但被某些诡异现象困扰的老手这篇都应该能给你一些可落地的参考。1. 动静分离为什么能立竿见影动态与静态请求的底层差异1.1 动态请求慢在哪儿静态请求又慢在哪儿很多人一说动静分离第一反应是把图片放一个目录接口放另一个目录这个理解方向是对的但要真正搞清楚为什么性能差距那么大得从请求的本质看起。动态请求的耗时分布在业务链路的各个环节应用容器的线程池调度、业务逻辑计算、数据库查询、模板渲染。一次正常的动态请求可能消耗几十到几百毫秒其中任何一个环节出现慢查询都会长时间占用一个应用线程。想象一下压测时某个接口查询订单列表数据库一条SQL走了全表扫描要800ms这800ms里Tomcat那个处理线程全程被占住。线程池假设200个线程只要几十个慢请求进来整个服务的响应能力就崩了。动态请求的特点决定了它吃的是CPU和线程资源瓶颈在应用层。静态请求则完全是另一回事。一张图片或一个CSS文件已经躺在磁盘上了服务器要做的无非是把文件字节搬给客户端。这个过程没有业务逻辑、没有数据库交互、没有模板渲染瓶颈只可能出现在磁盘I/O、网卡带宽和文件句柄数上。正常配置下一台物理机Nginx处理静态小文件的吞吐量轻松过万QPS而同样条件下一个Spring Boot应用处理动态接口能稳定在几百上千QPS就算不错了。这就是动静分离的第一层收益让应用服务器把宝贵的线程和CPU资源全部留给动态请求静态资源由Nginx这种专职的高性能Web服务器处理。如果静态资源继续留在应用服务器上后果就是一次静态请求占住一个应用线程大量线程在等待文件读取和网络传输应用真正的业务请求反而排不上队。1.2 sendfile与零拷贝Nginx静态处理效率的根基既然说Nginx处理静态文件效率高那到底高在哪儿这里必须提到sendfile这个系统调用它是Nginx作为静态服务器性能强悍的关键。传统静态文件传输的流程是这样的read()先把文件从磁盘读到内核缓冲区再一份拷到用户态内存然后write()把用户态数据拷到内核的socket缓冲区最后网卡把数据发出去。这个过程中数据在内核态和用户态之间来回拷贝了两次还夹杂着多次上下文切换。文件越大这种拷贝带来的开销越明显。而sendfile()实现了真正的零拷贝发送磁盘文件数据直接从内核页缓存进入socket缓冲区全程不走用户空间。Nginx默认开启sendfile配置就是一行sendfile on;这意味着Nginx处理静态文件时数据不需要经过Nginx进程本身的内存直接从内核搬运到网卡。再加上Nginx的epoll事件模型——每个worker进程可以同时监控成千上万个连接只有连接上有数据可读可写时才触发处理没有事件时进程可以歇着不会像一个连接一个线程那样被少数慢连接拖死。这也是为什么同样的静态资源放Tomcat里和放Nginx里性能差一个数量级。Tomcat的NIO虽然在纯动态场景下表现尚可但它的定位是应用容器要做会话管理、业务线程池调度静态文件处理并不是它设计的主场景。1.3 静态资源的静是相对的边界与例外动静分离设置前还有一个容易忽略的问题到底哪些算静哪些算动最常见的边界判断是看扩展名图片、CSS、JS、字体文件这些无争议算静态。但有几个例外值得注意。第一类是SPA应用。很多人以为服务端渲染的HTML才算动态其实纯前端的单页应用index.html本身也可以是静态文件由Nginx直接托管页面里的数据全部通过 /api/* 这种接口路径走后端代理。这种模式下前端构建产物html、js、css、图片全部静态化Nginx只把接口请求转发给后端动静分离的边界反而非常干净。第二类是需要鉴权的静态资源。比如登录后才能下载的安装包、会员专属的资料文档。这类文件虽然物理上是静态文件但直接暴露给所有人显然不合适。常见做法是在Nginx层配合 secure_link 模块给文件URL加签名或者通过 auth_request 子请求校验用户身份校验通过才放行文件下载。第三类是带有服务端渲染逻辑的页面。如果页面HTML需要根据用户身份、业务数据实时渲染那它本质是动态请求不应该伪静态化后丢给Nginx处理。强行分离只会导致页面内容不一致或缓存穿透。理解了这些边界再去看配置就不会盲目地把所有文件都交给Nginx而是有意识地按访问特征和安全性分流。2. 核心配置拆解location匹配机制、root与alias的取舍2.1 location匹配优先级等于、前缀、正则是怎么排序的动静分离的所有规则落点都在location。很多人在这里犯的第一个错误是以为location跟写代码一样按从上到下的顺序匹配命中第一个就结束。实际上Nginx的匹配顺序有一套严格规则理解它才能写出既高效又不打架的分离规则。优先级从高到低排列第一优先精确匹配路径完全一致时立即命中不再检查任何后续规则。第二优先^~前缀匹配命中后直接结束不再检查正则。第三优先正则匹配~区分大小写和~*不区分大小写按配置文件中的书写顺序执行一旦匹配到就停止。第四优先普通前缀匹配此时只记录最长匹配路径同时如果配置中还有正则正则仍然有资格覆盖最长前缀匹配除非前面用了^~。翻译成人话精确匹配最霸道其次是用^~明确指定拒绝正则检查的前缀然后是正则逐个试探最后才是普通前缀里的最长者胜。这个机制带来的实际影响正则location写多了每次请求都要逐个扫描一遍正则表达式性能开销客观存在。我见过有人把几十行正则全部堆在location层静态请求过来要先做几十次正则匹配白白把Nginx的PPS能力拉低了不少。合理做法是能用前缀匹配解决的就用前缀只在确实需要按文件类型兜底时再上正则。一个典型的动静分离配置可以长这样server { listen 80; server_name example.com; # 精确匹配命令行请求体小不走日志 location /favicon.ico { log_not_found off; access_log off; expires max; } # 前缀匹配明确的静态目录跳过正则检查 location ^~ /static/ { alias /data/www/static/; expires 30d; add_header Cache-Control public, no-transform; } # 正则兜底按扩展名匹配静态文件找不到再回退动态 location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|webp)$ { root /data/www; try_files $uri dynamic; } # 其余请求全部代理给后端动态服务 location / { proxy_pass http://backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } # 命名location静态文件找不到时的回退入口 location dynamic { proxy_pass http://backend; } }注意这里try_files $uri dynamic;的用法静态文件真实存在时直接返回文件不存在时自动回退到命名location里的动态代理避免一刀切404。这个细节在实际项目里很实用尤其前后端并行开发阶段前端静态资源还没全部构建完毕缺失文件能先打到后端接口而不是直接报404。2.2 静态目录配置root与alias的真实区别静态资源目录的配置是另一个高频翻车点。root和alias看起来都是指定文件路径实际拼接逻辑完全不同搞错的结果就是404刷屏。指令拼接逻辑配置示例请求 /static/img/a.png 时的实际文件root把完整URI拼接到root路径后面root /data/www;/data/www/static/img/a.pngalias把location匹配部分替换为alias路径alias /data/www/static/;/data/www/static/img/a.png简单说root是目录叠加alias是路径替换。如果location写的是^~ /static/而这里直接用了root那么实际找的目录会自动带上 /static/ 这一层这时你的物理目录结构里必须真的有 static 子目录。很多人的本地目录叫 assets 或者 public前端构建产物放在里面结果Nginx里root配完总是404就是因为这一层拼接导致路径对不上。alias则不会叠加location路径它用配置里写的路径直接替换掉URI中的匹配部分。好处是可以把前端目录结构调整成任意名字坏处是alias后面必须注意结尾斜杠。比如location /static/ { alias /data/www/static; }请求 /static/img/a.png 会被拼成 /data/www/staticimg/a.png中间少了一个斜杠直接文件找不到。这类问题日志里看到的都是文件路径异常不仔细看很容易排查半天。还有一点经验正则location里尽量别用alias。正则匹配到的不确定性路径会让alias拼接变得非常不可控如果你要用按扩展名的正则规则优先用root配合目录结构调整比在正则里折腾alias省心得多。2.3 缓存头与压缩动静分离场景下的header策略静态资源交给Nginx之后另一个核心工作就是让客户端和中间层尽量少回源。这块的配置直接决定用户二次访问的速度也决定源站的出口带宽压力。基础的缓存头配置location ^~ /static/ { alias /data/www/static/; expires 30d; add_header Cache-Control public, no-transform; }expires指令本质是帮你同时生成 Expires 头HTTP/1.0风格和 Max-Age 字段HTTP/1.1风格。30天数值具体怎么定要看你的静态资源更新频率构建工具带content hash的前端产物可以放心设半年甚至一年因为文件名变了就是新URL旧的缓存完全不影响但手工维护的、会原地覆盖的大文件缓存时间建议短一些否则发版后用户看到的一直是旧资源。gzip这块注意一个误区图片不要硬上gzip。jpg、png、webp这些格式内部本来就有压缩再gzip一层既压缩不了几个字节还白白消耗CPU。值得启用gzip的是文本类资源gzip on; gzip_comp_level 5; gzip_min_length 1k; gzip_types text/plain text/css application/javascript application/json application/xml image/svgxml;还有add_header的继承陷阱得单独提醒。Nginx里add_header不像普通指令那样天然向下继承它的行为是子级一旦出现add_header父级全部add_header失效。比如你在server层加了add_header X-Frame-Options SAMEORIGIN;然后某个静态location里只写了add_header Cache-Control public;那个location返回的响应里X-Frame-Options会消失。要么每个location里把需要的头全部写全要么用map统一管理响应头值再引用后者维护成本更低。3. 动静分离常见坑重现缓存不生效、证书不生效、多站点串站3.1 改完文件浏览器还是旧内容别冤枉Nginx动静分离上线后最典型的线上事故就是运维改了静态文件发版了但用户反馈页面还是旧样子。这时候第一反应往往是查Nginx缓存配置然后各种怀疑reload没生效其实问题往往出在整条缓存链路上。完整缓存链路是浏览器本地缓存 → CDN节点缓存 → 中间层Nginx缓存 → 源站文件。任何一个环节没有失效用户都看不到新内容。排查顺序建议这样走先用无痕窗口或强制刷新排除浏览器缓存然后在服务器上直接curl源文件带上Cache-Control: no-cache请求头看返回的是304还是200如果是200且内容已更新说明源站没问题接着还得检查是否有CDN层CDN节点的缓存刷新有时需要手动操作不是源站改了CDN就立刻变。更高效的根治方案是改资源命名策略。前端构建工具Webpack、Vite默认会生成带内容hash的文件名比如app.8f3d2a.js文件名一变就是全新URL旧缓存自动失效完全不需要去手动刷新任何节点。这在Node.js和纯静态资源场景下早已是标配动态服务端渲染的项目里如果有静态资源原地覆盖的情况建议也逐步迁移到这个模式下。3.2 SSL证书替换后不生效的两种典型原因动静分离后同一个Nginx上经常挂着多个server块一个是静态资源站点一个是API代理站点可能还有管理后台。443端口大家共用这时候证书替换的诡异问题就出现了。现象一明明刚替换了新证书reload也成功了用浏览器访问还是提示证书错误。常见原因有两个一是改错了server块——新证书写进了server_name是内网域名的块但外网访问匹配的是另一个块。Nginx匹配规则是同一端口下先按server_name匹配如果没有匹配项才落到listen里标记了default_server的那个块。你改的证书恰好不在default_server块里自然不生效。现象二证书文件本身和私钥对不上。注意区分两种错误如果crt和key不匹配nginx -t阶段就会直接报错这种好排查更隐蔽的是证书链不完整只给了域名证书没给中间证书浏览器会因为信任链断裂而报错但Nginx语法检查完全正常。用openssl快速验证openssl s_client -connect example.com:443 -servername example.com 2/dev/null | openssl x509 -noout -dates这个命令能看到证书的实际生效起止时间。如果服务器返回的证书生效时间还是旧的时间段或者SAN里没有你访问的域名那问题就定位在server块没匹配对或证书文件配错。一个重要提醒替换证书后不要用restart用reload。restart会中断所有现存连接生产环境动静分离的Nginx上往往有大量keepalive长连接restart会导致瞬时大量重连并发高的时段容易把后端冲垮。3.3 本地开发多站点域名与虚拟机端口配置冲突开发环境用本地虚拟机方式跑多站点是很常见的姿势笔记本上改代码代码同步到虚拟机里通过宿主机浏览器访问虚拟机里的Nginx。这个场景里动静分离也有自己的坑。典型事故是这样你在 /etc/hosts 里绑定了 dev1.example.com、dev2.example.com 都指到虚拟机IP虚拟机里Nginx配了多个server块。某天给dev2配了一套新的静态资源locationreload之后访问dev2发现页面加载的却是dev1的静态文件甚至接口都对不上。排查下来原因基本是这两个一是server_name写错或重复导致请求按第一个匹配到的server块处理而不是你想要的块二是明文IP访问被default_server接管——你在浏览器里直接输入开发机IP访问Nginx找不到对应server_name就会把所有这种请求塞给default_server块如果这个块恰好是dev1的配置看起来就像串站了。解决习惯很简单每个server块都显式写server_name并且单独建一个default_server块用来拦截未知域名的请求直接返回444关闭连接server { listen 80 default_server; return 444; } server { listen 80; server_name dev1.example.com; # dev1的动静分离配置 }这样任何没被明确匹配的Host都会落在这个拦截块要么空响应要么直接断连绝不会落到某个业务站点上。这套做法在容器化多站点场景里同样适用Nginx镜像挂载conf.d配置时如果多个conf文件里有相同server_name后加载的会覆盖先加载的配合default_server拦截能很容易发现问题。4. 配置生效与验证reload机制、灰度切换与访问日志验证4.1 nginx -s reload 为什么有时不生效动静分离规则调整后最常用也是最常被误解的操作就是reload。先说清楚reload到底做了什么。nginx -s reload 实际上是向master进程发送HUP信号。master收到信号后先做配置语法检查相当于自动跑了nginx -t如果配置有问题直接拒绝重载并把错误写进错误日志旧的worker进程继续跑你的改动没有生效但服务也没挂。只有配置完全没问题master才启动一组新的worker进程新连接全部由新worker处理旧的worker在处理完手头存量连接后优雅退出。所以reload是一种平滑重载理论上不会中断任何正在进行的请求。但实践中经常遇到我reload了但配置就是不变的情况梳理一下大致有三类原因。第一类改了错误的文件。Linux包里同时存在 /etc/nginx/conf.d/ 和 /etc/nginx/sites-enabled/ 两套配置目录是常态两个目录下可能有同名文件你改了其中一个但当前生效的实际是另一个。改完先执行nginx -T打印出完整的生效配置确认你改的内容真的在里面。第二类include目录挂载问题。容器场景里最常见你本地改了conf.d下的配置以为挂载到了容器的 /etc/nginx/conf.d/实际docker-compose或k8s里挂载源路径写错改的文件根本没进容器。进容器里cat一下对应文件最直接。第三类浏览器缓存让你误判。动态接口不可能有这种问题但静态文件配了强缓存后reload之前那次访问的缓存还在本地你刷新看到旧内容就以为是Nginx没生效实际上用curl直接打Nginx已经是新文件了。验证配置生效与否永远优先用curl而不是浏览器。4.2 用访问日志验证分流是否正确动静分离配好之后怎么确认静态请求真的走了静态location、动态请求真的走了代理最直观的办法就是给不同location配不同日志文件让数据说话。# 静态资源入口 location ^~ /static/ { alias /data/www/static/; access_log /var/log/nginx/static_access.log main; } # 动态代理入口 location / { proxy_pass http://backend; access_log /var/log/nginx/dynamic_access.log main; }跑一段时间后分别统计两个日志的行数和请求分布。如果 dynamic_access.log 里出现了大量 .png、.js、.css 结尾的请求说明静态请求没有正确落进静态location规则设计有问题。反过来如果 static_access.log 里出现了类似 /api/login 这样的路径说明精确匹配和前缀匹配的顺序没控制好某些规则被提前命中了。另一个验证维度是看 $upstream_addr。动态代理的请求日志里如果打印了upstream地址说明确实转给了后端静态请求理论上不应该有upstream值如果出现说明它在静态location里没被成功处理又落到了代理逻辑。自定义日志格式时把这个变量加上排查分流问题时能少走很多弯路。4.3 增量切换的实战思路动静分离从配好到全量上线不建议一次性把所有规则全部铺上去。尤其是存量业务系统静态资源原本都在应用服务器上突然全部切给Nginx任何遗漏都可能导致线上大面积404或样式丢失。我推荐的做法是分两到三步走。第一步先把最明确的静态目录切过去比如 /static/、/assets/ 这类路径不涉及正则兜底影响面可控。跑一两天观察应用服务器日志确认应用服务器收到的静态请求明显减少同时Nginx日志中静态请求正常。第二步再引入按扩展名的正则兜底方案并且配合try_files回退到动态代理这样即使个别文件没找到也不会直接404。第三步确认稳定后才把默认location里的请求代理逻辑收敛为纯动态同时检查静态location的缓存策略避免发版后缓存不刷新问题。切换完成后记得保留一份旧的location配置注释线上出问题时最快回滚的方式就是取消注释、注释新规则、reload而不是临时重新打补丁。这种新老规则共存的容错思维在做任何Web配置变更时都值得保留。5. 动静分离之后的容量评估与监控指标5.1 静态与动态的并发模型差异动静分离后Nginx一台机器同时承担了静态文件服务和动态请求代理两种职责。静态文件是Nginx直出性能瓶颈取决于Nginx自身参数动态请求要经过后端应用瓶颈就落在后端了。这个差异直接决定了调优方向完全不同。对静态服务默认参数基本够用但有几项要注意worker_processes建议设为autoNginx按CPU核数自动生成worker数量worker_connections表示每个worker能同时保持的最大连接数静态服务场景可以适当开大比如10240同时要把系统文件描述符限制调上去否则连接数上去了文件句柄不够用ulimit -n 65535还有内核参数net.core.somaxconn和tcp系统缓冲区如果在高并发压测时报出connection refused多半就是backlog队列满了。这类问题在Nginx最大并发链接数老是用超的反馈里很常见但很多时候不是真的到达了系统上限而是内核队列参数没有随业务量同步调大。对动态代理Nginx本身只是转发层真正的并发上限由后端决定。这里要关注的是proxy_read_timeout、proxy_send_timeout等参数是不是留有足够余量以及upstream的keepalive连接池有没有开启。没开keepalive时每个动态请求都要新建后端连接瞬时高并发很容易耗尽后端连接数开了keepalive能省下大量三次握手开销。5.2 状态页指标解读reading、writing与waiting动静分离后的健康检查先看Nginx自身的状态页。开启方式很简单location /nginx_status { stub_status on; access_log off; allow 127.0.0.1; deny all; }访问后返回的指标含义很多教程只列了不解释实际判断逻辑是这样的active connections是当前所有处于打开状态的连接数包含正在处理的和空闲的。reading表示Nginx正在读取请求头部的连接数这个数值通常很小如果持续偏高说明客户端发请求的速度很快但处理吞吐不足。writing表示正在向客户端写响应的连接数。压测时这个数值会涨注意观察它是否长时间保持高位不回落如果回落很慢要考虑是不是sendfile没开或者后端响应太慢导致Nginx一直占着连接等着写。waiting表示空闲的keepalive连接数。正常业务下waiting占比高是好事说明大量连接复用良好没有每请求都新建TCP连接。静态请求和动态请求在状态页上很难区分但可以配合access_log的拆分定时对比静态日志和动态日志的请求量比例再结合writing和waiting的变化趋势判断当前瓶颈在哪个环节。5.3 监控对接与调优参数从Nginx状态到告警动静分离规模上来之后人工盯状态页不现实接入监控才是出路。Nginx自身的监控接入成本很低开一个json格式的status接口或者直接用stub_status让采集器定时拉取。我之前用Zabbix接Nginx是走的这套流程Nginx开启stub_statusZabbix agent通过自定义key执行curl解析状态页数值传给Zabbix server做绘图和告警。接入后必须盯的指标我列一下连接数总量对比容量上限、reading/writing的突变、动态代理的upstream响应时间、5xx错误率、静态日志中404比例。响应时间曲线出现锯齿状上升大概率是后端某个实例开始抖动静态404比例突然升高多半是发版资源路径没对齐需要在监控里设好阈值。调优参数也有一个常见误区盲目加大keepalive_timeout。keepalive_timeout控制的是连接空闲多久后关闭设得太大空闲连接长时间占着文件描述符和内存反而在流量高峰时造成资源浪费设得太小连接频繁断开重建TCP握手开销上升。动静分离场景下静态资源通常建议30-60秒动态代理建议根据后端应用的实际连接池回收策略来定而不是统一调成一样的值。关于open_file_cache还有一个容易被忽略的优化点它缓存的是打开文件的相关信息路径、句柄、文件大小等能显著减少打开文件相关的系统调用open_file_cache max10000 inactive30s; open_file_cache_valid 60s; open_file_cache_min_uses 2;这个配置对图片、CSS等大量小文件的场景提升很明显但注意如果静态文件更新频繁缓存时间设太长会导致文件更新后Nginx仍然返回旧内容所以inactive和valid需要根据发版频率折中设置。动静态分离做得好不好我自己最看重三个数字静态请求平均响应时间是否稳定在个位毫秒级、动态代理的5xx率是否持续走低、以及发版后用户反馈缓存问题的频率。这三个数健康说明分离架构的收益真正落地了。最后再分享一个小习惯——每次改完Nginx配置先用nginx -t验证再reload然后在日志目录里留一份完整的nginx -T输出作为版本快照。遇到问题往回比对配置差异比靠记忆复盘省事得多。
返回列表