前两天朋友在群里发了个截图:Eureka 控制台里某个服务的实例状态突然变红,几秒后又恢复成 UP。他一开始怀疑是网络抖动、服务 OOM 或者被强制 kill,查了一圈没结论。最后把最近的操作挨个过了一遍,发现触发点是有人调了一下RefreshScope.refresh()。这个问题其实不算冷门,但它坑就坑在现象“看起来像宕机”,实际却和配置刷新的生命周期强相关。这篇就围绕这个标题,把我踩过的坑、查到的原理和最终的处理方式完整梳理一遍,给还被这个问题卡住的人一个参考。
1. 问题现象与影响范围
1.1 典型现象:刷新后服务实例瞬间消失
最直观的现象,就是在调用了RefreshScope.refresh()(或者通过 Spring Boot Actuator 的/actuator/refresh端点)之后,Eureka 控制台上对应应用的服务实例会立刻从可用列表里消失,或者状态变为 DOWN。等待几秒到几十秒不等,实例又会重新出现,状态恢复为 UP。这个“消失”的窗口时间不长,但足够让依赖该服务的调用方踩到报错。
我在本地复现的时候,盯着控制台看,进程本身没有任何异常,日志里也没有 ERROR。应用还活着,接口也能通,但注册中心里的实例就是掉了。第一次遇到这种情况,很容易误判成 Eureka 服务端的问题,或者怀疑是网络分区导致的心跳超时。实际上,问题出在客户端自己主动向服务端发送了注销请求,服务端只是被动执行。
1.2 影响的不仅是调用方,还有监控与发布流程
这个问题的破坏力,取决于你的下游是怎么发现服务的。如果下游用的是 Ribbon 默认的注册表拉取机制,那么最多少量请求在刷新那一瞬间失败,重试一次就过去了。但如果下游是直连 IP,或者本地缓存了实例列表且刷新频率很低,那这个下线窗口就会被明显放大,可能出现连续的连接失败、超时报警。
更麻烦的是监控侧。服务掉线会触发健康检查告警,值班同学会收到“实例数量异常”或“服务不可用”的通知。尤其在凌晨执行配置刷新脚本时,这种误报会消耗很多排查精力。还有发布流程,如果发布系统把“Eureka 实例是否在线”作为发布成功的前置判断条件,刷新配置时正好撞上发布窗口,就可能误判发布失败,导致重复发布或者回滚。
我个人的判断是:这个问题的核心并不是“刷新配置”这个动作本身有问题,而是刷新动作触发到了不该被触发的 Eureka 注册生命周期。只要搞清楚这条触发链,修复就很简单。
2. 刷新机制与下线链路拆解
2.1 RefreshScope 到底做了什么
先回到@RefreshScope的源头。RefreshScope本质上是 Spring Framework 里一个自定义的Scope实现,它和singleton、prototype一样管理 Bean 的生命周期,但策略不同:被@RefreshScope注解的 Bean,在第一次访问时会被创建并缓存到当前 Scope 的缓存中;当调用refresh()时,这个缓存会被清空,所有被管理的 Bean 会被销毁;之后再次访问 Bean 时,会重新创建。
看一段源码逻辑就清楚了(不同版本略有差异,但核心一致):
// ContextRefresher 中 refresh() 的核心动作 public synchronized Set<String> refresh() { Set<String> keys = refreshEnvironment(); this.scope.refreshAll(); return keys; }// RefreshScope.refreshAll() 的简化逻辑 public void refreshAll() { super.destroy(); this.context.publishEvent(new RefreshScopeRefreshedEvent()); }super.destroy()会遍历 Scope 缓存中的所有 Bean,逐个调用其销毁回调,然后清空缓存。这里我最想强调的是:RefreshScope.refresh()刷新的是所有被@RefreshScope标记的 Bean,不是某一个配置类。这是很多人忽略的点,也是后续问题的伏笔。你以为刷新的是自定义的UserConfig,但凡是这个作用域管理的其他 Bean,全部会被连带销毁重建。
2.2 Eureka 自动注册的生命周期逻辑
Eureka 客户端的自动注册,走的是 Spring Cloud Commons 里的AbstractAutoServiceRegistration,Eureka 的实现类叫EurekaAutoServiceRegistration。它的关键行为是:
- 实现了
SmartLifecycle,容器启动后会自动执行start(); start()里调用EurekaServiceRegistry.register(registration),向 Eureka 服务端发起注册;stop()里调用EurekaServiceRegistry.unregister(registration),向 Eureka 服务端发起注销;- 它还会监听
WebServerInitializedEvent,在 Web 服务器初始化完成后触发注册动作。
关键代码逻辑类似这样:
public class EurekaAutoServiceRegistration extends AbstractAutoServiceRegistration<EurekaRegistration> { @Override protected void register() { this.serviceRegistry.register(this.registration); } @Override protected void deregister() { this.serviceRegistry.unregister(this.registration); } @Override public void stop(Runnable callback) { this.deregister(); super.stop(callback); } }注意,EurekaAutoServiceRegistration默认不是@RefreshScope管理的 Bean,所以在正常的自动配置情况下,你调用RefreshScope.refresh()并不会直接销毁它。这也是为什么很多项目里刷新配置一切正常,从来没有遇到过下线问题。问题往往出现在“你手动把 Eureka 的某个组件也放进了@RefreshScope作用域”,或者“刷新导致注册信息来源发生变化,迫使 Eureka 走了重新注册的流程”。
2.3 一条完整的“下线-上线”时间线
为了说明白整个调用链路,我画一条时间线(不用图,文字描述足够清楚):
- 外部调用
RefreshScope.refresh(),或者请求/actuator/refresh端点; ContextRefresher.refresh()先刷新Environment配置,然后调用scope.refreshAll();scope.refreshAll()清空@RefreshScope的 Bean 缓存,执行每个 Bean 的destroy()回调;- 如果这个过程中,
EurekaAutoServiceRegistration或它依赖的EurekaRegistration被纳入了刷新范围,就会执行stop()逻辑,最终调用unregister(),向 Eureka 服务端发送注销请求; - Eureka 服务端收到注销请求,立即移除该实例;
- 随后,新的 Bean 被创建,
start()或lazy init逻辑重新触发注册,向服务端发送注册请求; - Eureka 服务端重新添加实例,状态恢复为 UP。
从第 4 步到第 7 步之间的耗时,正常情况是毫秒级到秒级。但如果在 Bean 重新创建过程中,构造函数或初始化逻辑里有网络调用、数据库查询、分布式锁获取等耗时操作,这个窗口会被拉到几秒甚至更长。下游在此期间请求该服务,就会遇到连接失败的异常。
3. 我踩过的几种典型触发场景
3.1 场景一:自定义 @RefreshScope 包装 Eureka 注册组件
这是我最先复现出来的场景,也是最容易理解的一条路。某些项目为了“动态调整注册信息”,会手动定义EurekaRegistration或EurekaInstanceConfigBean,而且顺手加了@RefreshScope。比如下面这种写法:
@Bean @RefreshScope public EurekaRegistration eurekaRegistration( EurekaClient eurekaClient, CloudEurekaInstanceConfig instanceConfig, ApplicationInfoManager applicationInfoManager) { return new EurekaRegistration(eurekaClient, instanceConfig, applicationInfoManager); }这段代码在启动时没问题,甚至运行期间也没问题。可一旦调用RefreshScope.refresh(),这个 Bean 就会被销毁重建。销毁过程中,与之关联的EurekaAutoServiceRegistration会感知到注册信息对象的生命周期变化,触发unregister()。之后新对象创建完成,再触发register()。整个过程在日志里会留下明显的注册/注销记录。
我给的判断标准很简单:在EurekaAutoServiceRegistration.stop()和start()方法上打上断点,如果 refresh 时断点命中了,说明 Eureka 注册组件确实被纳入了刷新生命周期。这是定位问题的第一抓手。
3.2 场景二:动态 instance-id 或 hostname 参与注册信息生成
还有一种更隐蔽的情况,不一定给 Eureka 组件加@RefreshScope,但只要配置刷新导致instanceId、hostname、ip-address等注册关键字段的值发生变化,就会产生“旧实例注销,新实例注册”的等效行为。
举个例子,你的配置里有这么一项:
eureka: instance: instance-id: ${spring.application.name}:${spring.cloud.client.ip-address}:${server.port}:${random.value}如果${random.value}或者某些从配置中心拉取的动态配置在刷新后变了,EurekaInstanceConfigBean重新绑定配置时,计算出的instanceId就和之前不一致。Eureka 客户端会按照新的instanceId去重新注册,而旧的实例在 Eureka 服务端还在,但心跳线程已经停了或者指向了新的 instanceId,最终旧实例会被服务端剔除。从调用方的视角看,就是服务先下线后上线,甚至出现“两个同名实例,一个 UP 一个 DOWN”的诡异画面。
这种情况下,断点打不到EurekaAutoServiceRegistration.stop()上,因为注销不是通过这个入口触发的。你需要去看 Eureka 服务端收到的注册心跳来源,以及客户端计算的instanceId是否发生了变化。
3.3 场景三:版本 bug 与全量 refresh 连坐
还有一类情况是 Spring Cloud 版本本身的问题。早期某些版本里,EurekaAutoServiceRegistration在不同配置条件下会被包装成代理对象,或者和EurekaClient的销毁逻辑产生连带效果。一旦RefreshScope.refresh()全量刷新,即使你没有手动把 Eureka 组件放进刷新范围,也可能因为刷新顺序、Bean 依赖关系,导致EurekaClient被提前 close,心跳线程终止,服务端等待几个心跳周期后把实例剔除。
这种问题比前两种更隐蔽,因为现场不会立刻现形,而是滞后几十秒才发生。你会看到 refresh 当时一切正常,过一会儿才收到服务下线报警。排查时除了关注注册/注销日志,还要看有没有心跳停止相关的异常,以及 Eureka 服务端日志里“lease 到期”之类的记录。
我建议的做法是:先确认你用的 Spring Cloud 版本,再去搜该版本对应的 issue 列表,看看有没有针对RefreshScope和 Eureka 生命周期的已知修复。这看起来像是在甩锅给版本,但我确实遇到过升级一个小版本之后问题就消失的情况。
4. 解决方案与最佳实践
4.1 优先校正:把 Eureka 相关组件移出刷新范围
解决问题的第一原则,是不要让 Eureka 的注册组件和动态刷新扯上关系。具体操作分两步:
第一步,检查所有 Bean 定义,凡是和 Eureka 注册直接相关的组件,一律不加@RefreshScope。包括但不限于:EurekaRegistration、EurekaInstanceConfigBean、EurekaAutoServiceRegistration、ApplicationInfoManager。如果你需要动态调整的是业务配置项,单独建一个配置类来承载,不要把注册信息混进去。
第二步,如果某些注册信息确实需要动态调整,比如 metadata-map 里的版本号、环境标签,你要做的不是刷新整个注册组件,而是调用ApplicationInfoManager提供的更新方法,去修改InstanceInfo的元数据。Eureka 本身支持实例元数据变更,但路径不应该走@RefreshScope全量销毁。
用一个反面案例来说明。我见过有人这样写过:
@RefreshScope @ConfigurationProperties(prefix = "eureka.instance.metadata-map") public class MetadataConfig { private String version; private String region; }然后把MetadataConfig的字段塞进EurekaRegistration的自定义实现里。这种设计的初衷是好的,但代价是每次刷新都会把注册组件拖下水。正确的做法是把 metadata 的更新放在一个独立服务里,通过ApplicationInfoManager.getInfo().setIsDirty()等方式触发增量更新,而不是重建 Bean。
4.2 兜底方案:刷新后手动重新注册
如果你的代码里已经用了@RefreshScope,且因为历史原因不太好改,那就加一个兜底补偿机制:在每次刷新完成后,主动执行一次注册操作。
Spring Cloud 提供了RefreshScopeRefreshedEvent事件,在refresh()执行完、缓存清空之后会发布。你可以监听这个事件,重新调用EurekaServiceRegistry.register()来恢复注册状态:
@Component public class EurekaReRegisterListener { @Autowired private EurekaServiceRegistry serviceRegistry; @Autowired private EurekaRegistration registration; @EventListener(RefreshScopeRefreshedEvent.class) public void onRefresh() { serviceRegistry.register(registration); log.warn("RefreshScope refresh completed, manually re-register to Eureka"); } }这里有一个细节要注意:RefreshScopeRefreshedEvent发布时,新的@RefreshScopeBean 不一定已经全部实例化完成,因为refreshAll()只负责清空缓存,后续 Bean 是在被注入或访问时才创建的。所以手动注册时,如果registration这个对象已经是最新生成的实例,那直接注册没问题;如果你拿到的是旧实例的引用,注册就可能失败或者注册到旧的 instanceId 上。
更稳妥的做法是,通过ObjectProvider延迟获取最新的EurekaRegistration:
@EventListener(RefreshScopeRefreshedEvent.class) public void onRefresh() { EurekaRegistration currentRegistration = provider.getIfAvailable(); if (currentRegistration != null) { serviceRegistry.register(currentRegistration); } }这种方式属于兜底,能保证服务不会长时间处于下线状态。但它解决的是“结果”问题,不是“根源”问题。该修的 Bean 定义还是要修。
4.3 工程治理:局部刷新、事件补偿与发布窗口
再往上走一层,从工程治理的角度,我有几个建议:
第一,控制RefreshScope.refresh()的使用频率和范围。如果你只改了某一个配置项,不要动不动就全量 refresh,更不要在一个业务接口里直接调用RefreshScope.refresh()。Spring Cloud Bus 也提供了按目标服务定向刷新的能力,能缩小影响面。
第二,把刷新操作和发布流程解耦。Eureka 实例上下线应该只和部署、健康状态相关,不应该被配置刷新这个动作影响。如果你们的配置中心刷新机制设计得不够精细,建议在刷新前后加一个“服务实例数检查”的中间步骤,发现实例掉线时自动重新注册或回滚配置。
第三,给关键服务配置一个“最少实例数”保护。在调用方侧,Ribbon 或者 LoadBalancer 可以配置忽略“实例数少于 N”的服务列表,避免在刷新窗口内将请求路由到一个已经不存在的实例上。这个属于防御性措施,不能解决根因,但能减少线上故障时间。
我把几种方案的对比整理了一下:
| 方案 | 解决层级 | 优点 | 缺点 | 推荐度 |
|---|---|---|---|---|
移出@RefreshScope范围 | 根因 | 彻底消除触发链路 | 需要改动 Bean 定义 | 高 |
| 刷新后手动重新注册 | 兜底 | 快速恢复注册状态 | 可能存在时序问题 | 中 |
| 升级 Spring Cloud 版本 | 根因/回避 | 修复已知 bug | 升级成本可能较高 | 中 |
| 局部刷新,减少全量 refresh | 预防 | 降低触发概率 | 不能根治 | 高 |
| 调用方忽略少实例列表 | 防御 | 减少调用失败 | 掩盖问题,需要配合其他方案 | 低 |
5. 排查技巧与避坑清单
5.1 三步定位:日志、断点、Eureka 服务端现场
遇到这个问题,我习惯按三个步骤排查,能少走不少弯路。
第一步看日志。刷新配置后,客户端日志里如果出现类似Unregistering application X with eureka service DEFAULT的记录,说明注销动作是由客户端主动发起的。如果没有这条日志,那问题更可能是心跳停止或 Eureka 服务端剔除,方向完全不同。
第二步打断点。在EurekaAutoServiceRegistration的stop()和destroy()相关方法上打断点,重新触发 refresh。如果断点命中,看看调用栈是从哪个 Bean 的销毁方法进来的。这一步能直接告诉你,是哪个@RefreshScopeBean 把 Eureka 的注册组件卷了进来。调用栈上通常能看到具体的 Bean 名称和创建位置。
第三步看 Eureka 服务端现场。去 Eureka 服务端查这个实例的注册时间、状态变化时间、最近心跳时间。如果状态变化时间和 refresh 调用时间高度吻合,基本可以确定就是这个环节的问题。如果时间上有个几十秒的延迟,那要怀疑是心跳线程中断导致的被动剔除。
5.2 预防性检查清单
下面这个清单,是我在每次项目上线前都会过一遍的,专门用来预防这类“配置刷新影响注册中心”的问题:
- 检查所有 Bean 定义,确认没有给 Eureka 注册相关类加
@RefreshScope; - 检查
eureka.instance.*配置项里是否有生成后不再变化的随机值,例如random.value、时间戳、环境变量插值; - 检查是否有自定义的
EurekaRegistration、EurekaInstanceConfigBeanBean 覆盖了自动配置; - 确认
refresh端点是否暴露给了不必要的内部系统,是否加了权限控制; - 确认
EurekaClient的底层 HTTP 客户端是否会被配置刷新影响(比如自定义的RestTemplate、连接池配置挂在@RefreshScope下); - 检查调用方是否有针对“实例数量骤降”的告警阈值,避免刷新窗口触发误报。
5.3 最后再分享一个小心得
我在排查这类问题最深的一个体会是:微服务里的很多故障,表面看是注册中心的问题、网络的问题,实际大概率是生命周期的问题。@RefreshScope这个特性本身很有用,但它把“配置更新”和“Bean 重建”绑定在了一起,而 Bean 重建就必然会触发销毁逻辑。如果你把一个有“对外副作用”的 Bean 放进这个作用域,那么刷新配置就不再是单纯的配置更新,而是一次小型的“软重启”。
所以我的建议很简单:@RefreshScope只用来管理那些纯粹承载数据的配置 Bean,凡是和外部系统有状态关联的组件(注册中心、消息队列连接、分布式锁客户端),都不要轻易放进去。如果实在要动态调整,走专门的 API 或者重新初始化逻辑,而不是依赖全量刷新。
这个内容后续还可以这样扩展:如果你想彻底搞懂@RefreshScope的边界,可以自己写一个带@PreDestroy的 Bean 放进刷新作用域,看它销毁时打印什么,再逐步把 Eureka 的组件加进去观察变化。亲手复现一次,比看十篇文章都有用。