☰
Nginx负载均衡生产级调优:调度算法、健康检查与压测验证
2026/9/29 3:21:16 网站建设 项目流程

上周半夜接到一个电话,朋友的服务从单机扩到三台,中间挂了一台 Nginx 做负载均衡,本以为能轻松扛住流量高峰,结果接口平均响应时间不降反升,翻后台日志发现其中一台业务机器几乎没接到请求,另一台被打到 CPU 满载。他第一反应是"Nginx 负载均衡是不是坏了",其实配置就那几行,真正让它稳定工作的东西全藏在upstream块的参数、健康检查的设计和部署细节里。Nginx 负载均衡这个题目看起来简单,网上随手一搜全是"三行配置搞定"的教程,但生产环境跑起来之后,会话丢失、连接数分化、后端半死不活、离线环境装不上、HTTPS 证书链不全这些问题会一个接一个冒出来。这篇我打算把这几年从最小可用配置一路做到生产级调优的整套东西梳理清楚:调度算法怎么挑、健康检查怎么设计、会话保持怎么做、SSL 在哪里终止、内网离线机器怎么装、WebSocket 长连接怎么转发,以及压测时怎么用数据证明流量真的被分匀了。刚接触反向代理的新手能照着把环境跑起来,已经跑了半年生产想再抠一抠性能的老手,应该也能从参数细节里捞到点东西。

1. 从"一台机器扛不住"到"三台机器不均衡":负载均衡真正要解决的三个问题

1.1 加机器之后为什么反而更慢

很多人对负载均衡的理解停留在"把请求分到多台机器上",于是扩完容发现慢,第一反应是机器不够。真实的因果往往反过来:加机器引入了一个新的单点(Nginx 本身)和一段新的网络跳转,如果没有配套的调优,性能反而会退。我见过最常见的三种退化场景,第一种是 Nginx 到后端默认走短连接,每个请求都要重新握手,后端机器的TIME_WAIT迅速堆到几万个端口耗尽;第二种是健康检查缺失,某台后端进程还在但数据库连接池已经炸了,Nginx 依然按权重把流量打过去,用户侧表现为"偶发超时";第三种是会话没做保持,用户登录态在几台机器之间来回漂移,每次切机器都要重新查一次数据库里的 session,等于把省下来的算力又还回去了。

这三个问题的共同点是:它们都不会在配置语法检查时报错,nginx -t永远显示 successful,只有真实流量打上去才暴露。所以我在设计任何一套负载均衡之前,会先把三个问题写在纸上——连接怎么复用、后端怎么判死、状态放在哪——这三个答案定下来,配置才有意义,否则就只是在抄别人博客里的模板。

1.2 反向代理和负载均衡不是一回事

这两个词经常被混着用,但它们的职责边界其实很清楚。反向代理解决的是"客户端不知道也不该知道后端是谁",核心动作是转发和改写请求头;负载均衡解决的是"在一组等价的后端里挑一个",核心动作是调度和容错。Nginx 用同一套机制同时实现这两件事,proxy_pass负责转发,upstream负责挑人,所以配置里经常看到proxy_pass http://backend;这种写法——这个backend不是域名,是上面定义好的 upstream 块的名字。

理解这一点很关键,因为它决定了你排错时该看哪一段。请求根本没到后端,那是反向代理层的问题(proxy_pass地址、resolver、网络连通性);请求到了后端但分配不均,那是 upstream 层的问题(权重、算法、健康状态)。我调试时习惯先在 Nginx 上curl一下后端直连地址确认真能通,再回头查 upstream,这样能省掉一半的猜测时间。

1.3 一个能跑的最小配置

先把地基搭起来。下面这份配置是能直接跑的最小集合,我把它放在/etc/nginx/conf.d/lb.conf里,通过主配置的include引入,方便后面单独改:

