所以这里如果判断hash不一致,就会立即再去注册中心获取全量数据来覆盖本地的脏数据。那么既然要获取这个hash值,此时的注册表就不能再有"写入"的操作了,例如register/cancel等,他们会改变注册表中实例的数量以及状态,所以这里就形成了一个互斥的操作:
这里也就是为何注册表和最近更新实例队列都是现成安全的,还要加读写锁的原因了,这里是需要有一个互斥的操作。
再来回头思考上面已经解释了EurekaServer中读写锁互换使用的场景了,这里大家肯定还会有其他疑惑,那么我们回过头再来思考以下几个问题:
站在作者的角度 EurekaServer为何这样设计读写锁的使用?
站在读者的角度 EurekaServer 增量获取注册表信息的性能如何?
注册表registry本身就是Map结构内存存取, 为何还要再使用缓存?
为何renew操作不加任何读写锁?这个明明是更新注册表的续约时间
1、EurekaServer中读写锁设计的思考看完上面的操作读者可能和我有同样的困惑,作者为何要这样设计?
首先,我们来梳理下业务场景:这是一个典型的读多写少的场景(EurekaClient 默认每30s拉一次注册表增量信息):
注册中心的"读操作":
读的时候必须要加全局锁防止新数据的写入更新,因为读的时候需要获取注册表的hash值,这里必须要加互斥锁
注册中心的"写操作":
注册中心的register/cancel/evict...等操作都是可以同步执行的,依托于ConcurrentLinkedQueue/ConcurrentHashMap并发容器的实现,这类更新最近更新队列或者修改注册表的操作都是线程安全的
反过来,如果上述一些操作的读写锁互换,等于说是在这两个并发容器上又加了一层写锁的逻辑,多一层互斥的性能损耗,性能返回会更差
2、EurekaServer 增量获取注册表信息的性能如何?我们可以看下EurekaClient获取注册表的流程操作:
虽然我们每次增量拉取注册表都是加的写锁,但是这里借助了缓存技术,每次增量获取数据并不一定都会执行加锁操作,配合缓存的时候可以减少写锁的使用频率
其他的对于最近更新队列recentlyChangedQueue或者注册表registry的写入更新操作都是线程安全的,他们不需要通过读写锁来保证
3、注册表registry本身就是Map结构 为何还要再使用一层缓存?其实答案已经在上面了,如果我们不借助于缓存,那么每次的增量获取操作都会针对于registry或者recentlyChangedQueue`去操作,每次都会加写锁,性能相对于直接读缓存会下降很多,所以这里借助了缓存来解决每次都需要加锁的问题
由此我们是否也可以想到另一个常用的框架 Spring是如何解决循环依赖问题的?答案也是使用多级缓存,到了这里有没有一种豁然开朗的感觉~
我们再继续深入思考一下,看下ResponseCacheImpl的代码实现:
我们举例一种场景,这里使用的是expireAfterWrite,当我们的缓存过期后,同时有1w个客户端来拉取注册表增量信息,都会走到加写锁的逻辑,此时注册中心的吞吐量会降低很多吗?
这里如果使用refreshAfterWrites会不会更好一些?因为refreshAfterWrite是后台异步刷新,其他线程访问旧值,只会有一个线程在执行刷新,不会出现多个线程刷新同一个Key的缓存
当然这些可能也是多虑的,我并没有去实际测试这种场景,我猜测在请求量很大的情况下,增量获取注册信息加写锁内部的逻辑也会执行很快,因为都是一些内存的操作。至于使用expireAfterWrite 则是能够节省很多内存空间,也许作者在心里也有过这种利弊抉择 …(⊙_⊙;)…
4、为何renew 续约不需要加锁?renew不加锁的原因很简单,续约操作是不会向最近更新队列中添加元素的,不会影响增量更新数据的拉取