java.net.ConnectException 这个报错,几乎每个写过 Java 的人都撞上过。报错信息往往就一行java.net.ConnectException: Connection refused: connect,简洁得像什么都没说,但它背后可能是服务没起来、端口写错了、绑定地址不对、容器网络不通、依赖仓库连不上,甚至只是启动顺序差了几秒。我第一次遇到它是在一个 Spring Boot 项目里,本地跑得好好的,一上测试环境就炸,查了大半天才发现是配置里数据库地址写的是别人的机器。从那以后我就养成了一个习惯:看到 Connection refused,先别改代码,先把"谁在连、连哪里、谁在听"这三件事弄清楚。这篇内容就是把这些年积累的排查套路、命令、配置和踩过的坑整理出来,适合刚接触网络编程的新手,也适合已经能写业务但一遇到连接问题就头大的开发者,看完你至少能有一套可复用的定位流程,而不是靠猜。
1. 先搞清楚报错语义:Connection refused 到底是谁拒绝了谁
1.1 从 TCP 三次握手的角度看"拒绝"这个动作
很多人把 Connection refused 理解成"网络不通",这个理解是偏的。网络不通通常表现为连接超时(Connection timed out),而 refused 恰恰说明网络是通的,包也送到了,只是对面明确地回了一个拒绝信号。
用 TCP 的视角来看:客户端发起连接,先发一个 SYN 包到目标 IP 和端口。如果目标主机上那个端口没有任何进程在监听,操作系统内核会直接回一个 RST(重置)包。客户端收到 RST,就立刻抛出java.net.ConnectException: Connection refused。整个过程非常快,通常几毫秒就返回了,这也解释了一个经验:秒拒大概率是端口没监听,慢拒(几十秒后超时)才是网络或防火墙的问题。
还有两种情况会产生类似但不同的结果。一种是防火墙把包直接丢弃(DROP)而不是拒绝,客户端迟迟收不到回应,最后超时;另一种是目标主机不可达,路由层返回 ICMP 消息,Java 层可能报 NoRouteToHostException。这三种错误的排查方向完全不同,所以第一步永远是先分清自己遇到的到底是哪一种。
注意:
Connection refused: connect这个带connect后缀的写法,在 Windows 平台上的 JDK 里更常见;同样是连接被拒,Linux 上通常只显示Connection refused。别因为后缀不一样就以为是两个不同的问题。
1.2 三类连接异常的分界线,别混在一起查
我在带新人时发现,最容易浪费时间的就是把这几类错误混为一谈。下面这张表可以当作第一道分诊台:
| 异常类型 | 典型信息 | 底层原因 | 排查方向 |
|---|---|---|---|
| ConnectException | Connection refused | 端口无进程监听,内核回 RST | 服务是否启动、端口是否一致、绑定地址 |
| SocketTimeoutException | connect timed out | 包被丢弃或路由不通 | 防火墙、安全组、路由、目标主机状态 |
| UnknownHostException | 主机名无法解析 | DNS 或 hosts 配置问题 | 域名拼写、DNS 服务、hosts 文件 |
| NoRouteToHostException | 无路由到主机 | 网络层不可达 | 网卡、子网、路由表 |
判断顺序建议这样走:先看异常类名,再看到底是"立刻失败"还是"等了一会儿才失败"。如果日志时间戳显示请求发出到报错不足 100ms,几乎可以锁定是端口问题;如果等了 20 秒以上,那就要往防火墙和网络策略方向查。
1.3 从堆栈里读出有效信息,别只盯着最后一行
java.net.ConnectException的堆栈里,最有价值的其实是at那几行。它们能告诉你到底是谁发起的连接。常见的发起方有几类:数据库驱动(比如 MySQL Connector 的ConnectionImpl.createNewIO)、HTTP 客户端(HttpURLConnection或 OkHttp、Apache HttpClient)、消息队列客户端(Kafka、RabbitMQ)、缓存客户端(Jedis、Lettuce),还有构建工具自己的下载器。
举个例子,如果堆栈里出现com.mysql.cj.jdbc.exceptions.SQLError.createCommunicationsException,那基本就是数据库连接问题,去查 JDBC URL 的 host 和 port 就行;如果出现org.apache.maven.wagon之类的类名,那说明是构建期下载依赖被拒,跟业务代码一点关系都没有。先看调用方是谁,再去查那个调用方配置的连接串,这一步能省掉大量无头苍蝇式的搜索。
2. 排查框架:三个问题锁定九成故障
2.1 谁在连、连哪里、谁在听
我把这套方法叫"三问定位法",简单但极其好用。
第一问,谁在连:是本地开发机上的应用,还是容器里的服务,还是远程服务器上的进程?发起方不同,网络路径完全不同,排查手段也不同。
第二问,连哪里:目标地址和端口具体是多少?这个信息一定要从实际运行时读取到的配置里拿,而不是从你记忆中的配置里拿。配置文件可能有多份(dev、test、prod),环境变量、启动参数、配置中心都可能覆盖它。我见过太多次"我明明改了啊",结果改的是没生效的那份。
第三问,谁在听:目标机器上那个端口到底有没有进程在监听?监听在哪个网卡上?只有 127.0.0.1 还是 0.0.0.0?这一步用一条命令就能验证。
这三问回答完,问题范围基本就缩小到一两个可能性了。剩下的只是验证。
2.2 五分钟快速排查清单
下面这套动作我基本是肌肉记忆了,全程不超过五分钟。
# 1. 看本机端口监听情况(Linux/macOS) ss -lntp | grep 8080 # 或者用 netstat netstat -lntp | grep 8080 # 2. Windows 下查看端口占用 netstat -ano | findstr 8080 # 3. 直接测试目标端口是否可连 nc -vz 127.0.0.1 8080 telnet 127.0.0.1 8080 # 4. 如果是 HTTP 服务,直接请求一下 curl -v http://127.0.0.1:8080/actuator/healthss -lntp输出里的几个字段值得说清楚。-l表示只看监听状态,-n表示不做名称解析(显示数字端口),-t是 TCP,-p显示进程。看输出时重点确认三件事:端口号是否一致、监听地址是127.0.0.1还是0.0.0.0、进程名是不是你以为的那个。如果连一行输出都没有,那答案已经很明确了:没人监听,问题在服务端启动环节。
2.3 三个命令的分工与使用时机
ss用来查本机,适合排查"服务到底起没起来"。它的输出最直接,0.0.0.0:8080表示监听所有网卡,127.0.0.1:8080表示只监听回环地址,这两者的区别在跨机器访问时是致命的。
nc或telnet用来做连通性验证,适合排查"从我这台机器能不能连到那台机器"。它们不关心应用协议,只做 TCP 握手,返回成功就说明端口是开的。telnet在有些精简系统上没装,nc也不一定有,那就退回到curl -v,从它的Connected to一行判断。
curl用来验证应用层,适合排查"端口通了但接口是不是正常"。这三个工具是递进关系,先用地板级的 TCP 握手确认,再往上走应用层,逐层收敛。
3. 高频场景拆解:不同上下文里的拒绝各有各的脾气
3.1 目标服务还没启动,或者启动到一半
这是最朴素也最常见的原因。微服务架构下,服务 A 启动时要调服务 B,如果 B 还在初始化,A 发出的连接自然被拒。这类问题的典型特征是:日志里第一波请求报 refused,过几十秒后自己就好了,或者重启一次就好了。
解决思路有三条。一是给调用加合理的重试和退避,第一次失败后等 1 秒、2 秒、4 秒再试,别一上来就放弃。二是配置连接超时别太长,连接超时设 2 秒左右就够,设成 30 秒反而会让问题暴露得很慢。三是梳理启动依赖,让下游先起。我在实际项目里一般用配置化的重试,示例如下:
@Bean public RestTemplate restTemplate() { SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); // 连接超时 2 秒,读超时 5 秒 factory.setConnectTimeout(2000); factory.setReadTimeout(5000); return new RestTemplate(factory); }注意:重试只能用在对幂等友好的操作上。HTTP GET 重试一般安全,涉及到写数据、扣款、下单这类操作,重试前一定要确认接口是不是幂等的,否则会把"连不上"变成"重复扣款"这种更麻烦的事。
3.2 端口对不上:配置漂移与硬编码的老毛病
端口不一致是第二大高频原因。常见形态包括:配置文件里写 8080,实际启动参数--server.port=9090覆盖了;Nginx 转发配置里指向 8081,后端服务起的却是 8080;容器映射的是宿主机 8082 到容器 8080,但客户端连的是 8081。这类问题最坑的地方在于,每一处配置单看都没问题,问题是它们没对齐。
我的经验是:连接地址只能有一个"事实来源"。要么全部走配置中心,要么全部走环境变量,别一半写在代码里、一半写在配置文件里。排查的时候,把运行时真正生效的值打印出来,比如在 Spring Boot 启动时把数据源 URL 打日志,或者通过 actuator 的/actuator/env端点查看。看到实际值,问题往往当场就明白了。
3.3 绑定地址的坑:127.0.0.1 与 0.0.0.0 的区别
这个坑我在容器化项目里见过太多次。服务在自己容器里监听127.0.0.1:8080,同一个容器里访问没问题,但一旦别的容器或宿主机来连,就会立刻被拒。原因是127.0.0.1只绑在回环网卡上,外部流量根本不进入这个网卡。
正确的做法是让服务监听0.0.0.0,也就是所有网卡。Spring Boot 里配置server.address=0.0.0.0,或者干脆不配这个属性,让默认值生效。Tomcat 的Connector默认绑的就是所有地址,但如果有人手动改过address属性,就要留个心眼。
同理,MySQL 的bind-address、Redis 的bind配置项都有这个问题。默认情况下它们往往只监听本机,一旦从另一台机器连过去,看到的症状就是 Connection refused,而不是用户名密码错误。排查时用ss -lntp看一眼监听地址,比翻配置文件快得多。
3.4 容器与虚拟网络:localhost 的语义差异
容器场景下,localhost的含义和在宿主机上完全不同。在容器内,localhost指容器自己,不是宿主机。如果应用配置里写的是localhost:3306,而这个 MySQL 跑在宿主机或另一个容器里,那必然会拒绝连接。
Docker Compose 场景下,正确写法是用服务名当主机名,比如jdbc:mysql://mysql-db:3306/demo,其中mysql-db是 compose 文件里定义的服务名,Docker 的内嵌 DNS 会把它解析到对应容器的 IP。如果用的是宿主机上的服务,Linux 下可以用host.docker.internal这个特殊域名(需要额外配置或较新版本支持),或者直接写宿主机的局域网 IP。
这里还有一个经常被忽略的点:容器启动顺序。Compose 的depends_on只保证启动顺序,不保证依赖服务已经就绪。MySQL 容器启动了但还没初始化完,此时应用去连,一样会被拒。稳妥的做法是加健康检查,让应用在依赖真正健康后再启动。
3.5 依赖仓库与插件下载被拒:构建期的拒绝
有一种 Connection refused 跟业务代码毫无关系,出现在构建阶段。典型场景是 Maven 拉依赖、Gradle 同步、IDE 初始化项目时,报错里带着一堆 URL,还提示"请检查 url、网络和代理设置"或者初始化失败。这种时候不要去看业务代码,问题在下载链路。
常见原因有三类。一是仓库地址不可达,或者配置的私服挂了;二是公司内网需要走 HTTP 代理才能出网,但构建工具或 IDE 没配代理;三是配置了错误的代理,导致请求全被发到一个不存在的地方。我遇到过最隐蔽的一次,是开发机上设置了系统级代理,但那个代理服务已经卸载了,所有请求都打到本地的代理端口上,结果自然是满屏 refused。
Maven 的settings.xml是排查重点,配置镜像时建议使用稳定可用的公共镜像:
<mirror> <id>aliyun-public</id> <mirrorOf>central</mirrorOf> <name>Aliyun Public Repository</name> <url>https://maven.aliyun.com/repository/public</url> </mirror>注意:如果你在公司内网,镜像地址和代理配置一定要找运维确认,别照搬网上博客。改完
settings.xml后建议执行mvn -U clean package强制更新快照,同时用mvn help:effective-settings确认最终生效的配置,避免"改了没生效"的经典问题。
Gradle 的话,检查gradle.properties里的代理相关属性和init.gradle中的仓库配置,IDE 里还有一层独立的代理设置,这三个地方要保证一致,否则很容易出现命令行能拉、IDE 拉不了的情况。
3.6 中间件连接:MySQL、Redis、Kafka 的典型拒绝
数据库场景下,Connection refused 和 Access denied 是两码事。前者是连不上,后者是账号密码不对。看到 refused,说明网络层就没通,别去折腾密码了。
MySQL 的常见原因包括:服务没启动(systemctl status mysqld看一眼)、端口不是 3306、bind-address只绑了本机、防火墙拦了 3306。Redis 类似,默认bind 127.0.0.1且开启保护模式,从别的机器连就是拒绝。Kafka 的坑更绕,它涉及listeners和advertised.listeners两个配置:客户端先连 bootstrap server 拿到元数据,元数据里返回的是advertised.listeners里配置的地址,如果那里写的是内网主机名而客户端解析不了或者连不上,就会报 refused。这种时候看客户端日志会发现,第一次连接是成功的,后面才失败。
排查中间件我一般这样走:先在目标机上ss -lntp确认端口在听,再从客户端机器nc -vz测连通,最后才用客户端工具连一次。三步都过,问题多半在配置的地址上,而不是网络。
4. 一次完整的实战定位记录
4.1 现场:服务启动正常,调用下游接口就报 refused
前阵子碰到一个案例,记录一下完整过程,比较有代表性。情况是这样:一个 Spring Boot 服务,本地开发一切正常,部署到测试环境后,调用下游的用户中心接口一直失败,日志里是java.net.ConnectException: Connection refused。后端同学第一反应是下游挂了,但下游服务的负责人说他们跑得好好的,访问日志里根本没有收到这个请求。
这就是典型的"还没到应用层就被拒了",说明连接在 TCP 层就没建立起来。判断依据很直接:如果是应用层报错,下游的访问日志里会有记录,哪怕返回 500;现在下游日志干干净净,说明包根本没被应用接收。
4.2 逐层验证:从本机到目标一步步收紧
第一步,在报错的应用机器上确认配置的目标地址。通过环境变量和配置中心查了一遍,实际生效的是user-center.internal:8080。注意这里是个内部域名,不是 IP。
第二步,测域名解析。用nslookup或者getent hosts user-center.internal查一下,解析出来是一个内网 IP,这一步正常。
第三步,测端口连通性。nc -vz user-center.internal 8080,结果是Connection refused。到这一步确认了,域名没问题,就是目标端口拒绝连接。
第四步,去目标机器上查监听。ss -lntp | grep 8080显示,服务确实在跑,但监听地址是127.0.0.1:8080,而不是0.0.0.0:8080。问题找到了:下游服务在一个容器里,配置文件里显式绑定了回环地址,导致只有容器内部能访问,跨容器就是拒绝。
4.3 修复与回归验证
修复方案很简单,把下游服务的监听地址改成0.0.0.0,重新发布。发布后再从调用方机器执行nc -vz user-center.internal 8080,这次返回succeeded。然后重新触发一次业务调用,接口正常返回,问题解决。
整个过程从开始定位到修复大概二十分钟,其中真正花时间的是"确认下游服务到底监听在哪"这一步,因为一开始大家都盯着调用方看,没人去目标机器上确认。这也是我想强调的点:Connection refused 的答案大概率在目标端,不在发起端。发起端只能告诉你"我被拒了",目标端才能告诉你"为什么拒"。
4.4 把这次经验固化下来
这个案例之后,我在团队里推了两个小改动。一是在所有服务的启动脚本里加了一行日志,把实际监听地址和端口打印出来,方便一眼确认。二是在部署流水线里加了个健康检查步骤,发布后自动从另一个容器去连目标端口,连不通就直接判定发布失败,别等到业务报错才发现。
健康检查这段可以用很朴素的脚本实现:
#!/bin/bash # 发布后连通性自检 HOST=$1 PORT=$2 RETRY=10 for i in $(seq 1 $RETRY); do if nc -z "$HOST" "$PORT"; then echo "port $PORT is reachable" exit 0 fi echo "waiting for $HOST:$PORT ... ($i/$RETRY)" sleep 2 done echo "port $PORT is NOT reachable" exit 1这个脚本看着简单,但在滚动发布、容器重启的场景里能拦掉不少低级问题,尤其是那种"服务还没起来就被调用"的时序问题。
5. 常见问题速查与避坑经验
5.1 一张表覆盖八成场景
| 现象 | 高概率原因 | 验证命令 | 处理方式 |
|---|---|---|---|
| 本地调用本地服务被拒 | 服务未启动或端口写错 | ss -lntp | grep 端口 | 启动服务、核对端口 |
| 跨机器访问被拒 | 监听在 127.0.0.1 | ss -lntp看地址 | 改绑 0.0.0.0 |
| 容器内访问宿主机被拒 | localhost 语义错误 | ip addr查网关 | 用服务名或宿主机 IP |
| 构建时下载依赖被拒 | 仓库地址或代理配置错误 | mvn -X看请求地址 | 修 settings.xml 与代理 |
| 连接数据库被拒 | 端口不通或 bind-address 限制 | nc -vz db 3306 | 改配置、开防火墙 |
| Kafka 首次能连后续被拒 | advertised.listeners 配错 | 查 broker 配置 | 改成客户端可达地址 |
| 偶发被拒,重试就好 | 下游启动慢或滚动发布 | 看报错时间分布 | 加重试 + 健康检查 |
5.2 我踩过的几个坑
第一个坑是只改配置不重启。有些配置是启动时读取的,运行期改了文件不生效。我一度以为是网络问题,折腾半天,最后重启一下就好了。判断方法很简单,看修改时间和你上次重启时间,如果配置比进程新,那就该重启了。
第二个坑是把超时当成拒绝。有一回日志里写的是 refused,但实际排查发现是防火墙做的静默丢弃,只是客户端框架把超时异常包装成了 ConnectException 的子类,误导了方向。后来我养成习惯,一定要看日志的时间戳,从请求发起到报错隔了多久,这个信息比异常类名还可靠。
第三个坑是代理设置残留。开发机上配过代理,工具卸载了但环境变量还在,导致所有请求都往一个不存在的地址发。排查方式就是检查http_proxy、https_proxy、HTTP_PROXY这些环境变量,以及 IDE、构建工具各自独立的代理配置。这类问题最隐蔽,因为命令行和 IDE 的表现可能不一样。
第四个坑是IPv6 优先。有些环境里localhost优先解析成::1,而服务只监听了 IPv4 的127.0.0.1,于是连接被拒。解决办法要么把服务改成同时监听,要么在客户端直接用127.0.0.1而不是localhost。这种问题在 macOS 上尤其常见,因为它的解析顺序和 Linux 有差异。
5.3 几条预防性的配置建议
与其每次出问题临时查,不如提前把该做的做了。连接超时统一设成 3 到 5 秒,别用默认的无限等待;关键的下游调用加上带退避的重试;服务启动日志里打印实际监听地址和关键连接串;健康检查走真实的 TCP 探测而不是简单的进程存活判断;环境变量和配置文件之间确定一个优先级规则并写进文档。
还有一点值得单独提:日志里把目标地址打出来。很多框架的默认日志只写"连接失败",不写连的是谁。在客户端初始化的时候加一行 info 日志,把 host 和 port 打出来,故障时能省下大量时间。这个改动成本极低,收益极高。
我个人在这些年处理这类问题的体会是,Connection refused 本身其实是个很"诚实"的错误,它几乎从不骗人,说端口不通就是端口不通。真正难的是找到那个生效的配置在哪儿,以及确认目标端到底在听什么。把"三问定位法"和那几条命令变成习惯,大部分同类问题都能在十分钟内定位到方向。最后再分享一个小技巧,如果机器上没装 nc 也没装 telnet,可以用 bash 自带的能力测端口,省得临时装工具:
# 不依赖 nc/telnet 的端口探测 timeout 2 bash -c 'cat < /dev/null > /dev/tcp/127.0.0.1/8080' && echo "open" || echo "closed"这个写法在大多数 Linux 和 macOS 的 bash 环境里都能用,出问题时手边有台机器就能立刻验证,比翻工具箱方便得多。