504 GATEWAY_TIMEOUT 配上一句Response took longer than timeout: PT30S是网关类服务里最容易被误判的一类报错。它的典型现场特别统一用户侧白屏或者转圈到底网关日志里刷出这么一行而业务服务自己的日志干干净净连个异常堆栈都没有。很多人的第一反应是服务挂了冲过去翻业务日志翻半天一无所获最后把超时时间往大一调问题消失了过两周又回来而且回来的时间点更随机。这篇东西就是想把这整条链路摊开讲一遍——504 到底是谁返回的、PT 这种超时值写法从哪冒出来的、怎么在几分钟内判断断点落在哪一段、以及把超时参数改大之外的治本做法。如果你在做微服务网关、API 网关运维、接口性能优化或者只是被 504 折腾过几轮下面的内容可以直接对着排查。1. 先把报错拆开504、GATEWAY_TIMEOUT 和 PTxxx 分别代表什么1.1 504 是网关返回的业务服务往往毫不知情HTTP 状态码里 5xx 分成两类一类是服务端自己出错了另一类是服务端作为代理角色拿不到上游的结果。504 属于后者语义是我作为网关/代理已经把请求转出去了但在约定的时间内没等到上游的响应。502 和 504 经常被混着讲区别其实很好记502 是我联系上了上游但上游给我的东西我看不懂或者连接直接被拒了504 是我压根没等到东西。这个区别在排查时是决定性的。收到 502第一件事去看上游服务是不是挂了、端口是不是没监听、健康检查是不是失败的收到 504上游八成是活着的只是慢了或者卡在某个外呼依赖上。你如果上来就去重启业务服务大概率是白折腾。还有一点特别值得说504 不一定由网关发出来的。从浏览器到最终的业务代码中间可能隔着 CDN、负载均衡、Nginx、服务网格的边车、Spring Cloud Gateway 这类应用网关任何一层都可能吐出 504。所以第一件事不是改配置而是确认这个 504 的响应头里有没有特征信息。Nginx 会带Server: nginx有些云负载均衡会带自己的标识应用网关一般会带Content-Type: application/json加一段 JSON 报错体。找到是谁发的后面的路才走得下去。1.2 PT30S 这种写法基本可以锁定是 Java 技术栈PT30S不是某个厂商自定义的格式它是 ISO-8601 里表示时间长度的标准写法。P 表示 PeriodT 表示 Time后面的 30S 就是 30 秒。常见的组合还有PT1M1 分钟、PT1M30S1 分 30 秒、PT0.5S500 毫秒、PT500MS这种非标准变体。关键在于Java 的java.time.Duration.toString()默认就会输出这种格式。你在日志里看到PT30S基本上可以断定打印这行日志的组件跑在 JVM 上。对比一下其他语言Go 的time.Duration打印出来是30s或者1m0sNode.js 一般直接给毫秒数字Python 的 timedelta 是0:00:30。所以这条信息本身就是个免费的线索——你面对的是一个 Java 系网关Spring Cloud Gateway、Zuul、自研的 Netty 网关范围一下就收窄了。Response took longer than timeout:这个措辞在 Spring Cloud Gateway 的NettyRoutingFilter里可以直接找到。它在构造 Reactor Netty 的 HttpClient 时会把responseTimeout传下去响应超时触发后打出这行 warn 日志同时抛出TimeoutException最终由DefaultErrorWebExceptionHandler转换成 504 返回给调用方。如果你用的是别的网关日志措辞会不一样但只要看到PT前缀往 Java 系方向查准没错。1.3 为什么超时值偏偏要打进日志里很多人觉得这行日志啰嗦其实它是排查的关键锚点。因为网关的超时值可能来自三个地方全局配置、路由级配置、代码里的硬编码默认值。能打印出具体的 PT 值说明这个值是从配置里读出来的不是写死的这意味着你有调整空间。反过来说如果你在配置里搜遍了response-timeout都找不到这个值那它很可能就是某个版本的默认值比如早期版本里 Reactor Netty 底层的默认行为。我在实际项目里养成的习惯是把网关的超时配置集中写在一个 yaml 里并且在启动时用一条日志把生效值打出来。听起来有点多余但真出问题的时候你不用再去猜到底哪份配置生效了打开日志一眼就能看到。这个动作的成本大概十分钟收益是每次排查省半小时。2. 一条请求要穿过多少道超时闸门2.1 从浏览器到数据库至少四层超时在同时工作这是 504 排查里最容易被忽略的一点超时从来不是一层的概念而是层层嵌套的。一个典型的请求链路大概是这样的浏览器/App 自己的请求超时通常是 30 到 60 秒CDN 或负载均衡的空闲超时常见 60 秒有些云产品默认 15 秒Nginx 或 Ingress 的proxy_read_timeout默认 60 秒应用网关Spring Cloud Gateway / Zuul的response-timeout业务服务自身的接口处理时间业务服务调用下游 HTTP/RPC 的超时数据库查询超时、连接池获取连接超时Redis、消息队列等中间件的命令超时。这八层里只要有任意一层先触发链路就会在那个点上断掉。而用户最终看到的 504只反映出最外层那个等不及了不反映真实原因在哪一层。这就是为什么很多团队把网关超时从 30 秒调到 60 秒问题依然存在的根本原因——真正卡住的可能是数据库那条 120 秒还没返回的查询网关调到 60 秒只是让它多等了一会儿最后还是超时只是失败率从 5% 变成了 3%看着像改善了其实什么也没解决。2.2 常见组件的默认超时值对照组件 / 位置参数名常见默认值说明Nginxproxy_connect_timeout60s与上游建连的时间Nginxproxy_read_timeout60s两次读操作之间的间隔不是总时长Nginxproxy_send_timeout60s两次写操作之间的间隔Spring Cloud Gatewayconnect-timeout45s连接上游的最长等待Spring Cloud Gatewayresponse-timeout多数版本未显式设置需显式配置否则受底层约束Tomcatconnection-timeout20s接收请求行的等待时间Tomcatkeep-alive-timeout与 connection-timeout 同值长连接空闲回收HikariCPconnectionTimeout30s从池里拿连接的最长等待HikariCPvalidationTimeout5s连接有效性校验MySQL JDBCsocketTimeout0不限制强烈建议显式设置Redis 客户端timeout视客户端而定Lettuce 默认 60sResilience4jtimeout-duration1sTimeLimiter 默认值很容易踩这张表最容易让人意外的是 Nginx 的proxy_read_timeout——它不是整个请求最多 60 秒而是两次成功读取数据之间的间隔不能超过 60 秒。也就是说一个持续吐数据的流式接口可以跑几个小时不超时只要它每隔 50 秒吐一个字节。这和 Spring Cloud Gateway 的response-timeout语义完全不同后者是从请求发出到响应结束的总时长。把这两个概念搞混在做流式接口或者大文件下载的时候必然踩坑。2.3 超时预算为什么不能让每一层都设 30 秒超时配置的核心原则叫超时预算timeout budget说人话就是外层给的时间必须比内层多而且要多出一截余量让内层有机会先超时、先返回一个明确的错误外层才有机会把这个错误原样透传回去。假设网关设了 15 秒业务服务的接口处理没设上限下游数据库查询也没设上限。那么当数据库那条查询跑了 40 秒的时候网关在 15 秒就断开了连接返回 504而业务服务的线程还在那儿傻等数据库。用户看到 504业务日志里什么异常都没有因为请求还没走到抛异常那一步。这就是504 但业务日志空白的经典成因。正确的做法是自外向内逐层收敛每一层比内层多留 20% 到 30% 的余量客户端60s负载均衡 / Nginx30s应用网关15s业务服务接口整体10s下游 RPC / HTTP 调用3s数据库查询2sRedis 命令500ms这样一旦某条数据库查询慢了2 秒就抛出明确的 SQL 超时异常业务服务捕获后返回一个带错误码的响应网关在 15 秒内正常收到并透传用户看到的是查询超时请稍后重试而不是一个什么都没有的 504。可观测性一下子从零信息变成了精确定位这个差别的价值远大于把网关超时调大。3. 网关侧配置response-timeout 到底该怎么设3.1 connect-timeout 和 response-timeout 是两件完全不同的事这两个参数经常被一起提但管的事情差得很远。connect-timeout管的是建立 TCP 连接这一段从发起连接到三次握手完成超了这个时间就抛连接超时。response-timeout管的是连接建立之后到拿到响应这一段。所以上游服务进程挂了、端口没监听、网络不通触发的是connect-timeout表现为连接被拒绝或连接超时上游服务活着但处理慢触发的是response-timeout表现为 504 GATEWAY_TIMEOUT。之所以要区分是因为两种情况的处理方式完全相反。连接超时基本是基础设施问题要么扩缩容要么检查网络和安全组响应超时是业务逻辑问题得去看具体哪个接口慢。如果你只调了 connect-timeout对 504 是一点用都没有的——这也是一类很常见的误操作。3.2 全局配置和路由级配置的写法差异Spring Cloud Gateway 支持两个层级的配置。全局配置走spring.cloud.gateway.httpclient值用Duration类型写也就是30s、1m这种人类友好的格式spring: cloud: gateway: httpclient: connect-timeout: 2000 response-timeout: 15s pool: max-connections: 500 max-idle-time: 30s acquire-timeout: 5000路由级配置走metadata这里有个非常容易踩的坑metadata 里的值单位是毫秒整数不是 Duration 字符串。也就是说全局写15s路由级要写15000spring: cloud: gateway: routes: - id: order-service uri: lb://order-service predicates: - Path/api/order/** metadata: connect-timeout: 2000 response-timeout: 15000我就见过有人照抄文档把路由级写成response-timeout: 15s结果解析失败或者被当成 15 毫秒接口全部瞬间超时整个订单链路挂了半小时。这种错误在本地测试很容易发现但如果是在半夜改的配置代价会比较大。另外还有一个很实用的开关把路由级的response-timeout设成-1表示这条路由不做响应超时限制。这个在流式接口场景里是刚需下面会细讲。3.3 流式接口和 SSEresponse-timeout 会误杀长连接前面提到过response-timeout是从请求发出到响应结束的总时长。对于普通的一问一答接口这个语义没问题但对于 SSE、长轮询、大文件流式下载这类接口问题就大了——这类连接可能持续几分钟甚至几十分钟响应永远不结束response-timeout到点就把它砍了。现象很有意思前端日志里会出现stream disconnected before completion或者idle timeout waiting for sse这类报错看起来像是网络抖动其实是网关主动断的。排查时如果只盯着前端和网络会绕很大的弯路。处理方式有几种可以按情况选方案一给这类路由单独设一个很大的response-timeout比如60000010 分钟并明确接受这个上限。方案二路由级设response-timeout: -1完全禁用响应超时改由空闲超时来控制。也就是连接里只要有数据在流动就不算超时长时间没有任何数据才断开。这才符合流式接口的真实需求。方案三流式接口不走应用网关直接走 LB 独立暴露绕开这层超时逻辑。我个人在项目里倾向方案二因为它最贴合流式语义。但要注意禁用响应超时后必须同时设置连接池的空闲回收时间否则连接池很容易被长时间挂着的连接占满进而引发新的问题。4. 一套能落地的 504 排查流程4.1 第一刀用 curl 的分段计时确认断点在哪一层排查 504 最不该做的事就是猜。有个几乎零成本的工具组合就是用 curl 的-w参数把各个阶段耗时打出来curl -o /dev/null -s -w \ dns%{time_namelookup} connect%{time_connect} tls%{time_appconnect} ttfb%{time_starttransfer} total%{time_total}\n \ -H Host: api.example.com \ https://10.0.0.1/api/order/query?id123这几个指标的含义要记清楚time_namelookup是 DNS 解析耗时time_connect是 TCP 建连耗时time_appconnect是 TLS 握手耗时time_starttransfer是首字节时间也就是从发请求到拿到第一个字节的时间time_total是总耗时。关键判断逻辑是这样的如果time_connect就很大说明网络或目标端口有问题跟业务无关如果time_connect正常但time_starttransfer很大说明请求到了服务端但服务端处理慢问题在业务侧如果time_starttransfer正常但time_total很大说明首字节很快但响应体传输慢常见于大响应体或者流式接口。用 IP 直连而不是域名能顺便绕过 DNS 和部分负载均衡进一步收窄范围。这个动作熟练之后一次只要十几秒比翻日志快得多。4.2 第二刀看网关自身的状态别急着甩锅给下游很多人一看到 504 就默认是下游慢其实网关自己出问题的概率也不低。重点看三样东西连接池有没有打满。Reactor Netty 的连接池默认上限是 500如果并发请求把它占满了新请求会卡在等待获取连接这一步表现出来也是超时。日志里通常会有PoolAcquireTimeoutException或者acquire-timeout相关的记录。用ss -s看 TIME_WAIT 和 ESTABLISHED 的数量分布能很快判断是不是连接堆积。JVM 有没有卡在 GC 上。用jstat -gcutil pid 1000 30连续采样 30 秒重点看 Full GC 的次数和耗时。如果排查期间发生了两三次 Full GC每次几百毫秒那这段时间内所有请求都被暂停了504 完全是 JVM 自己造成的。这种情况调网关超时是治标真正的解法是查内存泄漏或者调堆参数。线程池有没有被慢请求占满。用 jstack 抓一份线程栈jstack pid /tmp/stack.txt grep -A 30 http-nio /tmp/stack.txt | head -100如果看到大量线程都阻塞在同一个方法上比如某个 JDBC 调用或者某个 HTTP 客户端那答案就很明确了。Arthas 的trace命令在定位这类问题上效率更高trace com.example.OrderService query #cost 1000 -n 5这条命令会把耗时超过 1 秒的调用链路打出来最多抓 5 次慢在哪个方法、哪个子调用上一目了然。4.3 第三刀把下游依赖的耗时拆开看网关和业务服务都排除之后剩下的就是下游依赖。按经验504 的源头分布大致是这样的数据库慢查询占了很大一块常见于缺索引、大表关联、OR条件导致索引失效第三方接口调用尤其是支付、短信、地图这类外部服务网络抖动和对方限流都会造成长耗时缓存击穿大量请求同时穿透到数据库批量任务的定时调度恰好和流量高峰撞在一起抢走了数据库资源锁等待行锁、分布式锁、synchronized阻塞在同一个热点上。定位方法上我一般会先在数据库侧看慢查询日志把排查窗口期内的慢 SQL 捞出来。如果慢 SQL 里出现了意料之外的语句那就是答案。如果没有再去看应用侧对每个下游调用的埋点耗时把 P99 拉出来对比。有一个特别隐蔽的场景值得单独提某些接口在特定参数下才会慢。比如列表查询没传时间范围全表扫比如某个用户的数据量特别大。这类问题在压测和日常流量里都测不出来只有在真实请求进来的时候才爆发。所以排查时不要只复现接口本身要尽量拿真实的请求参数去复现这一点我在实际项目里吃过不止一次亏。4.4 常见问题与排查速查表现象最可能的原因快速验证方式504 且业务日志完全空白网关先超时业务还在跑调大网关超时后日志是否出现异常504 集中在某几个接口该接口本身慢或有慢依赖单接口压测看 P99504 随机分布、无规律GC 停顿或连接池抖动jstat 采样 连接池监控504 只在流量高峰出现资源竞争、线程池打满对比高低峰线程数504 且伴随 502上游实例掉线或扩缩容检查注册中心实例列表调大超时后失败率略降但仍在真实瓶颈在内层按超时预算逐层排查只有 SSE 或下载接口超时response-timeout 误杀长连接该路由设 -1 后验证本地复现不了参数或数据量差异用真实请求参数复现这张表是我自己在排查时用的一个索引遇到具体现象先对一眼能省掉很多试错时间。但它只是起点最终还是要靠日志和链路追踪把根因钉死。5. 治本不治标把超时从改参数变成工程设计5.1 长耗时接口该异步化就异步化别硬扛504 里有一部分是永远无法靠调参数解决的因为它们处理的就是需要几十秒甚至几分钟的业务批量对账、大报表导出、视频转码、AI 推理。这类接口放在同步请求-响应的模型里注定会超时调多大都不够。正确的做法是改成异步任务模型接口收到请求后立刻返回一个taskId真正的处理放到后台队列里跑客户端轮询或者通过推送拿结果。这样网关侧的请求在几百毫秒内就结束了超时配置再也不用为它单独开小灶。改造的时候有几个细节要注意。任务状态要有明确的终态别出现任务永远卡在处理中的情况任务结果要设置过期时间否则存储会无限膨胀要处理重复提交同一个用户连点两次不能生成两个任务通常用业务唯一键做幂等。这几点在改造初期很容易被漏掉等出问题再补成本会翻好几倍。5.2 熔断降级不是奢侈品是防止雪崩的必要手段当下游依赖开始变慢的时候如果不做任何干预请求会不断堆积线程池被占满最终整个服务不可用——这就是雪崩。熔断器的价值就在于当某个依赖的失败率超过阈值时直接快速失败不再把请求发出去给下游留出恢复的时间。Resilience4j 的配置大概是这个形式resilience4j: timelimiter: instances: orderService: timeout-duration: 3s cancel-running-future: true circuitbreaker: instances: orderService: sliding-window-size: 20 failure-rate-threshold: 50 wait-duration-in-open-state: 10s permitted-number-of-calls-in-half-open-state: 5cancel-running-future: true这个参数很关键。如果不设成 true超时之后那个 Future 还在后台继续跑线程并没有真正释放时间一长线程池照样被占满等于白设超时。这个点我在两个项目里都见过有人漏配排查起来非常绕。降级逻辑则要考虑清楚降级之后返回什么。返回空列表、返回缓存里的旧数据、返回明确的错误码这几种选择对用户体验的影响差别很大要按业务场景定不能统一套一个模板。5.3 可观测性让下一次超时在两三分钟内被定位前面所有的排查手段本质上都是在弥补信息不足。如果把该埋的点都埋上下一次 504 可能根本不需要翻日志。需要覆盖的东西按优先级排网关层的请求日志包含路由 ID、上游实例、状态码、耗时最好带上 traceId业务服务的接口耗时打点按接口维度统计 P50/P95/P99每个下游依赖的调用耗时数据库、Redis、外部 HTTP 分开统计连接池和线程池的实时指标活跃连接数、等待队列长度JVM 的 GC 指标频率和停顿时间。其中 traceId 是全链路串联的关键。只要网关、业务服务、下游调用都埋在同一个链路里拿到一个 traceId 就能把整条请求的所有耗时节点拉出来慢在哪一段一目了然。这个建设投入不算小但收益是持续的尤其是在故障频发的阶段。6. 实操踩坑记录与上线检查清单6.1 几个反直觉的坑第一个坑504 之后业务其实还在跑。网关超时断开连接只是断开了连接业务服务那边的请求线程并不会被自动取消它会继续执行完。如果这是个写操作比如下单、扣款用户看到失败重试一次实际上可能已经成功了两笔。这个问题的解法是幂等设计用请求唯一 ID 或者业务唯一键做去重别指望超时断开能自动回滚。第二个坑调大超时会让问题更晚暴露反而更难查。超时从 15 秒调到 60 秒慢请求会占用连接和线程更久高峰期更容易把资源耗光把一个局部问题放大成全局问题。所以调大超时只能是临时止血必须配合排查不能当成解决方案。第三个坑不同版本默认值不一样。Spring Cloud Gateway 各版本的response-timeout默认行为有过变化有的版本不设就不限制有的版本会落到别的地方。所以永远不要依赖默认值全部显式配置。这也是我前面建议启动时把生效值打出来的原因。第四个坑本地环境跟线上完全是两码事。有人在本地的 WSL 或者 Docker 环境里碰到设置容器超时之类的报错就以为跟线上 504 是一回事其实那多半是本地资源分配或者虚拟化层的问题跟网关超时链路没有任何关系。排查时先把环境边界划清楚别把两件不相干的事混在一起找原因。第五个坑只调网关不调内层。前面反复说过如果内层的数据库查询没有超时限制网关调到多大都只是延后失败。真正的解法是自内向外把每一层的超时都设上让内层先返回明确错误。6.2 超时参数到底怎么算出来不要凭感觉设用数据算。方法是取接口在正常负载下的 P99 耗时乘以 1.5 到 2 倍作为该层的超时值。举个具体的例子。假设订单查询接口在正常流量下 P99 是 800 毫秒那么数据库查询超时可以设在 2 秒P99 的 2.5 倍给足余量服务内部的 RPC 调用超时设在 2 秒业务服务接口整体超时设在 3 秒网关的response-timeout设在 5 秒外层负载均衡的读超时设在 8 秒。这样整条链路是收敛的任何一环出问题都会在相对靠内的地方先暴露出来。同时要注意这个值是随业务演变的接口变复杂了、数据量涨了P99 会漂移。所以我一般会建议每季度复查一次超时配置把它当成一个需要维护的东西而不是设一次就忘。现实里我见过太多项目超时值还是三年前定的早就和实际耗时脱节了。6.3 上线前的自查清单配置改完准备上线之前我一般会走一遍这个清单全局response-timeout和路由级response-timeout的单位是否搞清楚了一个是 Duration 一个是毫秒整数流式接口的路由有没有单独排除或者设成-1connect-timeout和response-timeout有没有都配上不要只配一个业务侧的数据库、Redis、外部 HTTP 调用超时有没有全部显式设置每个下游依赖的失败率和耗时有没有监控埋点熔断器的cancel-running-future有没有打开写接口的幂等有没有做用户重试会不会重复执行超时值有没有基于实际的 P99 算过而不是凭感觉启动日志里能不能看到所有生效的超时配置有没有一个可以随时查的 traceId 链路追踪入口。这个清单过一遍大概二十分钟但能挡掉绝大多数上线后又冒出来的 504。我在实际项目里的体会是504 这类问题从来不是靠一次调参解决的它更像是在提醒你这条链路上某几层的边界没划清楚。把边界划清楚之后同一个问题基本不会再来第二次。