📟 引言

@RefreshScope 是 Spring Cloud 提供的一个强大注解,专用于动态刷新 Spring Bean,尤其适用于 Spring Cloud Config 配置中心。当配置发生变更时,它可以重新加载 Bean,而无需重启整个应用。
通常,我们可能会按照如下方式使用 @RefreshScope
在常规场景下,这样的使用方式没有问题,许多示例代码也是如此。但是,在高并发业务调用链较长的情况下(例如下游服务异常导致超时),@RefreshScope 可能会引发严重的性能瓶颈甚至服务假死的问题。

⌨️ 线上问题案例

近期,我们在生产环境中遇到了一个严重的性能问题,原因正是 @RefreshScope 在高频业务中的误用。在修改线上配置后,服务出现了假死,无法响应请求。
:配置中心使用的是 nacos client 2.0.3 版本。
堆栈信息(部分简化):
 

问题分析

从堆栈信息可以看到,@RefreshScope 的核心逻辑是通过 代理模式 拦截方法调用,并在执行前加上读锁。然而,当 RefreshScope 触发 Bean 刷新 时,它会持有写锁,导致所有获取读锁的线程被阻塞,从而形成性能瓶颈。

触发条件

  • 高并发:多个线程同时调用 @RefreshScope 标注的 Bean 方法。
  • 配置变更:触发 refresh() 操作,销毁旧 Bean 并创建新 Bean。
  • 方法耗时:如果方法执行较慢,刷新操作会放大影响。

影响

  1. 请求阻塞:大量请求在 readLock.lock() 处阻塞,影响整体响应时间。
  1. 服务雪崩:请求积压可能导致线程池耗尽,最终导致服务假死。
 
下面,我们一起通过源码分析来详细了解一下问题是如何触发的。

📸 源码分析

@RefreshScope 依赖 org.springframework.cloud.context.scope.refresh.RefreshScope 这个类来管理 Bean 的生命周期。当一个 Bean 被 @RefreshScope 标记时,它不会作为标准的 Spring 单例 Bean,而是由 RefreshScope 代理管理。
我们来一起看下相关的源码。

@RefreshScope 注解定义解析

  • @Scope("refresh"):将作用域定义为 "refresh",并由 Spring 容器的 Scope 实现类 RefreshScope 来接管管理。
  • ScopedProxyMode.TARGET_CLASS:指定作用域代理模式为 TARGET_CLASS,即通过 CGLIB 动态代理机制代理该类。
    • CGLIB 动态代理生成一个子类代理对象,并在调用方法时通过代理实现逻辑拦截。
    • INTERFACES 模式不同,它可以直接代理类(而不仅限于接口),适合大多数业务场景。

RefreshScope 类的实现

Spring Cloud 通过自定义 Scope 实现类 org.springframework.cloud.context.scope.refresh.RefreshScope 来管理 @RefreshScope 标注的 Bean,核心功能是动态刷新 Bean。

RefreshScope 类的主要逻辑

  • scopeCache:内部维护一个缓存 ConcurrentHashMap<String, Object>,存放已经初始化的 Bean 实例。
  • get(String name, ObjectFactory<?> objectFactory)
    • 首先检查缓存中是否已经存在对应的 Bean。
    • 如果存在,则直接返回。
    • 如果不存在,则使用 ObjectFactory 创建新的实例并存入缓存。
  • refresh(String name)
    • 清除指定名称的 Bean 实例缓存。
    • 下次访问该 Bean 时,重新通过工厂方法创建新的实例。
  • refreshAll()
    • 清空整个缓存并重新触发所有 Bean 实例的初始化。
RefreshScope 是通过自动配置类 RefreshAutoConfiguration 注册到 Spring 容器中。
 

Bean 的加载与刷新

  • readLock():正常访问缓存时加读锁,可以并发访问。
  • 注意此时是执行完代理方法之后才会释放读锁
 
  • writeLock():刷新配置时加写锁,阻止其他线程读取,保证一致性。
 
可以写个demo,实测触发了刷新配置后, controller bean确实是刷新了的。
notion image
 

读写锁的处理

@RefreshScope 机制中,为了保证 Bean 的并发安全和一致性,Spring 使用了 java.util.concurrent.locks.ReentrantReadWriteLock 进行同步管理。

读写锁的核心逻辑

RefreshScope 内部有一个 ReentrantReadWriteLock,用于保证 scopeCache 在多线程访问时的安全性:
  • refreshLock.readLock():当访问 RefreshScope 代理的方法 时,加读锁,保证多个线程可以并发读取 Bean。
  • refreshLock.writeLock():当执行 refresh() 时,加写锁,阻塞所有读取操作,确保 Bean 刷新的一致性。
 

ReentrantReadWriteLock 分析

在 Java 并发框架 java.util.concurrent.locks.ReentrantReadWriteLock 中,ReadLock 允许多个线程同时持有,而 WriteLock 只允许单个线程独占。其底层基于 AbstractQueuedSynchronizer (AQS) 实现。
先说ReentrantReadWriteLock 在多线程中的特点:
  • 读线程 可以并发执行,不会互相阻塞。
  • 写线程会等待所有读线程释放后再执行
  • 读线程等待写线程完成后才能继续执行
  • ReentrantReadWriteLock 通过 状态位 state + AQS 队列 管理锁的获取和释放,确保线程安全高并发性能
 
