11695 字
58 分钟
第 7 篇:Nginx 限流、日志与故障排查

第 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 入口链路:

  1. 第一篇确定 Nginx 在后端架构中的位置;
  2. 第二篇讲清配置层级以及 serverlocation 的匹配过程;
  3. 第三篇完成反向代理、Header、超时、缓冲、WebSocket 与 SSE;
  4. 第四篇将单实例代理扩展为多实例负载均衡与高可用;
  5. 第五篇完成 TLS、证书自动续期和安全入口;
  6. 第六篇完成静态资源、浏览器缓存、代理缓存、压缩和 CDN 协作。

到这里,请求已经能够被安全接入、高效转发和分层缓存。然而系统仍然缺少两个生产环境不可缺失的能力:

  • 保护能力:当流量超过后端容量、单个用户刷接口、下载连接长期占用资源时,入口层应当主动削峰和拒绝;
  • 解释能力:当用户报告慢、502、504、429 或“偶发失败”时,团队应当能够通过日志和 Trace ID 判断问题发生在哪一层。

因此,第七篇不是简单罗列 limit_reqlimit_connaccess_log 指令,而是建立一套完整闭环:

恢复可信客户端身份
按用户、接口和全局容量实施分级限流
记录结构化请求与 upstream 指标
通过 Request ID 关联应用和下游日志
按状态码、耗时和网络层级定位根因
复盘并调整容量、阈值、降级和告警

独立插图 01:入口流量保护与可观测性全景
对应文件:01-traffic-protection-observability-overview.png

01 traffic protection observability overview 下一篇将把这套单机和容器入口能力带入 Kubernetes,讨论 Nginx 容器化、Ingress、Gateway API、配置热更新和灰度发布。


学习目标#

学完本文后,你应当能够:

  1. 区分请求速率、并发请求、TCP 连接数和后端线程数;
  2. 解释 limit_req 的漏桶模型,而不是把它简单理解成“每秒计数器”;
  3. 正确配置 limit_req_zonerateburstdelaynodelay
  4. 判断请求应该排队、立即放行还是拒绝;
  5. 使用 limit_req_dry_run 在不拦截流量的前提下评估策略;
  6. 使用 $limit_req_status 区分 PASSED、DELAYED、REJECTED 等状态;
  7. 理解 limit_conn 实际统计哪些连接;
  8. 说明 HTTP/2 和 HTTP/3 下并发请求的计数语义;
  9. 为下载、SSE、WebSocket 和慢接口选择合适的保护方式;
  10. 使用 mapgeo、API Key、用户 ID 和接口路径实现分级限流;
  11. 避免 NAT 出口、反向代理和伪造 X-Forwarded-For 导致的误伤;
  12. 设计可被日志平台可靠解析的 JSON Access Log;
  13. 区分 $request_time$upstream_connect_time$upstream_header_time$upstream_response_time
  14. 解释 upstream 重试时为什么日志变量可能出现多个逗号分隔值;
  15. 使用 $request_id 或可信上游 ID 串联 Nginx、Spring Boot 和下游服务日志;
  16. 区分 Access Log、Error Log、业务日志、指标和分布式 Trace 的职责;
  17. 判断 400、401、403、404、405、413、429、499、500、502、503、504 的常见生成方;
  18. 使用 nginx -tnginx -Tcurlssdigopenssltcpdump 等工具逐层排查;
  19. 完成 Docker Compose + Spring Boot 限流、日志和故障演练;
  20. 建立从告警、定位、恢复到复盘的生产故障处理流程。

一、问题背景:入口层既要“放行”,也要“拒绝”#

1.1 没有限流时,入口只是故障放大器#

假设一个搜索接口稳定容量为 300 QPS,数据库查询平均需要 80 ms。正常流量为 150 QPS 时,系统工作良好。某个营销活动开始后,流量突然到达 1,500 QPS。

