Spring Boot 项目启动到一半,控制台突然刷出一行java.net.ConnectException: Connection refused,很多人的第一反应是"网络不通"或者"服务没起来",然后就卡在这儿了。这个报错在 Java 生态里的出现频率高得离谱——手写的 Socket 客户端、Maven 拉依赖、数据库连接池初始化、微服务之间的 HTTP 调用、消息队列客户端重连,几乎每个需要建立连接的地方都可能蹦出它的变体。它的麻烦之处在于:异常信息只有短短几个字,但能触发它的原因少说十几种,而且外层框架往往把它包装成更"友好"的提示,比如连接池初始化失败、依赖下载失败、注册中心连不上。这篇内容我会把java.net.ConnectException Connection refused从 TCP 层到应用层拆开讲一遍,给出一套我平时排障用的顺序,再配上各个高频场景的具体处理办法。不管你是刚开始写 Java 的学生,还是天天和微服务打交道的后端,都能从里面找到能直接抄的排查步骤。
1. 报错信息拆解:先弄明白是谁拒绝了谁
1.1 从异常对象本身读出有效信息
java.net.ConnectException是SocketException的子类,它只在发起连接的一方抛出,也就是说,报错的主语永远是"你的程序",不是"对方服务"。这一点很多人会搞混。完整的异常栈通常是这样的:
java.net.ConnectException: Connection refused (Connection refused) at java.base/java.net.PlainSocketImpl.socketConnect(Native Method) at java.base/java.net.AbstractPlainSocketImpl.doConnect(AbstractPlainSocketImpl.java:412) at java.base/java.net.Socket.connect(Socket.java:609) at com.example.DemoClient.main(DemoClient.java:15)从这段栈里能榨出的信息其实不少。socketConnect是 native 方法,说明失败发生在操作系统内核层面,Java 代码根本没机会参与;异常的第一行没有紧跟 IP 和端口,说明 JDK 只拿到了内核返回的错误码ECONNREFUSED,目标地址信息需要你自己去代码里找。这就解释了为什么排障第一步永远是:确认你的代码到底在连哪个 IP 和端口。我见过太多次,开发者对着日志里的"Connection refused"查了半天,最后发现连接地址是从某个配置项读进来的,而那个配置项的值跟他以为的完全不一样。
所以拿到报错后,别急着 ping、别急着 telnet,先在代码里定位连接地址的来源。如果是配置文件,把配置读出来打印一遍;如果是硬编码,直接看代码;如果是 DNS 解析出来的,还要留意解析结果是不是你预期的那个 IP。很多Connection refused的根因,在打印出真实连接目标的那一刻就水落石出了。
1.2 三种"看起来一样"的拒绝
虽然异常类型都叫ConnectException,但从现象上可以粗略分成三类,排查手法完全不同。
第一类是目标主机在,但端口没人监听。这是最经典的Connection refused。内核收到 SYN 包后,查了一下本机没有进程在这个端口上listen,于是直接回一个 RST,客户端立刻抛出异常。整个过程非常快,通常是毫秒级返回。特征就是"秒失败",不是卡了几秒才失败。
第二类是目标主机在,端口也有人监听,但监听地址不匹配。比如服务只绑定了127.0.0.1:8080,你从另一台机器访问它的8080,内核认为这个包没有对应的监听套接字,照样回 RST。这种情况特别容易出现在容器里、多网卡机器上,以及各种"本地测试好好的,一部署就挂"的场景中。
第三类是主机或中间链路主动拒绝。比如防火墙配置了REJECT动作(而不是DROP),会主动回一个 ICMP 端口不可达或者 TCP RST。这时候现象和"端口没人监听"一模一样,但你去目标机器上查,服务明明在跑。区分它的办法是看在哪一层被拒:如果从 A 机器连不上、从 B 机器能连上,那大概率是网络策略问题;如果所有机器都连不上,先怀疑服务本身。
把这三类分清,后面所有排查动作都会变得有方向感。我一般会先用一句话问自己:是"没人接电话",还是"电话号码拨错了",还是"电话被前台拦了"。这三个判断对应的排查路径完全不一样。
2. TCP 层原理:为什么会被拒,以及和超时的本质区别
2.1 三次握手时 RST 是怎么冒出来的
要理解Connection refused,绕不开 TCP 的三次握手。客户端发 SYN,服务端回 SYN+ACK,客户端再回 ACK,连接建立。问题出在第一步和第二步之间:服务端在收到 SYN 时,会去查找有没有一个处于监听状态、且地址端口匹配的套接字。找不到,内核就直接回一个带 RST 标志的包,客户端收到 RST 后立刻终止连接尝试,抛出ConnectException。
关键点在于"地址端口匹配"这几个字。Linux 的套接字匹配规则是:先精确匹配(IP + 端口都一致),再匹配通配地址(0.0.0.0或::)。所以一个服务绑定0.0.0.0:8080,能接受所有网卡来的 8080 连接;绑定127.0.0.1:8080,就只能接受环回地址来的连接。这不是 Java 特有的行为,任何语言的 Socket 服务都遵循同一套规则。
这也解释了为什么 Docker 里跑服务特别容易踩坑。容器内进程如果只监听127.0.0.1,那么容器的端口映射-p 8080:8080把宿主机流量转发进来时,目标地址是容器的eth0地址而不是环回地址,匹配不上,直接 RST。这是新手做容器化最常撞的一堵墙,而且报错信息完全一样,光看日志看不出任何区别。
2.2 refused 和 timed out 的排查分岔口
很多人把ConnectException: Connection refused和SocketTimeoutException: connect timed out当成一回事,其实它们指向的问题域截然不同,混在一起查会浪费大量时间。
Connection refused意味着对方明确应答了,只是拒绝了你。网络链路是通的,包能到达目标,目标也能把包送回来。所以问题范围被压缩到了:目标机器上的服务状态、监听配置、防火墙策略。你不需要检查路由、DNS、运营商链路这些东西。
connect timed out意味着包发出去了,但没等到任何回应。可能是目标地址不可达、路由黑洞、防火墙DROP了包、或者服务端 SYN 队列满了来不及处理。这时候你要查的是网络可达性和中间链路的策略。
我自己的判断口诀是:秒失败查服务,慢失败查网络。默认连接超时一般是几秒到几十秒,如果日志时间戳显示从发起到失败只隔了几十毫秒,那基本可以确定是 RST 类型的拒绝,直接跳到服务端排查。反过来,如果卡了很久才报错,先别动服务端代码,从网络层往上查。
这个区分看似基础,但实际排障时它能帮你省掉一半的无用功。我见过有人拿着Connection refused的日志去抓包分析路由跳数,也见过有人对着connect timed out反复重启服务,都是因为没先做这个分流。
3. 分层排查实战:一套我从下往上用的五步法
3.1 第一步:用系统工具确认连接性
拿到报错,先别改代码。打开终端,用最朴素的工具确认一次连接状态。telnet是最老牌的选择:
telnet 192.168.1.100 8080如果立刻返回Connection refused,说明和 Java 报的是一回事,问题在目标端。如果连上了但没输出(黑屏),说明端口通,问题在你的应用层代码或协议交互上。telnet在有些精简版系统上没装,可以换nc:
nc -zv 192.168.1.100 8080 # 或者指定超时时间,避免卡死 nc -zv -w 3 192.168.1.100 8080-z表示只探测不发送数据,-v输出详细信息,-w 3设 3 秒超时。这个命令的好处是返回码很干净,适合放进脚本里做批量探测。
再进阶一点,用curl可以直接看到 HTTP 层的响应:
curl -v --connect-timeout 3 http://192.168.1.100:8080/health--connect-timeout专门控制连接阶段的超时,和读取阶段的超时分开设置,能更精确地判断是连接问题还是响应问题。我习惯在排查时把这三个工具都试一遍,因为它们的底层行为略有差异,有时候telnet通了但curl报错,那基本可以排除网络层,焦点直接转到 HTTP 协议或应用逻辑上。
注意:
telnet和nc探测的是 TCP 层连通性,不会触发应用的业务逻辑,所以对生产环境相对安全,但也不要在高并发时段对核心服务做批量扫端口,会给目标机器带来额外的连接开销。
3.2 第二步:回到目标机器看监听状态
确认了 TCP 层确实拒绝,下一步就是登上目标机器,看看到底有没有进程在监听那个端口。netstat是老命令,新一点的系统推荐用ss,速度更快:
# 查看所有 TCP 监听端口 ss -lntp # 只看指定端口 ss -lntp | grep 8080输出大概长这样:
LISTEN 0 128 127.0.0.1:8080 0.0.0.0:* users:(("java",pid=12345,fd=78))这里有两个信息必须盯住。第一个是第四列的本地地址。如果是127.0.0.1:8080,那外部访问必然被拒,这就是前面说的监听地址不匹配问题。解决办法是让服务绑定0.0.0.0。以 Spring Boot 为例:
# application.properties server.address=0.0.0.0 server.port=8080不显式配置时 Spring Boot 默认就是监听所有地址,但有些团队为了"安全"会手动改成127.0.0.1,结果部署到容器或多网卡机器上就出问题。我个人的建议是:除非明确只想本机访问,否则不要绑定环回地址,访问控制交给防火墙去做,职责更清晰。
第二个要盯的是Recv-Q 那一列的数字。它表示当前已完成三次握手、等待应用程序accept的连接数。如果这个数字持续接近监听队列的上限(第二列Send-Q的值,默认 128 或 511),说明应用处理连接的速跟不上,新来的连接可能被内核丢弃或拒绝。这种情况在高并发压测时很常见,表现出来就是间歇性的Connection refused。
# 查看内核层面的队列上限参数 sysctl net.core.somaxconn sysctl net.ipv4.tcp_max_syn_backlog如果确认是队列被打满,可以调大这两个值,同时在应用侧检查线程池配置。Netty 的backlog参数、Tomcat 的accept-count都属于这一类。Java 里创建 ServerSocket 时也可以指定:
ServerSocket serverSocket = new ServerSocket(8080, 511);第二个参数就是 backlog。这个值设得太小,高并发下就等着收Connection refused吧。
3.3 第三步:排查绑定地址与协议栈的细节
监听状态看着正常,但连接还是被拒,这时候要考虑一些更隐蔽的因素。最常见的是IPv4 与 IPv6 的地址族问题。
假设服务用 Java 的InetAddress.getByName("localhost")解析地址,在某些系统上它会优先返回::1(IPv6 环回),而你的服务只监听了127.0.0.1(IPv4 环回)。连接::1:8080时内核找不到匹配的监听套接字,直接拒绝。这类问题的诡异之处在于:手动telnet 127.0.0.1 8080是通的,代码里就是不行。
排查手段是显式指定地址族:
# 强制走 IPv4 ss -lnt4 # 强制走 IPv6 ss -lnt6Java 端可以通过 JVM 参数调整优先级:
-Djava.net.preferIPv4Stack=true这个参数在 Windows 上出现过不少次问题,加上之后往往立竿见影。另一种更保险的做法是在代码里直接写127.0.0.1而不是localhost,绕开解析环节的不确定性。
还有一种情况是端口被别的进程抢了。比如你启动服务时报端口占用,但前面的实例还没完全退出,或者被另一个不相关的程序占了。用lsof可以精确定位:
lsof -i :8080输出会列出所有占用这个端口的进程 PID 和名字。看到不认识的进程,先确认是不是僵尸进程或者别的服务,再决定是干掉它还是给你的服务换端口。我遇到过运维同事的监控 agent 偷偷占用了某个常用端口的案例,排查了很久才发现。
3.4 第四步:检查防火墙与安全策略
服务在跑、地址也对,但从其他机器连不上,这时候嫌疑最大的就是防火墙。Linux 上主要看两套:iptables和firewalld。
# 查看 iptables 规则(需要 root) iptables -L -n --line-numbers # 查看 firewalld 放行的端口 firewall-cmd --list-ports firewall-cmd --list-all关键区别在于规则的动作。DROP是默默丢包,客户端会表现为超时;REJECT是主动回拒绝包,客户端表现为Connection refused。所以如果你的防火墙用的是REJECT,那现象和"服务没启动"完全一样,这是最容易误导人的地方。
# 临时放行一个端口测试 firewall-cmd --zone=public --add-port=8080/tcp # 或者临时关闭防火墙做对照实验(测试完记得开回来) systemctl stop firewalld我的习惯做法是:先用systemctl stop firewalld做个对照实验,如果关闭后立刻能连上,那问题就锁定在防火墙,再慢慢配规则;如果还是不行,说明方向错了,回头查服务。这比一上来就研究规则语法高效得多。
注意:关闭防火墙只是临时验证手段,绝不能长期保持这个状态,验证完要立刻恢复并配置正确的放行规则。生产环境操作前务必确认影响范围和相关流程。
3.5 第五步:容器与编排层的额外陷阱
服务跑在 Docker 或 Kubernetes 里时,Connection refused的原因又多出好几层。按我的经验,最常见的四个坑分别是:
端口映射写错。docker run -p 8080:80是把宿主机 8080 映射到容器 80,如果容器里的服务监听的是 8080,那映射就写反了。检查命令:
docker port <container_id> docker inspect <container_id> | grep -A 10 Ports容器内服务绑定环回地址。前面提过,容器内监听127.0.0.1的话,端口映射转发过来的流量匹配不上。让服务监听0.0.0.0是唯一正确的做法。
容器网络模式问题。用--network host时容器共享宿主机网络栈,端口不用映射;用默认的 bridge 模式时必须显式-p。两种模式混着用,很容易出现"我明明映射了端口却连不上"的情况。
Kubernetes 的 Service 与 Pod 标签不匹配。Service 靠selector匹配 Pod 标签,标签对不上时 Endpoints 列表是空的,kubectl get endpoints会显示<none>。这时候访问 Service 的 ClusterIP,行为等同于访问一个没有后端的地址,表现就是连接被拒。
kubectl get endpoints <service-name> kubectl describe svc <service-name> kubectl get pods --show-labels还有一个容易被忽略的点:Pod 的readinessProbe没通过时,Pod 不会进入 Endpoints,Service 转发不到它。所以Connection refused有时候不是网络问题,而是"健康检查没过,Pod 被摘了"。先看kubectl describe pod里 Events 和探测结果,比盲目查网络快得多。
4. 高频场景逐个击破
4.1 本地 Java 客户端连接服务端
这是最纯粹的场景,也最适合用来理解报错本质。下面这段代码是我平时用来复现和学习用的最小示例:
import java.io.IOException; import java.net.InetSocketAddress; import java.net.Socket; public class ConnectProbe { public static void main(String[] args) { String host = "127.0.0.1"; int port = 8080; int timeoutMs = 3000; try (Socket socket = new Socket()) { long start = System.currentTimeMillis(); socket.connect(new InetSocketAddress(host, port), timeoutMs); long cost = System.currentTimeMillis() - start; System.out.println("连接成功,耗时 " + cost + " ms"); } catch (IOException e) { long cost = System.currentTimeMillis(); System.err.println("连接失败: " + e.getClass().getName() + " -> " + e.getMessage()); System.err.println("失败耗时约 " + cost + " ms(相对启动时间)"); } } }这段代码有两个用心的地方。一是用了带超时的connect(InetSocketAddress, int)而不是无参的connect(),避免无限等待。二是打印了耗时,方便你判断是"秒拒"还是"超时"。耗时是区分故障类型最便宜的指标,不需要任何额外工具。如果失败耗时在 10 毫秒以内,九成是 RST;如果接近你设置的 3000 毫秒,那就是超时,问题在网络链路。
另外要注意,Socket用了 try-with-resources,连接成功也会自动关闭,避免测试代码泄漏连接。虽然是个小细节,但在写排查脚本时养成习惯没坏处。
4.2 构建工具拉依赖失败
Maven 或 Gradle 构建时报Connection refused,指向的是依赖仓库地址。这类问题的特点是有时候重试就好了,有时候一直失败。原因通常有三种:仓库地址配置错了、本机网络对目标仓库不通、或者企业内网需要走特定的镜像仓库。
Maven 的配置在settings.xml里,重点看<mirrors>段落:
<mirrors> <mirror> <id>internal-repo</id> <mirrorOf>central</mirrorOf> <name>internal repository</name> <url>http://repo.internal.example.com/repository/maven-public/</url> </mirror> </mirrors>如果这个 URL 写的是一个内网地址,而你在外网环境下构建,那必然Connection refused。反过来,如果配置里还残留着一个已经下线的老仓库地址,也会出现同样的报错。我的排查顺序是:先mvn -X打开调试日志,找到它实际请求的 URL,再用curl手动请求那个 URL 确认连通性。两步就能定位。
mvn -X clean package 2>&1 | grep -i "download\|repository" curl -v http://repo.internal.example.com/repository/maven-public/Gradle 的排查思路一样,配置文件换成build.gradle里的repositories块或者init.gradle。这里有个经验:构建失败的报错经常被包装成"无法解析依赖",底层的Connection refused藏在--stacktrace输出里,不加参数根本看不到。所以遇到依赖解析失败,第一件事就是加--stacktrace或-X。
4.3 数据库与缓存连接失败
连 MySQL、Redis 这类中间件时报Connection refused,排查逻辑和普通服务一样,但有几个中间件特有的点值得一提。
MySQL 默认只监听127.0.0.1:3306,这是官方包的默认配置。要让远程连接,需要改my.cnf:
[mysqld] bind-address = 0.0.0.0改完重启服务。如果只改了这一处还是连不上,再检查用户权限,因为 MySQL 的用户是跟来源主机绑定的:
SELECT user, host FROM mysql.user;'app'@'localhost'和'app'@'%'是两个不同的账号,前者只能本机登录。不过要注意,Connection refused是连接层错误,和认证失败(Access denied)是两回事,不要混着查。连接被拒说明连 TCP 都没握上手,认证根本没走到。这个区分能帮你快速缩小范围。
Redis 的情况类似,默认配置:
bind 127.0.0.1 protected-mode yes要让其他机器访问,需要把bind改成实际网卡地址或0.0.0.0,同时按需调整保护模式。改动前要确认访问控制策略,不要为了图省事把服务暴露在不该暴露的网段上。
Java 侧还有个常见问题:连接池初始化时机。很多框架在应用启动时就创建连接池并做一次连接测试,如果此时数据库还没起来(比如容器编排里的启动顺序问题),就会报Connection refused并导致应用启动失败。解决办法是配置连接池的延迟初始化或重试策略。以 HikariCP 为例:
spring.datasource.hikari.initialization-fail-timeout=-1 spring.datasource.hikari.connection-timeout=30000initialization-fail-timeout=-1表示启动时连接失败不阻塞,后续按需重试。当然更好的办法是在编排层加上依赖顺序和健康检查,让数据库先就绪。
4.4 微服务之间的调用报错
微服务场景下Connection refused往往被包装了一层,比如 Feign 报feign.RetryableException,或者 RestTemplate 报ResourceAccessException,最里层的 cause 才是ConnectException。排查时一定要把完整异常链打出来,只看最外层信息会误导方向。
try { restTemplate.getForObject(url, String.class); } catch (ResourceAccessException e) { Throwable cause = e.getCause(); System.err.println("根因: " + (cause != null ? cause.getMessage() : "unknown")); }常见的三类原因:服务注册信息过期(实例已经下线但注册中心还没摘除)、服务实例正在滚动更新(旧实例停了新实例还没起来)、以及服务间网络策略不通。针对第一类,可以在客户端配置更激进的健康检查和缓存刷新间隔;针对第二类,滚动更新的maxUnavailable和maxSurge参数要调好,保证任何时刻都有可用实例;针对第三类,就要去查网络策略和安全组了。
注意:微服务调用链上,一个
Connection refused可能来自任何一个下游服务,必须结合链路追踪 ID 定位到具体是哪个实例、哪个端口,不要凭猜测逐个服务重启,那样只会扩大影响面。
5. 常见问题速查与避坑经验
5.1 问题速查表
下面这张表是我从多年排障记录里整理出来的,按"现象 → 最可能原因 → 验证方式"组织,方便对着查:
| 现象 | 最可能的原因 | 快速验证 |
|---|---|---|
| 毫秒级返回 refused | 端口无监听或监听地址不匹配 | ss -lntp | grep 端口看绑定地址 |
| 从本机通、远程不通 | 绑定在127.0.0.1或防火墙 REJECT | 改绑0.0.0.0,再测防火墙策略 |
| 容器映射后连不上 | 容器内服务绑定环回地址 | docker exec进容器内ss -lntp |
| 压测时偶发 refused | 监听队列 backlog 打满 | ss -lnt看 Recv-Q 是否贴近 Send-Q |
| K8s Service 连不上 | Endpoints 为空或探针未通过 | kubectl get endpoints |
| 构建时 refused | 仓库地址配置错误或网络不通 | mvn -X看实际请求 URL |
| 启动时连数据库 refused | 数据库未就绪,连接池立即初始化 | 查看两边的启动时间戳 |
| 间歇性 refused | 服务滚动更新,实例切换空窗 | 看部署日志和实例数量变化 |
表格里每一行的验证方式都是"一条命令能出结果"的,尽量不引入额外的分析工具,这样在紧急故障时反应最快。
5.2 我踩过的那些坑
坑一:把localhost当成万能地址。早期写代码习惯性用localhost,直到有次在容器里怎么都连不上,才发现解析结果和实际监听地址对不上。现在我写连接配置基本都用127.0.0.1,需要对外就写0.0.0.0并明确注释,减少不确定性。
坑二:忽略防火墙规则的动作类型。曾经花了一下午排查一个Connection refused,最后发现是防火墙用了REJECT而不是DROP,导致现象和服务没启动完全一样。记住这个区别后,我现在看到"秒拒"会先怀疑防火墙,看到"超时"才去查服务状态。
坑三:不看完整异常栈。微服务项目里,外层异常往往很有误导性。有一次报的是序列化失败,实际根因是调用下游时Connection refused,框架把网络异常吞掉后包装成了别的错误。从那以后我养成了习惯:任何调用异常,先e.printStackTrace()或者用日志框架打全栈,看清楚 cause 链的最底层。
坑四:忘了检查监听队列。本地测试一切正常,一上压测就间歇失败。查了很久才发现是 backlog 设置太小,高并发时新连接直接被拒。调整somaxconn和应用侧的 backlog 参数后问题消失。这个坑的教训是:功能测试通过不代表并发场景没问题,压力测试环节不能省。
坑五:容器里重启服务后配置没生效。改了配置文件,docker restart之后发现还是老行为,原因是配置文件是通过挂载卷映射进去的,宿主机上改的路径和容器内读取的路径不是同一个。现在我会在容器里cat一遍配置文件确认内容,再重启,避免这种低级但耗时的错误。
最后分享一个我自己用的小习惯:每次遇到Connection refused,我会在笔记本里记一行——时间、目标地址端口、最终根因。攒到现在已经有几十条了,再遇到类似报错时翻一翻,很多时候几秒钟就能定位方向。这个报错的种类其实就那么些,见得多了自然就有直觉了。