47.1 反向代理
一句话
反向代理是“代表后端接收请求”的那一层:它终结 TLS、做路由与负载均衡、缓冲、限流、注入可观测信息;价值在于把横切关注点从业务里拿出来,风险在于多了一跳,就多了一个超时、缓冲与连接池的失效点。
落到生产是四步:选型(Nginx / Envoy / HAProxy)→ 一份能过
nginx -t的配置 → 用日志与超时预算验证 → 留一条秒级回滚。少任何一步,都只是“知道反向代理是什么”,不是方案。
它到底替后端做了什么
| 职责 | 具体动作 | 不做会怎样 |
|---|---|---|
| TLS 终结 | 证书管理、握手、HTTP/2 协商 | 每个服务都要管证书与握手 CPU |
| 路由 | 按 host/path/header 分发 | 后端要暴露多个端口,客户端感知拓扑 |
| 负载均衡 | 轮询/最少连接/一致性哈希 | 单实例打满,其它闲着 |
| 缓冲 | 收完请求再转发、收完响应再回传 | 慢客户端拖住后端连接 |
| 压缩 | gzip/br、按 Accept-Encoding 协商 | 带宽浪费、首屏变慢 |
| 限流/鉴权 | 入口统一拦截 | 每个服务重复实现 |
| 可观测 | access log、trace id、上游耗时 | 出问题只能猜 |
🔑 反向代理是唯一能看到“完整请求生命周期”的地方:客户端 IP、TLS 版本、上游耗时、响应大小都在这里。所以它是可观测性最好的埋点位置,也是最容易被塞进过多逻辑的地方(塞多了就变成 ESB 反模式)。
四层还是七层
| 维度 | 四层(L4) | 七层(L7) |
|---|---|---|
| 看得到 | IP/端口/TCP 标志 | HTTP 方法、路径、头、Cookie |
| 转发依据 | 五元组 | 路径/header/权重 |
| 性能 | 更高(不改写、不解析) | 低一些(解析 + 可能的缓冲) |
| 典型 | LVS、NLB、IPVS | Nginx、Envoy、HAProxy、网关 |
| 适合 | 数据库代理、大流量 TCP、需要保留客户端行为 | HTTP API、灰度、鉴权、限流 |
生产上常见“L4 在前 + L7 在后”:L4 扛连接数与 DDoS,L7 做业务路由。L4 看不到 HTTP,所以限流与灰度只能放在 L7。两者的职责边界与算法对比见 8.9 四层与七层负载均衡。
选型:Nginx / Envoy / HAProxy
| 维度 | Nginx(OSS) | Envoy | HAProxy |
|---|---|---|---|
| 配置模型 | 静态 conf + reload | xDS 动态下发,改路由不重启 | conf + runtime API(socat 改权重、摘节点) |
| 上游变更 | 变量 + resolver,或靠控制器渲染配置 | EDS 原生 | runtime API / DNS |
| 协议 | h1/h2,h3 视版本;gRPC 走 grpc_pass | h1/h2/h3/gRPC 原生 | h1/h2,h3 视版本 |
| 主动健康检查 | OSS 只有被动(max_fails/fail_timeout) | 主动 + 被动 + 离群摘除 | 主动 check,能力最强 |
| 扩展方式 | Lua(OpenResty)/ njs / C 模块 | Wasm / Lua | Lua / 多进程 |
| 可观测 | access log + stub_status | stats/Prometheus/tracing 原生 | stats socket + log |
| 典型位置 | 单机或小团队入口、静态资源 + 反代 | K8s/网格数据面、上游频繁变化 | 纯四层/七层 LB、TLS 终结 |
我的判断是:上游少于 50 个、变更按天计,Nginx 足够且最省人;上游按分钟变(K8s 里 Pod 随时重建),别自己写模板生成 nginx 配置,用 Ingress 控制器或 Envoy;只要四层转发与极高连接数,HAProxy/LVS 更直接。
入口流量(南北向)与内部服务互调(东西向)的分工见 47.5 南北向与东西向流量——同一个代理不该同时承担两种流量,这是选型之后紧接着要切的边界。
⚠️ 三种情况别硬上 Nginx:
- 上游几秒一变:OSS 版没有动态 upstream,只能“渲染配置 + reload”。reload 会起新 worker(内存瞬时翻倍),长连接(SSE/WebSocket)还会让老 worker 常驻。
- 要按请求做熔断、配额、协议转换:这些是网关能力,见 47.2 API 网关;具体哪一层该做鉴权与限流,见 47.4 鉴权与限流应放在哪一层。塞进 nginx 会变成 ESB。
- 要 HTTP/3 生产可用、gRPC 双向流治理:Envoy 更成熟(判断,取决于你的版本与团队)。
Nginx 的三个内部概念(看懂配置的前提)
| 概念 | 是什么 | 决定了哪些配置 |
|---|---|---|
| master / worker | master 只解析配置、管 worker;worker 处理连接,通常按核数配(auto) | worker_processes、reload 的语义(换 worker 不换 master) |
| 事件循环 | 每 worker 单线程 epoll、非阻塞;一个阻塞调用就卡住整条 worker | worker_connections 的上限;Lua/njs 里禁止同步 IO |
| 共享内存 zone | 跨 worker 共享状态:限流计数、SSL 会话、缓存索引 | limit_req_zone、ssl_session_cache、proxy_cache_path |
由此得到两个反直觉结论:并发上限是 workers × worker_connections,不是 CPU 核数;limit_req 的 rate=10r/s 是整个 zone 的合计值,不是每个 worker 10r/s——很多“限流没生效/生效过头”的排查,最后都落在这一点上。
TLS 在哪终结
| 终结位置 | 做法 | 代价 | 适合 |
|---|---|---|---|
| 云 L7 LB | 证书托管、自动轮换,回源 HTTP | 多一跳、信任云 | 不想自己管证书 |
| 入口代理(本节配置) | 自己管证书,客户端 h2、限流、日志都在这层 | 证书运维压在自己身上 | 自建入口 |
| 入口 + 回源 TLS | re-encrypt(proxy_ssl_*) | 多一次握手与 CPU | 合规要求端到端加密 |
| 服务间 mTLS | Sidecar/Mesh 做身份 | 需要身份体系 | 零信任内网 |
证书过期是最常见的入口 P0:手工换证书,就一定会在某个周末过期。落地要求是 ACME 自动续期 + 续期后 reload,并对“剩余有效期 < 30 天”告警。ssl_protocols TLSv1.2 TLSv1.3; 关掉 1.0/1.1;浏览器只在 TLS 上协商 h2,所以 h2 与 TLS 终结在同一层最省事。
数字一:轮询在“慢节点”面前会崩
4 台后端:3 台 300 QPS、1 台只有 150 QPS(慢节点)。负载 1000 QPS 持续 10 秒:
| 策略 | 最大等待 | 慢节点最终队列 | 超过 200ms 的请求 |
|---|---|---|---|
| 轮询 | 6664ms | 1000 | 2425 / 10000 |
| 最少连接 | 1ms | 0 | 0 |
| 两次随机取优(P2C) | 40ms | 2 | 0 |
轮询把 25% 的流量(250 QPS)固定压给只能处理 150 QPS 的节点,队列每秒涨 100 条,10 秒后积压 1000 条、等待 6.6 秒——一台慢节点拖垮了整个入口的 P99。
| 策略 | 优点 | 缺点 | 适用 |
|---|---|---|---|
| 轮询 | 简单、均匀 | 无视负载,慢节点必崩 | 后端完全同质 |
| 最少连接 | 自动避开慢节点 | 需要维护计数;突增时抖动 | 请求耗时差异大 |
| P2C(两次随机取优) | 接近最少连接,无需全局状态 | 略差于最优 | 大规模、分布式决策 |
| 一致性哈希 | 缓存命中率高 | 节点变化导致倾斜 | 有状态/缓存场景 |
| 加权 | 按容量分配 | 权重需人工维护 | 异构机器 |
最少连接的实现细节:统计的是“在途请求数”而不是“连接数”。开了 keep-alive 或 HTTP/2 后,一条连接上可能同时有多个请求,只数连接会失真(HTTP/2 下连接数几乎恒定,最少连接退化成轮询)。
数字二:不复用连接,握手就吃掉并发
10 万 QPS、RTT 0.5ms:
不复用:每秒 10 万次 TCP 握手,≈ 50 个连接同时处于“握手中”
复用:每后端 200 条长连接 → 握手只发生 800 次;代理连接池上限 = 800 在途
TLS 还要再加 2 个 RTT(1.0 RTT 的 TLS 1.3 也至少 1 RTT),握手开销翻倍。所以代理到上游必须开 keep-alive,并且连接池大小要 ≥ 并发 × 余量:池子太小,请求在代理内部排队;池子太大,上游连接数暴涨。
⚠️ 代理的 idle timeout 必须小于上游的 keepalive timeout。如果上游 55s 就关空闲连接、代理 75s 才回收,中间 20s 里的请求会拿到一个“已被对端关闭”的连接,表现为偶发 502——这类问题极难复现,因为只发生在时间窗口内。(方向搞反是高频事故:代理收得比上游晚,就等于主动留了一批死连接。)
容量怎么估:先算,再配
在途请求 ≈ QPS × 平均耗时(s) # Little's law
连接数 ≈ 在途请求 × 2 × 1.3 # 客户端侧 + 上游侧,再留 30% 给空闲与突发
单机上限 = worker_processes × worker_connections
算例:8000 QPS、平均耗时 200ms → 在途 1600;连接约 3200,乘 1.3 得 4160。4 个 worker × 1024 = 4096,差一点,先打满的就是它——要么调到 2048/worker,要么加机器。
worker_connections 数的是客户端和上游两侧的总连接,别只按入口并发估。另外三个会偷偷占连接的东西:慢客户端(读响应慢)、大响应、TLS 握手中的连接。再配 limit_conn 防单 IP 占满,worker_rlimit_nofile 给到 worker_connections 的 2 倍(还要留给日志与静态文件)。
缓冲:不是所有响应都能缓冲
| 类型 | 缓冲 | 原因 |
|---|---|---|
| 普通 JSON | 开 | 慢客户端不占后端连接 |
| 大文件下载 | 关(或限流) | 缓冲会把内存打满 |
| SSE / 长轮询 | 关 | 缓冲会让事件延迟到连接关闭才发出 |
| WebSocket | 不适用(升级后是隧道) | 需要 Upgrade/Connection 头透传 |
| 流式 gRPC | 关 | 同 SSE |
Nginx 的 proxy_buffering off、Envoy 的 buffer 配置都是这个开关。缓冲的收益是“隔离慢客户端”,代价是“内存 + 首字节延迟”,按响应类型分别配置,不要全局开或全局关。
上游也可以自己声明:响应头带 X-Accel-Buffering: no,nginx 会对这条响应关缓冲——比在入口按路径猜哪个接口是流式更可靠。
头部:必须处理的几个点
| 头 | 正确做法 | 错误做法 |
|---|---|---|
X-Forwarded-For | 追加客户端 IP 到已有链 | 覆盖(丢失原始 IP) |
X-Forwarded-Proto | 设为 https(TLS 在代理终结) | 不设,后端以为是 http,重定向成 http |
Forwarded(标准) | 按 RFC 7239 格式 | 只支持 XFF,跨厂商不兼容 |
| hop-by-hop 头 | 删除 Connection、Keep-Alive、Transfer-Encoding、Upgrade(除升级场景) | 原样透传,导致上游解析错误 |
Host | 按上游需要改写或保留 | 盲目透传,后端虚拟主机匹配失败 |
安全相关:只信任来自已知代理的 XFF。如果直接信任客户端传来的 X-Forwarded-For,攻击者可以伪造 IP 绕过限流与风控。每一跳具体改哪个字段、Location 怎么回写成对外地址,见 8.10 反向代理与转发策略。
一份能上线的 Nginx 配置
目标:一条
nginx -t就能过,且覆盖 TLS、真实 IP、上游长连接、限流、SSE 与可观测的最小骨架。域名、IP、证书路径换成你自己的。
# /etc/nginx/nginx.conf —— 单机入口骨架(nginx 1.24+ 语法)
user nginx;
worker_processes auto;
worker_rlimit_nofile 65535;
events {
worker_connections 4096; # 每 worker 的连接上限(客户端+上游都算)
multi_accept on;
}
http {
include mime.types;
default_type application/octet-stream;
# ① 日志:四个字段就能定位 90% 的入口问题
log_format main '$remote_addr [$time_local] "$request" $status '
'rt=$request_time urt=$upstream_response_time '
'ua=$upstream_addr us=$upstream_status rid=$req_id';
access_log /var/log/nginx/access.log main;
server_tokens off; # 不向公网暴露版本号
sendfile on;
tcp_nopush on;
keepalive_timeout 65s;
client_max_body_size 10m; # 默认 1m,超大上传直接 413
# ② 客户端带了 request id 就沿用,没有才生成——跨服务追踪要靠它
map $http_x_request_id $req_id {
default $http_x_request_id;
"" $request_id;
}
# ③ 限流:单 IP 10 r/s、突发 20;超出返回 429 而不是 503
limit_req_zone $binary_remote_addr zone=perip:10m rate=10r/s;
limit_conn_zone $binary_remote_addr zone=connperip:10m;
limit_req_status 429;
limit_conn_status 429;
upstream api_backend {
least_conn;
server 10.0.1.11:8080 max_fails=3 fail_timeout=10s;
server 10.0.1.12:8080 max_fails=3 fail_timeout=10s;
keepalive 64; # 每 worker 缓存的空闲长连接上限
keepalive_requests 1000;
keepalive_timeout 55s; # 必须小于上游的 60s,否则复用死连接
}
server {
listen 80;
server_name api.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2; # 1.25.1+ 会告警,可换成 listen 443 ssl; + http2 on;
server_name api.example.com;
ssl_certificate /etc/nginx/certs/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1h;
# ④ 前面还有 L4/云 LB 时才需要:把 $remote_addr 还原成真实客户端
set_real_ip_from 10.0.0.0/8;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
location = /healthz {
access_log off;
return 200 "ok\n";
}
location /api/ {
limit_req zone=perip burst=20 nodelay;
limit_conn connperip 50;
proxy_pass http://api_backend; # 不带 URI:/api/... 原样转给上游
proxy_http_version 1.1;
proxy_set_header Connection ""; # 清掉 hop-by-hop,长连接才生效
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_set_header X-Request-ID $req_id;
proxy_connect_timeout 2s;
proxy_send_timeout 10s;
proxy_read_timeout 15s;
proxy_next_upstream error timeout; # 默认值,别顺手加 502/504
proxy_next_upstream_tries 2;
proxy_buffering on;
proxy_buffers 8 16k;
proxy_buffer_size 16k;
proxy_redirect off; # 前提:上游只发相对地址;发绝对地址改成 default
}
# ⑤ SSE / 流式:单独 location,关缓冲、放宽读超时
location /api/stream/ {
proxy_pass http://api_backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header Accept-Encoding ""; # 不让上游压缩,避免事件攒批
proxy_buffering off;
gzip off;
proxy_read_timeout 3600s; # 按心跳间隔的 2–3 倍给
}
}
# ⑥ 状态页只监听本机:连接数/请求数是判断“谁先打满”的第一手数据
server {
listen 127.0.0.1:8080;
location = /nginx_status {
stub_status;
access_log off;
}
}
}
几个容易抄错的地方:location /api/ 配 proxy_pass http://api_backend;(不带 URI)是原样转发;写成 http://api_backend/ 就会把 /api/ 前缀剥掉。Connection "" 不是可选项——少了它,keepalive 64 完全不生效。
每条参数为什么是这个数
| 参数 | 上线值 | 依据 | 配错的后果 |
|---|---|---|---|
worker_connections | 4096 | 单机并发 = workers × 该值;按“QPS × 耗时 × 2 × 1.3”反推 | 打满后报 worker_connections are not enough,直接 502/超时 |
worker_rlimit_nofile | 65535 | ≥ 2 × worker_connections,还给日志与静态文件留余量 | too many open files,worker 反复重启 |
keepalive(upstream) | 64 | 每 worker 每上游缓存的空闲连接上限,约等于该 worker 并发 / 上游数 | 太小:高 QPS 下握手不断;太大:上游空闲连接堆积 |
proxy_http_version + Connection "" | 1.1 + 空 | HTTP/1.0 不支持长连接;不清 Connection 则 nginx 不缓存连接 | keepalive 形同虚设,TIME_WAIT 暴涨 |
proxy_connect_timeout | 2s | 内网 RTT < 1ms,建连失败应快速摘除 | 后端宕机时请求挂住 60s(默认),worker 被占满 |
proxy_read_timeout | 15s | 接口 P99 的 2–3 倍,且小于调用方超时 | 太短:长任务 504;太长:慢上游拖死 worker |
keepalive_timeout(客户端侧) | 65s | 要大于前置 LB 的空闲超时,让 LB 先关连接 | 比 LB 短:LB 复用到已关闭连接,偶发 502 |
client_max_body_size | 10m | 默认 1m,上传/导入接口必改 | 线上直接 413,日志里是 client intended to send too large body |
limit_req / limit_conn | 10r/s + burst 20 + 50 并发 | 阈值必须来自压测或历史峰值,不能拍脑袋 | burst 太小:正常用户被 429;不配:单 IP 能打满 |
max_fails / fail_timeout | 3 / 10s | 被动健康检查:10s 内 3 次失败则摘除 10s | 默认 1 次就摘,抖动即误摘;太长则故障实例继续接流量 |
proxy_next_upstream | error timeout(+ tries 2) | 默认对已发出的 POST 不重试,除非加 non_idempotent | 加 502/504 或 non_idempotent:POST 被重放;tries 默认 0 = 不限次 |
resolver + valid= | 如 10.0.0.2 valid=10s | proxy_pass 用变量时必需;否则启动时解析一次、永不更新 | 上游 IP 变了不生效,502 到下一次 reload |
超时:三层,缺一不可
| 超时 | 含义 | 典型值 | 缺了会怎样 |
|---|---|---|---|
| connect | 与上游建连 | 1–3s | 后端宕机时请求挂住 |
| read/response | 等上游首字节/完整响应 | 按接口 P99 的 2–3 倍 | 慢接口占满代理 worker |
| idle | 长连接空闲 | 小于上游 keepalive(如 55s vs 60s) | 复用已关闭连接,偶发 502 |
代理的超时要小于上游调用方的超时,否则调用方先放弃、代理还在等,白白占用连接(见 35.5 超时、重试与退避)。这三层在 Nginx 里就是 proxy_connect_timeout、proxy_read_timeout、keepalive_timeout:前两个默认 60s,第三个默认 75s——不写,就是拿默认值上线。
重试:只在“连接没建起来”时安全
| 场景 | 能否重试 | 原因 |
|---|---|---|
| 连接被拒绝/建连超时 | 可以(幂等时) | 请求没发出去 |
| 上游返回 503 | 可以(有上限) | 通常表示过载,重试要配合预算 |
| 响应超时 | 谨慎 | 请求可能已执行 |
| POST 非幂等 | 不要 | 重复下单/扣款 |
代理层的重试会放大流量,必须配重试预算(见 35.5 超时、重试与退避)。很多线上事故的根因就是“代理默认重试 3 次 + 上游超时 5s”,故障时流量翻 3 倍。
Nginx 的默认值要背下来:proxy_next_upstream 默认 error timeout——error 覆盖建连、发送请求、读响应头三个阶段,所以“请求已经发出去”也可能触发换机;但默认不重试已发出的非幂等请求(POST/LOCK/PATCH),除非显式加 non_idempotent。proxy_next_upstream_tries 默认 0,不限次数。
于是“顺手加一条 http_502 提升成功率”有两个坑:对 GET 是把故障流量放大到下一台;对 POST 要么根本不生效,要么你加了 non_idempotent,那就真的会重复下单。
可运行的负载均衡仿真
package main
import (
"fmt"
"math/rand"
)
type backend struct {
capPerMs float64 // 每毫秒能处理多少请求
q float64 // 当前排队
maxQ float64
}
func (b *backend) process() {
b.q -= min(b.q, b.capPerMs)
}
func main() {
// 4 台后端:3 台 300 QPS,1 台只有 150 QPS(慢节点)
mk := func() []*backend {
return []*backend{{300.0 / 1000, 0, 0}, {300.0 / 1000, 0, 0}, {300.0 / 1000, 0, 0}, {150.0 / 1000, 0, 0}}
}
const durationMs = 10_000 // 10 秒,每秒 1000 请求
rnd := rand.New(rand.NewSource(7))
type result struct {
name string
maxWait float64 // ms
slowQ float64
slowMax float64
over200 int
}
var results []result
policies := []string{"轮询", "最少连接", "两次随机取优"}
for _, policy := range policies {
bs := mk()
rr, over200 := 0, 0
var maxWait float64
for ms := 0; ms < durationMs; ms++ {
var idx int
switch policy {
case "轮询":
idx = rr % len(bs)
rr++
case "最少连接":
idx = 0
for i := 1; i < len(bs); i++ {
if bs[i].q < bs[idx].q {
idx = i
}
}
case "两次随机取优":
a, b := rnd.Intn(len(bs)), rnd.Intn(len(bs))
idx = a
if bs[b].q < bs[a].q {
idx = b
}
}
b := bs[idx]
wait := b.q / b.capPerMs // 预计排队等待时间
if wait > maxWait {
maxWait = wait
}
if wait > 200 {
over200++
}
b.q++
if b.q > b.maxQ {
b.maxQ = b.q
}
for _, x := range bs {
x.process()
}
}
results = append(results, result{policy, maxWait, bs[3].q, bs[3].maxQ, over200})
}
fmt.Printf("%-14s %10s %10s %8s %12s\n", "策略", "最大等待", "慢节点队列", "队列峰值", ">200ms 请求")
for _, r := range results {
fmt.Printf("%-14s %8.0fms %9.0f %9.0f %10d/10000\n", r.name, r.maxWait, r.slowQ, r.slowMax, r.over200)
}
// 连接复用:10 万 QPS 时短连接与长连接的握手成本
qps, rttMs := 100_000.0, 0.5
fmt.Printf("\n上游连接:%.0f 万 QPS,每次新建连接要 1 个 RTT(%.1fms)做 TCP 握手,TLS 再加 2 RTT\n",
qps/10000, rttMs)
fmt.Printf(" 不复用:每秒 %.0f 万次握手 ≈ %.0f 个连接同时处于握手中\n",
qps/10000, qps*rttMs/1000)
const perBackend, backs = 200, 4
fmt.Printf(" 复用(每后端 %d 条长连接):握手只发生 %d 次;代理自身的连接池上限 = %d 在途\n",
perBackend, perBackend*backs, perBackend*backs)
}
输出:
策略 最大等待 慢节点队列 队列峰值 >200ms 请求
轮询 6664ms 1000 1001 2425/10000
最少连接 1ms 0 1 0/10000
两次随机取优 40ms 2 7 0/10000
上游连接:10 万 QPS,每次新建连接要 1 个 RTT(0.5ms)做 TCP 握手,TLS 再加 2 RTT
不复用:每秒 10 万次握手 ≈ 50 个连接同时处于握手中
复用(每后端 200 条长连接):握手只发生 800 次;代理自身的连接池上限 = 800 在途
参数怎么给
| 你的场景 | 配置重点 |
|---|---|
| 常规 HTTP API | 七层代理 + 最少连接/P2C + keep-alive + 缓冲开 |
| 大文件下载/视频 | 缓冲关、限速、range 透传、超时放宽 |
| SSE/WebSocket | 升级头透传、缓冲关、idle 超时按心跳定 |
| 灰度发布 | 按 header/cookie/权重路由,保留回切开关 |
| 多租户入口 | 按 host 路由 + 每租户限流(35.7 限流) |
| 内部服务互调 | L4 或 sidecar,避免多层代理叠加 |
改完配置怎么上线:reload、灰度、回滚
| 操作 | 命令 / 做法 | 语义与代价 |
|---|---|---|
| 校验 | nginx -t | 只查语法与文件是否可达;证书过期、上游不可达它不管 |
| 生效 | nginx -s reload | master 起新 worker,老 worker 处理完存量连接后退出 |
| 收尾 | worker_shutdown_timeout 30s; | 否则 SSE/WebSocket 会把老 worker 拖住,内存瞬时翻倍 |
| 灰度 | split_clients 按 cookie/参数分两套 upstream,或改 weight(规则设计见 47.3 路由与灰度) | 回滚 = 改回权重 + reload,秒级 |
| 二进制升级 | kill -USR2 <master pid> | 起新 master;失败则给老 master 发 -HUP、新 master 发 -QUIT |
| 回滚配置 | Git 回退 → nginx -t → reload | 前提:配置在 Git 里,且 CI 用同版本 nginx 校验过 |
reload 不是免费的:每次 reload 都会新起一批 worker,长连接还会拖住老 worker。靠“每分钟渲染配置 + reload”做服务发现的方案,迟早撞上这两条。
配置即代码的三条底线:
- 配置进 Git,线上禁止手改——手改的部分会在下一次发布时消失,故障时没人知道差异在哪。
- CI 用与生产同版本的 nginx 跑
nginx -t,不通过不许合。 - 变更单写清三件事:改了什么、预期哪个指标怎么变、怎么回滚。
验证:不压测也能看出配置对不对
# 1) 语法 + 最终生效值(include 之后),别看源码片段
nginx -t && nginx -T | grep -nE 'keepalive|proxy_read_timeout|worker_connections'
# 2) 一次请求拆四段:DNS / TCP / TLS / 首字节
curl -sS -o /dev/null -w 'dns=%{time_namelookup}s tcp=%{time_connect}s tls=%{time_appconnect}s ttfb=%{time_starttransfer}s total=%{time_total}s\n' \
https://api.example.com/api/users
# 3) 从 access log 算 P50/P99(rt=$request_time,urt=$upstream_response_time)
awk -F'rt=' '{split($2,a," "); print a[1]}' access.log | sort -n | \
awk '{v[NR]=$1} END{printf "p50=%.3fs p99=%.3fs n=%d\n", v[int(NR*0.5)], v[int(NR*0.99)], NR}'
# 4) 连接花在哪:客户端侧 vs 上游侧
ss -s
ss -tn state established '( dport = :8080 )' | wc -l # 上游长连接数应稳定,不随 QPS 线性涨
三条判断就够了:TLS 段占比高 → 会话复用/证书链有问题;urt 高而 rt 不高 → 上游慢,不是代理;rt 高而 urt 低 → 请求在代理里排队(连接池、worker 数、缓冲)。
502/504 直接对着 error.log 的关键字查:
| error.log 关键字 | 含义 | 先查什么 |
|---|---|---|
connect() failed (111: Connection refused) | 上游没监听 | 端口、进程、是否刚重启 |
no live upstreams | 全被 max_fails 摘除 | 上游健康、fail_timeout 是否过长 |
upstream prematurely closed connection | 上游主动断开 | 上游 OOM、上游 keepalive 小于代理 |
upstream timed out (110) | 上游超时:看后半句是 while connecting 还是 while reading | 连不上查网络/proxy_connect_timeout;读超时查上游 P99/proxy_read_timeout |
SSL_do_handshake() failed | 回源 TLS 失败 | 证书链、SNI、协议版本 |
client intended to send too large body | 413 | client_max_body_size |
上线检查清单
- [ ]
nginx -t在 CI 和发布机上各跑一遍,用的是同版本 nginx - [ ] access log 里的客户端 IP 是真实 IP,不是前置 LB 的
- [ ] 上游长连接生效:
ss里 ESTAB 数稳定,不随 QPS 线性增长 - [ ] 超时预算成立:connect < read < 调用方超时,越靠外越大
- [ ] 502/504/429 都有日志关键字告警,不是等用户报障
- [ ] 限流阈值来自压测或历史峰值,有据可查
- [ ] 证书自动续期,且对“剩余有效期 < 30 天”告警
- [ ] 大文件与 SSE 路由单独验证过(缓冲、超时、压缩)
- [ ] 回滚演练过:改错一条配置,能在 5 分钟内恢复
- [ ] 上游下线顺序演练过:先摘 LB → 等 2 个健康检查周期 → 再停进程
反模式
| 反模式 | 后果 |
|---|---|
| 轮询分发到异构/慢节点 | 慢节点积压,P99 被一台机器拖垮 |
| 只数连接不数在途请求 | HTTP/2 下最少连接退化成轮询 |
| 代理 idle 超时 > 上游 keepalive | 复用已被对端关闭的连接,偶发 502 |
proxy_pass 末尾多写一个 / | 路径前缀被剥掉,上游 404 |
配了 keepalive 却没配 Connection "" | 长连接不生效,高 QPS 下 TIME_WAIT 暴涨 |
在 location 里用 if 做路由 | 行为不可预期(if is evil),排查成本极高 |
| 上游地址写死在配置里 | 扩容/迁移要改配置 + reload,故障时只能手动兜底 |
| 证书手工续期 | 到期当天全站不可用,而且常在周末 |
只在测试环境跑过 nginx -t | 版本/模块差异到线上才暴露 |
| 全局开响应缓冲 | 大文件打满内存,SSE 事件延迟 |
| 无条件透传客户端 XFF | 伪造 IP 绕过限流/风控 |
| 透传 hop-by-hop 头 | 上游解析错误、连接复用异常 |
| 任一层默认重试 3 次且无预算 | 故障时流量 ×3,雪崩加速 |
| 在代理里写业务逻辑 | 变成 ESB,改一次配置影响全站 |
| 上下游都设超时但没有预算关系 | 上游先放弃、下游还在跑,资源浪费 |
| 健康检查只探端口 | 进程活着但依赖全挂,流量照打 |
自检
Q1 4 台后端里 1 台只有一半性能,轮询策略下最大等待 6.6 秒、2425 个请求超过 200ms。为什么“平均分配”会造成这么严重的后果?
▸ 答案:轮询分配的是请求数,不是处理能力。慢节点每秒只能处理 150 个,却被分到 250 个,每秒净积压 100 个;队列在 10 秒内涨到 1000,等待时间 = 队列 / 处理速率 = 1000/0.15 ≈ 6.6 秒。而且积压是单向恶化的:等待越久,客户端越可能超时重试,流量进一步增加。最少连接/ P2C 通过“看过载信号”把流量移走,等待回到毫秒级。
Q2 最少连接在 HTTP/2 场景下为什么会退化成轮询?怎么修?
▸ 答案:最少连接统计的是“连接数”时,HTTP/2 的多路复用让每条连接长期存在、承载的流数量不同但连接数恒定,于是所有后端“连接数”都一样,选择退化成轮询。修法是统计**在途请求数(in-flight streams)**而不是连接数:Envoy 的 least_request 用 active_requests,Nginx 的 least_conn 在 HTTP/2 下需要注意版本行为。另一个办法是用 P2C + 在途数,天然适配多路复用。
Q3 上游 60 秒关空闲连接、代理 75 秒才回收,会出现什么问题?为什么难查?
▸ 答案:在 60–75 秒这个窗口里,代理池里保留着已被上游关闭的连接,新请求复用它会立刻收到 RST,表现为偶发 502/连接重置。难查的原因:① 只在“连接空闲超过 60 秒又被复用”时触发,与请求内容无关;② 频率取决于流量波动,压测时可能完全复现不了;③ 日志里只有一个 502,没有“连接年龄”信息。修法:代理 idle timeout 小于上游 keepalive timeout(如 55s vs 60s);健康检查这一层,Nginx OSS 只有被动的 max_fails/fail_timeout,要主动检查得用 Plus 或外部探针。
Q4 为什么代理层的自动重试很危险?什么情况下可以重试?
▸ 答案:代理的重试发生在“它不知道请求是否已执行”的位置——响应超时可能代表后端已经扣款成功。盲目重试会重复下单、重复发券;同时故障时重试会放大流量(3 次重试 = 3 倍压力),加速雪崩。可以重试的情况:连接被拒绝/建连失败(请求肯定没发出去)、幂等请求(GET/PUT)、上游明确返回 503 且带重试预算。安全做法是把重试决策交给知道幂等语义的调用方,代理只重试“确定没被处理”的那一档(建连失败)。
Q5 反向代理和 API 网关的边界在哪?什么时候不该再加一层代理?
▸ 答案:反向代理解决“传输与分发”(TLS、路由、LB、缓冲、压缩),API 网关在此之上加“API 治理”(鉴权、配额、协议转换、版本、开发者门户)。每加一层代理就多一次网络跳转、一套超时/连接池/证书配置,以及一个故障点;如果只是为了“统一加个 header”,用 sidecar 或库更划算。判断标准:这一层是否承担了独立于业务的横切职责,且能被多个服务复用;如果只有一两个服务用,就不值得加。
Q6 配置里写了 keepalive 64;,但 ss 看到发往上游的连接仍随 QPS 线性增长、TIME_WAIT 很高。最可能是哪两处漏了?
▸ 答案:① 没配 proxy_http_version 1.1 和 proxy_set_header Connection ""——HTTP/1.0 默认不支持长连接,Connection 不清掉 nginx 也不会缓存连接;② 把 keepalive 64 当成了并发上限——它只是每个 worker、每个 upstream 缓存的空闲连接数,上限是 workers × 64,并发超过它照样新建。排查顺序:先 nginx -T | grep -A3 keepalive 看最终配置,再看 ss 的 ESTAB 数与 QPS 是否同向增长。
Q7 8000 QPS、平均耗时 200ms、4 个 worker、worker_connections 1024,够不够?
▸ 答案:不够。在途请求 ≈ 8000 × 0.2 = 1600;客户端 + 上游两侧约 3200 条连接,再留 30% 余量约 4160,已经超过 4 × 1024 = 4096。这还没算慢客户端、大响应与 TLS 握手占用的连接。修法:worker_connections 提到 2048,或加机器;同时把 worker_rlimit_nofile 给到 2 倍以上。注意 worker_connections 数的是两侧总连接,不是入口并发。
Q8 发布窗口里 502 集中在“正在被下线”的实例上,按什么顺序排查?
▸ 答案:① 摘流顺序——先让实例从 LB/注册中心摘除,等 2 个健康检查周期,再停进程;反了就是“停进程时还挂着流量”。② 连接生命周期——实例退出时把 keepalive 池里的连接 RST 掉,代理如果 idle 比上游长,就会复用死连接;上游优雅退出要留 drain 时间,代理侧 idle 要小于上游 keepalive。③ 被动健康检查——max_fails/fail_timeout 会把“正在退出但还没死”的实例继续当健康,要配合主动摘流,而不是靠它兜底。日志特征是 upstream prematurely closed connection 或 Connection refused。