如果 Nginx 不做任何保护,它会尽力把请求全部转发给后端。结果通常不是“后端处理得慢一点”,而是形成级联故障:

  1. 应用线程池被占满;
  2. 请求在应用线程池或连接池中排队;
  3. 数据库连接池耗尽;
  4. 数据库出现慢查询与锁竞争;
  5. 健康检查也无法及时完成;
  6. Nginx 开始记录 502、504;
  7. 客户端自动重试进一步放大流量;
  8. 原本健康的接口也被拖垮。

入口层如果只负责接收和转发,它会把外部突发毫无缓冲地传递给后端。真正的生产入口必须同时承担:

  • 流量整形;
  • 容量保护;
  • 异常主体隔离;
  • 明确拒绝;
  • 保护结果可观测。

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

02 limit req leaky bucket

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 可以配置在 httpserverlocation。它把前面定义的共享区应用到当前请求处理链路。

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 比使用很小的每秒速率更直观,例如:

  • 密码找回;
  • 短信验证码;
  • 导出任务创建;
  • 高成本模型调用。

三、burstdelaynodelay#

独立插图 03:burst、delay 与 nodelay 行为对比
对应文件:03-burst-delay-nodelay-comparison.png

03 burst delay nodelay comparison

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_reqlimit_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

04 limit conn vs limit req

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
→ 云负载均衡器
→ Nginx

Nginx 看到的 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 的默认模块。需要使用:

Terminal window
nginx -V 2>&1 | grep http_realip_module

确认构建参数。

6.3 real_ip_recursive on 的意义#

启用递归查找后,Nginx 会在代理链中寻找最后一个非受信任地址作为客户端地址。

信任链必须与实际网络拓扑一致:

只信任 CDN / LB 的固定出口
而不是信任 0.0.0.0/0

6.4 多用户共享 NAT#

即使 Real IP 正确,同一企业、学校、家庭或移动网络仍可能共享公网出口 IP。

因此,成熟策略通常是:

  • 未登录:按 IP 较宽松限制;
  • 已登录:按用户 ID 或 API Key 限制;
  • 高成本接口:用户级 + 全局级双重限制;
  • 合作方:按客户端凭据和合同配额;
  • 内部服务:按服务身份和调用方维度。

独立插图 05:分级限流策略
对应文件:05-tiered-rate-limit-policy.png

05 tiered rate limit policy

七、使用 mapgeo 构建分级策略#

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

06 structured access log pipeline

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=json

URI、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

07 request timing breakdown

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

08 trace id propagation

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 log
request_id=abc...
Spring Boot log
request_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

09 error log levels

11.1 基础配置#

error_log /var/log/nginx/error.log warn;

级别从严重到详细通常包括:

emerg
alert
crit
error
warn
notice
info
debug

配置为 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:

Terminal window
nginx -V 2>&1 | grep -- --with-debug

官方预编译 Linux 包可能提供单独的 nginx-debug 二进制。

Debug Log 内容非常详细,生产环境全量开启可能带来:

  • 大量磁盘写入;
  • 性能影响;
  • 日志快速增长;
  • 敏感数据暴露风险;
  • 排查噪声。

可以使用 debug_connection 只跟踪特定来源,但仍应在受控窗口内启用并及时关闭。


十二、常见状态码的责任归属#

独立插图 10:HTTP 状态码生成方矩阵
对应文件:10-http-status-ownership-matrix.png

10 http status ownership matrix

12.1 400 Bad Request#

可能由 Nginx 生成:

  • 请求行非法;
  • Header 格式错误;
  • Host 不合法;
  • 请求头超过限制;
  • TLS 流量发到 HTTP 端口。

也可能由应用生成参数校验错误。

排查:

Terminal window
curl -v
nginx -T
tail -f /var/log/nginx/error.log

12.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
  • rootalias
  • 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。

需要结合:

status
upstream_status
error_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

11 troubleshooting flow

