网关504排障:握手正常为何服务超时? 我最近碰到一件挺有意思的事办公终端到内网网关的 Handshake 一切正常TCP 三次握手秒通、TLS 证书校验也没问题可偏偏一访问内网 Agent 服务的接口前端统一网关就稳定回 504 Timeout。身体已经成功握手回家服务却在超时里头闭门思过这种割裂感做后端和网关的同学应该都懂。这篇文章我把这次排障完整拆开先从响应头和计时数据定位 504 到底是谁抛的再从四层链路查到七层应用最后落到连接池、健康检查、超时配置这些容易被忽略的细节。内容偏向实操适合正在维护内部 Agent 服务、网关或者分布式系统的人。你不需要有多深的理论基础跟着这套排查顺序走大部分 504 都能在一小时内找到根因。1. 先别急着怪网络握手成功不代表服务正常1.1 “握手回家”到底证明了什么老实说看到告警那一刻我第一反应也是去查网络。毕竟监控面板上明明写着“握手成功”几个关键节点也都显示可达那问题还能出在哪等我把抓包和日志翻了个底朝天才意识到自己犯了一个经典错误把传输层的握手成功当成了整个服务链路的通行证。Handshake 成功这件事覆盖范围其实非常窄。它只说明客户端和网关之间完成了 TCP 三次握手并且可选地把 TLS/SSL 的证书链、随机数交换、对称密钥协商都跑完了。翻译成人话就是你的设备确实能够走到对方的门口门禁卡也刷开了但这跟门后面那个服务是不是健在、是不是愿意搭理你完全是两码事。就像你拿着工卡成功刷进了公司大楼不代表每一间会议室的投影仪都正常也不代表你要找的同事恰好没在开会。所以 504 排障的第一步就是先在心里划一条界线握手成功属于传输层和安全层504 属于 HTTP 应用层。传输层证明“路通、门开”应用层才决定“服务是否真的把活儿干完了”。如果一开始就把这两层混在一起很容易陷入“网络明明通着为什么还是超时”的死胡同里转不出来。顺便说一句很多内部系统的接入链路里还会有多重跳转客户端先跟接入网关握手接入网关再转发给业务网关业务网关接着往 Agent 服务上转发。你在客户端看到握手成功只能证明你到第一跳网关没问题后面每一跳都可能继续产生新的超时。这也是我后来习惯在链路里保留完整 traceId 的原因之一后面会细说。1.2 504 从哪来先看返回头再谈优化拿到 504 之后先别急着去改 Agent 服务的代码更别直接重启网关。第一件事是确认这个 504 是谁返回的它到底出自整条链路里的哪一个具体节点不同网关返回的 504 特征不一样。有的会在响应头里带自己的标识比如Via头、Server头有的会在响应体里返回一段 JSON 错误码比如某个网关框架固定返回{code:504,msg:gateway timeout}。你只需要在客户端发起一次带-v的请求就能看到大量信息curl -v -s -o /dev/null -w HTTP %{http_code} | total %{time_total}s | TTFB %{time_starttransfer}s\n https://api.internal.example.com/agent/health-v会打印出完整的响应头-w则帮我们把关键耗时打印出来。注意看Server、Via这两个头。如果返回的是企业内部统一接入网关的标识那问题范围就缩小到“接入网关 - 业务网关 - Agent”这一段如果返回的是 Agent 服务所属业务网关生成的 504那重点应该在“业务网关 - Agent”这一段。另一个很重要的数据源是网关的 access log。主流负载均衡和入口转发组件都会记录每个请求转发的上游耗时。拿 Nginx 举例日志里有一个变量叫$upstream_response_time它记录的是网关把请求交给上游服务器之后到拿到完整响应所消耗的时间。如果你在日志里看到这个值稳定在 4.9 秒而网关配置的proxy_read_timeout恰好是 5 秒那几乎可以锁定上游 Agent 服务在 5 秒内没有返回数据网关等不及直接替你返回了 504。这一步的目标是切分责任。只要确认“网关已经转发出去但上游没在约定时间内响应”后面排查的重点就从网络转向 Agent 服务本身。反过来如果日志显示upstream_response_time非常短比如几十毫秒那就说明上游很快就报错了或者是网关压根没转发出去这时候再去查服务端就有点南辕北辙了。2. 从四层到七层链路排查的标准动作2.1 分段计时curl -w 的妙用确认完 504 是从网关出来了接下来要搞清楚请求到底卡在哪一段。这里我强烈建议先在命令行用 curl 做一次分段计时把一次请求拆成几个时间片段看。不要凭感觉觉得“好像挺快的”数字不会骗人。curl 提供了非常细粒度的计时字段最常见的几个是time_namelookupDNS 解析耗时time_connectTCP 建连耗时time_appconnectTLS/SSL 握手耗时time_starttransfer从发起请求到收到第一个字节的耗时也就是 TTFBtime_total整个请求的总耗时实际操作长这样curl -o /dev/null -s -w \ DNS: %{time_namelookup}s\nTCP: %{time_connect}s\nTLS: %{time_appconnect}s\nTTFB: %{time_starttransfer}s\nTotal: %{time_total}s\n \ https://agent.internal.svc/api/health看四组数字之间的相对关系就能把问题切成更小的块。假如 TCP 和 TLS 都很快都在 10 毫秒以内但time_starttransfer突然跳到 30 秒说明请求早就到了服务端是服务端迟迟不回话。假如 TCP 本身就耗时很久甚至连接直接失败那说明四层链路上有丢包、防火墙限制或者 DNS 解析到了一个不可达的地址。假如 DNS 的耗时占了总耗时的一大半说明域名解析环节有问题比如本地 DNS 缓存过期、上游 DNS 响应慢这时候要优先检查解析配置。这里还有一个非常实用的技巧在同一台机器上分别对网关地址和 Agent 服务直连地址各跑一次上面的命令。如果经过网关时 TTFB 很高、直连 Agent 时 TTFB 正常说明问题出在网关到 Agent 这一段要么是转发配置有问题要么是两者之间的网络路径有隐患如果直连 Agent 也很慢那基本可以拍板问题在 Agent 服务自身网络不用继续背锅了。2.2 抓包确认SYN/ACK 之外的重传信号curl 的分段计时能告诉我们“卡在哪个阶段”但没法告诉我们“网络在这段做了什么”。遇到间歇性 504或者计时结果忽快忽慢时直接上 tcpdump 抓包往往能看到很多你以为正常但实际上不正常的细节。举例来说如果网络路径上有丢包TCP 就会不断重传重传又不一定每次都立刻出现所以直观感受就是服务“时而超时、时而正常”。单纯靠 curl 多试几次很难量化但抓包一下就清清楚楚。命令可以参考下面这个在网关节点上过滤目标 Agent 服务的 IP 和端口抓 200 个包就停tcpdump -i eth0 -nn host 192.168.10.21 and port 8080 -w /tmp/timeout.pcap -c 200先触发一次 504然后停止抓包把 pcap 文件拖到本地用 Wireshark 打开。重点看几个信号。第一有没有大量的TCP Retransmission如果有说明网络上确实丢包或者对端的处理能力跟不上导致确认包迟迟没回来。第二有没有TCP ZeroWindow零窗口表示接收方的接收缓冲区已经满了应用层长时间不取数据TCP 只能告诉对方“我先不收了”。翻译成业务语言就是服务端进程还活着但要么线程全部卡住要么连接池耗尽根本没有人在消费网络缓冲区的数据。第三看 TCP 握手完成之后到第一个 HTTP 请求字节之间有没有异常延迟。如果握手成功、但没有后续数据流转那八成是请求到达后在某个队列里等待资源这种等待在抓包上表现为“一切安静得可怕”。需要提醒的是tcpdump 抓包属于四层视角它只能证明“包确实送到了但没有回应”不能直接告诉你应用为什么卡住。所以我的习惯是抓包和 jstack 线程快照同步进行抓包证明数据已经到达服务端线程快照则告诉我服务端内部卡在了哪一行代码上。两个证据放在一起根因基本就藏不住了。2.3 网关侧检查upstream 配置和健康检查把网关配置翻出来看是必经的一步因为很多时候 504 只是表象超时阈值设置不合理才是催命符。以最常用的 Nginx 为例和 504 直接相关的三个参数是proxy_connect_timeout、proxy_send_timeout、proxy_read_timeout。它们分别控制建立到上游连接的超时、向上游发送请求数据的超时、等待上游响应数据的超时。一段典型的配置长这样upstream agent_cluster { server 192.168.10.21:8080 max_fails3 fail_timeout10s; server 192.168.10.22:8080 max_fails3 fail_timeout10s; keepalive 32; } location /api/agent { proxy_pass http://agent_cluster; proxy_connect_timeout 3s; proxy_send_timeout 5s; proxy_read_timeout 30s; proxy_next_upstream error timeout http_502 http_503; }大部分 504 其实是proxy_read_timeout触发的上游已经接受连接开始处理请求了但处理时间超过了配置的阈值网关等不到响应只能返回 504。这里有一个很容易踩的坑很多人把proxy_read_timeout调到很大比如 120 秒觉得这样就不会超时了。实际上这只是把问题往后推如果 Agent 服务本身线程池已经满了、连接池已经耗尽调再大的超时也救不回来反而会让大量请求积压最终触发更严重的雪崩。除了超时配置还要看上游健康检查。很多网关注册了多个 Agent 实例然后通过健康检查自动摘除不健康的节点。但常见的问题是检查接口太“表面”。比如某个接口只返回了一个固定的 200 JSON没有去探测数据库、Redis、线程池水位结果服务已经卡得不行了健康检查依然显示“活着”。健康检查的正确做法是让它反映服务的真实可用性后面我会单独展开讲。如果网关层用的不是 Nginx而是其他网关组件或者云上的负载均衡服务思路也一样找到它对应的 upstream、超时时间、健康检查配置把这些参数和 Agent 服务的实际状态对应起来看不要指望一次就能找到答案但方向对了剩下的就是时间问题。3. 真正让 Agent “闭门思过”的三类常见故障3.1 连接池/线程池被打满当网关确认请求已经到达 Agent 服务而 Agent 内部又迟迟不给响应时第一个要怀疑的就是线程池和连接池。这两种资源一旦被打满服务进程看起来还活着端口也在监听但它已经没有能力再处理任何新请求了。想象一个场景Agent 收到业务请求后要把数据写入数据库。数据库连接池配置了最大 50 个连接。正常情况下 50 个连接绰绰有余但某一天有一个慢查询突然出现每条 SQL 要跑 10 秒一秒进来 10 个请求很快 50 个连接全部被占住。剩下的请求只能排队等连接释放等到天荒地老。网关上配置的proxy_read_timeout只有 5 秒于是 5 秒一过就 504。排查这种问题最快的手段是抓线程快照。Java 系服务用jstack pid非 Java 服务也有类似工具比如 Go 的 pprof、Python 的 py-spy。抓下来的线程栈里如果大量线程都停留在同一个获取连接的方法上比如HikariCP.getConnection那基本实锤了。有一点值得注意线程池满和连接池满不是一回事。线程池满的表现是大量线程处于RUNNABLE或WAITING但不是在等连接而是在排队等线程调度连接池满的表现则是线程已经拿到调度资源了却在等一个空闲连接线程栈会停在连接获取的地方。两种问题的修复方向完全不同线程池满要优化并发模型、减少阻塞等待连接池满要查慢 SQL、加连接或者做读写分离。另外还要看 GC 日志。Java 服务如果频繁 Full GC每次停顿几秒即使线程池和连接池都没满请求也会因为“进程冻结”而超时。这种问题在监控上通常表现为 CPU 突然拉高、或者 GC 曲线出现锯齿状平时很难发现只有把 GC 日志翻出来才能看得明白。所以排查 504 时别只盯着网络指标进程内部的 CPU、内存、GC、线程、连接池每个维度都可能藏着答案。3.2 依赖组件拖后腿Redis、数据库、注册中心还有一类非常常见的 504Agent 服务本身 CPU 不高、内存也正常但它依赖的下游组件没有按时回应导致请求同步阻塞在那里活活把网关等到超时。举个例子。Agent 在处理请求时需要从 Redis 里读一个配置项同时把结果写进数据库还要调用另一个内部接口获取上下文信息。这三个依赖任何一个没有超时控制都可能成为定时炸弹。默认情况下很多 Redis 客户端和 HTTP 客户端的连接超时时间设置得很长有的甚至不设超时。一旦 Redis 发生主从切换或者内部接口所在服务正在经历 GCAgent 这边就傻傻地等等完 Redis 等数据库等完数据库等接口最终网关那边等到超时就 504 了。解决思路是给每一个外部依赖调用都设置明确的超时并且按梯度来。我自己习惯的默认值是访问 Redis 200 毫秒、访问数据库 1 秒、访问下游 HTTP 500 毫秒。超过以后不要无限重试而是快速失败能降级就降级。比如配置项读不到就用本地缓存兜底下游接口失败就先返回部分数据。真正的线上系统不是靠“永不超时”来保证稳定的而是靠“该失败的时候快速失败、该降级的时候稳准狠”来维持整体可用。依赖里还有一类容易被忽略的是注册中心和配置中心。比如 Agent 启动时要从配置中心拉配置如果配置中心此时正在抖动而 Agent 的拉取逻辑是同步且没有超时的启动就会卡住。网关那边看健康检查不过自然把所有请求打到其他实例看起来像是“新的实例有问题”其实是配置中心在拖后腿。这类问题平时不出声一出声就是大面积故障所以给中间件调用设计超时和重试策略时一定要把“启动阶段”的依赖也考虑进去。3.3 网络路径上的隐形杀手防火墙、DNS、MTU如果说已经把应用层翻了个遍还没找到根因那就该怀疑那些平时看不见、也很难一眼定位的网络因素了。我遇到过的坑主要有三类。第一类是防火墙策略的“半开”状态。有的安全策略只对 SYN 握手做了放行但业务请求的实际数据包、尤其是大于某个长度的包会被中间设备丢弃或者限速。表现出来就是握手很快成功大请求超时小请求正常。这种问题用 curl 探测比较难发现但抓包时能看到明显的重传现象。可以用不同 ICMP 包大小做对比测试比如先ping -s 1400再ping -s 1472如果小包通、大包不通说明 MTU 相关策略或者中间设备有问题。第二类是 DNS 缓存指向了旧 IP。云原生环境下 Agent 实例经常滚动发布旧实例销毁、新实例重建IP 会变。如果某个网关的本地 DNS 缓存用的是早先解析到的老地址它在转发时就会把请求发到一个已经不存在的地址上。表现是某些节点持续超时但其他节点都正常。排查方法也很简单在出问题的网关上解析一下 Agent 服务的域名看解析出来的 IP 和实际实例地址是否一致。多翻几个节点发现不一致的基本就是问题源。第三类比较隐蔽是链路 MTU 不一致导致的“大包黑洞”。比如内部链路经过多层封装后两端 MTU 不同又没有正确协商超过某个长度的大包就会静默丢失。TCP 重传也救不回来因为每次重传的还是那个大包。这种情况下抓包看到的现象是握手包都正常但一旦开始传输大 payload立刻出现大量重传且总在同一个包长度区间附近失败。修复方向通常是调整 MTU 配置或者把 TCP 分段策略改成更保守的设置。这类问题容易在应用团队和网络团队之间互相甩锅但只要你手里有抓包文件谁的问题一眼就能看出来。4. 一次 504 排障的实际过程记录4.1 现象和时间线光说方法论太干我拿一次真实发生过的排障过程来完整走一遍。某天下午三点多监控告警突然响起内网 Agent 服务大面积返回 504持续时间将近二十分钟。奇怪的是所有办公终端到接入网关的 Handshake 都非常正常四层连通、证书校验、密钥协商全部秒开。团队里立刻分成两派业务组说是网络问题网络组说是服务问题。告警群里的第一轮讨论几乎没有任何有效信息因为两边都只拿到了自己那侧的结果。于是我们定了一个规矩先拉取统一入口网关的 access log按时间切出 504 的请求提取上游响应时间的分布同时在 Agent 服务的监控面板上拉取线程数、连接池、GC 三个维度的曲线。四点多的时候数据出来了网关日志里上游响应时间大面积落在 4 到 5 秒之间而网关配置的proxy_read_timeout是 5 秒。也就是说 Agent 服务普遍在 4 到 5 秒内没有把响应返回给网关。同时 Agent 监控面板上数据库连接池的活动连接数在故障时段直接顶到了最大值且一直顶着不回落。问题焦点第一次清晰起来不是网关不转发而是 Agent 服务里有一层很明显的资源阻塞。4.2 逐步缩小范围从网关到容器确认焦点后我们做了几个针对性操作。第一个是在 Agent 服务所在节点直接抓包确认请求确实到达了服务端口第二个是在故障未完全恢复时连续抓了三次线程快照第三个是把日志里那个时段所有涉及数据源操作的响应时间拉出来统计。抓包结果说明请求早就到了线程快照里的画面就更有意思了大量线程都停在同一条调用链上具体点是某个数据访问框架在等待获取数据库连接状态是TIMED_WAITING等待时间已经超过系统配置的获取连接超时时间。再配合日志统计发现故障时段数据库的活跃会话数非常高而且大量会话都在执行同一类批量查询。真相已经很近了不是 Agent 服务整体挂了是某个批量任务把数据库连接池和会话资源全部占满导致所有正常业务请求都拿不到连接只能排队等待排到 5 秒以上就被网关放弃。为了进一步确认根因我们把这个批量任务的执行时间点和故障时段对齐发现它每天下午三点半左右触发每次处理一批历史数据循环里一条条执行 SQL整个任务最长要跑二十多分钟。数据库连接池一共 50 个连接这个任务自己就占了接近 30 个剩下的根本满足不了业务请求的并发需求。4.3 根因与修复方案原因彻底清楚了批处理任务与在线业务争抢同一个数据库连接池在线业务请求等连接的时间超过了网关的读超时从而表现为 504。这个问题不是网络不通不是握手失败而是典型的“应用层资源被占满服务迟迟无法响应”。修复方案一共改了四处。第一把这个批量任务改成小批量分批提交控制它每次只占固定数量的连接同时加了任务并发上限第二为批量任务单独分配一个只读数据源让它和在线业务连接池物理隔离互不挤兑第三调整任务调度时间从下午业务高峰挪到凌晨低峰第四给数据库操作代码补上了更明确的超时控制避免极端情况下无限等待。另外我们还做了个预防性改动把网关的proxy_read_timeout从原来的 5 秒调到 15 秒但这个调整是在 Agent 侧服务能力已经恢复之后才做的不是为了掩盖问题。调得太长反而会让客户端长时间挂着这里面的分寸很重要。改完后的连续一周504 彻底消失每天下午的定时故障也没有再出现。回头看这个案例最典型的价值在于它提醒我们健康检查显示正常、握手显示正常都不代表服务真的有能力正常响应连接池水位才是决定性的指标。5. 避坑指南与经验沉淀5.1 超时参数设置给慢请求留条活路超时参数不是随便填的它是整条调用链上各个组件之间的一种契约。我见过太多线上事故根源就是某个环节的超时设置不合理要么太短导致正常慢请求被误杀要么太长导致资源被无效请求占满。最推荐的做法是让整条链路的超时时间呈严格的梯度递减客户端等待时间要大于网关超时网关超时要大于服务间调用超时服务间调用超时要大于资源访问超时。举个例子客户端 SDK 设置 60 秒网关设置 30 秒Agent 调下游 HTTP 设置 5 秒Agent 访问 Redis 设置 200 毫秒。这样才能保证最内层依赖先超时快速失败然后向上层返回明确错误而不是每一层都无限等待最后在某一层突然暴雷。另外一个经验是不要把 504 简单地等同于“服务慢”。如果只是响应慢Agent 侧会把任务做完再返回只是总时长超过了网关阈值如果是资源耗尽Agent 可能永远等不到可用连接返回时间完全看资源何时释放。这两种情况在网关日志上表现接近但修复方式完全不同。所以拿到 504 样本后先别急着调超时花十分钟看看上游到底是在“认真干活”还是在“排队等资源”方向对了再动手。5.2 一定要有的健康检查和优雅下线健康检查接口是网关和负载均衡判断上游可用性的唯一依据但这个接口的质量很多人根本没上心。最差的健康检查就是回一个硬编码的 200这跟没有健康检查没什么区别因为它对真实可用性毫无参考价值。正确姿势是让健康检查接口能够快速反映服务的核心依赖状态。比如在检查逻辑里尝试获取一个数据库连接设置 200 毫秒超时成功才返回 200再检测线程池的活动线程数和队列积压量如果队列积压超过阈值就返回 503检测 Redis 连通性、磁盘剩余空间是否过低等等。目的只有一个让网关能提前把不健康的实例从负载均衡池里摘出去而不是让请求继续打到一台已经“闭门思过”的实例上。优雅下线在云原生环境里同样重要。一个 Agent 实例收到终止信号后应该先向注册中心注销自己然后等正在处理的请求完成再真正退出。如果下线动作没有这个顺序就会有一段时间内注册中心里还保留着这个实例但实际端口已经关闭网关转发过去就是连接失败或者超时。别小看这种几秒到十几秒的空窗期高并发下足以触发一波 504。5.3 日志与链路追踪让排障不再靠猜最后一条经验可能是整个排障过程里最值得投资的链路追踪。没有它之前我们查 504 完全靠分工协作一方看网关日志、一方看服务日志、一方看抓包最后把碎片拼出来猜。有了 traceId 之后所有环节只要按照同一套规范打日志就能把一次请求从入口到出口完整串起来。具体落地上其实不复杂。网关入口生成一个 traceId通过 HTTP Header 传给下游 AgentAgent 内部每次调用依赖时也继续传递这个 Header。日志打印时把 traceId 带上这样一次 504 发生之后只需要在日志平台搜这个 traceId就能看到请求到底在哪个环节耗时最长、在哪一步直接报错。相比过去靠时间戳去对齐日志效率提升不是一点点。如果团队暂时没条件上全链路监控系统也可以先从轻量方案开始自己写一个简单的日志过滤器在入口生成 traceId然后在关键依赖调用的前后各打一条带耗时的日志。别嫌土这个土办法在关键时刻能救命我第一次用它定位到连接池问题的时候才知道过去按时间戳对日志有多痛苦。最后说点实在的。我踩过最深的坑就是一开始看到握手成功就觉得“网络没问题”然后绕了一大圈去检查防火墙和 DNS最后才发现是业务代码把数据库连接池堵死了。后来学乖了凡是 504第一件事永远是确认谁返回的、上游卡了多久再去看日志、抓包、线程快照按这个顺序走绝大多数问题一小时以内能定位。如果你也在维护内网 Agent 服务或者任何跑在网关后面的分布式组件建议把上面这套排查顺序存下来。真遇到“握手成功、服务超时”的诡异现象心里先默念三遍握手成功只是序曲服务能不能在规定时间内干活才是关键。别慌慢慢来先查清楚是谁在闭门思过。