又见到503了。周五下午两点半,运营在群里甩了一张截图,用户反馈页面上出现一行“Service Unavailable”,我打开监控,错误率曲线已经拉成一条直线。第一反应是去翻发布记录,最近半小时没有上线;再翻业务日志,几乎看不到Java异常堆栈。看到这个情况我反而不慌了——HTTP 503 Service Unavailable这种状态码,绝大多数时候不是你的代码写错了,而是系统在用状态码跟你说话:“我现在真的忙不过来,或者我依赖的东西已经掉了。”
这篇文章不是单纯的状态码说明,而是把我在生产环境里折腾503的完整经验做个复盘:先讲清楚503的真实语义,再拆解最常见的触发场景,然后给出一套拿到503后十分钟内能完成的排查SOP,最后聊聊从架构层面怎么让它别再反复出现。适合正在被线上503折磨的后端、运维、SRE同学,也适合刚入门想搞明白HTTP状态码的开发者。
1. 先搞清楚503到底在说什么:状态码语义和常见误区
1.1 503是“临时状态”,不是“程序错误”
RFC 9110对503 Service Unavailable的定义很明确:服务器当前因为过载或计划内维护,暂时无法处理请求。这里的关键词是“临时”。也就是说,503在语义上带着“过一会儿再来可能就好了”的暗示,所以规范里专门配套了一个响应头Retry-After,让服务器告诉客户端大概多久后能恢复。
这个语义区别在实际排查里特别重要。500 Internal Server Error对应的是“你的请求让我的代码抛异常了”,排查重点是代码和日志;503对应的是“我还没开始处理或者处理不了你的请求”,排查重点是容量、资源和依赖状态。我刚接手线上问题的时候,看到503第一反应是去翻Exception日志,翻了半天一无所获,最后发现是线程池被打满。那之后我就养成了一个习惯:遇到503,先问自己三个问题——是不是过载?是不是正在停机维护?是不是依赖服务出问题了?
1.2 502、503、504、429,别用错方向
很多同学会把502、503、504混在一起,甚至直接统称“网关报错”。但它们指向的问题层级完全不同:
| 状态码 | 含义 | 典型触发场景 | 第一排查重点 |
|---|---|---|---|
| 500 | 服务器内部错误 | 代码异常、SQL错误、空指针 | 应用日志与异常堆栈 |
| 502 | 网关收到上游非法响应 | 上游进程崩溃、连接被重置、协议解析失败 | 上游进程状态和网关到上游的连接 |
| 503 | 服务暂时不可用 | 线程池/连接池打满、节点被摘除、维护模式 | 容量指标、健康检查、依赖状态 |
| 504 | 网关等待上游超时 | 上游处理太慢、慢SQL、下游RPC卡住 | 上游RT、慢请求、数据库慢查询 |
| 429 | 请求过多被限流 | 触发限流规则、配额耗尽 | 限流阈值、配额策略 |
429和503在实际系统中经常被混淆。如果你们在网关层做了限流,我更建议规范地返回429而不是503,原因很直接:503会引导客户端走“服务不可用”的重试逻辑,而429明确告诉客户端“你或者你的调用方请求太密了,请按Retry-After慢下来”。如果统一用503,客户端拿不到准确的限流语义,可能会以更激进的频率重试,反而放大流量。我实际遇到过,某客户端对503的默认策略是立即重试三次,正好把一个已经过载的服务压得更死。所以,状态码的选择不只是规范问题,它会直接参与客户端的失败重试行为,进而影响整个系统的稳定性。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 生产环境最常见的三类503:过载、停机、依赖掉线
2.1 过载:一个慢接口就能拖垮整个服务
线程池打满是最经典的503来源。以Spring Boot内嵌Tomcat为例,默认最大线程数maxThreads是200。如果某个接口因为一条SQL没走索引,RT从50毫秒涨到2秒,在QPS不变的情况下,同一时刻占用的线程数直接翻了40倍,200个线程很快会全部被占满。Tomcat线程池满之后,新请求会先进accept队列,accept队列也满就直接拒绝连接,给客户端返回503。
我实际排查过一个案例:接口本身QPS只有300,正常情况下很轻松。但某个运维任务在凌晨跑了一个全量数据同步,把数据库IO打满,所有涉及相关表的查询都从几十毫秒变成了几秒,业务接口拿不到数据库连接,线程全部阻塞在等待连接池释放上,最后表现为大面积503。这个案例的教训是:过载不一定来自流量洪峰,一个慢依赖或者一个重任务,同样能把整个服务压垮。
连接池耗尽也是同一个道理。数据库连接池、Redis连接池、下游HTTP连接池,任何一个池的资源被占满且等待超时,应用都可能选择快速失败返回503。这里有一个调参逻辑值得单独说:连接池的maximum-pool-size不是越大越好,数据库服务端的连接数是有限的,连接太多反而拖垮数据库;真正要匹配的是业务高峰期的并发需求和最坏情况下的连接等待时间。
2.2 主动停机:发布和运维操作造成的“假故障”
503的语义包括计划内维护,但实际生产里,很多维护操作本来不应该产生503,产生503是因为流量摘除和停机动作的顺序没有配合好。
以Kubernetes滚动发布为例:一个Deployment有5个副本,发布时K8s会先启动新Pod,再把老Pod终止。如果应用没有配置优雅停机逻辑,老Pod收到SIGTERM后会立刻停止接受新请求,但此时Service的Endpoints对象里可能还保留着老Pod的IP,新流量依然会被转发过去,连接直接失败,网关层表现为503或502。很多同学上线后看到503,第一反应是新版本代码有Bug,回滚之后发现还是503,实际上问题出在停机流程而不是代码。
正确的做法有三层:一是K8s的Deployment里配置preStop钩子,在Pod真正停止前sleep几秒,给负载均衡器移除节点的时间;二是把terminationGracePeriodSeconds设长一些,让应用有足够时间处理存量请求;三是应用侧开启优雅停机,Spring Boot从2.3版本开始支持spring.lifecycle.timeout-per-shutdown-phase配置,配合server.shutdown: graceful,能让应用在停止前先把在途请求处理完。这三层配置配合好,滚动发布基本不会引入503。
2.3 依赖掉线:雪崩往往不是从入口开始的
微服务架构下,503经常是“下游问题传导到上游”的结果。服务A调用服务B,服务B调用服务C。当C出现故障或过载时,B内部的线程会大量阻塞在等待C返回上,B的线程池被占满后,B的入口也开始超时;接着A调用B也超时,A的线程池也满了。最后用户访问A的接口,拿到503。这个过程就是典型的雪崩效应。
应对雪崩的核心思路是四个字:快速失败。调用下游时必须有超时时间,不能无限等待;超时后要走降级逻辑,比如返回缓存数据或者默认值;同时用熔断机制防止对已经故障的下游发起无意义的调用,让下游有机会恢复。在网关层看到503的时候,不要只盯着网关本身,要顺着调用链往下游查——数据库、Redis、消息队列、外部HTTP接口,任何一个环节慢了,在入口表现都可能只是503。
另外还有一个容易忽略的点:如果你的服务通过域名调用外部接口,DNS解析异常或DNS超时也会导致连接无法建立,在调用方看来就像依赖服务掉线了。这类问题排查起来非常绕,我把它放在后面“非典型故障”里专门讲。
3. 拿到503后的十分钟排查SOP:从响应头到线程栈
3.1 第一步:不要只看截图,用curl拿完整响应头
遇到503,我最反感的就是群里发一张浏览器截图,只有“Service Unavailable”几个字,什么信息都没有。正确做法是直接在一台能连通服务的机器上用curl打一次请求,把完整响应头和耗时信息拿下来。
bash复制curl -i -s -o /tmp/resp.txt -w "http_code=%{http_code} time_connect=%{time_connect} time_total=%{time_total}\n" https://api.example.com/v1/order/123
cat /tmp/resp.txt
这条命令会同时输出响应头和请求耗时。time_connect是TCP建连耗时,如果这个值很大,说明问题出在网络链路、负载均衡或DNS上,请求可能根本没有到达应用;如果time_connect很小但time_total很大,说明连接建立没问题,问题在后端处理速度上。响应头里还要重点看有没有Retry-After字段,这个字段能直接告诉你故障是不是预期的维护行为。
有一次我排查一个偶发503,自己curl几次都正常,只有特定区域的用户报障。后来发现是某个区域节点的负载均衡器健康检查频率太高,把还在启动中的应用节点误判为不健康,导致那一小部分流量被拒绝。这种问题如果只翻应用日志,永远查不出来。所以第一步永远是复现请求,把现场信息拿全。
3.2 第二步:网关日志里的upstream_status,定位503出自哪一层
如果架构前面有Nginx或者Kubernetes Ingress,那么503可能由网关自己返回,也可能是后端应用返回。这两个来源的排查方向完全不同,所以网关日志里必须有关键字段来区分。
Nginx默认的combined日志格式没有upstream_status,靠默认日志很难判断。建议提前把log_format改成下面这样:
nginx复制log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for" '
'upstream_status=$upstream_status '
'request_time=$request_time '
'upstream_response_time=$upstream_response_time';
access_log /var/log/nginx/access.log main;
判断逻辑很简单:如果access log里同时看到status=503和upstream_status=503,说明是上游应用自己返回的503,接下来应该去查应用;如果status=503但upstream_status=-(或为空),说明Nginx找不到可用的上游节点,自己直接回了503,接下来要查的是上游节点列表和健康检查状态。这个字段就像路标,能帮你把排查范围立刻缩小一半。
3.3 第三步:应用和系统层的快速体检
确认503来自应用层之后,按下面的顺序快速体检,每一项都有明确的命令和观察点。
- 线程状态分布:
jstack <pid> | grep "java.lang.Thread.State" | sort | uniq -c | sort -nr,看大量线程是否卡在BLOCKED或WAITING状态;如果大量线程在等待某个锁或等待数据库连接,说明瓶颈在依赖。 - Tomcat线程池指标:如果项目接入了Actuator,直接看
/actuator/metrics/tomcat.threads.busy,busy数量接近max时,说明线程池已经打满。 - GC和内存:
jstat -gcutil <pid> 1000,连续观察几次,如果Full GC频繁且Old区占用很高,要怀疑内存泄漏或堆大小配置不合理。 - 系统资源:
top看CPU和负载,ss -s看网络连接状态,ulimit -n看文件描述符上限。 - 是否被系统杀掉:
dmesg -T | grep -i killed,如果出现OOM-Killer相关日志,说明进程内存超过限制,被内核强制结束。
有一类容易搞混的情况:系统负载极高导致应用响应慢,网关层看是504超时;但如果负载高到健康检查都响应不过来,负载均衡器会把节点摘掉,后面的请求就变成503。所以504和503在同一个故障里可能是前后脚出现的关系,排查时要把它们当成一条链路上的两个信号。
3.4 第四步:顺着阻塞点查依赖,别在业务代码里打转
如果线程栈显示大量线程阻塞在数据库或Redis相关调用上,第一时间看数据库和缓存的运行状态。
数据库侧:打开慢查询日志,执行show processlist;,看有没有长时间未提交的事务或堆积的慢SQL。Redis侧:INFO clients看当前连接数和阻塞的客户端数量,SLOWLOG GET 10看慢命令。下游RPC侧:看调用超时配置和熔断指标。
我个人的排查顺序已经固化成一条流水线:curl复现现场 -> 网关upstream_status定层级 -> 线程栈找阻塞点 -> 顺着阻塞点查依赖。这套流程在绝大多数情况下能帮助快速定位根因。真正的难点反而不在技术本身,而在于很多团队没有提前把网关日志和探针配置好,导致故障时手里没数据。至于DNS解析相关的坑,因为排查起来非常绕,我放到后面的非典型故障部分专门展开。
4. 彻底解决503:容量、参数、架构、发布流程四个层面
4.1 容量规划:没有压测数据,扩容就是拍脑袋
遇到503就扩容,这是很多团队的直觉反应,但扩多少才是对的?答案是看压测数据。
假设业务高峰期入口QPS是5000,单机在压测环境下能稳定扛住800 QPS,且P99 RT在业务容忍范围内,那么理论上最少需要7台机器。加上30%到50%的安全余量,实际部署建议控制在12到15台。如果只扩到8台,平时看起来够用,一旦流量增长10%到20%,503马上会回来。
压测的时候不能只看QPS,还要关注RT的分布。有些服务在压测到900 QPS时,平均RT还行,但P99已经飙到3秒,这在业务上已经是不可接受的。所以压测的通过标准应该是“目标QPS下P99 RT达标”,而不是服务器没宕机就算扛住。
4.2 参数调优:几个值得反复校准的数字
Tomcat线程池相关参数,Spring Boot里是这样配置的:
yaml复制server:
tomcat:
threads:
max: 400
min-spare: 40
accept-count: 200
max-connections: 10000
connection-timeout: 5000ms
这几个参数的关系需要理清楚。连接到达Tomcat后,max-connections控制的是Tomcat可以同时接受的连接总数;当连接数超过这个值,新连接会进入accept-count队列排队;当accept队列也满时,新连接才会被拒绝。所以如果你的maxThreads调到400但acceptCount还是默认100,高并发下请求会很快排满队列然后被拒。调参时要同步考虑这三层,不能只管其中一个。
数据库连接池HikariCP的配置也常被调错:
yaml复制spring:
datasource:
hikari:
maximum-pool-size: 50
minimum-idle: 10
connection-timeout: 3000
leak-detection-threshold: 10000
connection-timeout是获取连接的等待时间。设得太小,数据库瞬间抖动一下就会出现大量获取连接失败;设得太大,故障时请求全部堆在等待上,用户体验更差。我一般的做法是让connection-timeout略大于数据库正常情况下的P99获取耗时,同时配合leak-detection-threshold来暴露连接泄漏问题。
Nginx这一层也有几个容易被忽略的点:worker_processes应该设置为auto,让Nginx按CPU核心数自动匹配;upstream里配keepalive连接复用,避免每次请求都重新建连。另外,proxy_connect_timeout不要用默认的60秒,在高并发场景下调到5秒以内,能让Nginx更快放弃连不上的上游节点,避免请求长时间挂起。
4.3 高可用设计:健康检查、熔断和降级
负载均衡层的健康检查决定了流量不往坏节点上打。Nginx最简单的被动健康检查是这样配置的:
nginx复制upstream backend {
server 10.0.0.1:8080 max_fails=3 fail_timeout=30s;
server 10.0.0.2:8080 max_fails=3 fail_timeout=30s;
keepalive 32;
}
max_fails=3表示连续失败3次就认为节点不可用,fail_timeout=30s表示30秒内不会再往这个节点转发。这个配置能解决一部分问题,但它是被动探测,只有请求失败达到阈值才会摘除节点。更理想的方案是用独立的健康检查定期探测。
在Kubernetes里,要区分readinessProbe和livenessProbe这两个探针。readiness决定是否把流量转发给Pod,liveness决定是否重启Pod。我见过很多团队把两个探针配置成同一个接口,结果应用只是短暂过载,健康检查来不及响应,Pod就被重启了,反而加重故障。正确的做法是readiness检查应用的就绪状态,频率可以高一点;liveness检查进程是否还活着,频率要低,避免误杀。
熔断和降级是应用层的保命手段。当下游依赖出现异常时,不要无限重试,而是快速失败返回降级数据。比如商品详情页依赖了价格服务,价格服务挂了,可以临时返回缓存中的价格,而不是让整个页面503。Java生态里Resilience4j和Sentinel都是比较成熟的方案,关键是要把超时、熔断阈值、降级策略提前定义好,而不是等故障了才临时加。
4.4 发布与维护:让维护操作不再产生503
如果是计划内的发布或维护,503应该被控制在最小范围甚至完全避免。
发布流程中,滚动发布要配置好优雅停机,preStop钩子加一段延迟,terminationGracePeriodSeconds给足。如果业务对连续性要求很高,蓝绿发布或金丝雀发布是更好的选择:先启动新版本验证,确认没问题再切流量,出问题可以一键切回,整个过程用户无感知。
运维手动操作时,要遵循“先摘流量,再停服务”的原则。比如要重启一台机器,先把它从负载均衡的上游列表里禁掉,等服务处理完存量请求,再执行重启。这个流程最好做成自动化,靠人记的话,半夜处理故障时很容易漏掉。
5. 几个容易翻车的“非典型”503:来自真实故障复盘
5.1 健康检查接口把自己检查死了
有一个故障我印象特别深:某个服务的健康检查接口/actuator/health内部逻辑比较复杂,会查一次数据库。平时没问题,但数据库主从切换的几十秒里,所有Pod的查询都超时,健康检查全部失败,K8s把这些Pod的endpoint摘除,网关把剩余流量集中打到还剩下的Pod上,剩下那台Pod很快也过载了。最后的结果是,本来只是数据库切换几十秒的小问题,被健康检查机制放大成全站503。
这个案例的教训是:健康检查要分级。给K8s的readiness检查,只检查进程和本地关键资源,不要每次请求都去连数据库;给负载均衡或监控系统的检查,可以做依赖探活,但探测频率要低,超时时间要短。健康检查接口是稳定性组件,它的设计原则是不引入额外的故障源。
5.2 压测流量误入生产,限流规则正常误伤
有一次客户反馈下午三点出现持续几分钟的503,我们查了半天,最后发现是压测平台配置的域名写错了,几千QPS的压测流量直接打到了生产网关,触发了网关限流规则,正常用户也跟着被限流,返回503。这个故障里,限流规则本身是正常工作的,它忠实地保护了后端不被打垮,但代价是会误伤正常流量。
处理办法是压实测环境隔离:压测流量要走独立的压测域名,永不和生产域名混用;压测平台要有审批和校验机制,压测开始前检查目标环境类型;网关的限流规则要支持按调用方标识区分流量来源,给压测流量打上特殊标记。如果公司架构允许,最好是压测直接走独立的压测环境,完全碰不到生产。
5.3 空闲连接被中间设备回收,偶发503查不到原因
这个坑很隐蔽。应用和Nginx之间、或者Nginx到上游之间如果隔着防火墙或云负载均衡,这些中间设备通常对空闲连接有一个回收超时。当Nginx或应用侧keep-alive的空闲时间超过中间设备的回收时间,下一条请求复用到这条已回收的连接时,就会收到连接重置,错误表现为偶发502或503,而且频率不高,很难复现。
排查这类问题,要看网关日志里对应请求的upstream_status和连接状态,同时把Nginx的keepalive_timeout调小到比中间设备空闲超时略小,让连接在中间设备回收之前就被主动关闭。同理,TCP层的keep-alive和HTTP层的keep-alive是两个不同机制,调参时要分别考虑。这类问题的关键词是“偶发”,如果你遇到503是零零星星出现、业务日志又没有明显异常,优先怀疑连接复用问题。
5.4 容器资源限制与JVM配置不匹配
容器化部署之后,JVM的默认行为值得反复确认。JDK 8u191以上版本默认开启了UseContainerSupport,JVM会感知容器限制,但如果你用的是老版本JDK,或者在启动参数里显式限制了线程数,就可能出现JVM按宿主机核心数计算线程池大小,实际容器CPU配额不够,导致线程大量排队。内存方面,如果容器memory limit设置了,但JVM堆内存参数没有按容器的限制调整,堆很容易把容器内存吃满,触发内核OOM-Killed,进程直接被干掉,表现为服务突然不可用。
我处理过一起告警,Pod反复重启,服务一会儿正常一会儿503,最后定位是JVM堆设成了4G,容器memory limit只有3G,一有大流量进来就触发OOM-Killed。把堆大小和容器限制的匹配关系调整好之后,问题消失。所以在容器化场景下,资源参数必须同时看JVM侧和容器侧,任何一边配置不合理,都会以各种奇怪的方式表现出503。
5.5 DNS解析异常:调用外部接口时最隐蔽的掉线
前面提到依赖掉线的问题,这里展开说一个DNS导致的真实场景。服务A通过域名调用外部服务B,B的DNS解析偶尔超时或返回错误IP,A的连接池缓存了错误IP,于是后续所有请求都连不上B,A内部大量线程超时,最终A的入口返回503。整个过程中,A的业务代码没有任何改动,外部服务B也可能正常,问题完全出在DNS解析链路上。
排查这类问题,要看应用的连接池配置和DNS缓存策略,用dig和nslookup对比不同出口节点的解析结果。生产环境的服务调用外部域名时,建议使用独立的DNS服务,并配置合理的缓存时间,同时监控DNS解析的成功率和耗时。这类坑最麻烦的地方在于:所有技术栈看起来都正常,但就是偶发故障,不往DNS方向想的话,排查几天都不一定有结果。
现在再回头看文章开头那个周五下午的503,根因是监控系统在凌晨自动触发了一轮全量数据同步,把数据库连接池占满,业务接口拿不到连接只能快速失败。当时我们停掉同步任务、杀掉积压连接,十几分钟后就恢复了,但这次故障让我彻底改变了面对503的方式。我现在处理503有一套固定动作:先curl -i拿响应头,再看网关日志里的upstream_status,然后顺着线程栈找阻塞点,最后落到容量和依赖上。整套流程下来基本能在较短时间内定位根因。
最后分享两个小建议:一个是提前把网关的log_format和健康检查探针配置优化好,别等故障来了才发现日志里缺字段;另一个是维护窗口期间返回503时,记得统一在响应头带上Retry-After,客户端能按这个时间平滑重试,告警量会少很多。下次遇到503的时候,先稳住心态,把它当成系统在给你递线索,而不是当敌人。