13.1 配置检查#

Terminal window
nginx -t
nginx -T
nginx -V
  • nginx -t:语法和引用文件检查;
  • nginx -T:输出完整展开配置;
  • nginx -V:版本、编译参数、模块。

13.2 请求层#

Terminal window
curl -v http://localhost:8088/api/normal
curl -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 网络与端口#

Terminal window
ss -lntp
ss -s
lsof -i :8080
nc -vz backend 8080

容器中:

Terminal window
docker compose ps
docker compose exec nginx getent hosts backend
docker compose exec nginx curl -v http://backend:8080/api/normal

13.4 DNS#

Terminal window
dig backend.example.com
getent hosts backend

确认:

  • 查询结果;
  • TTL;
  • IPv4 / IPv6;
  • 容器 DNS;
  • Nginx 是否在启动时解析后长期使用旧 IP;
  • 是否配置动态 resolve

13.5 TLS#

Terminal window
openssl s_client \
-connect api.example.com:443 \
-servername api.example.com \
-showcerts

13.6 抓包#

Terminal window
tcpdump -i any -nn host 10.0.1.10 and port 8080

抓包适合确认:

  • SYN 是否发出;
  • 是否重传;
  • 对端是否 RST;
  • 数据是否到达;
  • 谁先关闭连接。

不要在未授权环境抓取敏感业务流量。

13.7 系统资源#

Terminal window
top
vmstat 1
iostat -x 1
free -m
df -h
ulimit -n
cat /proc/sys/fs/file-nr

日志写满磁盘、文件描述符不足和内存压力都可能表现为入口异常。


十四、最小可运行实验#

独立插图 12:最小实验架构
对应文件:12-lab-architecture.png

12 lab architecture

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.sh

14.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 build
WORKDIR /workspace
COPY pom.xml .
RUN mvn -q -DskipTests dependency:go-offline
COPY src ./src
RUN mvn -q -DskipTests package
FROM eclipse-temurin:21-jre
WORKDIR /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;
@SpringBootApplication
public 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;
@Component
public 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;
@RestController
public 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 启动与检查#

Terminal window
docker compose build
docker compose up -d
docker compose ps
docker compose exec nginx nginx -t
docker compose exec nginx nginx -T

14.11 普通请求#

Terminal window
curl -i http://localhost:8088/api/normal

预期:

  • 返回 200;
  • 响应包含 X-Request-ID
  • Nginx 日志与应用日志能找到相同 ID;
  • Access Log 含 upstream 耗时。

14.12 测试请求限流#

Terminal window
for i in $(seq 1 20); do
curl -s -o /dev/null \
-w "%{http_code}\n" \
http://localhost:8088/api/limited &
done
wait

预期观察到:

  • 一部分 200;
  • 一部分请求被延迟;
  • 超过 burst 后出现 429;
  • $limit_req_status 出现 PASSED、DELAYED 或 REJECTED。

使用 hey

Terminal window
hey -n 100 -c 20 http://localhost:8088/api/limited

14.13 测试连接限制#

第一个终端:

Terminal window
curl -N http://localhost:8088/api/stream

再启动多个相同请求,观察超过连接限制后的状态。

14.14 测试 504#

Terminal window
curl -i http://localhost:8088/api/slow

后端睡眠 5 秒,而 Nginx proxy_read_timeout 为 3 秒,预期返回 504。

查看日志:

Terminal window
docker compose logs -f nginx
docker compose logs -f backend

14.15 测试 502#

停止后端:

Terminal window
docker compose stop backend
curl -i http://localhost:8088/api/normal

预期 Nginx 返回 502,并在 Error Log 中记录连接失败。

恢复:

Terminal window
docker compose start backend

14.16 测试 499#

/api/slow 的 Nginx 超时大于客户端超时,然后:

Terminal window
curl --max-time 1 http://localhost:8088/api/slow

