同样是一句 Connection reset,有时是 Spring Boot 给调用方写响应时发现连接已经断了,有时却是 Java 调下游接口时读不到结果。只搜异常最后一行,很容易查错方向。

先把这次请求经过的环节列出来,再确认异常发生在哪一个读写动作上:

调用方 → Nginx / 网关 → Spring Boot → 下游 HTTP 服务

这条链路里不只有一条连接。对 Spring Boot 来说,直接连过来的“客户端”可能是 Nginx;对下游服务来说,Java 应用又变成了客户端。

本文按 Java 17/21、Spring Boot 3.5 的 Servlet / Tomcat 10.1 应用说明,代理配置示例适用于开源 Nginx 1.24 及以上版本。WebFlux、HTTP/2 和不同客户端库的异常包装会有差别,排查时要保留完整异常链。

先看调用栈:正在读谁,还是正在写给谁

JDK 对 SocketException 的定义是创建或访问 Socket 时出现错误。异常文本本身不会替你标出整条请求链里哪个组件先出了问题。

先找靠近业务代码的那一段调用栈:

当时正在做什么重点看哪一段
Controller 已处理完,向响应流写 JSON 或文件Spring Boot 到它的直接调用方
服务端正在读取请求体或上传内容调用方到 Spring Boot 的请求输入
Java HTTP 客户端发送请求、读取下游响应Java 到下游服务的连接
TLS 握手阶段就失败先分开查握手、证书和连接中断,不要按业务超时处理

如果异常外层是 ClientAbortException,再包着 Broken pipe 或连接重置,重点看当时是不是在写响应。Tomcat 的 API 文档把这个异常用于标记远端客户端中止请求。这里的“远端客户端”仍然可能是代理,不能直接翻译成“用户关了网页”。

顺手保留这几项:发生时间、接口路径、请求 ID、应用实例、执行到的业务步骤,以及异常所在的读写方法。涉及异步任务时,先检查 traceId 有没有在线程切换时丢掉,否则很难把两边的日志对上。

把代理和应用日志放到同一条时间线上

先用已有日志,不够再补字段。下面是一份排查用的 Nginx 文本日志格式,不是完整站点配置:

# 定义放在现有的 http {} 内。
log_format connection_diag
    'time=$time_iso8601 rid=$request_id '
    'method=$request_method uri="$uri" status=$status '
    'rt=$request_time upstream="$upstream_addr" '
    'ustatus="$upstream_status" connect="$upstream_connect_time" '
    'header="$upstream_header_time" response="$upstream_response_time"';

# 挂到要排查的 server {} 或 location {} 内。
access_log /var/log/nginx/connection-diag.log connection_diag;

日志目录要适合当前部署方式,并允许 Nginx 写入。合并配置后先检查配置语法,再按现有发布流程生效。log_format 的位置及 access_log 的上下文见 Nginx 日志模块文档。

rid 是这份示例里的 Nginx 请求 ID,不会自动变成 Java MDC 里的 traceId。项目已有统一请求 ID 时,沿用现有传递方式,确认代理和应用记录的是同一个字段;暂时没有统一 ID,就结合时间、路径和具体实例缩小范围,不要仅凭两条日志时间接近就认成同一次调用。

这几项时间也不要混着读:

  • rt 是 Nginx 处理这次请求的耗时,不是数据库执行时间。
  • connect 是连接上游的耗时,HTTPS 上游还涉及握手。
  • header、response 分别帮助观察收到上游响应头和接收上游响应的耗时。

一次请求如果尝试了多个上游,这些字段可能有多组值;缺失项也可能显示为 -。应和上游地址、状态一起对照,不能把整列当成一个数字。上游模块文档说明了这些变量的含义。

看到 499,也要看断开时处于哪个阶段

499 可以作为 Nginx 观察到客户端提前关闭连接的一条线索。还要追问:是调用方等待太久,还是主动取消?Nginx 前面是否还有负载均衡或其他网关?

反过来,没有 499 也不能排除响应发送中途断开。响应状态已经确定后,客户端再关闭连接,访问日志可能仍保留原状态;还要检查错误日志和响应是否完整。Nginx 开发者对客户端断开的说明专门区分了这些阶段。

总在差不多的秒数断开,就把超时一层层列出来

假设几次失败都接近 30 秒,先把调用方、外层网关、Nginx、Java 客户端、下游服务各自的超时配置放在一起。这只是定位思路,不代表看到 30 秒就已经找到了某个默认值。

尤其要区分:

  • 建立连接允许等多久;
  • 等响应头或下一段数据允许多久;
  • 整次调用最多持续多久;
  • 业务自身是否还有排队、事务或处理时限。

proxy_read_timeout 控制的是从被代理服务读取响应时,两次读取之间的等待,不是整个 HTTP 响应传输的总时限。把它调大,也不会改变外层调用方已经设置的总等待时间。具体行为见 Nginx 代理读取超时文档。

需要从调用端做一次对照时,可以在有权限的测试环境运行下面的命令。它适用于 Linux / macOS 的 shell,机器需要安装 curl;把域名、路径换成实际可安全重复访问的只读接口。示例的 3 秒和 20 秒是探测上限,不是生产参数建议:

curl --silent --show-error \
  --connect-timeout 3 --max-time 20 \
  --output /dev/null \
  --write-out 'http=%{http_code} peer=%{remote_ip} connect=%{time_connect} first_byte=%{time_starttransfer} total=%{time_total}\n' \
  'https://api.example.test/api/ping'

同时看 curl 的错误信息和退出码。http=000 表示没有拿到可用的 HTTP 响应码,不能据此断言服务端没有执行请求。

first_byte 从操作开始计时,包含前面的准备和连接阶段,不能直接当成 Java 方法耗时。各字段及超时选项可在 curl 手册核对。走域名和直连应用的结果不同,只能帮助缩小范围;路径、Host、协议、鉴权和实际实例还要对应。

如果问题集中在发布重启期间,继续对照 部署更新怎样避免短暂 502。如果是 Java 调下游时持续等待,继续看 HTTP 客户端超时排查。

空闲后第一下失败,别只用“重试就好了”收尾

连接空闲一段时间后,第一次调用失败,紧接着第二次成功,可以把连接复用列为调查方向,但这还不是结论。

对照失败前的空闲时长、客户端连接池的空闲连接策略、代理或负载均衡的空闲时限,以及下游是否刚好重启。必要时在测试环境比较新建连接和复用连接的表现。核对实际客户端库的配置后再调整,不要同时改 TCP 参数、Nginx 和全部线程池,最后只剩“现在不报了”。

还有一个业务问题必须单独确认:没收到结果,不等于请求没有执行。

创建订单、扣库存、付款回调这类请求,可能已经完成业务写入,只是响应在返回时断了。重试前先按业务唯一标识确认结果,明确幂等处理、重试次数和总时间预算。HTTP 语义 RFC 9110对自动重试非幂等请求有专门约束;Nginx 的 proxy_next_upstream non_idempotent 也不是可以随手打开的排错开关。

最后复测三种情况

先让正常请求完整返回,再检查原先会失败的慢请求或空闲后首个请求;最后在测试环境主动取消一次调用,观察代理和应用各自记录了什么。

复测时除了异常次数,还要看响应是否完整、业务是否只执行了一次、请求 ID 是否连续,以及请求结束后连接和任务有没有正常释放。本文给出的是排查顺序与配置示例,没有把它们当成某次线上事故的实测结果。

如果目前连报错来自代理还是应用都分不清,先回到 Nginx / Spring Boot 502、504 排查,把出错的连接段确定下来,再继续调整对应环节。