第 7 篇:Nginx 限流、日志与故障排查——从入口流量保护到全链路可观测性
专栏:《Nginx 从入口代理到云原生流量治理》
文章序号:第 7 篇
Nginx 实验版本:Nginx Open Source 1.30.3(稳定版)
当前主线版本:1.31.2
后端实验版本:Spring Boot 4.1.0、Java 21
实验环境:Docker Compose、Linux、curl、hey
更新时间:2026-07-08
本文在专栏中的位置
前六篇已经逐步完成了一条可用于生产系统的 Nginx 入口链路:
- 第一篇确定 Nginx 在后端架构中的位置;
- 第二篇讲清配置层级以及
server、location的匹配过程; - 第三篇完成反向代理、Header、超时、缓冲、WebSocket 与 SSE;
- 第四篇将单实例代理扩展为多实例负载均衡与高可用;
- 第五篇完成 TLS、证书自动续期和安全入口;
- 第六篇完成静态资源、浏览器缓存、代理缓存、压缩和 CDN 协作。
到这里,请求已经能够被安全接入、高效转发和分层缓存。然而系统仍然缺少两个生产环境不可缺失的能力:
- 保护能力:当流量超过后端容量、单个用户刷接口、下载连接长期占用资源时,入口层应当主动削峰和拒绝;
- 解释能力:当用户报告慢、502、504、429 或“偶发失败”时,团队应当能够通过日志和 Trace ID 判断问题发生在哪一层。
因此,第七篇不是简单罗列 limit_req、limit_conn 和 access_log 指令,而是建立一套完整闭环:
恢复可信客户端身份 ↓按用户、接口和全局容量实施分级限流 ↓记录结构化请求与 upstream 指标 ↓通过 Request ID 关联应用和下游日志 ↓按状态码、耗时和网络层级定位根因 ↓复盘并调整容量、阈值、降级和告警独立插图 01:入口流量保护与可观测性全景
对应文件:01-traffic-protection-observability-overview.png
下一篇将把这套单机和容器入口能力带入 Kubernetes,讨论 Nginx 容器化、Ingress、Gateway API、配置热更新和灰度发布。
学习目标
学完本文后,你应当能够:
- 区分请求速率、并发请求、TCP 连接数和后端线程数;
- 解释
limit_req的漏桶模型,而不是把它简单理解成“每秒计数器”; - 正确配置
limit_req_zone、rate、burst、delay和nodelay; - 判断请求应该排队、立即放行还是拒绝;
- 使用
limit_req_dry_run在不拦截流量的前提下评估策略; - 使用
$limit_req_status区分 PASSED、DELAYED、REJECTED 等状态; - 理解
limit_conn实际统计哪些连接; - 说明 HTTP/2 和 HTTP/3 下并发请求的计数语义;
- 为下载、SSE、WebSocket 和慢接口选择合适的保护方式;
- 使用
map、geo、API Key、用户 ID 和接口路径实现分级限流; - 避免 NAT 出口、反向代理和伪造
X-Forwarded-For导致的误伤; - 设计可被日志平台可靠解析的 JSON Access Log;
- 区分
$request_time、$upstream_connect_time、$upstream_header_time和$upstream_response_time; - 解释 upstream 重试时为什么日志变量可能出现多个逗号分隔值;
- 使用
$request_id或可信上游 ID 串联 Nginx、Spring Boot 和下游服务日志; - 区分 Access Log、Error Log、业务日志、指标和分布式 Trace 的职责;
- 判断 400、401、403、404、405、413、429、499、500、502、503、504 的常见生成方;
- 使用
nginx -t、nginx -T、curl、ss、dig、openssl、tcpdump等工具逐层排查; - 完成 Docker Compose + Spring Boot 限流、日志和故障演练;
- 建立从告警、定位、恢复到复盘的生产故障处理流程。
一、问题背景:入口层既要“放行”,也要“拒绝”
1.1 没有限流时,入口只是故障放大器
假设一个搜索接口稳定容量为 300 QPS,数据库查询平均需要 80 ms。正常流量为 150 QPS 时,系统工作良好。某个营销活动开始后,流量突然到达 1,500 QPS。
如果 Nginx 不做任何保护,它会尽力把请求全部转发给后端。结果通常不是“后端处理得慢一点”,而是形成级联故障:
- 应用线程池被占满;
- 请求在应用线程池或连接池中排队;
- 数据库连接池耗尽;
- 数据库出现慢查询与锁竞争;
- 健康检查也无法及时完成;
- Nginx 开始记录 502、504;
- 客户端自动重试进一步放大流量;
- 原本健康的接口也被拖垮。
入口层如果只负责接收和转发,它会把外部突发毫无缓冲地传递给后端。真正的生产入口必须同时承担:
- 流量整形;
- 容量保护;
- 异常主体隔离;
- 明确拒绝;
- 保护结果可观测。
1.2 限流不是安全鉴权,也不是无限容量
限流能降低刷接口、突发流量和资源争用造成的风险,但它不能代替:
- 登录和身份认证;
- 权限校验;
- WAF 和攻击特征识别;
- 业务配额;
- 数据库隔离;
- 熔断与降级;
- 应用幂等;
- 云厂商 DDoS 防护。
例如,攻击者拥有大量不同 IP 时,单纯按 IP 限流可能失效;大量正常用户共享企业 NAT 出口时,按 IP 限流又可能误伤。
因此,限流 Key 必须根据系统身份模型设计,而不是看到示例就复制:
limit_req_zone $binary_remote_addr zone=per_ip:10m rate=10r/s;1.3 限流策略必须由容量数据驱动
合理阈值至少需要以下输入:
- 后端单实例稳定吞吐;
- 实例数量和负载均衡方式;
- 数据库、缓存和第三方依赖容量;
- 接口平均和 P99 耗时;
- 请求体与响应体大小;
- 是否存在自动重试;
- 用户正常操作频率;
- 可接受的排队时延;
- 峰值与日常流量比例;
- 业务是否允许快速失败。
一种可操作的思路是:
入口总限额≈ 后端稳定容量× 安全系数− 内部任务与健康检查预算安全系数不应固定照搬。写接口、慢接口和强依赖数据库的接口通常需要更保守;纯缓存命中的公共查询接口可以更宽松。
二、流量保护模型:速率、突发、并发和总容量
独立插图 02:limit_req 漏桶限速模型
对应文件:02-limit-req-leaky-bucket.png

