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。

关联