开篇首先想思考一个问题,没有注册中心 Dubbo 还能玩下去吗?
当然可以,只要知道服务提供者地址相关信息,消费者配置之后就可以调用。如果只有几个服务,这么玩当然没问题。但是生产服务动辄成千上百,如果每个服务都需要手写配置信息,想象一下是多么麻烦。
好吧,如果上面的问题都不是事的话,试想一下如果一个服务提供者在运行过程中宕机,消费者怎么办?消费者不知情,所以它还会不断把请求发往服务提供者,然后不断失败。这个时候唯一的办法就是修改服务地址信息,然后重启服务。
可以看到如果没有注册中心,分布式环境中服务查找发现将会非常麻烦,一切需要手工配置,无法完成自动化。所以这里就需要一个第三者,协调服务提供者与消费者之间关系,这就是注册中心。
注册中心主要作用如下:
注册中心工作流程总体比较简单,流程图大致如下:
主要工作流程可以分为如下几步:
Dubbo 之前版本主要可以使用 ZooKeeper,Redis 作为注册中心 ,而随着 Dubbo 版本不断更新,目前还支持 nacos,consul,etcd 等做为注册中心。
ps: 以下源码基于 dubbo 2.7.3 版本
注册中心实现使用模板模式,源码位于 dubbo-registry 模块,类关系如下图:
最上层的 RegistryService
接口定义了核心方法,分别为注册,取消注册,订阅,取消订阅以及查询。
中间层抽象类主要实现通用逻辑,如:AbstractRegistry
实现缓存机制,FailbackRegistry
实现失败重试功能。
底层 ZookeeperRegistry
等为具体实现类,实现与 ZooKeeper 等注册中心交互的逻辑。
接下去我们具体分析 AbstractRegistry
与 FailbackRegistry
逻辑。
AbstractRegistry
缓存实现的原理如果每次服务调用都需要调用注册中心实时查询可用服务列表,不但会让注册中心承受巨大的流量压力,还会产生额外的网络请求,导致系统性能下降。
其次注册中心需要非强依赖,其宕机不能影响正常的服务调用。
基于以上几点,注册中心模块在 AbstractRegistry
类中实现通用的缓存机制。这里的缓存可以分为两类,内存服务缓存以及磁盘文件缓存。
内存服务缓存很好理解也最容易实现,AbstractRegistry
使用一个 ConcurrentMap
保存相关信息。
private final ConcurrentMap<URL, Map<String, List<URL>>> notified = new ConcurrentHashMap<>();
这个集合中 key 为消费者的 URL,而 value 为一个 Map 集合。这个内层 Map 集合使用服务目录作为 key,分别为 providers,routers,configurators,consumers 四类,value 则是对应服务列表集合。
由于服务重启就会导致内存缓存消失,所以额外增加磁盘文件缓存。
文件缓存默认位置位于 ${user.home}/.dubbo/
文件夹,文件名为dubbo-registry-${application.name}-${register_address}.cache
。可以设置 dubbo.registry.file
配置信息从而修改默认配置,实现源码如下:
String filename = url.getParameter(Constants.FILE_KEY, System.getProperty("user.home") + "/.dubbo/dubbo-registry-" + url.getParameter(Constants.APPLICATION_KEY) + "-" + url.getAddress() + ".cache");
ps: {application.name} 取自 `dubbo.application.name` 信息,{register_address} 取值注册中心地址信息。缓存文件完整名称为:
C:\Users\xxx/.dubbo/dubbo-registry-dubbo-auto-configure-consumer-sample-127.0.0.1:2181.cache
缓存文件内容使用 properties
配置文件格式,即 key=value
格式。key为服务接口名称,value 为服务列表,由于服务可能存在多个,将会使用空格分隔。
缓存文件的加载
dubbo 程序初始化的时候,AbstractRegistry
构造函数将会从本地磁盘文件中将数据读取到 Properties
对象实例中,后续都将会先写入 Properties
,最后再将里面信息再写入文件。
缓存初始化的源码为下图。
缓存文件的保存与更新
缓存文件将会通过 AbstractRegistry#notify
方法保存或更新。客户端第一次订阅服务获取的全量数据,或者后续回调中获取到新数据,都将会调用 AbstractRegistry#notify
方法,用来更新内存缓存以及文件缓存。
notify
方法源码如下图:
在保存文件缓存方法中,首先把根据 URL 取出的数据,拼接成字符串,然后写入上面提到过的 properties
对象中,最后输出到文件中。
这里可以选择两种保存方式,同步或异步。由于 notify
可能被多次调用,为了提高系统能,系统默认使用异步方式保存。
saveProperties
方法源码如下:
doSaveProperties
方法最终将会将信息写入缓存。考虑到保存方法可能会被多个线程同时调用,这里使用 CAS 方法,首先比较版本大小,若小于,代表有新线程正在写入信息,本次更新直接丢弃。
其次考虑到多个 dubbo 应用可能共用一份缓存文件,所以这里使用文件排他锁当做分布式锁,防止多个应用并发操作同一份文件。
一旦文件写入异常或者获取锁失败,保存操作将会不断重试,直到超过最大次数。
ps: dubbo 2.7.2 之前重试没有设置最大次数,如果文件没有权限保存,保存将会一直失败,异步线程将会陷入死循环。
doSaveProperties
方法源码如下:
FailbackRegistry
重试机制
FailbackRegistry
继承 AbstractRegistry
,实现了 register
,subscribe
等通用法,并增加 doRegister
,doSubscribe
等模板方法,交由子类实现。
如果 doRegister
等模板方法发生异常,会将失败任务放入集合,然后定时再次调用模板方法。
FailbackRegistry
失败重试集合分别为:
以 subscribe
方法为例,这里将会调用这些 doSubscribe
的模板方法。如果发生异常将会读取缓存文件中内容,然后加载服务。最后新建异步定时任务加入重试集合中,然后由定时器去重试这些任务。
FailbackRegistry#subscribe
方法源码:
在 addFailedSubscribed
中将会新建定时任务,然后交由定时器执行。定时任务默认最大重试次数为 3 次,调用时间间隔默认为 5 s。
addFailedSubscribed
源码如下:
其他失败重试任务都比较类似,全都继承自 AbstractRetryTask
父类,类关系如下图。
总结
本文主要讲述注册中心作用,工作流程,通用缓存机制以及失败重试机制。从中可以学到模板模式,以及多线程并发技巧。
这里没有涉及到具体注册中心实现,由于目前最主要使用 ZooKeeper 作为注册中心,所以下篇将会聊聊 ZooKeeper 注册中心原理,敬请期待。