2.1 limit_req 限制的是请求处理速率
Nginx 的 ngx_http_limit_req_module 使用漏桶方法限制按 Key 计算的请求处理速率。
基础配置:
http { limit_req_zone $binary_remote_addr zone=per_ip:10m rate=5r/s;
server { location /api/search { limit_req zone=per_ip burst=10; proxy_pass http://backend; } }}这段配置可以拆成两个阶段:
阶段一:定义共享状态区
limit_req_zone $binary_remote_addr zone=per_ip:10m rate=5r/s;$binary_remote_addr:限流 Key;zone=per_ip:10m:名为per_ip的 10 MB 共享内存区;rate=5r/s:每个 Key 的稳定处理速率;- 共享内存由 Worker 共同使用,因此不是“每个 Worker 各 5r/s”。
limit_req_zone 只能放在 http 上下文中。它只是定义状态区,不会自动限制任何请求。
阶段二:在具体位置启用
limit_req zone=per_ip burst=10;limit_req 可以配置在 http、server 或 location。它把前面定义的共享区应用到当前请求处理链路。
2.2 Key 决定了“谁共享同一个额度”
常见 Key:
# 客户端 IP$binary_remote_addr
# 经过可信 Real IP 处理后的客户端 IP$binary_remote_addr
# API Key$http_x_api_key
# 应用鉴权后回传给 Nginx 的用户 ID$http_x_user_id
# 租户 + 用户"$http_x_tenant_id:$http_x_user_id"
# 接口 + 用户"$uri:$http_x_user_id"Key 设计错误会导致两类问题:
Key 过粗
所有请求共用一个 Key:
limit_req_zone "global" zone=one:10m rate=100r/s;这可以保护总容量,但任意一组高流量用户都可能挤占其他用户。
Key 过细
把高基数字段直接放入 Key:
"$request_uri:$http_user_agent:$http_x_random_id"大量不同值会快速消耗共享内存,并让攻击者通过不断变化 Key 绕过限制。
比较合理的生产方案往往同时使用多个层次:
每用户 / 每 IP 限制 +接口级或虚拟主机总量限制2.3 为什么常使用 $binary_remote_addr
$binary_remote_addr 使用固定长度二进制形式保存 IP:
- IPv4 为 4 字节;
- IPv6 为 16 字节。
相比字符串形式的 $remote_addr,它通常能更节省共享内存。它并不会自动解决“真实客户端 IP”问题;如果 Nginx 前面还有 CDN 或云负载均衡器,必须先正确配置 Real IP 信任链。
2.4 rate 是平滑速率,不是自然秒窗口计数
配置:
rate=5r/s不能简单理解为:
每个自然秒开始时重置计数,允许瞬间打入 5 个请求漏桶模型更接近于按固定节奏处理请求。5r/s 大致意味着平均每 200 ms 释放一个处理机会。
这也是为什么短时间连续发出多个请求时,即使“这一秒总数没有明显超过 5”,仍可能触发 DELAYED 或 REJECTED。
2.5 rate 可以按秒或按分钟
rate=10r/s;rate=30r/m;当需要表达“每分钟少量操作”时,r/m 比使用很小的每秒速率更直观,例如:
- 密码找回;
- 短信验证码;
- 导出任务创建;
- 高成本模型调用。
三、burst、delay 和 nodelay
独立插图 03:burst、delay 与 nodelay 行为对比
对应文件:03-burst-delay-nodelay-comparison.png

3.1 没有 burst 时几乎不允许突发
limit_req zone=per_ip;默认 burst=0。当请求到达速度超过稳定速率时,超额请求会很快被拒绝。
用户正常点击页面时,经常会并发加载多个 API,因此生产环境很少只设置 rate 而完全没有 burst。
3.2 默认行为:超额请求排队
limit_req zone=per_ip burst=10;超出稳定速率但仍在 burst 范围内的请求会被延迟,使实际处理速度保持在 rate 附近。超过等待区容量后,请求被拒绝。
这种方式能平滑突发,但也会引入入口排队:
总延迟= Nginx 排队时间+ upstream 处理时间+ 响应传输时间如果 burst 设置很大,用户可能不是立即收到 429,而是在 Nginx 中等待很久后才得到响应。
3.3 nodelay:burst 内立即处理
limit_req zone=per_ip burst=10 nodelay;nodelay 不会取消限流。它表示:
- 仍然按照漏桶状态计算额度;
- burst 范围内的超额请求不在 Nginx 排队;
- 请求立即传递给后端;
- 已占用的 burst 槽位需要随着时间恢复;
- burst 满后仍然拒绝。
因此,nodelay 保护的是平均速率和持续突发,不会平滑瞬时并发。后端必须有能力承受 burst 内请求同时到达。
3.4 delay=N:部分立即处理
limit_req zone=per_ip burst=20 delay=5;其思想是:
- 前一部分超额请求不延迟;
- 超出
delay阈值后的请求按速率排队; - 超过 burst 总容量后拒绝。
适合既需要容纳小规模正常并发,又不希望把大突发全部立即压给后端的接口。
3.5 如何选择
| 接口 | 建议 |
|---|---|
| 登录、验证码 | 低 rate、小 burst,通常快速拒绝 |
| 普通查询 | 允许小 burst,可使用 nodelay |
| 高成本搜索 | 小 burst,允许排队但限制最大等待 |
| 创建订单 | 应用配额和幂等优先,入口限流作为保护 |
| 模型推理 | 用户级 + 全局级,常需要排队系统 |
| 下载 | limit_conn 与带宽限制通常更重要 |
| SSE/WebSocket | 连接数和生命周期控制更重要 |
四、拒绝状态、日志和 Dry Run
4.1 默认拒绝状态是 503
limit_req 和 limit_conn 默认拒绝状态码均为 503。对于对外 API,很多团队更愿意显式返回 429:
limit_req_status 429;limit_conn_status 429;429 的语义更清楚,但是否附加 Retry-After、客户端是否自动重试以及重试间隔,仍需业务协议约定。
不要让所有客户端在同一秒立即重试,否则会形成重试风暴。客户端应采用:
- 指数退避;
- 随机抖动;
- 最大重试次数;
- 仅对幂等请求自动重试。
4.2 $limit_req_status
该变量可记录当前请求的限流结果,例如:
PASSED;DELAYED;REJECTED;DELAYED_DRY_RUN;REJECTED_DRY_RUN。
将它加入 Access Log:
log_format api_json escape=json '{' '"request_id":"$request_id",' '"status":$status,' '"limit_req_status":"$limit_req_status"' '}';
access_log /var/log/nginx/access_json.log api_json;仅看 429 数量不够。你还需要知道:
- 哪个接口被限制;
- 哪个 Key 被限制;
- 是持续超额还是瞬时突发;
- 是否影响正常用户;
- 后端当时是否已经过载;
- 请求是否在 Nginx 中延迟。
4.3 Dry Run 先观测再拦截
limit_req_dry_run on;limit_conn_dry_run on;Dry Run 模式会正常计算和记录超额状态,但不会真正延迟或拒绝请求。
推荐上线流程:
设计 Key 与阈值→ Dry Run→ 观察至少一个完整业务周期→ 分析误伤和峰值→ 小流量启用→ 监控 429、延迟与业务成功率→ 逐步扩大直接在生产环境复制一个未经数据验证的 rate=1r/s,通常会造成误伤。
4.4 日志级别
limit_req_log_level warn;limit_conn_log_level warn;如果高流量场景下大量请求被拒绝,把每条拒绝都写成 error 可能造成日志噪声和额外 I/O。日志级别应与告警规则配合,不要仅依赖 error log 计数。
五、limit_conn:限制并发中的请求
独立插图 04:limit_conn 与 limit_req 对比
对应文件:04-limit-conn-vs-limit-req.png

5.1 基础配置
http { limit_conn_zone $binary_remote_addr zone=per_ip_conn:10m; limit_conn_zone $server_name zone=per_server_conn:10m;
server { limit_conn per_server_conn 1000;
location /download/ { limit_conn per_ip_conn 2; } }}这里分别限制:
- 单个客户端 IP 同时处理的下载请求;
- 当前虚拟主机的总并发请求数量。
5.2 不是所有已建立 TCP 连接都会被计数
官方语义是:只有当连接正在处理请求,并且完整请求头已经读取时,才计入限制。
因此不能把 limit_conn 简单解释成操作系统 ss 看到的 TCP ESTABLISHED 数量。
还需要区分:
- 空闲 Keep-Alive 连接;
- 正在读取请求体的连接;
- 正在等待 upstream 的请求;
- 正在向慢客户端发送响应的请求;
- HTTP/2 一条 TCP 连接上的多个并发 Stream。
5.3 HTTP/2 与 HTTP/3
在 HTTP/2 和 HTTP/3 中,每个并发请求会被视为单独连接进行 limit_conn 计数。
这意味着:
1 条 HTTP/2 TCP 连接≠ limit_conn 只计 1浏览器可能在一条连接上并发发送多个请求,连接限制仍可以约束并发请求数量。
5.4 适用场景
下载
location /download/ { limit_conn per_ip_conn 2; limit_rate_after 5m; limit_rate 2m;}单用户同时下载过多大文件会占用:
- 文件描述符;
- 网络带宽;
- Nginx 连接;
- upstream 连接;
- 磁盘 I/O。
SSE
SSE 请求可能持续数分钟或数小时。QPS 不高,但每个连接长期占用资源,因此连接限制比单纯速率限制更重要。
WebSocket
WebSocket 完成 Upgrade 后进入长连接。入口需要同时考虑:
- 单 IP 连接数;
- 单用户连接数;
- 全局连接容量;
- Worker 连接上限;
- upstream 连接上限;
- 心跳和空闲超时。
慢接口
接口 QPS 看起来不高,但平均耗时很长时,并发数可能很高:
并发量 ≈ 吞吐量 × 平均响应时间10 QPS、平均耗时 20 秒,稳定并发约为 200。此时仅按 QPS 限制不足以保护后端。
六、真实客户端 IP 与信任边界
6.1 前面存在代理时,$remote_addr 可能只是代理地址
典型链路:
用户→ CDN→ 云负载均衡器→ NginxNginx 看到的 TCP 对端可能是云负载均衡器,而不是用户。如果直接按 $binary_remote_addr 限流,所有用户可能共享同一个额度。
6.2 不能无条件相信 X-Forwarded-For
公网客户端可以主动发送:
X-Forwarded-For: 1.2.3.4如果 Nginx 无条件使用该值,攻击者可以不断伪造地址绕过限流,日志中的客户端 IP 也会失真。
正确做法是只信任确定的上游代理网段:
set_real_ip_from 10.0.0.0/8;set_real_ip_from 192.168.0.0/16;
real_ip_header X-Forwarded-For;real_ip_recursive on;ngx_http_realip_module 不是所有自编译 Nginx 的默认模块。需要使用:
nginx -V 2>&1 | grep http_realip_module确认构建参数。
6.3 real_ip_recursive on 的意义
启用递归查找后,Nginx 会在代理链中寻找最后一个非受信任地址作为客户端地址。
信任链必须与实际网络拓扑一致:
只信任 CDN / LB 的固定出口而不是信任 0.0.0.0/06.4 多用户共享 NAT
即使 Real IP 正确,同一企业、学校、家庭或移动网络仍可能共享公网出口 IP。
因此,成熟策略通常是:
- 未登录:按 IP 较宽松限制;
- 已登录:按用户 ID 或 API Key 限制;
- 高成本接口:用户级 + 全局级双重限制;
- 合作方:按客户端凭据和合同配额;
- 内部服务:按服务身份和调用方维度。
独立插图 05:分级限流策略
对应文件:05-tiered-rate-limit-policy.png
七、使用 map 与 geo 构建分级策略
7.1 IP 白名单
geo $limit_whitelist { default 0; 10.0.0.0/8 1; 192.168.0.0/16 1;}再通过 map 生成空 Key:
map $limit_whitelist $limit_key { 1 ""; 0 $binary_remote_addr;}请求 Key 为空时不会被计入共享区:
limit_req_zone $limit_key zone=public_api:20m rate=20r/s;这比在多个 location 中复制大量 if 更可控。
7.2 按 API Key 分类
map $http_x_api_key $client_tier { default anonymous; "partner-a" premium; "partner-b" standard;}Nginx 原生 limit_req 不能根据变量动态改变同一个 zone 的 rate。常见做法是定义多个 zone,然后路由到不同 location 或由上游 API Gateway/应用配额系统处理。
例如:
limit_req_zone $http_x_api_key zone=premium:10m rate=100r/s;limit_req_zone $http_x_api_key zone=standard:10m rate=20r/s;limit_req_zone $binary_remote_addr zone=anonymous:10m rate=5r/s;复杂套餐配额、按月计费和分布式多入口一致额度,通常不适合只靠单机 Nginx 共享内存完成。
7.3 单主体限制与全局限制叠加
limit_req_zone $binary_remote_addr zone=per_ip:20m rate=10r/s;limit_req_zone $server_name zone=per_server:10m rate=500r/s;
location /api/expensive { limit_req zone=per_ip burst=10 nodelay; limit_req zone=per_server burst=100;}两个限制同时生效:
- 防止单个主体独占;
- 防止大量不同主体一起压垮后端。
注意继承规则:如果当前层定义了自己的 limit_req,父层同类配置不会按“自动追加”方式继承。应通过 nginx -T 检查最终展开配置。
八、Access Log:从文本记录升级为结构化事件
独立插图 06:结构化 Access Log 数据流
对应文件:06-structured-access-log-pipeline.png

8.1 Access Log 应回答什么
一条可用于生产排障的访问日志至少应回答:
- 谁请求;
- 请求了什么;
- 什么时候请求;
- 返回什么状态;
- 总耗时多久;
- 是否命中限流;
- 访问了哪个 upstream;
- upstream 连接耗时;
- upstream 首字节耗时;
- upstream 完整响应耗时;
- 是否发生重试;
- 请求和响应大小;
- Request ID 是什么。
默认 combined 格式适合人工阅读,但不够支撑复杂排障。
8.2 JSON 日志格式
log_format api_json escape=json '{' '"timestamp":"$time_iso8601",' '"request_id":"$request_id",' '"remote_addr":"$remote_addr",' '"realip_remote_addr":"$realip_remote_addr",' '"host":"$host",' '"server_name":"$server_name",' '"method":"$request_method",' '"request_uri":"$request_uri",' '"uri":"$uri",' '"protocol":"$server_protocol",' '"status":$status,' '"request_length":$request_length,' '"bytes_sent":$bytes_sent,' '"body_bytes_sent":$body_bytes_sent,' '"request_time":$request_time,' '"upstream_addr":"$upstream_addr",' '"upstream_status":"$upstream_status",' '"upstream_connect_time":"$upstream_connect_time",' '"upstream_header_time":"$upstream_header_time",' '"upstream_response_time":"$upstream_response_time",' '"limit_req_status":"$limit_req_status",' '"limit_conn_status":"$limit_conn_status",' '"http_referer":"$http_referer",' '"http_user_agent":"$http_user_agent"' '}';必须使用:
escape=jsonURI、User-Agent 和 Header 可能包含引号、反斜杠、换行或控制字符。手工拼接但不进行 JSON 转义,会生成无法解析甚至可被日志注入污染的内容。
8.3 不要记录敏感信息
通常不应直接记录:
Authorization;- Cookie 全量;
- Session ID;
- 密码;
- Token;
- 请求体;
- 身份证、手机号等隐私字段;
- URL 中的签名参数。
即使排障需要,也应:
- 脱敏;
- 只记录哈希或末尾少量字符;
- 设置短保留周期;
- 限制查询权限;
- 建立审计记录。
8.4 日志写文件还是 stdout
传统主机
access_log /var/log/nginx/access_json.log api_json;error_log /var/log/nginx/error.log warn;需要配合:
- logrotate;
- 磁盘容量告警;
- 文件权限;
- 日志采集 Agent;
- 保留周期。
Docker / Kubernetes
官方容器镜像通常将日志连接到 stdout/stderr。容器环境推荐让运行平台统一收集日志,不要把日志永久写在容器可写层。
但高流量系统仍应关注:
- stdout 阻塞;
- 日志采集背压;
- 单条日志过大;
- 多行日志解析;
- 容器运行时日志轮转;
- 节点磁盘被日志写满。
8.5 条件日志
例如只记录非健康检查请求:
map $request_uri $loggable { default 1; /actuator/health 0;}
access_log /var/log/nginx/access_json.log api_json if=$loggable;也可以将错误请求单独写入文件,但不要为了节省空间完全丢弃正常请求,否则无法比较基线和异常。
九、耗时变量:慢请求究竟慢在哪里
独立插图 07:请求耗时拆解
对应文件:07-request-timing-breakdown.png

9.1 $request_time
表示从读取客户端请求首字节开始,到写入 Access Log 前的总时间。
它可能包含:
- 客户端慢速上传请求体;
- Nginx 限流排队;
- upstream 连接;
- upstream 处理;
- upstream 响应传输;
- Nginx 缓冲;
- 向慢客户端发送响应。
因此:
request_time 很高不等于后端业务处理一定很慢9.2 $upstream_connect_time
表示建立 upstream 连接所需时间。若 upstream 使用 TLS,还包括握手时间。
高值可能指向:
- 后端端口连接慢;
- 网络丢包;
- SYN 重传;
- 后端连接队列满;
- DNS 解析或地址问题;
- TLS 握手成本;
- 未复用连接。
9.3 $upstream_header_time
从建立请求链路到收到 upstream 响应头的时间。
它常用于近似判断:
后端开始返回结果前花了多久如果 connect 很低、header 很高,通常应进一步检查:
- 应用线程池排队;
- 数据库慢查询;
- 锁竞争;
- 下游调用;
- GC;
- 业务计算。
9.4 $upstream_response_time
表示接收 upstream 完整响应所需时间。大响应、流式响应和后端慢速发送会使它增大。
9.5 多值意味着重试或多 upstream 阶段
例如:
{ "upstream_addr": "10.0.1.10:8080, 10.0.1.11:8080", "upstream_status": "502, 200", "upstream_connect_time": "0.001, 0.002", "upstream_response_time": "0.010, 0.120"}表示请求先访问第一个节点得到 502,然后切换到第二个节点成功。
日志分析系统不能假设这些字段永远只有一个数字。
9.6 常见组合判断
| 现象 | 初步方向 |
|---|---|
| request_time 高,upstream_response_time 低 | 客户端慢、限流排队、上传或下行慢 |
| connect_time 高 | 网络、后端监听队列、连接建立 |
| connect 低,header 高 | 应用处理、数据库、下游依赖 |
| header 低,response 高 | 大响应、流式响应、后端慢速输出 |
| upstream 字段为空 | Nginx 直接响应、未进入代理或提前拒绝 |
| 多个 upstream 值 | 重试、内部跳转或多个 upstream 阶段 |
十、Trace ID:把不同日志串成一次请求
独立插图 08:Trace ID / Request ID 传播链路
对应文件:08-trace-id-propagation.png

10.1 Nginx 内置 $request_id
Nginx 提供 $request_id 变量,可用于当前请求的唯一标识:
proxy_set_header X-Request-ID $request_id;add_header X-Request-ID $request_id always;并写入 Access Log:
'"request_id":"$request_id"'Spring Boot 读取 X-Request-ID 后写入 MDC:
Nginx access logrequest_id=abc...
Spring Boot logrequest_id=abc...
下游调用X-Request-ID: abc...10.2 保留上游 ID 还是重新生成
如果 Nginx 前面有可信 CDN、云 LB 或 API Gateway,它可能已经生成 Request ID。
一种策略:
map $http_x_request_id $request_correlation_id { default $http_x_request_id; "" $request_id;}但公网客户端也能主动发送 X-Request-ID。若直接接受,需要至少考虑:
- 最大长度;
- 字符集;
- 换行和控制字符;
- 是否允许覆盖;
- 是否可能造成日志注入;
- 是否要求全局唯一。
更稳妥的方案是:
- 仅信任受控上游传入;
- 公网入口始终生成内部 ID;
- 原始外部 ID 作为单独字段保留并严格转义。
10.3 Request ID 不等于分布式 Trace
Request ID 主要用于日志关联。OpenTelemetry Trace 通常包含:
- Trace ID;
- Span ID;
- Parent Span;
- 采样;
- 服务间上下文传播;
- 时间线与拓扑。
Nginx Open Source 的基础配置可以传播 traceparent,但完整 Span 生成、导出和语义约定通常需要:
- OpenTelemetry 模块;
- NGINX 发行版或第三方集成;
- Sidecar / Agent;
- 应用 SDK;
- 可观测平台。
不要把“有一个 X-Request-ID”描述成已经拥有完整分布式追踪。
十一、Error Log:记录 Nginx 运行异常
独立插图 09:Error Log 级别与边界
对应文件:09-error-log-levels.png

11.1 基础配置
error_log /var/log/nginx/error.log warn;级别从严重到详细通常包括:
emergalertcriterrorwarnnoticeinfodebug配置为 warn 会记录 warn 及更严重级别。
11.2 Error Log 与 Access Log 的区别
Access Log
以请求为中心:
- 请求方法;
- URI;
- 状态码;
- 耗时;
- upstream;
- 客户端;
- 限流结果。
Error Log
以 Nginx 内部事件为中心:
- 连接 upstream 失败;
- 文件权限;
- 配置运行问题;
- 请求头过大;
- 证书和握手问题;
- 限流拒绝;
- DNS 解析;
- 模块错误;
- Worker 级异常。
排障时应结合两者:
Access Log 找到 request_id 与现象→ Error Log 搜索时间、客户端或 request_id→ 应用日志继续关联11.3 Debug Log 的前提
仅写:
error_log /var/log/nginx/debug.log debug;并不保证有效。Nginx 构建时需要支持 debug:
nginx -V 2>&1 | grep -- --with-debug官方预编译 Linux 包可能提供单独的 nginx-debug 二进制。
Debug Log 内容非常详细,生产环境全量开启可能带来:
- 大量磁盘写入;
- 性能影响;
- 日志快速增长;
- 敏感数据暴露风险;
- 排查噪声。
可以使用 debug_connection 只跟踪特定来源,但仍应在受控窗口内启用并及时关闭。
十二、常见状态码的责任归属
独立插图 10:HTTP 状态码生成方矩阵
对应文件:10-http-status-ownership-matrix.png

12.1 400 Bad Request
可能由 Nginx 生成:
- 请求行非法;
- Header 格式错误;
- Host 不合法;
- 请求头超过限制;
- TLS 流量发到 HTTP 端口。
也可能由应用生成参数校验错误。
排查:
curl -vnginx -Ttail -f /var/log/nginx/error.log12.2 401 Unauthorized
通常由应用、认证网关或 auth_request 子请求生成。
检查:
- Authorization 是否被转发;
- Token 是否过期;
- CORS 是否导致浏览器未发送凭证;
- 认证子请求返回什么状态;
- CDN 是否缓存了不应缓存的 401。
12.3 403 Forbidden
常见原因:
- Nginx
deny; - 文件权限;
- 目录没有 index 且 autoindex 关闭;
- 应用权限不足;
- WAF 拦截;
- SELinux 或容器挂载权限。
12.4 404 Not Found
先判断由谁返回:
upstream_status 为空→ 可能 Nginx 直接 404
upstream_status=404→ 后端返回 404检查:
server/location;root与alias;proxy_pass尾部斜杠;- 应用 Context Path;
- SPA fallback;
- 内部重定向。
12.5 405 Method Not Allowed
可能由:
- Nginx
limit_except; - 静态文件处理;
- 应用路由方法限制;
- CORS 预检处理错误;
- 代理或重定向改变方法。
12.6 413 Content Too Large
通常与:
client_max_body_size有关,也可能由应用上传限制或前置 CDN/LB 产生。
不要只在 Nginx 放大限制,还要同步检查:
- 云 LB;
- API Gateway;
- Spring Boot;
- Servlet 容器;
- 对象存储;
- 上传超时与临时文件空间。
12.7 429 Too Many Requests
可能由:
- Nginx
limit_req_status 429; - Nginx
limit_conn_status 429; - API Gateway;
- 应用配额;
- 下游第三方接口。
应记录:
- 限流状态;
- 具体 zone;
- 请求主体;
- URI;
- 后端负载;
- Retry-After 策略。
12.8 499 Client Closed Request
499 是 Nginx 日志中常见的非标准状态,表示客户端在 Nginx 完成响应前断开连接。
常见原因:
- 用户取消;
- 浏览器页面跳转;
- 客户端超时小于 Nginx 和后端;
- 上游代理先超时;
- 移动网络中断;
- 客户端重试;
- 用户刷新页面。
大量 499 不应简单归因于“客户端问题”。如果应用非常慢,客户端被迫超时,也会产生 499。
12.9 500 Internal Server Error
通常是应用异常,但 Nginx 内部重写循环、脚本模块或文件处理也可能产生 500。
需要结合:
statusupstream_statuserror_log应用异常栈12.10 502 Bad Gateway
典型原因:
- 后端端口未监听;
- 容器名解析失败;
- Connection refused;
- Connection reset by peer;
- upstream 返回非法 HTTP;
- 响应头过大;
- TLS upstream 协议或 SNI 不匹配;
- 没有可用 upstream;
- Unix Socket 权限错误。
12.11 503 Service Unavailable
可能来自:
limit_req/limit_conn默认拒绝码;- 应用主动返回;
- upstream 无可用节点;
- 维护页;
- 云平台健康检查和摘除逻辑。
因此生产 API 推荐把 Nginx 限流状态显式改为 429,降低歧义。
12.12 504 Gateway Timeout
通常表示 Nginx 等待 upstream 数据超过读取超时。
常见误区:
看到 504→ 把 proxy_read_timeout 从 60s 改成 600s这可能只是让请求占用资源更久。应先确认:
- 应用线程是否排队;
- 数据库是否慢;
- 下游是否超时;
- 是否存在死锁;
- 客户端是否愿意等待;
- 业务是否应改为异步任务;
- 是否应降级或快速失败。
十三、标准排查工具与顺序
独立插图 11:限流、日志与故障排查流程
对应文件:11-troubleshooting-flow.png

13.1 配置检查
nginx -tnginx -Tnginx -Vnginx -t:语法和引用文件检查;nginx -T:输出完整展开配置;nginx -V:版本、编译参数、模块。
13.2 请求层
curl -v http://localhost:8088/api/normalcurl -I http://localhost:8088/curl --resolve api.example.com:443:127.0.0.1 https://api.example.com/curl -H 'X-Request-ID: test-001' http://localhost:8088/api/normal观察:
- DNS;
- 连接;
- TLS;
- 请求 Header;
- 响应 Header;
- 状态码;
- 重定向;
- Request ID。
13.3 网络与端口
ss -lntpss -slsof -i :8080nc -vz backend 8080容器中:
docker compose psdocker compose exec nginx getent hosts backenddocker compose exec nginx curl -v http://backend:8080/api/normal13.4 DNS
dig backend.example.comgetent hosts backend确认:
- 查询结果;
- TTL;
- IPv4 / IPv6;
- 容器 DNS;
- Nginx 是否在启动时解析后长期使用旧 IP;
- 是否配置动态
resolve。
13.5 TLS
openssl s_client \ -connect api.example.com:443 \ -servername api.example.com \ -showcerts13.6 抓包
tcpdump -i any -nn host 10.0.1.10 and port 8080抓包适合确认:
- SYN 是否发出;
- 是否重传;
- 对端是否 RST;
- 数据是否到达;
- 谁先关闭连接。
不要在未授权环境抓取敏感业务流量。
13.7 系统资源
topvmstat 1iostat -x 1free -mdf -hulimit -ncat /proc/sys/fs/file-nr日志写满磁盘、文件描述符不足和内存压力都可能表现为入口异常。
十四、最小可运行实验
独立插图 12:最小实验架构
对应文件:12-lab-architecture.png

14.1 目录结构
nginx-column-07/├── docker-compose.yml├── nginx/│ ├── nginx.conf│ └── conf.d/│ └── default.conf├── backend/│ ├── Dockerfile│ ├── pom.xml│ └── src/main/java/com/example/demo/│ ├── DemoApplication.java│ ├── RequestIdFilter.java│ └── DemoController.java└── scripts/ ├── test-rate-limit.sh ├── test-connection-limit.sh └── test-errors.sh14.2 docker-compose.yml
services: nginx: image: nginx:1.30.3 container_name: nginx-column-07 ports: - "8088:8088" volumes: - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro - ./nginx/conf.d:/etc/nginx/conf.d:ro depends_on: backend: condition: service_healthy networks: - nginx-lab
backend: build: context: ./backend container_name: nginx-column-07-backend expose: - "8080" environment: SERVER_PORT: "8080" healthcheck: test: ["CMD", "wget", "-qO-", "http://localhost:8080/actuator/health"] interval: 5s timeout: 2s retries: 20 networks: - nginx-lab
networks: nginx-lab: driver: bridge不要在生产中只依赖 depends_on 判断服务永久可用。它主要解决启动顺序,运行期故障仍需要超时、重试边界、健康检查和监控。
14.3 nginx/nginx.conf
user nginx;worker_processes auto;
error_log /var/log/nginx/error.log warn;pid /var/run/nginx.pid;
events { worker_connections 4096;}
http { include /etc/nginx/mime.types; default_type application/octet-stream;
# 日志格式只能定义在 http 上下文。 log_format api_json escape=json '{' '"timestamp":"$time_iso8601",' '"request_id":"$request_id",' '"remote_addr":"$remote_addr",' '"method":"$request_method",' '"request_uri":"$request_uri",' '"status":$status,' '"request_length":$request_length,' '"bytes_sent":$bytes_sent,' '"request_time":$request_time,' '"upstream_addr":"$upstream_addr",' '"upstream_status":"$upstream_status",' '"upstream_connect_time":"$upstream_connect_time",' '"upstream_header_time":"$upstream_header_time",' '"upstream_response_time":"$upstream_response_time",' '"limit_req_status":"$limit_req_status",' '"limit_conn_status":"$limit_conn_status",' '"user_agent":"$http_user_agent"' '}';
access_log /var/log/nginx/access.log api_json;
# 按客户端 IP 的请求速率。 limit_req_zone $binary_remote_addr zone=per_ip_api:10m rate=5r/s;
# 全局接口保护:所有请求共用 server_name 作为 Key。 limit_req_zone $server_name zone=per_server_api:10m rate=100r/s;
# 并发限制。 limit_conn_zone $binary_remote_addr zone=per_ip_conn:10m;
sendfile on; keepalive_timeout 65s;
include /etc/nginx/conf.d/*.conf;}14.4 nginx/conf.d/default.conf
upstream backend { server backend:8080;}
server { listen 8088 default_server; server_name _;
limit_req_status 429; limit_conn_status 429;
add_header X-Request-ID $request_id always;
location = /healthz { access_log off; return 200 "ok\n"; }
location /api/normal { limit_req zone=per_ip_api burst=5 nodelay; limit_req zone=per_server_api burst=20;
proxy_set_header Host $host; proxy_set_header X-Request-ID $request_id; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_connect_timeout 2s; proxy_read_timeout 10s; proxy_pass http://backend; }
location /api/limited { # 用低阈值观察排队和拒绝。 limit_req zone=per_ip_api burst=2;
proxy_set_header X-Request-ID $request_id; proxy_pass http://backend; }
location /api/slow { limit_req zone=per_ip_api burst=3; limit_conn per_ip_conn 2;
proxy_set_header X-Request-ID $request_id; proxy_connect_timeout 2s; proxy_read_timeout 3s; proxy_pass http://backend; }
location /api/download { limit_conn per_ip_conn 1; limit_rate_after 1m; limit_rate 512k;
proxy_set_header X-Request-ID $request_id; proxy_pass http://backend; }
location /api/stream { limit_conn per_ip_conn 2;
proxy_buffering off; proxy_read_timeout 30s; proxy_set_header X-Request-ID $request_id; proxy_pass http://backend; }
location /api/error { proxy_set_header X-Request-ID $request_id; proxy_pass http://backend; }}14.5 backend/pom.xml
<?xml version="1.0" encoding="UTF-8"?><project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation=" http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>4.1.0</version> <relativePath/> </parent>
<groupId>com.example</groupId> <artifactId>nginx-column-07</artifactId> <version>1.0.0</version>
<properties> <java.version>21</java.version> </properties>
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency>
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-actuator</artifactId> </dependency> </dependencies>
<build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build></project>14.6 backend/Dockerfile
FROM maven:3.9.11-eclipse-temurin-21 AS buildWORKDIR /workspace
COPY pom.xml .RUN mvn -q -DskipTests dependency:go-offline
COPY src ./srcRUN mvn -q -DskipTests package
FROM eclipse-temurin:21-jreWORKDIR /app
COPY --from=build \ /workspace/target/nginx-column-07-1.0.0.jar \ /app/app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/app.jar"]14.7 DemoApplication.java
package com.example.demo;
import org.springframework.boot.SpringApplication;import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplicationpublic class DemoApplication {
public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); }}14.8 RequestIdFilter.java
package com.example.demo;
import jakarta.servlet.FilterChain;import jakarta.servlet.ServletException;import jakarta.servlet.http.HttpServletRequest;import jakarta.servlet.http.HttpServletResponse;import org.slf4j.MDC;import org.springframework.stereotype.Component;import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@Componentpublic class RequestIdFilter extends OncePerRequestFilter {
private static final String HEADER = "X-Request-ID"; private static final String MDC_KEY = "requestId";
@Override protected void doFilterInternal( HttpServletRequest request, HttpServletResponse response, FilterChain filterChain ) throws ServletException, IOException {
String requestId = request.getHeader(HEADER);
if (requestId == null || requestId.isBlank()) { requestId = "application-generated"; }
MDC.put(MDC_KEY, requestId); response.setHeader(HEADER, requestId);
try { filterChain.doFilter(request, response); } finally { MDC.remove(MDC_KEY); } }}生产系统应校验 Request ID 的长度和字符集,而不是无条件写入日志。
14.9 DemoController.java
package com.example.demo;
import jakarta.servlet.http.HttpServletRequest;import org.springframework.http.MediaType;import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.RestController;import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
import java.nio.charset.StandardCharsets;import java.time.Instant;import java.util.Map;
@RestControllerpublic class DemoController {
@GetMapping("/api/normal") public Map<String, Object> normal(HttpServletRequest request) { return Map.of( "message", "normal response", "requestId", request.getHeader("X-Request-ID"), "time", Instant.now().toString() ); }
@GetMapping("/api/limited") public Map<String, Object> limited(HttpServletRequest request) { return Map.of( "message", "limited endpoint", "requestId", request.getHeader("X-Request-ID") ); }
@GetMapping("/api/slow") public Map<String, Object> slow(HttpServletRequest request) throws InterruptedException {
Thread.sleep(5_000);
return Map.of( "message", "slow response", "requestId", request.getHeader("X-Request-ID") ); }
@GetMapping( value = "/api/download", produces = MediaType.APPLICATION_OCTET_STREAM_VALUE ) public byte[] download() { return new byte[5 * 1024 * 1024]; }
@GetMapping( value = "/api/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE ) public StreamingResponseBody stream() { return outputStream -> { for (int i = 1; i <= 10; i++) { String event = "data: event-" + i + "\n\n"; outputStream.write(event.getBytes(StandardCharsets.UTF_8)); outputStream.flush(); Thread.sleep(1_000); } }; }
@GetMapping("/api/error") public Map<String, Object> error() { throw new IllegalStateException("intentional test error"); }}14.10 启动与检查
docker compose builddocker compose up -ddocker compose ps
docker compose exec nginx nginx -tdocker compose exec nginx nginx -T14.11 普通请求
curl -i http://localhost:8088/api/normal预期:
- 返回 200;
- 响应包含
X-Request-ID; - Nginx 日志与应用日志能找到相同 ID;
- Access Log 含 upstream 耗时。
14.12 测试请求限流
for i in $(seq 1 20); do curl -s -o /dev/null \ -w "%{http_code}\n" \ http://localhost:8088/api/limited &donewait预期观察到:
- 一部分 200;
- 一部分请求被延迟;
- 超过 burst 后出现 429;
$limit_req_status出现 PASSED、DELAYED 或 REJECTED。
使用 hey:
hey -n 100 -c 20 http://localhost:8088/api/limited14.13 测试连接限制
第一个终端:
curl -N http://localhost:8088/api/stream再启动多个相同请求,观察超过连接限制后的状态。
14.14 测试 504
curl -i http://localhost:8088/api/slow后端睡眠 5 秒,而 Nginx proxy_read_timeout 为 3 秒,预期返回 504。
查看日志:
docker compose logs -f nginxdocker compose logs -f backend14.15 测试 502
停止后端:
docker compose stop backendcurl -i http://localhost:8088/api/normal预期 Nginx 返回 502,并在 Error Log 中记录连接失败。
恢复:
docker compose start backend14.16 测试 499
让 /api/slow 的 Nginx 超时大于客户端超时,然后:
curl --max-time 1 http://localhost:8088/api/slow客户端先终止连接,Nginx 可能记录 499。具体表现取决于连接关闭时点。
14.17 清理
docker compose down --remove-orphans十五、从开发配置升级到生产配置
15.1 第一阶段:只记录,不拦截
limit_req_dry_run on;limit_conn_dry_run on;同时在日志中记录:
limit_req_statuslimit_conn_statusrequest_timeupstream_response_timeURI用户或租户维度15.2 第二阶段:先保护最危险接口
优先选择:
- 登录;
- 验证码;
- 搜索;
- 导出;
- 大模型推理;
- 大文件下载;
- 已知被刷接口。
不要第一次上线就对所有 API 使用同一阈值。
15.3 第三阶段:增加总量保护
单用户限制无法防止大量不同用户同时涌入。需要接口或服务总量保护:
limit_req zone=per_user ...;limit_req zone=per_server ...;15.4 第四阶段:接入指标与告警
建议监控:
- 请求总量;
- 429 比例;
- DELAYED 比例;
- 连接拒绝;
- P50/P95/P99;
- upstream connect/header/response time;
- 499、502、503、504;
- 每个接口和客户端分布;
- 后端线程池;
- 数据库连接池;
- 日志丢失和采集延迟。
15.5 第五阶段:配合应用降级
入口只能看到协议和流量。业务层更了解:
- 用户等级;
- 操作价值;
- 是否可降级;
- 是否允许异步;
- 是否可返回旧数据;
- 是否能排队;
- 是否应该熔断下游。
成熟系统通常是:
Nginx 粗粒度保护+ API Gateway / 应用配额+ 应用线程池隔离+ 下游熔断+ 降级与异步任务十六、常见错误与反例
16.1 错误:所有接口都按 IP 使用 1r/s
现象
企业用户、学校网络或移动 NAT 下大量正常用户被误伤。
原因
多个用户共享公网出口 IP。
修复
登录后优先使用用户 ID、API Key 或租户维度;IP 仅作为匿名层保护。
16.2 错误:无条件信任 X-Forwarded-For
现象
攻击者不断改变 Header 绕过限制,日志中的客户端地址不可信。
原因
没有配置受信任代理边界。
修复
使用 set_real_ip_from 限定可信代理,并校验实际链路。
16.3 错误:burst 设置成几千
现象
请求在 Nginx 排队很久,用户最终仍然超时;Nginx 保留大量等待请求。
原因
把 burst 当成“更多容量”,而不是等待区或额度负债。
修复
根据可接受排队时延和后端瞬时容量计算,必要时快速失败。
16.4 错误:使用 nodelay 后认为已经削峰
现象
burst 内大量请求同时进入后端,线程池瞬间耗尽。
原因
nodelay 取消了排队,不会平滑瞬时并发。
修复
降低 burst、使用默认延迟或 delay=N,并增加全局并发保护。
16.5 错误:只限制单 IP,不限制总量
现象
每个客户端都没有超额,但总流量仍然压垮后端。
修复
叠加服务或接口总量 zone。
16.6 错误:用 limit_req 控制 WebSocket 总连接数
现象
握手 QPS 被限制,但连接建立后长期占用资源,系统仍然耗尽。
修复
增加连接数、空闲超时、心跳、用户会话限制和系统容量控制。
16.7 错误:JSON 日志没有 escape=json
现象
日志平台解析失败,User-Agent 或 URI 中的字符破坏整行 JSON。
修复
使用:
log_format api_json escape=json ...;16.8 错误:记录 Authorization 和 Cookie
现象
Token 进入日志平台,扩大敏感信息暴露范围。
修复
删除敏感字段,或仅记录经过脱敏的标识。
16.9 错误:看到 504 就不断增加超时
现象
请求等待更久、并发占用增加,故障扩大。
修复
通过 upstream 时间、应用日志、数据库和下游依赖定位根因;评估异步化和降级。
16.10 错误:把 499 全部归咎于客户端
现象
忽略了应用慢导致客户端被迫超时。
修复
比较客户端超时、Nginx 超时、upstream 耗时和业务 P99。
16.11 错误:生产全局开启 debug
现象
日志量暴增、磁盘压力和信息暴露风险上升。
修复
确认构建支持后,仅对特定来源、短时间开启,并准备回滚。
16.12 错误:只查看 Error Log
现象
能看到“upstream timed out”,却不知道具体 URI、状态、耗时和 Request ID。
修复
联合 Access Log、Error Log、应用日志、指标与 Trace。
十七、生产故障排查流程
17.1 第一步:明确用户现象
收集:
- 具体时间;
- 请求域名和 URI;
- Request ID;
- 客户端网络;
- 状态码;
- 是否可复现;
- 是否所有用户受影响;
- 是否只影响某个区域、租户或版本。
17.2 第二步:判断入口是否可达
检查:
- DNS;
- CDN / LB;
- TCP;
- TLS;
- Nginx Worker;
- 端口监听。
17.3 第三步:判断是否被入口拒绝
关注:
statuslimit_req_statuslimit_conn_statusupstream_addr 是否为空如果 upstream 为空且状态 429,通常请求没有进入后端。
17.4 第四步:判断 upstream 阶段
关注:
upstream_addrupstream_statusupstream_connect_timeupstream_header_timeupstream_response_time17.5 第五步:关联应用和下游
用 Request ID 搜索:
- 应用访问日志;
- 异常栈;
- 数据库慢查询;
- RPC / HTTP 下游;
- MQ;
- 缓存;
- 第三方接口。
17.6 第六步:恢复优先
根据风险选择:
- 回滚配置;
- 临时扩容;
- 降低入口上限;
- 关闭非核心功能;
- 返回缓存;
- 熔断下游;
- 切流;
- 快速失败;
- 延长超时仅作为经过评估的临时措施。
17.7 第七步:复盘
复盘至少包括:
- 触发条件;
- 监控为何发现或未发现;
- 哪一层最先耗尽;
- 限流是否生效;
- 是否误伤;
- 客户端重试是否放大;
- 日志是否足够;
- Request ID 是否贯通;
- 配置发布和回滚是否可靠;
- 后续容量和演练计划。
独立插图 13:生产故障响应闭环
对应文件:13-production-incident-feedback-loop.png
十八、生产实践与能力边界
18.1 单机共享内存不是分布式配额系统
limit_req_zone 的状态在同一 Nginx 实例 Worker 之间共享,但多个独立 Nginx 实例通常各自计算。
如果有 4 个入口实例,每实例配置用户 10r/s,用户在负载均衡下可能获得接近 40r/s 的总额度。
需要全局严格配额时,应考虑:
- API Gateway 的集中配额;
- Redis 等分布式计数;
- 服务端用户配额;
- 云厂商限流服务;
- 将流量稳定粘到特定入口的方案及其风险。
18.2 Nginx 限流适合入口保护,不适合复杂计费
适合:
- 粗粒度 IP / Key 限制;
- 接口削峰;
- 防止单主体占用;
- 连接保护;
- 快速拒绝。
不适合独立承担:
- 月度套餐;
- 可充值额度;
- 跨地域精确配额;
- 复杂优先级队列;
- 公平调度;
- 业务补偿;
- 精确计费。
18.3 日志不是指标,指标也不是 Trace
- 日志:具体事件和上下文;
- 指标:聚合趋势和告警;
- Trace:跨服务调用时间线;
- Profile:CPU、内存等运行热点。
生产可观测性应组合使用,而不是把所有信息都塞进 Access Log。
18.4 限流拒绝也需要产品设计
用户看到 429 时,应有明确行为:
- 是否提示稍后重试;
- 是否返回 Retry-After;
- 是否进入异步队列;
- 是否展示任务进度;
- 是否允许高级用户更高额度;
- 是否提供配额查询;
- 是否记录审计事件。
十九、面试与复习问题
19.1 核心问题
1. limit_req_zone 与 limit_req 的区别是什么?
limit_req_zone 在 http 上下文定义 Key、共享内存和速率;limit_req 在具体上下文引用该 zone 并设置 burst、delay 或 nodelay。只定义 zone 不会自动限制请求。
2. rate=10r/s 是否表示每个自然秒最多瞬间通过 10 个请求?
不是。它是漏桶式平滑速率。连续突发请求可能被延迟或拒绝,具体取决于 burst 和 delay 配置。
3. nodelay 是否关闭限流?
没有。它只是让 burst 范围内的超额请求立即处理,不在 Nginx 排队;burst 满后仍拒绝。
4. 为什么按 IP 限流可能误伤?
NAT、企业网络、校园网络和移动网络可能让多个用户共享公网 IP;前置代理配置错误也会让所有用户显示为同一个代理 IP。
5. limit_conn 是否统计所有已建立 TCP 连接?
不是。只有正在处理请求且完整请求头已读取的连接会被计数。HTTP/2/3 下每个并发请求按独立连接计数。
6. 499 表示什么?
客户端在 Nginx 完成响应前关闭连接。它可能是用户取消,也可能是服务过慢导致客户端超时。
7. $request_time 与 $upstream_response_time 有何区别?
request_time 是 Nginx 看到的请求总生命周期;upstream_response_time 是从 upstream 获取响应所用时间。前者还可能包含客户端上传、限流排队和向慢客户端发送响应。
8. 为什么 upstream 时间变量可能包含多个值?
请求可能发生 upstream 重试、内部跳转或访问多个 upstream,多个值按顺序记录。
9. 为什么 JSON 日志需要 escape=json?
URI、User-Agent 和 Header 中可能有引号、反斜杠和控制字符。不转义会导致无效 JSON 或日志注入。
10. Request ID 与 OpenTelemetry Trace ID 是否相同?
不一定。Request ID 通常用于日志关联;分布式 Trace 还包含 Span、父子关系、采样和跨服务时间线。
19.2 场景分析题
场景一:所有用户突然收到 429,但后端很空闲
排查:
- Real IP 是否把所有用户识别为同一个 LB 地址;
- Key 是否为空或固定;
- rate 是否过低;
- zone 是否应用到了错误 location;
- 是否发生继承覆盖;
- 查看
$limit_req_status和 remote_addr 分布。
场景二:QPS 不高,但后端线程池满
可能是请求耗时长,导致并发高。使用 Little’s Law 思路检查吞吐与平均耗时,并增加 limit_conn、线程池隔离、下游超时和异步化。
场景三:Nginx 记录大量 499
比较:
- 客户端超时;
- CDN / LB 超时;
- Nginx 超时;
- upstream header/response time;
- 应用和数据库耗时;
- 是否发生用户取消或页面切换。
场景四:502 偶发,第二次重试成功
查看 upstream 多值日志,判断是否第一个节点连接失败、第二个成功。继续检查节点健康、连接复用、发布摘流、DNS 和被动故障参数。
场景五:日志平台偶尔解析 JSON 失败
检查是否使用 escape=json,是否存在多行应用日志、日志截断、采集器拼接和非法编码;不要只修复某个 User-Agent。
19.3 配置排错题
题一
limit_req_zone $binary_remote_addr zone=one:10m rate=5r/s;
server { location /api/ { proxy_pass http://backend; }}为什么没有限流?
因为只定义了 zone,没有在请求上下文中配置:
limit_req zone=one burst=...;题二
set_real_ip_from 0.0.0.0/0;real_ip_header X-Forwarded-For;问题是什么?
信任任意来源提供的客户端 IP,攻击者可以伪造 Header。应只信任实际 CDN、LB 或内部代理网段。
题三
log_format json '{"uri":"$request_uri","ua":"$http_user_agent"}';问题是什么?
没有 escape=json,变量内容可能破坏 JSON。应使用:
log_format json escape=json ...;二十、本文总结
本篇解决了两个生产入口核心问题:
- 系统如何在流量过载前保护后端;
- 系统出错后如何用证据定位问题。
需要记住的关键结论:
limit_req限制速率,limit_conn限制并发中的请求,两者语义不同;burst是突发等待区或额度空间,不是新增后端容量;nodelay不会削平瞬时流量;- 限流 Key 必须建立在可信真实客户端身份上;
- 单用户限制必须与接口或服务总量保护结合;
- Dry Run 是生产上线限流的重要步骤;
- Access Log 应使用 JSON 转义并记录状态、耗时、upstream、限流结果和 Request ID;
$request_time高不代表一定是后端慢;- upstream 多值通常意味着重试或多阶段处理;
- 499、502、503、504 必须结合生成方和日志判断;
- Debug Log 需要构建支持,不应长期全局开启;
- Nginx 单机共享内存不能天然提供多实例全局精确配额;
- 故障处理应形成容量、保护、监控、恢复和复盘闭环。
下一篇将进入 Docker 与 Kubernetes 场景,讨论 Nginx 容器化、Service 发现、Ingress、Gateway API、配置热更新、TLS Secret、灰度发布与可观测性。
二十一、文章验收清单
内容完整性
- 解释请求速率、突发和并发的区别;
- 解释
limit_req_zone与limit_req; - 解释
burst、delay、nodelay; - 解释
limit_conn与 HTTP/2/3 语义; - 覆盖白名单、用户级和全局级限流;
- 覆盖真实 IP 信任边界;
- 提供结构化 JSON Access Log;
- 解释关键耗时变量;
- 提供 Request ID 传播方案;
- 解释 Error Log 和 Debug Log;
- 覆盖常见状态码;
- 提供完整实验;
- 提供生产演进和故障排查;
- 提供面试题和答案。
技术边界
- 以 Nginx Open Source 为主;
- 没有将 API Gateway 配额能力写成 Nginx 默认能力;
- 没有把 Request ID 描述为完整分布式 Trace;
- 说明 Real IP 模块的构建边界;
- 说明 Debug Log 需要
--with-debug; - 说明多实例 Nginx 的配额并非天然全局共享;
- 区分 429、499、502、503、504 的责任边界。
实验可执行性
- 提供目录结构;
- 提供 Docker Compose;
- 提供 Nginx 完整配置;
- 提供 Spring Boot 后端代码;
- 提供启动、验证、故障模拟和清理命令;
- 提供
nginx -t与nginx -T检查方式。
参考资料
-
Nginx Download
https://nginx.org/en/download.html -
Module
ngx_http_limit_req_module
https://nginx.org/en/docs/http/ngx_http_limit_req_module.html -
Module
ngx_http_limit_conn_module
https://nginx.org/en/docs/http/ngx_http_limit_conn_module.html -
Module
ngx_http_log_module
https://nginx.org/en/docs/http/ngx_http_log_module.html -
Module
ngx_http_upstream_moduleEmbedded Variables
https://nginx.org/en/docs/http/ngx_http_upstream_module.html -
Module
ngx_http_realip_module
https://nginx.org/en/docs/http/ngx_http_realip_module.html -
Nginx Debugging Log
https://nginx.org/en/docs/debugging_log.html -
Nginx Alphabetical Index of Variables
https://nginx.org/en/docs/varindex.html -
Spring Boot Project
https://spring.io/projects/spring-boot
资料访问日期:2026-07-08。