客户端先终止连接,Nginx 可能记录 499。具体表现取决于连接关闭时点。

14.17 清理#

Terminal window
docker compose down --remove-orphans

十五、从开发配置升级到生产配置#

15.1 第一阶段:只记录,不拦截#

limit_req_dry_run on;
limit_conn_dry_run on;

同时在日志中记录:

limit_req_status
limit_conn_status
request_time
upstream_response_time
URI
用户或租户维度

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 ...;

现象#

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 第三步:判断是否被入口拒绝#

关注:

status
limit_req_status
limit_conn_status
upstream_addr 是否为空

如果 upstream 为空且状态 429,通常请求没有进入后端。

17.4 第四步:判断 upstream 阶段#

关注:

upstream_addr
upstream_status
upstream_connect_time
upstream_header_time
upstream_response_time

17.5 第五步:关联应用和下游#

用 Request ID 搜索:

  • 应用访问日志;
  • 异常栈;
  • 数据库慢查询;
  • RPC / HTTP 下游;
  • MQ;
  • 缓存;
  • 第三方接口。

17.6 第六步:恢复优先#

根据风险选择:

  • 回滚配置;
  • 临时扩容;
  • 降低入口上限;
  • 关闭非核心功能;
  • 返回缓存;
  • 熔断下游;
  • 切流;
  • 快速失败;
  • 延长超时仅作为经过评估的临时措施。

17.7 第七步:复盘#

复盘至少包括:

  • 触发条件;
  • 监控为何发现或未发现;
  • 哪一层最先耗尽;
  • 限流是否生效;
  • 是否误伤;
  • 客户端重试是否放大;
  • 日志是否足够;
  • Request ID 是否贯通;
  • 配置发布和回滚是否可靠;
  • 后续容量和演练计划。

独立插图 13:生产故障响应闭环
对应文件:13-production-incident-feedback-loop.png

13 production incident feedback loop#

十八、生产实践与能力边界#

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_zonelimit_req 的区别是什么?#

limit_req_zonehttp 上下文定义 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,但后端很空闲#

排查:

  1. Real IP 是否把所有用户识别为同一个 LB 地址;
  2. Key 是否为空或固定;
  3. rate 是否过低;
  4. zone 是否应用到了错误 location;
  5. 是否发生继承覆盖;
  6. 查看 $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 ...;

二十、本文总结#

本篇解决了两个生产入口核心问题:

  1. 系统如何在流量过载前保护后端
  2. 系统出错后如何用证据定位问题

需要记住的关键结论:

  • 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_zonelimit_req
  • 解释 burstdelaynodelay
  • 解释 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 -tnginx -T 检查方式。

参考资料#

  1. Nginx Download
    https://nginx.org/en/download.html

  2. Module ngx_http_limit_req_module
    https://nginx.org/en/docs/http/ngx_http_limit_req_module.html

  3. Module ngx_http_limit_conn_module
    https://nginx.org/en/docs/http/ngx_http_limit_conn_module.html

  4. Module ngx_http_log_module
    https://nginx.org/en/docs/http/ngx_http_log_module.html

  5. Module ngx_http_upstream_module Embedded Variables
    https://nginx.org/en/docs/http/ngx_http_upstream_module.html

  6. Module ngx_http_realip_module
    https://nginx.org/en/docs/http/ngx_http_realip_module.html

  7. Nginx Debugging Log
    https://nginx.org/en/docs/debugging_log.html

  8. Nginx Alphabetical Index of Variables
    https://nginx.org/en/docs/varindex.html

  9. Spring Boot Project
    https://spring.io/projects/spring-boot

资料访问日期:2026-07-08。

第 7 篇:Nginx 限流、日志与故障排查
https://jupiter-ws.cn/posts/backend/nginx/07_nginx_rate_limiting_logging_troubleshooting/
作者
Jupiter
发布于
2026-07-08
许可协议
CC BY-NC-SA 4.0