微服务一旦拆起来,第一个躲不开的问题就是:Service A 到底怎么调到 Service B?在这个Java微服务系列第三篇里,我专门聊聊“基于服务发现的服务调用”,核心就是Feign和Dubbo这两条主流路线。先说结论:现在做微服务基本都要接注册中心(Nacos是国产项目里最常用的),服务提供者把地址注册上去,消费者拿着“服务名”就能找到对方,至于找到以后是走HTTP还是走RPC,那就是Feign和Dubbo的差别了。这篇文章适合刚开始做微服务拆分、正在纠结服务间调用方式选型的Java工程师;我会把原理、可复制的配置、还有我踩过的坑一次说清楚,读完你就知道怎么搭一条完整可用的调用链。
1. 喊了这么久服务发现,它到底在解决什么问题
很多新手把“服务发现”当作一个玄乎的概念,其实它就是微服务拆完之后的刚需。在一个稍微复杂点的业务里,拆出来的服务可能有十几个,每个服务还不止一台实例——上线的、扩缩容的、故障下线的,随时在变。这时候如果还用单体时代“写死IP”那套,根本撑不住。
1.1 从"写死IP"到"按名字找服务"
想想看,单体应用里模块之间互相调用,最直接的写法就是:
// 以前这么干还凑合,微服务这么干就是找死 String userUrl = "http://192.168.1.10:8080/user/getById";单体时代模块都在一个进程里,同一份配置,IP和端口基本不动,写死问题不大。可微服务拆开以后呢?
- 每个服务可能有多个实例,你写死其中一个,扩容的实例等于白扩。
- 实例宕机、下线、重新发布,IP和端口都会变,写死的地址马上就失效了。
- 没有一个统一的“负载均衡入口”,请求全打在一台机器上,高并发一来直接压垮。
这就好比以前你找朋友玩,记的是他家门牌号,结果他搬了好几次家,你还拿着旧地址跑,当然次次扑空。服务发现要解决的就是这件事:把“记门牌号”换成“查通讯录”,你不需要知道朋友现在住哪,只要叫他的名字,通讯录会告诉你他当前的实时位置。映射到技术上,“名字”就是服务名,“通讯录”就是注册中心,实时位置就是当前存活的实例IP和端口。
1.2 服务发现背后的四步核心机制
注册中心看着简单,但要支撑生产环境的动态变化,内部其实有一套完整机制,我拆开讲:
- 服务注册:服务提供者在启动时,把“服务名 + IP + 端口 + 元数据”写到注册中心。比如
user-service在192.168.1.10:8080启动,注册中心里就多了一条实例记录。 - 心跳续约:服务提供者要定期向注册中心发心跳,证明自己还活着。Nacos里临时实例默认5秒发一次心跳,如果超过一定时间没收到心跳,这个实例就会被标记为不健康,最终被剔除。
- 服务订阅与变更通知:服务消费者启动时,向注册中心订阅它关心的服务名,注册中心把当前实例列表推给消费者。实例列表发生变化(新增、减少、不健康),注册中心会实时通知消费者,消费者本地维护一份缓存。
- 负载均衡:消费者发起调用前,从本地缓存的实例列表里,按轮询、随机或者权重策略选出一台实例,拼出目标地址,发起真正的网络请求。
这里有个细节值得注意:消费者本地是有缓存列表的,并不是每次调用都去查注册中心。这么做的好处是性能好,不依赖注册中心的实时网络;代价是实例变更后配置有短暂延迟。所以生产上部署新实例后,有时会发现旧实例还在被调用——这不是玄学,是缓存和心跳机制的正常表现。
1.3 为什么现在Nacos成了主流选择
服务发现这概念最早不是Nacos先做的,早年Spring Cloud生态里大家用Eureka、Zookeeper,但现在国内新项目基本默认Nacos,原因很实际:
- Eureka 2.0停更了,老项目维护成本高,没人敢在新项目里引它。
- Zookeeper本身是CP模型,在注册这个场景下,节点之间要强一致,网络抖动时会出现“宁可不可用也不给你错误数据”的情况;而注册中心这种场景,更适合AP模型,保证可用性优先,容忍短暂的数据不一致。
- Nacos默认支持AP模式(临时实例),同时也支持切换CP模式(持久实例),还能当配置中心用,注册中心和配置中心二合一,少维护一套中间件。
- 和Spring Cloud Alibaba、Dubbo的整合都是原生的,国内文档和资料也多。
所以下面我讲的Feign和Dubbo两种调用方式,注册中心我都会用Nacos来做演示。这不是唯一答案,但绝对是当前最省心的答案。
2. Feign:把HTTP调用写成"本地接口"的声明式玩法
Feign是Spring Cloud生态里标准的声明式HTTP客户端。它的特点是“像调本地接口一样调远程服务”,对开发者来说几乎没有学习成本。我第一次接触的时候也觉得神奇——一个接口上加几个注解,Spring就自动帮你生成了实现,你不用关心URL怎么拼、HTTP请求怎么发。
2.1 Feign的完整调用链路拆解
先看Feign从“接口定义”到“真实请求”之间发生了什么,我按顺序拉一条链路:
- 你写了一个
@FeignClient标注的接口,定义了方法、请求路径、参数。 - Spring在启动时对这个接口做动态代理,生成一个代理实现类。
- 调用方法时,代理把方法签名翻译成一个HTTP请求:请求方法(GET/POST)、路径、请求体、请求头。
- 关键一步:Feign把URL中的
服务名交给Spring Cloud LoadBalancer(新版里替代了老Ribbon),由它去注册中心按服务名拉取实例列表。 - LoadBalancer从实例列表中按负载均衡策略选一台,拼出真实的
http://ip:port/请求路径。 - 发起真正的HTTP调用,拿到响应后把JSON反序列化成方法返回的类型。
所以Feign管的是“请求怎么拼”,LoadBalancer管的是“请求发给谁”,注册中心管的是“谁还活着”。三者配合,才是完整的服务发现调用。
2.2 Nacos + Feign 的最小可用配置
说再多不如直接给一份能跑的配置。我假设你有一个user-service(服务提供方)和一个order-service(服务调用方),都注册到同一个Nacos。我先给调用方接Feign。
第一步:引入依赖,这里注意版本配套。我以Spring Boot 2.6.x + Spring Cloud 2021.x + Spring Cloud Alibaba 2021.x为例:
<dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-openfeign</artifactId> </dependency> <dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId> </dependency> <!-- 新版没有Ribbon了,必须手动引LoadBalancer,不然Feign起不来 --> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-loadbalancer</artifactId> </dependency>注意:很多人只引了Nacos和OpenFeign,启动时还报
No Feign Client for loadBalancing defined或者服务名解析不了,就是因为漏了LoadBalancer。这是Spring Cloud新版迁移后最常见的一个坑。
第二步:启动类上打开Feign开关:
@SpringBootApplication @EnableFeignClients public class OrderServiceApplication { public static void main(String[] args) { SpringApplication.run(OrderServiceApplication.class, args); } }第三步:在order-service里定义一个调用user-service的接口:
@FeignClient(name = "user-service", fallback = UserClientFallback.class) public interface UserClient { @GetMapping("/user/{id}") User getById(@PathVariable("id") Long id); }这里有两个最容易错的地方:name必须和对方在Nacos注册的服务名完全一致;RequestMapping的路径必须和对方实际接口路径完全一致,猜错一个就是404。服务提供方那边就正常写Spring MVC接口,服务名在application.yml里配置:
spring: application: name: user-service cloud: nacos: discovery: server-addr: 127.0.0.1:8848第四步:在调用方业务代码里,直接注入UserClient使用:
@Service public class OrderService { private final UserClient userClient; public OrderService(UserClient userClient) { this.userClient = userClient; } public User getUser(Long id) { return userClient.getById(id); } }你在这个业务方法里,完全感觉不到远程请求的存在,它就像调本地方法一样。但背后已经走完了“注册中心拉实例 → 负载均衡选实例 → HTTP调用”一整条链路。
2.3 Feign的超时、重试与降级配置
接口能跑通只是第一步,生产环境必须处理超时和异常。我给一段常用的配置:
feign: client: config: default: connectTimeout: 5000 readTimeout: 5000 user-service: readTimeout: 10000 circuitbreaker: enabled: true- connectTimeout:建立连接的超时时间,我一般给3秒到5秒。如果目标服务一连就卡住,5秒足够暴露问题了。
- readTimeout:等待响应体返回的超时,这个要看业务。如果对方接口内部查库+调第三方,耗时本来就长,你给得太短就会频繁超时;这里我给的是5秒,实际要按最慢的响应时间留余量。
- 局部配置:上面写了
user-service单独覆盖了readTimeout为10秒,这比配置全局值更方便——不是所有服务的耗时都一样,逐个服务调优才是生产级做法。
降级:加fallback要打开feign.circuitbreaker.enabled=true,然后写一个实现UserClient的类,被Spring容器接管:
@Component public class UserClientFallback implements UserClient { @Override public User getById(Long id) { return User.empty(); // 返回兜底空对象,别让上游调用报错 } }重试这块我要多说一句:Spring Cloud OpenFeign默认不重试。我见过有人自己配了重试,一个请求下游已经执行成功但响应超时了,重试又把请求打了一遍——如果接口不幂等,就产生了两份订单。我的原则是:默认不重试,如果要重试,必须是查询类幂等接口,且重试次数控制在1次。
Feign还有一个非常反直觉的坑:GET方法传对象参数会直接报错。Feign对GET的Query参数解析有限,你如果写@GetMapping("/user") User getByCondition(UserCondition condition),启动不会报错,调用时大概率拼不出正确参数。常规做法是用@SpringQueryMap或者把参数拆开一个个传。
3. Dubbo:高性能RPC调用的另一种打开方式
如果你的服务间调用很频繁、对性能敏感,或者需要更细粒度的治理能力,Dubbo是比Feign更硬核的方案。Dubbo是阿里开源的RPC框架,后来捐给了Apache,到现在国内大量核心业务还在用它。
3.1 Dubbo为什么比HTTP调用更快
Feign走的是HTTP + JSON,Dubbo默认走的是TCP + 自定义二进制协议。两者的性能差距主要来自这三个点:
- 协议层开销:HTTP协议本身有大量头信息(Method、Headers、Cookies等),即使没有业务数据也得带上;而Dubbo协议的请求头紧凑得多,传输体积明显更小。
- 连接复用:Feign每次调用一般都要建立一次TCP连接(除非开启了HTTP连接池,但很多项目默认没配),Dubbo则维护长连接和连接池,消费者和提供者之间保持常驻连接,省去反复建连、断连的开销。
- 序列化:Feign常用JSON序列化,可读性好但体积大;Dubbo默认Hessian2,还有Kryo、Protobuf等更高性能的选择,序列化出来的字节更小,反序列化也更快。
我并不是说Feign就一定慢得不能用,绝大多数业务场景HTTP的几百毫秒延迟都不是瓶颈。但如果你有那种单块业务里几十万、上百万次服务间调用、对TPS有硬性要求的时候,Dubbo的路由效率、连接复用、更细的线程模型,优势就体现出来了。
3.2 基于Nacos的Dubbo配置示例
Dubbo的整合思路和Feign有个本质区别:它要求把接口单独抽到一个公共Jar包里。比如我把UserService接口放到一个user-api模块,提供方user-service依赖并在实现类上加@DubboService,调用方order-service依赖并在字段上注入@DubboReference。为什么这样设计?因为RPC需要“强类型”——调用方拿到的代理对象要实现同一个接口类型,接口都不一致,代理压根没法生成。
先看公共接口模块:
public interface UserService { User getById(Long id); }服务提供方:
@DubboService public class UserServiceImpl implements UserService { @Override public User getById(Long id) { return new User(id, "张三"); } }dubbo: application: name: user-service registry: address: nacos://127.0.0.1:8848 protocol: name: dubbo port: -1这里protocol.port: -1表示每个实例随机分配一个端口,适合多实例部署在主机的情况,避免端口冲突。默认dubbo协议端口是20880,如果两台实例在同一台机器上不配置随机端口就会撞。
服务调用方:
@Service public class OrderService { @DubboReference private UserService userService; public User getUser(Long id) { return userService.getById(id); } }dubbo: application: name: order-service registry: address: nacos://127.0.0.1:8848 consumer: timeout: 5000 retries: 0消费者这边不用配置@DubboService,思路非常清晰:提供方“暴露服务”,消费方“引用服务”,中间的“谁在哪儿”全部交给Nacos。Dubbo的调用流程大致是:消费者启动时从注册中心订阅接口,拿到提供者的地址列表,然后按负载均衡策略选出地址,走长连接发起RPC请求。
3.3 Dubbo的负载均衡、超时与集群容错配置
Dubbo的强大之处在治理,我挑几个生产上一定会用到的配置说明。
负载均衡:在@DubboReference上可以指定负载均衡策略:
@DubboReference(loadbalance = "roundrobin", timeout = 3000, retries = 0) private UserService userService;策略有四种,我实际用下来这么选:
| 策略 | 英文名 | 适用场景 |
|---|---|---|
| 随机 | random | 默认,适合各实例性能差异不大的情况 |
| 轮询 | roundrobin | 希望请求均匀分配,避免热点实例压力过大 |
| 最少活跃调用 | leastactive | 慢实例会被动减少流量,适合性能参差不齐的环境 |
| 一致性哈希 | consistenthash | 希望相同参数请求打到同一实例,适合有状态服务或缓存友好场景 |
超时配置的优先级我要特别强调下:方法级配置 > 接口级配置 > 消费方全局配置 > 提供方配置。很多人只在消费方配了全局5秒,结果某个方法实际要8秒,接口直接超时失败。建议精确到方法或者接口配。
重试:Dubbo默认retries=2,意思是除了第一次请求,失败后还会额外重试2次,最多发起3次。这个默认值对非幂等接口非常危险,比如扣库存、创建订单,一旦下游执行成功但响应丢失,重试就会造成重复扣减。我的一般建议是:写操作必须显式配retries=0,读操作可以保留重试,但也要保证接口幂等。
集群容错:默认failover(失败自动切换其他实例),还有failfast、failsafe、forking等。这里我提醒一点,failover配合retries会让一个请求在多个实例上执行,只适合读操作;写操作建议failfast(快速失败,让上游感知后自己处理)或者retries=0的failover。
服务分组与版本:接口上可以加version,比如@DubboService(version = "1.0.0")、@DubboReference(version = "1.0.0")。做灰度发布、AB测试、多版本共存时这是标配——老版本服务不上线,新版本消费者仍能通过version把流量区分开,这就弥补了服务拆分后经常遇到的“接口升级不敢动”的困境。
4. Feign和Dubbo,到底怎么选
这个问题我几乎每做一个微服务项目都会被问到。与其给一个“XX更好”的结论,我更愿意把决策维度摊开,让团队自己看图说话。
4.1 六维核心对比
我从协议、性能、治理、生态、调试、门槛六个角度做了一张表:
| 对比维度 | Feign(Spring Cloud OpenFeign) | Dubbo |
|---|---|---|
| 通信协议 | HTTP/1.1,REST风格,天然适合对外开放接口 | 默认Dubbo自定义TCP协议;Dubbo 3也支持Triple(HTTP/2) |
| 序列化方式 | JSON为主,可读性强、跨语言友好 | Hessian2、Kryo、Protobuf,体积小、速度快 |
| 网络开销 | 每次请求携带完整HTTP头;若无连接池则频繁建连 | 长连接复用+紧凑协议头,高并发下优势明显 |
| 性能表现 | 中规中矩,绝大多数业务足够 | 相同硬件下吞吐量更高,延迟更稳定 |
| 服务治理 | 需配合Spring Cloud Sentinal/Hystrix等,配置较分散 | 自带负载均衡、容错、降级、路由、隐式传参、泛化调用,体系完整 |
| 可调试性 | 直接curl、Postman、浏览器就能测,直观 | 需要依赖telnet、QOS命令或专门的测试工具,门槛高 |
| 跨语言支持 | 只要提供HTTP接口,什么语言都能调 | 多语言支持有,但生态远不如Java生态成熟 |
| 学习成本 | 低,写接口+注解就能上手 | 中高,涉及协议、注册模型、插件体系、注册中心概念 |
4.2 我的选型建议
基于以上对比,我给出几条实际经验,不教条,但大概率不会错:
- 对外接口、跨团队联调多、需要给第三方提供REST API:优先Feign。因为HTTP接口谁都能看、谁都能测,你不必指望所有对接方都懂Dubbo。
- 内部核心链路、调用频繁、对性能和稳定性要求高:优先Dubbo。尤其是那些后台异步任务、核心交易链路上服务间调用特别密集的场景,Dubbo的长连接和紧凑协议能省下不少资源。
- 团队刚转型微服务,成员普遍不熟悉RPC:先Feign。项目能快速跑起来比什么都重要,不要一开始为了“性能优势”上一套复杂体系把自己搞崩。
- Spring Cloud 和 Dubbo 可以共存:很多人以为选了Dubbo就不能用Feign,实际上两者可以同时存在于一个项目里。对外暴露API走Feign,内部服务间核心调用走Dubbo,这个混合模式在大型项目里很常见。
Dubbo 3出来以后,原来“Dubbo不够云原生”的短板也在补:Triple协议基于HTTP/2,兼容gRPC,跨语言能力大幅增强;应用级服务发现模型也让它在Kubernetes环境更顺畅。所以我的判断是:短中期内,Feign各自有明确场景,不存在谁取代谁。你的选择本质上是“想让团队用什么级别的抽象去管理服务间通信”。
5. 常见问题与排查实战
最后这部分,我把自己和身边同事在Nacos + Feign/Dubbo调用上踩过的坑、排查思路全部整理出来。这些东西你刷文档刷不到,但生产环境一定会遇到。
5.1 服务调通之前先确认的3件事
所有调用失败问题都可以先自查这三项:
- Nacos服务列表里有没有注册上?登录Nacos控制台,找到“服务管理”页,确认服务名、IP、端口都在。如果控制台里压根没有,问题在提供方,别在消费方瞎调参。
- 服务名和命名空间/分组是否匹配?Feign的
@FeignClient(name=...)、Dubbo的注册地址、Nacos的namespace三个地方必须对齐。我见过一个项目,开发环境用namespace: dev,消费方忘了配,结果一直拿不到实例,查了很久才发现是命名空间隔离了。 - 消费方能访问到提供方的IP和端口吗?注册中心显示实例是内网IP,消费方在容器或跨网段环境,网络不通,调用一样会失败。这个用Postman或telnet直接测那个IP:端口就能定位。
5.2 Feign报“Load balancer does not have available server for client: xxx”
这个报错我想单独拿出来讲,因为它出现的频率实在太高了。它翻译过来就是:Feign知道要调xxx服务,但LoadBalancer从注册中心拿不到任何可用实例。排查步骤:
- 看提供方是否注册成功:Nacos控制台确认。
- 看消费方是否引了
spring-cloud-starter-loadbalancer:新版Spring Cloud必引。 - 看两者是否在同一个Nacos namespace/group:前面已经提醒过。
- 看提供方是否被保护阈值“屏蔽”了:如果提供方实例很少,健康比例低于保护阈值,Nacos会不进健康实例列表,这种情况在控制台能看到实例状态异常。
还有一个多网卡导致的人身坑:服务主机有多个网卡,注册到Nacos的IP是内网的管理网段,消费者在业务网段访问不到。解决方式是显式指定注册IP:
spring: cloud: nacos: discovery: ip: 192.168.10.1005.3 Dubbo接口忽好忽坏,一会超时一会正常
这是Dubbo环境很典型的问题。症状是调用偶尔报Read timed out,但过一会又好了。常见原因有三个:
- 提供方线程池被打满:Dubbo默认的线程池大小固定,如果某个慢接口拖住了所有线程,新请求只能排队,等待时间超过消费方timeout就报超时。解决方向是给慢接口单独配线程池、调大线程数、或者让消费方超时更宽容。
- 消费者超时设置太激进:
@DubboReference上的timeout如果小于提供方实际处理时间,就会偶发超时。把timeout调大,观察是否能改善。 - 服务实例之间存在严重性能差异:比如新旧两个版本同时部署,新实例是新的物理机,性能好,旧实例是共享虚拟机,响应慢。用
leastactive负载均衡策略可以让慢实例少接流量,或者直接把慢实例下线。
定位的时候不要只看日志里的异常,要连着看提供方的线程池活跃度、GC情况、和网络链路一起查。
5.4 版本兼容性坑位速查
| 场景 | 坑点 | 建议 |
|---|---|---|
| Spring Boot 2.4+ 搭配 Spring Cloud 2020+ | Ribbon被移除,Feign若没引LoadBalancer会挂 | 引入spring-cloud-starter-loadbalancer |
| Nacos 1.x 与 Nacos 2.x | 2.x默认gRPC端口9848,防火墙忘开,实例注册不上 | 服务器需要同时开放8848和9848端口 |
| Dubbo 2.7 与 Dubbo 3 | 注册模型从接口级改为应用级,老消费者可能找不到新提供者 | 升级时配置register-mode=instance兼容,或一起升级 |
| Spring Cloud Alibaba 版本与Spring Boot不对应 | 版本错配启动就报NoClassDefFoundError | 对照官方版本说明选配套版本,不要最新打架 |
5.5 一张配置速查表
我把Feign和Dubbo的核心配置点整理成一张表,方便你抄作业:
| 配置项 | Feign | Dubbo |
|---|---|---|
| 注册中心地址 | spring.cloud.nacos.discovery.server-addr | dubbo.registry.address=nacos://127.0.0.1:8848 |
| 服务名配置 | spring.application.name | dubbo.application.name |
| 远程接口声明 | @FeignClient(name="服务名") | @DubboReference |
| 超时配置 | feign.client.config.default.readTimeout | dubbo.consumer.timeout或@DubboReference(timeout=...) |
| 重试控制 | 默认不重试,可自定义Retryer | @DubboReference(retries=0)写操作必须配 |
| 负载均衡 | LoadBalancer默认轮询,可配@LoadBalanced或自定义 | @DubboReference(loadbalance="roundrobin"),四种策略 |
| 降级/容错 | Feign fallback类 | 集群容错模式cluster=failover/failfast/failsafe |
| 启动开关 | 启动类加@EnableFeignClients | 不需要额外开关,引入starter即可 |
我个人的习惯是:能用Feign先跑通的项目,不要一上来就上Dubbo;但要上Dubbo的项目,一定要安排人把服务治理玩明白再铺开。服务调用从来不是“通”就完了,超时、重试、容错、隔离、观测这些才是生产环境真正考验人的地方。你可以在实际项目里先小范围验证,跑一两个核心接口,对比一下两种方式在你业务下的表现,再决定要不要把整套调用方式统一掉。毕竟,架构选型最终还是要为业务和团队的长期维护服务。