下面来分析具体的实现。
ReentrantReadWriteLock 内部,读锁和写锁共享一个同步器 Sync,其状态 state 由 AQS 维护:
  • 低 16 位(state & 0xFFFF):表示读锁的个数
  • 高 16 位(state >>> 16):表示写锁的重入次数
  • exclusiveCount(state) 获取写锁的状态
  • sharedCount(state) 获取读锁的状态
 
假设当前多线程对读写锁的操作是:
  • 前面 3 个线程获取了读锁
  • 现在 1 个线程尝试获取写锁
  • 后面 4 个线程尝试获取读锁

3 个线程获取读锁

当 3 个线程获取读锁时:
  1. 线程 T1、T2、T3 进入 readLock.lock()
  1. tryAcquireShared() 读取 state
  1. 如果没有写锁sharedCount(state) 自增
  1. state 低 16 位 存储读线程个数(例如 state = 0x00030000 表示有 3 个读线程)
  1. 线程进入共享模式,允许多个线程并行读取
源码分析
🔹 前 3 个线程分别执行 state += SHARED_UNIT,变为 0x00010000 → 0x00020000 → 0x00030000

1 个线程尝试获取写锁

当线程 T4 尝试获取写锁:
  1. 检查 state,发现 sharedCount(state) != 0(有 3 个读线程)。
  1. 写锁不能与读锁共存,进入 AQS 等待队列
  1. 进入阻塞状态,等待读锁释放
源码分析
🔹 T4 发现 sharedCount(state) != 0,无法获取写锁,进入等待队列

后面 4 个线程尝试获取读锁

当线程 T5 - T8 尝试获取读锁:
  1. 进入 tryAcquireShared() 方法
  1. 发现有 等待中的写锁(T4)读锁无法获取,进入等待队列
  1. T5-T8 进入阻塞状态

读锁释放 & 写锁获取

T1、T2、T3 释放读锁:
  1. releaseShared() 方法执行 state -= SHARED_UNIT
  1. 读锁释放完成state = 0x00030000 → 0x00020000 → 0x00010000 → 0x00000000
  1. 由于等待队列首位是写锁(T4),T4 唤醒并获取写锁
  1. state 变为 0x00000001(表示写锁被获取)

写锁释放 & 读锁重新获取

T4 释放写锁
  1. state = 0x00000001 → 0x00000000
  1. T5-T8(等待中的读线程)被唤醒
  1. T5-T8 获取读锁
  1. state 再次增长到 0x00040000
 

整体流程总结

时间点
操作
锁状态 (state)
队列等待情况
T1 获取读锁
读取 state=0,成功获取
0x00010000
T2 获取读锁
读取 state=0x00010000,成功获取
0x00020000
T3 获取读锁
读取 state=0x00020000,成功获取
0x00030000
T4 获取写锁
发现 sharedCount(state) != 0,进入等待
0x00030000
T4 进入队列
T5 获取读锁
发现 等待中的写锁(T4),进入等待
0x00030000
T4、T5 进入队列
T6 获取读锁
发现 等待中的写锁(T4),进入等待
0x00030000
T4、T5、T6 进入队列
T7 获取读锁
发现 等待中的写锁(T4),进入等待
0x00030000
T4、T5、T6、T7 进入队列
T8 获取读锁
发现 等待中的写锁(T4),进入等待
0x00030000
T4、T5、T6、T7、T8 进入队列
T1-T3 释放读锁
依次释放读锁
0x00020000 → 0x00010000 → 0x00000000
唤醒 T4
T4 获取写锁
成功获取写锁
0x00000001
T5-T8 仍在队列
T4 释放写锁
state = 0
0x00000000
唤醒 T5-T8
T5-T8 重新获取读锁
state += SHARED_UNIT
0x00040000
读锁继续执行
 
由此,我们也可以直观的看出使用了 @RefreshScope 的类,在多线程执行同一方法时的情况。当某一个业务线程因为不知名原因,执行缓慢时,此时触发配置更新,会导致后续所有需要获取读锁的线程,都需要等待写锁释放,从而影响整体性能。
notion image
 

🚨 如何避免 @RefreshScope 带来的性能问题?

❌ 误用:直接作用于业务 Bean

⚠️ 问题:
  • 业务方法阻塞:当 @RefreshScope 作用于业务 Bean,刷新 Bean 需要销毁并重新创建,所有并发调用都会被阻塞。

✅ 最佳实践:独立配置 Bean

❗ 建议:将 @RefreshScope 作用于 专门的配置类,避免影响业务逻辑。
然后在业务 Bean 中 只注入配置
✅ 优势:
  1. 避免业务方法被锁DeviceService 不需要被刷新,提升性能。
  1. 提高稳定性:只刷新配置,不影响正常业务调用。
  1. 更好的分层设计:解耦配置与业务逻辑。

📞 总结

  • @RefreshScope 适用于动态配置刷新,但不应用于业务 Bean
  • 核心问题RefreshScope 使用读写锁,可能导致高并发场景下的服务阻塞
  • 最佳实践
    • @RefreshScope 限定在配置 Bean,避免业务方法受影响。
    • 分离配置类和业务逻辑,确保 Bean 只用于读取配置,而不涉及复杂计算。

🔋 参考资料

 
 
初识YOLO目标检测算法关于我最近一周用iphone前置摄像头扫描的事
Loading...