又到了临近发版的时间点某服务一启动就抛No provider available日志里清清楚楚指向DubboReference注入的那一行。绝大多数人第一反应是“提供方没起来”可查一圈提供方进程好好的注册中心也看得到服务端口也通唯独消费端启动失败。真正的原因往往藏在DubboService与DubboReference的初始化过程里——一个要等 Spring 容器刷新完成之后才导出另一个在 Bean 属性注入阶段就急着创建代理时序上天然差了半拍。这篇文章就把这两条初始化链路完整拆开注解如何被扫描ServiceBean如何导出ReferenceBean的代理如何生成以及我在实际项目里踩过的时序坑和对应的排查思路。刚接触 Dubbo 注解开发、或者正被这类启动问题折磨的同学看完应该能对“为什么报错”“怎么定位”有一个清晰的框架。1. 两条初始化链路的分工注解背后是两套不同的Spring钩子要理解DubboService和DubboReference的初始化先得跳出注解本身看一眼 Dubbo 是在 Spring 生命周期的哪些节点介入的。Spring 启动过程中有两个关键的扩展位一个是BeanDefinitionRegistryPostProcessor它在 Bean 定义注册阶段就能扫描类、动态注册新的 BeanDefinition另一个是BeanPostProcessor它在 Bean 实例化前后、属性注入时干预单个 Bean。Dubbo 的提供者端和消费者端刚好各自占用了一个位置两边的时间点天然不同。1.1 从Service/Reference到DubboService/DubboReference老项目里见过不少Service和Reference这是 Dubbo 2.7 时代提供的注解。问题在于Service和 Spring 自己的org.springframework.stereotype.Service重名了包名稍微导错Dubbo 的服务就静默扫不到排查成本很高。所以 2.7.x 开始官方提供了DubboService和DubboReference语义更明确老注解保留兼容但不再推荐。新项目直接用DubboService/DubboReference就好不用纠结。1.2 扫描入口DubboComponentScan与容器初始化Dubbo 不是平白无故知道哪些 Bean 是 RPC 服务的。使用纯 Spring 配置时需要显式声明EnableDubbo或DubboComponentScan(basePackages com.example.rpc)使用 Spring Boot 场景时一般靠 starter 的自动配置配合dubbo.scan.base-packages指定扫描路径。不管哪种方式最后都会落到一个核心入口——3.x 里是ServiceClassPostProcessor它在postProcessBeanDefinitionRegistry阶段用ClassPathBeanDefinitionScanner去扫包凡是类上带了DubboService的都会为它注册一个对应的ServiceBean定义。消费者端则是另一个入口ReferenceAnnotationBeanPostProcessor。它是InstantiationAwareBeanPostProcessorAdapter的子类在 Spring 往 Bean 里注入属性的时候扫描字段和方法上的DubboReference然后把代理对象创建出来塞回去。这里有个非常重要的结论提供者的服务导出发生在容器刷新事件之后而消费者的代理注入发生在 Bean 实例化期间。这一前一后的错位是后面几乎所有初始化时序问题的根源。1.3 一条启动时间线我把 Dubbo 注解驱动的启动过程压缩成一张时间线先建立整体印象后面每个环节再展开Spring 阶段Dubbo 在做什么关键类BeanDefinition 注册阶段扫描DubboService注册ServiceBean的 BeanDefinitionServiceClassPostProcessorBean 实例化与属性注入阶段扫描DubboReference创建代理并注入字段ReferenceAnnotationBeanPostProcessor容器刷新完成ContextRefreshedEvent触发DubboBootstrap.start()遍历所有ServiceBean执行导出DubboBootstrapApplicationListener记忆点就一句话先扫提供者再注消费者最后统一导出提供者。消费者创建代理的时候提供者往往还没导出这就是很多“同一个 JVM 里服务提供方和消费方一起启动却报找不到服务”的原因。2.DubboService初始化从 Bean 到端口导出的完整链路提供者这一侧的初始化表面上就是“启动完多监听一个端口”但内部分成注册、装配、导出、注册中心上报四个层面。任何一个环节出问题表现都是服务“看起来起来了”但别人调不到。2.1 注册 ServiceBean 时注解属性去了哪里ServiceClassPostProcessor扫到带DubboService的类并不会直接把实现类注册成服务而是先注册一个ServiceBean的 BeanDefinition。DubboService注解上的group、version、timeout、retries、loadbalance、weight、interfaceClass这些属性都会在这个阶段被解析出来灌到ServiceBean里。这里有个细节注解上如果没有显式写interfaceClassDubbo 会自动判断——如果实现类只有一个接口就用这个接口如果有多个接口必须在注解里明确指出暴露哪一个否则导出的时候会报接口不明确的错误。真正的实现类 Bean 实例化完成后Dubbo 会把实例塞进ServiceBean的ref字段。也就是说ServiceBean更像是一个“适配器”把 Spring 管理的实现对象和 Dubbo 的ServiceConfig串起来最终所有配置都会在导出时拼装到 URL 参数上。理解 Dubbo 的人都知道一句话Dubbo 的一切配置最终都是 URL服务注册、服务发现、路由规则全是围绕 URL 在转。2.2 导出为什么非要等到 ContextRefreshedEvent服务导出这一步没有放在 Bean 创建的时候而是放在 SpringContextRefreshedEvent事件之后这是有意的设计。导出一个 RPC 服务依赖的东西不少注册中心客户端要就绪、配置中心的数据要拉下来、某些扩展点要能从容器里拿到。只有等 Spring 容器整体 refresh 完成这些依赖才可靠。所以DubboBootstrapApplicationListener监听到ContextRefreshedEvent后调用DubboBootstrap.start()在start()里遍历所有ServiceBean单例逐个调用export()。这也是为什么你断点打在实现类的PostConstruct里时服务还没导出而打在ApplicationRunner里时服务已经可以调用了。2.3doExportUrls内部一个实现类怎么变成注册中心里的一行记录ServiceBean.export()最终走到ServiceConfig.export()核心是doExportUrls()。这一步按协议循环处理默认走 dubbo 协议。流程大概是ProxyFactory.getInvoker(ref, interfaceClass, url)把真实实现类包装成AbstractProxyInvoker。默认用 Javassist 生成代理也可以通过dubbo.proxyjdk切换成 JDK 动态代理。包装后的 Invoker 的invoke方法本质上就是在反射调用你写的业务方法。如果有注册中心URL 的协议头是registry://扩展加载器会适配出RegistryProtocol。RegistryProtocol.export会先做一次“本地导出”也就是把服务导出到本地DubboProtocol然后再向注册中心注册 provider URL。DubboProtocol.export负责真正开端口。默认 20880exporterMap里维护了 serviceKey 到 exporter 的映射端口绑定失败时会抛BindException这是最常见的启动失败原因之一。注册中心侧拿到 provider URL 后写入服务列表并标记动态注册dynamictrue同时订阅配置覆盖规则。此时你才能在注册中心控制台看到这个服务。这个过程里RegistryProtocol很关键它相当于一道“门面”先本地导出再注册上报。所以哪怕注册中心暂时连不上本地端口也可能已经开了服务自身“进程活着”和“真正被调通”是两码事。2.4 同一个 JVM 里的调用injvm 本地导出的存在感很多人忽略DubboService默认会附带一个本地协议导出。当 consumer 和 provider 在同一个 JVM 时Dubbo 会额外生成一个injvm协议的 invoker消费端优先走本地调用不走网络。这也是为什么本地联调时两个服务在同一个 Spring 容器里明明没开远程端口也能调通。如果显式设置了scopelocal就强制只做本地导出scoperemote则禁止本地导出。这个机制平时没存在感但排查“为什么本地通、测试环境不通”的时候要先想到它——本地“通”有可能是 injvm 在兜底而不是你的网络链路由问题。3.DubboReference代理对象是怎么凭空出现的消费者端的DubboReference看起来只是往字段上放了一个注解Spring 一刷新字段里就多出一个可用的接口对象。这个对象不是接口的实现类而是一个动态代理背后的 Invoker 链才是真正的调用核心。3.1ReferenceAnnotationBeanPostProcessor的介入时机ReferenceAnnotationBeanPostProcessor在postProcessProperties里遍历当前 Bean 的字段和 setter 方法凡是标注了DubboReference的都会触发doGetInjectedBean。这里有两个值得注意的点一是代理创建发生在“这个消费端 Bean 初始化”的同时而不是等容器完全启动二是同一个引用会被缓存重复注入同一个接口不会反复创建代理而是共享一个ReferenceBean。如果DubboReference标注在静态字段上这个后置处理器是不会处理的字段注入后仍然是 null这个问题我用过一次就记住了。静态字段不走实例属性注入要么改成非静态要么通过ApplicationContext手动拿ReferenceBean。另外注解标注在接口类型上没问题但如果标注的是具体实现类类型而该类没有对应的接口代理没法生成启动阶段就会报错。3.2 代理创建的三层结构Invoker、Cluster、ProxyReferenceBean的getObject()会走ReferenceConfig.get()最终进createProxy(map)。创建一个代理大概是三层拼装refer 层有注册中心时RegistryProtocol.refer创建RegistryDirectory向注册中心订阅 provider 列表和配置覆盖规则。订阅返回的 provider URL 会被转成对应的 Invoker。这一步决定了“调用哪个机器”。cluster 层目录里的 Invoker 不止一个多提供方、多注册中心Cluster把这些 Invoker 组装成一个ClusterInvoker默认是FailoverClusterInvoker还嵌套了消费侧的过滤器链比如MonitorFilter、ContextFilter这些。这一步决定了“失败之后怎么办”。proxy 层最后ProxyFactory.getProxy(invoker, interfaceClass)生成 Javassist 或 JDK 动态代理。代理方法的逻辑很简单把方法名、参数、附加信息封装成RpcInvocation丢给invoker.invoke()走负载均衡选出一个 provider Invoker序列化后通过 Netty 发出去。所以DubboReference注入的字段本质上是一个“遥控器”你按按钮调方法它翻译成 RPC 请求然后通过背后那一大串 Invoker 链找到真正执行的人。这个理解很重要排查超时、重试、负载均衡问题的时候都要回到这条链路上来。3.3 check 与 lazy启动时到底连不连DubboReference有两个跟初始化强相关的属性。check表示启动时是否检查 provider 存在。缺省为开启状态老版本默认就是启动检查如果注册中心里没有任何可用 providercreateProxy阶段会直接抛IllegalStateException: No provider available阻止 Spring 启动。设成checkfalse后代理照样创建但首次真实调用时如果还没有 provider会在调用时报错。这个属性的本质是“把问题提前暴露”还是“延迟到运行时暴露”没有绝对好坏看场景。lazy则更进一步默认 false。设成true时注入的其实是一个懒加载代理真正创建远程代理的动作被推迟到第一次调用方法时才执行。lazytrue配合checktrue时检查动作也会被推迟等于放弃了启动期校验。我的建议是研发环境用checkfalse减少启动失败干扰生产环境保留checktrue让问题在上线时立刻暴露别把生产也设成 false否则服务没发布成功你还要等调用报错才发现。3.4 代理缓存与多配置引用Dubbo 对引用的缓存有几个层次理解之后能解释不少“怪现象”。同一个接口、同样的 group/version多次DubboReference注入拿到的是同一个代理但如果两个字段的group或version不同会生成两个不同的代理因为它们对应的是不同的ReferenceBean。缓存 key 与接口、group、version、url 等配置强相关所以“我改了注解上的 timeout为什么行为没变”这类问题多半是缓存命中了旧配置需要连配置一起确认。另外还要注意constants或parameters这类附加参数它们也会影响 URL 的组装但不会体现在日志的报错里排查时容易忽略。4. 初始化时序的暗礁Provider 还没导出Consumer 已经要代理这一节是重点中的重点。因为前两节的结论凑在一起就会得出一个必然结果在同一个进程里服务消费方创建代理的时间点早于服务提供方真正导出的时间点。这不是 Dubbo 写错了是 Spring 生命周期决定的。4.1 问题根源两个阶段天然错位回顾第 1 节的时间线DubboReference的代理注入发生在 Bean 实例化阶段此时 Spring 正在一个个创建 Bean、填充属性而DubboService的导出要等ContextRefreshedEvent事件也就是容器整个 refresh 完成后才会触发。一个靠前一个靠后中间隔着所有 Bean 的创建时间。如果你在同一个工程里又提供又消费消费方 Bean 被创建得早它去注册中心订阅 provider 列表时发现提供方还没注册如果此时checktrue启动直接失败。4.2 同一 JVM 内“自产自销”的三个解法碰到这种同一个进程内既暴露服务又引用自身服务的情况我一般按顺序试这三招消费端checkfalse。最直接代价是放弃了启动期检查。适合联调环境但不建议生产无脑照搬。拆进程。把提供方和消费方放到不同部署单元从架构上消除时序依赖。如果项目还小这个动作成本高但长期看最干净。调整 Bean 的初始化顺序。让消费方 Bean 晚一点创建比如用DependsOn依赖提供方实现 Bean。这个技巧能用但治标不治本Bean 一多就管不过来。我在实际项目里的做法是联调环境给所有引用设置checkfalse靠健康检查脚本确认服务可用生产环境开checktrue配合 readiness 探针让发布系统等服务真正起来后再切流量。这样两头兼顾。4.3 循环依赖与早期代理的纠缠比时序错位更麻烦的是循环依赖。比如服务 A 的实现类注入了 B而 B 里DubboReference引用了 A 的接口。A 的ServiceBean还没导出B 在创建时却已经要订阅 A 了配上checktrue就直接报“No provider”。如果循环依赖里还掺了构造器注入Spring 可能直接抛BeanCurrentlyInCreationException。我处理过几个类似案例最终都发现是模块划分问题两个服务互相调用往往意味着边界画错了应该把公共逻辑抽到下层。实在避免不了互相引用时至少把DubboReference放在字段上而不是构造器参数里并且一个方向的引用设置lazytrue先把 Bean 创建出来调用时再真正建连。4.4 多注册中心、多协议时的初始化顺序更难排查服务多了以后一个引用可能同时订阅多个注册中心一个服务也可能同时暴露 dubbo 和 tri 两种协议。初始化顺序的“错位”问题会升级成“不一致”注册中心 A 里已经有 provider 了注册中心 B 里还没有或者 dubbo 协议端口已经开好tri 协议还在绑定。报错表现非常像“找不到服务”但根因是某个注册中心或某个协议还没就绪。这种场景下光看消费端日志不够要两边一起看提供端有没有分别向每个注册中心注册消费端有没有把每个注册中心的 provider 都 merge 进同一个RegistryDirectory。我习惯在启动阶段把dubbo.application.qos-enable打开默认开用运维端口快速看每个服务的注册状态比反复猜快得多。5. 把这套过程翻译成排查技能日志、断点与一张排错表理解了初始化链路之后再遇到启动问题就不该靠玄学了。下面是几个我常用的定位手段都是围绕前面讲的几个关键节点展开的。5.1 启动日志里该盯哪几行正常启动时提供端日志会出现类似Export dubbo service开头、包含接口名和地址的行注册中心侧会有服务注册成功的记录消费端启动完成后日志里一般会有完成订阅、连接 provider 的记录。失败时不要盯着最后抛出来的异常看No provider available只是结果真正的原因在前面几行可能是Failed to subscribe、Cannot connect to registry、BindException: Address already in use。我的习惯是出问题先grep -i error\|exception\|failed把启动日志过一遍找到第一条异常再从那里往前后各看 50 行。5.2 值得打断点的源码位置如果你需要深入定位下面这几个位置是性价比最高的断点ServiceClassPostProcessor.postProcessBeanDefinitionRegistry确认DubboService是否被扫到对应的ServiceBean是否注册。ReferenceAnnotationBeanPostProcessor.postProcessProperties/doGetInjectedBean确认DubboReference是否被处理代理是在哪个 Bean 的创建过程中生成的。ServiceConfig.doExportUrlsFor1Protocol看导出时的 URL 参数、协议、端口是否符合预期。RegistryProtocol.export和RegistryProtocol.refer看 provider 有没有注册consumer 有没有订阅到列表。DubboProtocol.export看端口是否真的绑定成功exporterMap里有没有这个 serviceKey。断点打上之后实际操作中我会重点关注exporterMap的大小、directory里 provider 的数量这两个值。前者为 0 说明服务没导出后者为 0 说明订阅链路有问题方向一下子就清楚了。5.3 一张表常见启动异常与处置方向启动异常/现象可能的根因处置方向No provider available且 checktrue导出未完成 / 注册中心无 provider / group、version 不匹配看注册中心服务列表核对接口、group、versionBindException: Address already in use端口被占用或同一个进程重复启了两次换端口或排查僵尸进程Not found exported service消费端引用的接口与提供端不一致核对 jar 包版本、接口全限定名连不上注册中心注册中心地址、账号配置错误或网络不通检查配置和连通性不一定是 Dubbo 问题找不到对应协议扩展没有引入对应协议模块依赖检查依赖里是否有 tri/dubbo 对应的二方包同一个接口两个 group 互相串缓存 key 只看接口没看 group确认注解上的 group 是否都正确设置5.4 一个我保留到现在的排查习惯最后分享一个我用了很久的小习惯。凡是涉及 Dubbo 启动问题的工单我都会先让对方贴两段东西一段是提供端以Export dubbo service开头的日志一段是消费端从启动到报错之间前 20 行日志。这两段一拼八成的问题都能对号入座。如果对不上再考虑是不是跨环境配置差异——本地通、测试环境不通那种基本先去查injvm和注册中心地址。这套方法帮我省了不少时间也让我对DubboService和DubboReference的初始化过程有了更深的体感所谓的“服务调不通”很多时候不是网络问题而是生命周期问题。