upstream app_backend { server 10.0.0.11:8080 weight=1; server 10.0.0.12:8080 weight=1; server 10.0.0.13:8080 weight=1; } server { listen 80; server_name app.internal; location / { proxy_pass http://app_backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_connect_timeout 3s; proxy_read_timeout 30s; } }

这里有几个新手容易忽略的点。proxy_connect_timeout默认 60 秒太长了,后端如果已经挂了,Nginx 会傻等一分钟才切换,用户体感就是"页面转圈然后报错",压到 3 秒能让故障切换快一个数量级。Host头要显式传,某些后端框架靠它做虚拟主机路由或生成绝对链接,不传会拿到 Nginx 自己的地址。至于三台后端到底分得匀不匀,光看配置看不出来,得靠后面第 8 节的压测数据来验证。

2. upstream 里的六种调度算法:选错算法等于白扩三台机器

2.1 默认轮询与 weight:权重到底按什么算

不写任何算法参数时,Nginx 用的是加权轮询(round-robin)。注意它叫"加权"轮询——即使你没写weight,每台机器的默认权重也是 1,所以三台机器严格轮流接单。这个默认行为在机器配置完全一致、每个请求耗时也差不多的时候表现最好,一旦机器配置差异大(比如一台 2 核一台 16 核),就会出现"弱机被打爆、强机在划水"。

weight的取值是相对值而不是百分比,写weight=5和weight=1,表示前者承担 5/6 的流量。我一般按 CPU 核数或压测出来的 QPS 上限来配权重,比如三台机器压测单机上限分别是 800、800、1600 QPS,那就写weight=1 1 2,比拍脑袋写数字靠谱得多。另外要提醒一句,权重调大不等于能力变强,如果瓶颈在后端的数据库上,给应用层加权重只会让数据库更早崩,先把瓶颈定位清楚再动手。

2.2 ip_hash 与 hash $request_uri:会话保持的两条路

ip_hash是最省事的会话保持方案,原理是把客户端 IP 的前三段(IPv4 的 /24)做哈希,同一个网段的请求会固定落到同一台后端。配置只有一行:

upstream app_backend { ip_hash; server 10.0.0.11:8080; server 10.0.0.12:8080; }

它的优点是不需要后端做任何改造,缺点是两个:一是同公司、同小区、同运营商出口的用户会被哈希到同一台机器,宿舍楼里几百号人可能全压在一台上;二是当某台后端被判死之后,它的流量会被重新分配到其他机器,重新分配后的映射关系会整体位移,原本粘住的会话照样丢。所以在公网 To C 场景我基本不用ip_hash,只在纯内网、客户端 IP 相对稳定的小系统里图省事。

另一种是hash $request_uri或者按 cookie 里的用户标识做哈希,把"同一类请求"或"同一个用户"钉到固定后端,适合缓存命中率敏感的场景,比如后端自己做了本地文件缓存的图片服务。代价是需要你自己保证哈希键的稳定性,一旦键变了,缓存命中率会瞬间归零。

2.3 least_conn 与"等开销负载均衡"的真实含义

least_conn会把新请求交给当前活跃连接数最少的那台机器。它的适用场景是请求耗时差异很大——有的接口 20 毫秒返回,有的要跑 3 秒,这时候轮询会不公平,快机器闲着、慢机器排队。least_conn能动态平衡,配置同样是加一行。

这里要澄清一个经常被说混的概念:等开销负载均衡。它不是一个具体的算法名,而是一种假设——假设每台后端处理单个请求的消耗相同,所以只按连接数或请求数来分配就是合理的。轮询、least_conn全都建立在这个假设之上。如果后端机器配置天差地别,或者请求本身有重有轻(比如导出报表和查询列表走同一个 upstream),这个假设不成立,任何单纯数连接数的算法都会失准。此时的正确做法是拆 upstream,把重任务和轻任务分开调度,而不是指望算法帮你解决。

2.4 算法选型对照表

算法配置方式适用场景主要风险
加权轮询默认,或weight=n后端同构、请求耗时接近慢请求堆积,弱机被打爆
least_connleast_conn;请求耗时差异大长连接场景下判断失准
ip_haship_hash;内网、需会话保持且不想改代码NAT 出口导致流量倾斜
hash $keyhash $request_uri;缓存命中率敏感键变化导致缓存全失效
随机random two;后端数量多、希望避免热点分布均匀性略差于轮询
一致性哈希hash $key consistent;后端频繁扩缩容需要额外考虑虚拟节点数

我的默认选择是:能用加权轮询就用,有明确的长短请求混合再上least_conn,会话保持优先在应用层解决(见第 5 节),算法本身不应该承担状态管理的职责。

3. 藏在 upstream 块里的七个参数:max_fails、fail_timeout、backup、keepalive

3.1 max_fails 与 fail_timeout 是一对联动开关

这两个参数必须一起看。max_fails是在fail_timeout时间窗口内允许的失败次数,超过就把这台机器标记为不可用,并且在接下来一个fail_timeout周期内不再向它发请求;周期结束后会尝试恢复,放一个请求进去试探。默认值是max_fails=1、fail_timeout=10s,这个默认值相当激进——一次网络抖动就会把一台健康机器踢下线 10 秒。

upstream app_backend { server 10.0.0.11:8080 max_fails=3 fail_timeout=15s; server 10.0.0.12:8080 max_fails=3 fail_timeout=15s; }

我在生产里通常调成max_fails=3 fail_timeout=15s到30s。经验数值是这样推的:假设单个后端每秒处理 200 个请求,10 秒的摘除窗口意味着 2000 个请求被转移,如果后端真的只是抖了一下,这个代价太大;反过来如果把max_fails设成 10,真正宕机的机器会多吃 10 个失败请求才被摘掉,用户侧就是零星的 502。3 次是一个在两者之间比较舒服的折中。

还有个坑要提前说:失败计数是按 upstream 整体累加的,不是按客户端来源区分,而且proxy_next_upstream触发的重试也会计入失败次数。这意味着如果后端返回 502 的频率刚好卡在阈值附近,会出现"机器反复上线又下线"的抖动,日志里表现为 upstream 状态不断切换。遇到这种情况我会先把fail_timeout拉长,让状态稳定下来再查后端根因。

3.2 keepalive 不配,端口会被 TIME_WAIT 吃掉

这是我最想强调的一条。Nginx 到后端默认使用 HTTP/1.0 的短连接,每个转发请求都要走一次三次握手和四次挥手,高并发下 Nginx 侧和后端侧的TIME_WAIT会疯狂增长。我见过一台机器netstat出来的TIME_WAIT有六万多条,本地端口范围直接被打满,新的转发请求拿不到可用端口,报Cannot assign requested address。

解决方案是在 upstream 里开启长连接池:

upstream app_backend { server 10.0.0.11:8080; server 10.0.0.12:8080; keepalive 64; keepalive_requests 1000; keepalive_timeout 60s; } location / { proxy_pass http://app_backend; proxy_http_version 1.1; proxy_set_header Connection ""; }

keepalive 64表示每 worker 进程为这个 upstream 缓存 64 个空闲长连接,数值按"后端 QPS × 平均耗时"估算再留余量,一般 32 到 128 之间够用。注意两个必须配套的动作:proxy_http_version要显式设为 1.1,因为长连接是 1.1 才有的特性;Connection头要清空,否则客户端传来的Connection: close会被透传给后端,长连接池直接失效。这两个点漏掉任何一个,keepalive参数就是个摆设,配置不报错但毫无效果——这类"看起来生效其实没生效"的配置是最难排查的。

3.3 proxy_next_upstream:重试是蜜糖也是毒药

默认情况下,请求在error和timeout时会自动切到下一台后端重试,这正是负载均衡高可用的核心。但默认值不包含 HTTP 状态码,也就是说后端返回 502、503 时 Nginx 不会重试,直接把错误页面给用户。我一般会加上:

proxy_next_upstream error timeout http_502 http_503 http_504; proxy_next_upstream_tries 2; proxy_next_upstream_timeout 10s;

加上之后故障转移更彻底,但同时要意识到风险:重试是整个请求重发,如果一个 POST 请求已经写了一半数据、后端在返回响应前挂掉,重试到另一台机器可能导致重复下单。所以更稳妥的策略是只对幂等请求开放状态码重试,非幂等接口保持默认,把幂等性保障放到应用层去做(比如业务侧的唯一请求 ID)。我在金融类项目里的做法就是:查询类 location 打开全部重试,写入类 location 单独定义只重试error timeout,用两个location块隔离,别图省事全局打开。

3.4 backup 与 down:灰度下线的两个标签

backup标记的机器平时不接流量,只在所有主机器都不可用时顶上,适合放一台低配兜底机器——它的存在意义是"网站别彻底打不开",而不是分担压力。down则是手动把某台机器标记为不接流量,专门用于运维操作。

这两个标签的组合用法是平滑下线:先把目标机器的权重改成 0 或者直接打上down,reload 一次,此时新的请求不再进来,但已经建立的连接还能自然跑完;等一个keepalive_timeout加上业务最长处理时间之后,再登录那台机器去做重启、升级或者摘除。我见过有人直接kill后端进程,导致正在处理的请求全部中断,用户侧看到 502,这类问题完全可以通过"先摘流量再动手"避免。这套流程在灰度发布时同样适用,一台一台地摘、一台一台地放,出问题能立刻回滚。

4. 健康检查的盲区:被动探测救不了"半死不活"的后端

4.1 被动检查的判断时机与盲区

开源版 Nginx 只有被动健康检查,也就是靠真实请求的失败来推断后端状态。它的判断依据就是第 3 节讲的max_fails和fail_timeout。被动检查的盲区在于:它必须等到有请求失败才知道后端出事了。如果那台机器的权重只有 1/10,可能要等好几秒才有请求落到它身上,这几秒里用户就在吃错误;如果后端是"能接收连接但处理极慢"的半死状态,请求不会立刻报错,而是卡在proxy_read_timeout上,要等超时才算失败,这个滞后期可能长达几十秒。

还有一个更隐蔽的情况:后端进程还在、端口还 listen 着,但依赖的数据库或缓存挂了,代码里有没有兜底逻辑直接决定返回 500 还是正常响应。如果返回的是 200 但内容全是错误提示,被动检查永远发现不了,Nginx 会一如既往地往它身上灌流量。

4.2 主动健康检查的三种落地方式

想解决这个盲区,就得让探活请求主动打过去。开源 Nginx 没有这个能力,我实际用过的三种替代方案是:

第一种是编译第三方模块(如nginx_upstream_check_module),在 upstream 里加check interval=3000 rise=2 fall=3 timeout=1000 type=http;,它会定时向后端发探活请求,连续fall次失败就摘除,连续rise次成功就恢复。这是最接近商业版体验的方案,代价是 Nginx 需要重新编译,离线环境里要提前把源码和补丁文件准备好。

第二种是使用集成了健康检查的发行版(比如 Tengine),配置语法类似,对不想折腾编译的人更友好。

第三种是外部脚本方案:写个定时任务,用curl探测每个后端的健康接口,健康状态变化时改写 upstream 配置文件并nginx -s reload。这个方案最土但最通用,任何环境下都能落地,缺点是有 reload 延迟、脚本本身要写得足够健壮。我早期项目里就用的这套,脚本里加了互斥锁防止并发 reload,跑了好几年没出过岔子。

后端的探活接口要专门设计,别拿首页做探活——首页依赖多、响应慢,探测结果失真。我会让后端提供一个/healthz,只检查进程存活和关键依赖(数据库连接、缓存连接),正常返回 200 加一行文本,异常返回 503,响应控制在 10 毫秒以内。

4.3 平滑下线的完整操作步骤

把前面的东西串成一套可执行流程,这是我发版时的固定动作:

  1. 修改配置,把目标后端设为down或者weight=0,执行nginx -t确认语法正确。
  2. 执行nginx -s reload,Nginx 会启动新的 worker 进程,旧 worker 继续处理存量连接直到自然结束。
  3. 观察该后端机器上的连接数,等活跃连接降到 0(一般等keepalive_timeout + 最慢接口耗时的余量,30 到 60 秒足够)。
  4. 登录后端做重启或升级。
  5. 恢复配置里的down标记,reload,观察日志确认有请求正常返回。
  6. 用第 8 节的压测方法快速验证流量分布恢复正常。

注意:reload不是重启,旧 worker 不会被强杀,所以正在处理的请求不会中断。但如果你的业务里有超长连接(比如文件上传、WebSocket),步骤 3 的等待时间要按实际最长连接时长来算,必要时用worker_shutdown_timeout兜底强制退出。

5. 会话保持失效的排查链路:登录态为什么总是丢

5.1 NAT 环境下 ip_hash 的集体失效

前面提过ip_hash的缺陷,这里展开说一个真实案例。某公司内部系统上了ip_hash,上线后反馈"整个办公区要么都能登录,要么都登不上,还得重启服务"。排查发现办公区通过一个出口 NAT 访问,所有员工对外呈现同一个 IP,ip_hash把它们全都哈希到了同一台后端。这台机器一重启,整个办公区就集体掉线。更麻烦的是同一台机器被反复压测,另外两台完全空闲。

这个案例说明一个原则:凡是涉及客户端 IP 的负载决策,都要先问一句"客户端 IP 在到达 Nginx 之前经过了哪些代理"。如果remote_addr拿到的永远是网关地址,基于它的哈希就没有意义。此时要么改用 cookie 哈希,要么把 session 外置,两条路都比硬撑ip_hash强。

5.2 cookie 粘滞与共享存储的取舍

cookie 粘滞的思路是给首次访问的用户种一个 cookie,里面带上后端标识,后续请求按这个标识路由。Nginx 开源版没有内置这个能力,需要靠hash $cookie_xxx加后端配合,或者引入第三方模块。

我更推荐的是把状态外置——用 Redis 之类的集中存储放 session,Nginx 就可以放心地用轮询,后端任何一台都能处理任何用户的请求。这样扩容、重启、灰度都不影响用户登录态,架构上少一层耦合。代价是引入了一个新的依赖,Redis 本身要做高可用,并且每次请求多一次网络往返。对小系统来说这个代价可能不划算,对任何有扩容需求的系统来说,这笔账迟早要还。

5.3 一次完整的排查记录

有人问过我"用户明明登录了,刷新页面就变游客",我按下面的顺序查的,这里把链路完整写出来供你复用:

第一步,看会话丢的是不是有规律。如果只在某些操作后丢,大概率是 cookie 的Path或Domain配错了;如果是随机丢,怀疑路由不稳定。

第二步,在 Nginx 日志里加上游信息。用log_format把$upstream_addr记进去,观察同一个用户的连续请求是不是落到了不同后端。这一步能直接确认是不是负载均衡导致的。日志格式我常用的是:

log_format lb '$remote_addr - $http_x_forwarded_for [$time_local] ' '"$request" $status $body_bytes_sent ' 'upstream=$upstream_addr time=$upstream_response_time';

第三步,如果确认是路由漂移,检查是否所有节点都能读到同一份 session。常见的坑是某一台机器上的缓存没清、或者 session 存的是本地文件,其他机器读不到。

第四步,检查 cookie 的Secure和SameSite属性。在 HTTPS 站点上,如果 cookie 带了Secure但用户走的还是 HTTP,浏览器会直接丢弃它,表现就是"登录成功但下次请求没带 cookie"。这个坑在从 HTTP 切 HTTPS 的过程中极其常见。

这条链路我从上到下走过十几遍,基本上第四步之前就能定位到原因。关键是要有"观测点"——也就是日志里能反映上游选择的那个字段,没有它就只能靠猜。

6. 在负载均衡层终止 HTTPS:证书、协议与真实 IP 转发

6.1 自签名证书的交互式生成流程

内网系统上 HTTPS,证书往往没有正规签发渠道,只能用自签。Nginx 官方文档给的是交互式命令,执行后按提示一步步输入信息:

mkdir -p /etc/nginx/ssl && cd /etc/nginx/ssl openssl req -x509 -nodes -newkey rsa:2048 -days 3650 \ -keyout server.key -out server.crt

回车之后会依次提示输入 Country Name(两位国家代码,比如 CN)、State or Province Name、Locality Name、Organization Name、Organizational Unit Name、Common Name、Email Address。这里只有一个字段值得认真对待,就是Common Name:它必须是用户浏览器里输入的访问地址。如果你生成时填了localhost,但用户用app.internal访问,浏览器照样会弹"证书不受信任",很多人第一次配自签就栽在这个字段上。

Organization 那几个字段随便填不影响功能,但如果证书要分发给多个系统,填成统一的组织名便于日后识别。-days 3650是十年有效期,内网系统取这个值比较省事,公网证书现在规范已经压到一年以内,别照搬。

生成完之后要配到 Nginx 里:

server { listen 443 ssl; server_name app.internal; ssl_certificate /etc/nginx/ssl/server.crt; ssl_certificate_key /etc/nginx/ssl/server.key; location / { proxy_pass http://app_backend; } }

浏览器告警的根源是自签证书不在客户端的信任库里。真正的"消除告警"动作不在服务器端,而是把这张证书(或者签发它的根证书)导入每台客户端机器的受信任存储区。这一步没法靠 Nginx 解决,做内网项目时要提前和运维确认分发方式。

6.2 协议版本与加密套件怎么定

默认的 TLS 配置为了兼容性会打开一些老旧协议,我在内部系统里会收紧到:

ssl_protocols TLSv1.2 TLSv1.3; ssl_prefer_server_ciphers on; ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384; ssl_session_cache shared:SSL:10m; ssl_session_timeout 10m;

ssl_session_cache这个参数很实用但容易被忽略,它把 TLS 会话缓存在内存里,客户端重连时可以跳过完整的握手过程,在移动网络弱信号、频繁断连重连的场景下能省下可观的延迟。10m 大约能缓存四万个会话,对绝大多数系统够用。

证书链的问题要单独提一下。有 CA 签发的证书通常会给你一个证书文件和一个或多个中间证书,如果只配了服务器证书而没拼上中间证书,部分浏览器会报"证书链不完整"。正确做法是把服务器证书和中间证书按顺序拼成一个文件:

cat server.crt intermediate.crt > fullchain.crt

然后ssl_certificate指向fullchain.crt。这个坑在手机端尤其明显,桌面浏览器可能因为缓存了中间证书而正常显示,手机上一打开就告警,排查起来很容易走弯路。验证方法是openssl s_client -connect 域名:443 -showcerts,看输出的证书链是否完整。

6.3 X-Forwarded-For 与后端识别真实来源

流量经过 Nginx 之后,后端看到的连接来源全是 Nginx 的地址,所有的访问日志、风控、限流都会失效。标准做法是加转发头:

proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Host $host;

$proxy_add_x_forwarded_for的行为是在客户端传来的X-Forwarded-For后面追加当前remote_addr,所以这个头是一个逗号分隔的列表。后端取的时候要明确策略:如果 Nginx 前面没有其他代理,取列表里第一个就是真实客户端 IP;如果前面还有一层网关,第一个可能是伪造值,应该取倒数第二个或者按已知的代理层数来数。不加验证地直接信任这个头会带来伪造风险,内网系统里也建议在后端做一次格式校验。

X-Forwarded-Proto是给后端判断"用户到底是走 HTTP 还是 HTTPS"用的,很多框架靠它决定生成的链接是http://还是https://,不传的话在 HTTPS 站点上会生成一堆 HTTP 链接,触发混合内容告警。

7. 生产部署的四个硬骨头:离线安装、开机自启、多站点、WebSocket 转发

7.1 离线环境安装 Nginx 的完整流程

内网机器不能连外网,装 Nginx 只能走离线包。两条路可选:一是用发行版的包管理器做本地安装,二是有源码编译。包管理器的方式需要提前在外网机器上用yumdownloader或apt-get download把 Nginx 及其所有依赖(pcre、zlib、openssl的开发包等)一次性拉全,拷进内网后yum localinstall ./*.rpm批量安装,优点是路径规范、systemd 单元文件自动生成;缺点是依赖梳理麻烦,少一个包就要来回拷。

源码编译的流程更可控,我的固定步骤是:

tar -zxvf nginx-1.x.tar.gz cd nginx-1.x ./configure \ --prefix=/usr/local/nginx \ --with-http_ssl_module \ --with-http_v2_module \ --with-http_realip_module \ --with-http_stub_status_module \ --with-http_gzip_static_module \ --with-stream \ --with-pcre=../pcre-8.45 \ --with-zlib=../zlib-1.3 \ --with-openssl=../openssl-3.0.x make && make install

--with-http_stub_status_module我强烈建议带上,它提供一个轻量状态页,能看到当前活跃连接数、已处理请求数,第 8 节做压测验证全靠它。--with-http_realip_module用于从转发头里还原客户端 IP,--with-stream是四层代理支持,做数据库或消息队列转发时用得上。把pcre、zlib、openssl的源码目录一起带上编译,可以避免依赖系统库版本不一致导致的各种诡异问题,在国产化环境或者基础镜像比较精简的机器上尤其重要。

编译前记得装好gcc、make以及pcre-devel、zlib-devel、openssl-devel这几套开发工具,离线环境下这些也得提前备齐。

7.2 systemd 开机自启与平滑重载

源码编译安装的 Nginx 没有自动生成服务单元,需要手写一个/usr/lib/systemd/system/nginx.service:

[Unit] Description=nginx - high performance web server After=network.target [Service] Type=forking PIDFile=/usr/local/nginx/logs/nginx.pid ExecStartPre=/usr/local/nginx/sbin/nginx -t -c /usr/local/nginx/conf/nginx.conf ExecStart=/usr/local/nginx/sbin/nginx -c /usr/local/nginx/conf/nginx.conf ExecReload=/bin/kill -s HUP $MAINPID ExecStop=/bin/kill -s QUIT $MAINPID PrivateTmp=true [Install] WantedBy=multi-user.target

写完之后systemctl daemon-reload,再systemctl enable nginx就能开机自启。这里有三个细节值得说:Type=forking是必须的,因为 Nginx 默认是后台守护进程模式;ExecStartPre里带-t做语法预检,配置写错时服务启动会直接失败,而不是留一个半死的进程;ExecReload用的是 HUP 信号,对应nginx -s reload的平滑重载语义,不会中断存量连接。

如果机器上已经装了包管理器版本的 Nginx,别重复装源码版,两者会抢同一个端口和 PID 文件路径,表现是"启动成功但访问到的是另一个版本"。

7.3 一台机器跑多个 Web 项目的 server 拆分

小团队资源紧张,一台 Nginx 上挂好几个项目是常态。做法是按域名或路径拆server块,每个项目一套 upstream,文件放在conf.d/下用include conf.d/*.conf统一引入,这样新增项目只要加文件不用改主配置。按域名区分的写法是:

server { listen 80; server_name shop.example.com; location / { proxy_pass http://shop_backend; } } server { listen 80; server_name api.example.com; location / { proxy_pass http://api_backend; } }

没有多余域名的场景就按路径前缀拆,location /shop/和location /api/分别指向不同 upstream。这里有个容易踩的坑:proxy_pass带不带结尾斜杠,转发路径完全不同。proxy_pass http://backend;会把原始 URI 原样转发,proxy_pass http://backend/;会把 location 匹配到的前缀替换掉。前者适合后端已经按完整路径注册路由的情况,后者适合你要把前缀剥掉的情况。这个差别我在两个项目之间反复搞混过,现在的习惯是先在浏览器里打一个测试请求,看后端日志里收到的路径对不对,确认之后再往下写业务配置。

还有个细节是server_name的匹配顺序:精确域名 > 前缀通配符 > 后缀通配符 > 正则 > default_server。如果某个请求返回了不该返回的项目页面,八成是server_name没匹配上,落到了第一个 server 块(默认 server)。给主站显式加上default_server标记,能避免这种意外。

7.4 WebSocket 长连接的转发配置

实时通信类服务(软交换的信令端口、IM、推送、在线协作)走的是 WebSocket,用普通的proxy_pass会一直提示连接被关闭。原因是 WebSocket 需要 HTTP 的升级握手,而 Nginx 默认不处理Upgrade头。标准配置是先定义一个映射:

map $http_upgrade $connection_upgrade { default upgrade; '' close; } upstream ws_backend { server 10.0.0.21:5066; server 10.0.0.22:5066; } server { listen 443 ssl; server_name ws.example.com; location /ws/ { proxy_pass http://ws_backend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_set_header Host $host; proxy_read_timeout 3600s; proxy_send_timeout 3600s; proxy_buffering off; } }

三个要点:Connection头不能写死成upgrade,要用map动态取值,否则普通 HTTP 请求也会被当成升级请求;超时时间要拉长到小时级,WebSocket 是长连接,默认的 60 秒会让连接每隔一分钟被掐断重连;proxy_buffering off关闭缓冲,保证消息实时性,否则 Nginx 可能会攒一批数据再发。

关于负载均衡策略,WebSocket 长连接有个天然特性:连接一旦建立就会一直粘在某台后端上,所以常规的轮询只影响新连接的初始分配。如果多台后端之间需要共享状态(比如同一通话的两端必须落在同一台机器上),要么按业务标识做hash,要么让后端通过共享存储同步状态。单纯堆后端数量并不能提升单条长连接的吞吐,分配的合理性全靠新连接数量是否均匀。

8. 压测验证:怎么用数据证明流量真的被分匀了

8.1 压测方法与被观测指标

配置写完不算完,必须用数据验证。我的方法是三台后端各自的访问日志分别统计,同时用压测工具打一段时间,对比每台机器收到的请求数和平均响应时间。压测工具用wrk或ab都可以,命令大概长这样:

wrk -t4 -c200 -d60s --latency http://app.internal/api/list

-t4是四个线程,-c200是 200 个并发连接,-d60s跑一分钟。跑完后三台后端分别执行:

awk '{print $4}' access.log | cut -d: -f2 | sort | uniq -c

看每分钟的请求数分布。理想情况下三台的比例应该接近配置的权重比例,偏差在 5% 以内。如果某台明显偏低,先别急着改配置,往下看第 8.2 节。

除了请求数,还要看几个关键指标:Nginx 状态页里的Active connections(活跃连接数)反映实时压力;后端日志里的$upstream_response_time(上游响应时间)反映后端处理能力;系统层面的CPU和load反映机器是否真的吃力。只看其中一个指标很容易误判,比如连接数均匀但响应时间不均,说明算法没问题、后端能力有问题。

8.2 三种典型不均衡现象的定位

第一种:请求数分布严重偏离权重。先检查后端健康状态,被标记为down或者处于fail_timeout周期的机器自然收不到流量。用状态页或者错误日志确认有没有后端被摘除。

第二种:请求数差不多,但某台机器 CPU 明显高。这通常说明请求本身"重量"不一样,同一批请求里有长耗时任务和短任务,轮询无法感知。解决方案是拆 upstream,把重任务单独分一组,或者换成least_conn。

第三种:请求数分布均匀,但长连接场景下新连接分配不均。WebSocket 或者开启了keepalive的场景里,连接建立之后就固定在那儿了,某个时刻的并发连接数取决于历史累计,短时间压测看不出问题,跑上几小时就会偏移。这种情况要么定期重连,要么在后端做连接数均衡。

8.3 内核与 Nginx 层的关键参数

压测打到高并发时,瓶颈往往不在 Nginx 配置而在系统参数。几个我会检查的:

参数建议值作用
net.core.somaxconn65535加大 accept 队列长度
net.ipv4.ip_local_port_range10240 65000扩大可用本地端口范围
net.ipv4.tcp_max_tw_buckets65535容纳更多 TIME_WAIT
net.ipv4.tcp_tw_reuse1允许复用 TIME_WAIT 连接(仅出向)
fs.file-max1000000系统级文件描述符上限
worker_rlimit_nofile65535Nginx 进程可用 fd 数
worker_connections10240单 worker 最大连接数

worker_processes设成auto,让它跟随 CPU 核数;worker_connections要结合文件描述符上限来定,公式是"系统 fd 上限 ÷ worker 进程数"。我遇到过worker_connections配了 65535 但系统ulimit -n只有 1024 的情况,Nginx 启动时不会报错,压测一上来就疯狂报too many open files。所以调参数要成对地调,改完记得 reload 并用状态页确认连接数真的上去了。

另外要避开一个流传很广的错误建议:tcp_tw_recycle。这个参数在较新的内核里已经被移除,在还在支持它的内核上开启后,NAT 环境下的客户端会出现随机连接失败,因为它丢掉了 TCP 时间戳的语义。任何让你打开这个参数的教程,都可以直接关掉。

9. 我踩过的六个坑与对应的修复动作

把上面散落的经验收拢成一张表,这些是我实际在项目里反复踩过、也反复帮别人排查过的:

现象根因修复动作
后端端口被 TIME_WAIT 占满upstream 未配 keepalive加keepalive n,并设proxy_http_version 1.1和清空Connection头
偶发 502,日志有 upstream 重试某后端半死但被动检查发现不了加主动健康检查,探活接口只检查关键依赖
登录态随机丢失路由漂移 + session 存本地session 外置到集中存储,或改 cookie 哈希
整个办公区同时掉线NAT 出口导致ip_hash全部命中一台弃用ip_hash,改轮询加共享 session
手机上提示证书不安全,桌面正常证书链缺中间证书cat server.crt intermediate.crt > fullchain.crt
站点间歇性打不开,无规律内核参数与 Nginx 连接数不匹配成对调整 fd 上限和worker_connections

这六个坑有个共同规律:它们全都不违反 Nginx 的配置语法。nginx -t会说一切正常,reload 也不会报错,只有真实流量、真实用户、真实时间才能暴露。所以我现在的习惯是,任何一次负载均衡相关的改动,都要走一遍"配置检查 → 灰度摘节点 → 上线 → 压测验证 → 观察半小时日志"的流程,任何一个环节跳过,后面都会用更长的时间还回去。

最后分享一个我很依赖的排查小技巧:在 Nginx 日志格式里固定加上$upstream_addr和$upstream_response_time这两个字段。它们几乎能回答关于负载均衡的所有问题——请求到底去了哪台、那台花了多久、有没有发生重试(重试时会记录多个用逗号分隔的地址)。我负责过的每一个线上事故里,这两个字段都提供了最直接的线索,比任何监控大盘都来得快。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询