bigywater.tech
微服务 · 22

proxy_next_upstream:哪些错误会重试到下一个节点,非幂等请求的风险

哪些错误会被 nginx 换到下一个上游重试?5xx 算不算?POST 这样的非幂等请求重试了会怎样?换几次由谁决定?这篇用 10 种上游故障 × 6 种配置的矩阵,加订单重复创建的实验量出来。

50 个
加 non_idempotent 后,100 个 POST 里被 A 和 B 都创建了一次的订单数(默认配置是 0,但有 50 个 504 的订单其实已经被 A 创建)
3.0 → 1.0
三个上游都回 503 时,默认与 proxy_next_upstream_tries 1 下,每个请求打到上游的次数
502 / BadStatusLine
上游响应头有一行没有冒号时 client 看到 nginx 的 502(invalid_header 才重试);状态行就不是 HTTP 时 client 的 HTTP 库抛 BadStatusLine

哪些问题会重试

实测 · 本机执行输出
  A 的故障                                     | 默认 (error timeout) | + http_500     | + http_502 http_503 http_504 | + invalid_header | + http_404     | off           
  ------------------------------------------+--------------------+----------------+------------------------------+------------------+----------------+---------------
  连接被拒                                      | B(重试了)             | B(重试了)         | B(重试了)                       | B(重试了)           | B(重试了)         | 502           
  读响应超时(沉默 1.8 s)                           | B(重试了)             | B(重试了)         | B(重试了)                       | B(重试了)           | B(重试了)         | 504           
  连接被重置(收到请求后 RST)                          | B(重试了)             | B(重试了)         | B(重试了)                       | B(重试了)           | B(重试了)         | 502           
  状态行就不是 HTTP(整行垃圾)                         | BadStatusLine      | BadStatusLine  | BadStatusLine                | BadStatusLine    | BadStatusLine  | BadStatusLine 
  状态行正常,但有一行响应头没有冒号                         | 502                | 502            | 502                          | B(重试了)           | 502            | 502           
  HTTP 500                                  | 500                | B(重试了)         | 500                          | 500              | 500            | 500           
  HTTP 502                                  | 502                | 502            | B(重试了)                       | 502              | 502            | 502           
  HTTP 503                                  | 503                | 503            | B(重试了)                       | 503              | 503            | 503           
  HTTP 504                                  | 504                | 504            | B(重试了)                       | 504              | 504            | 504           
  HTTP 404                                  | 404                | 404            | 404                          | 404              | B(重试了)         | 404           
  响应头发完后只给 2 个字节的响应体就断开(Content-Length 100) | IncompleteRead     | IncompleteRead | IncompleteRead               | IncompleteRead   | IncompleteRead | IncompleteRead
  默认 error timeout 只处理“连接/读写出错”和“超时”:HTTP 5xx 的应答是上游认真回的,nginx 默认原样转给 client;要重试 5xx 必须显式加 http_5xx;invalid_header(状态行正常、但某行响应头不合法)在默认下不重试,client 收到 nginx 的 502;如果连状态行都不是 HTTP,nginx 按 HTTP/0.9 的简单响应处理,把那一行原样当作响应体转给 client,client 的 HTTP 库抛 BadStatusLine,换上游的 invalid_header 也不会触发;off 关掉所有重试;响应头已经发给 client 之后(最后一行)无论怎么配都不能换上游

默认只有连接错误和超时;5xx 要显式写 http_5xx;响应体发了一半无论怎么配都不能换上游。

非幂等请求

实测 · 本机执行输出
  配置                                                | client 看到的状态码      | 订单被 A 记下 | 订单被 B 记下 | 同一个订单被 A 和 B 都记下(重复创建) | 共有多少个订单被记下过
  --------------------------------------------------+--------------------+----------+----------+------------------------+------------
  默认                                                | {504: 50, 200: 50} | 50       | 50       | 0                      | 100        
  proxy_next_upstream error timeout non_idempotent; | {200: 100}         | 50       | 100      | 50                     | 100        
  默认配置下,client 约一半的请求(落在 A 的)拿到 504,但这些订单其实已经被 A 记下了(client 以为失败,实际成功);non_idempotent 让 nginx 在超时后把 POST 重发给 B,client 拿到 200,代价是同一个订单在 A 和 B 都被创建一次。解决办法是让 POST 带幂等键,而不是靠网关的重试策略

POST 超时后默认不重试,client 看到失败但订单已创建;允许重试则会重复创建。要靠幂等键。

重试次数

实测 · 本机执行输出
  配置      | client 看到的状态码 | 上游收到的总次数 | 平均每个请求 | A1 / A2 / A3 各收到
  --------+---------------+----------+--------+-----------------
  默认      | {503: 30}     | 90       | 3.0    | [30, 30, 30]    
  tries 2 | {503: 30}     | 60       | 2.0    | [15, 30, 15]    
  tries 1 | {503: 30}     | 30       | 1.0    | [10, 10, 10]    

默认试遍所有上游,会把一个失败的请求放大;tries 封顶。

分步演示

proxy_next_upstream
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Python 3.14.8 / Go / nginx / Envoy / etcd / Consul / Nacos / OpenTelemetry / Prometheus(按各篇注明用到哪些及版本);所有服务都是本机自己起的进程,端口 9150 以上,只监听 127.0.0.1,不连任何外部服务,不碰本机已有的实例;故障用 kill -9 进程、让连接断开或在代码里注入延迟与错误来制造;计时用 time.perf_counter,数字每次运行会有小幅抖动。nginx 是 Homebrew 的 1.31.6(proxy_read_timeout 1 s,所有 server 写了 max_fails=0);“A”的各种坏行为由本进程里的 Python 服务(HTTP 状态码)或原始 TCP 服务(沉默、RST、非法头、中途断开)扮演;矩阵里每个格子只测一次
    • 没有验证:POST 在 nginx 其他版本里的默认重试行为;proxy_next_upstream 与 upstream keepalive 的相互作用;http_403 / http_429 / http_404 以外的更多条件;gRPC 的 grpc_next_upstream;请求体很大且已经部分发送时的重试行为
    京ICP备2026067502号-1京公网安备11010502063487号