购买
下载掌阅APP,畅读海量书库
立即打开
畅读海量书库
扫码下载掌阅APP

1.4 如何根据设计实现多场景的发号器

根据1.3节的设计方案,本节介绍如何实现多场景发号器,并聚焦于项目结构、实现要点、并发处理等主题,并对关键的代码实现进行注解。

1.4.1 项目结构

首先,我们的多场景发号器支持多种配置模式:嵌入发布模式、中心服务器发布模式、REST发布模式,因此我们对要实现的项目结构做个整体规划,如图1-2所示。

图1-2

对应的项目结构如下:

对应的每个项目元素的职责和功能如下。

● vesta-id-generator:所有项目的父项目。

● vesta-id-generator/vesta-intf:发号器抽象出来的对外的接口。

● vesta-id-generator/vesta-service:实现发号器接口的核心项目。

● vesta-id-generator/vesta-server:把发号器服务通过Dubbo服务导出的项目。

● vesta-id-generator/vesta-rest:通过Spring Boot启动的REST模式的发号器服务器。

● vesta-id-generator/vesta-rest-netty:通过Netty启动的REST模式的发号器服务器。

● vesta-id-generator/vesta-client:导入发号器Dubbo服务的客户端项目。

● vesta-id-generator/vesta-sample:嵌入式部署模式和Dubbo服务部署模式的使用示例。

● vesta-id-generator/vesta-doc:包含架构设计文档、压测文档和使用向导等文档。

● vesta-id-generator/deploy-maven.sh:一键发布发号器依赖Jar包到Maven库。

● vesta-id-generator/make-release.sh:一键打包发号器。

● vesta-id-generator/pom.xml:发号器的Maven打包文件。

● vesta-id-generator/LICENSE:开源协议,本项目采用Apache License 2.0。

● vesta-id-generator/README.md:入门向导文件。

我们基于以下原则划分项目。

● 我们开发的发号器要适用于多种用途、多种场景,我们不能简单地建设一个项目,把所有的需求都堆砌在一起,需要根据功能职责对项目进行划分,因此,我们主要将项目拆分成发号器服务的接口模块、实现模块,针对不同的发布模式的服务导出项目。

● 我们开发的是一个开源项目,希望该开源项目简单实用,使用者下载后根据项目结构即可判断如何使用。因此,我们在根项目中增加了README文档,以及更丰富的doc项目下的文档,并且提供了一键打包和发布的脚本,还提供了演示使用发号器项目的示例项目。

● 我们分离了发号器的接口项目和实现项目,因为不同场景下的需求不一样,对于REST发布模式,不需要依赖发号器的接口和实现;对于Dubbo服务的客户端,只需要依赖发号器的接口即可;对于嵌入式发布模式,不但需要依赖发号器的接口,还需要依赖它的实现。

1.4.2 服务接口的定义

根据前面对需求的整理,我们对多场景发号器的接口实现如下:

其中主要包含如下服务方法(按照重要程度排列)。

● genId():这是分布式发号器的主要API,用来产生唯一ID。

● expId(long id):这是产生唯一ID的反向操作,可以对一个ID内包含的信息进行解读,用人可读的形式来表达。

● makeId(...):用来伪造某一时间的ID。

● transTime(long time):该方法用于将整型时间翻译成格式化时间。

上面的接口定义简单、清晰、易懂,只定义了必要的功能。

1.4.3 服务接口的实现

在实现类的设计上,我们设计了两层结构:抽象类 AbstractIdServiceImpl 和实体类IdServiceImpl。抽象类AbstractIdServiceImpl实现那些在任何场景下都不变的逻辑,而可变的逻辑被放到了实体类中实现;实体类IdServiceImpl则是最通用的实现方式。

实现类的类图如图1-3所示。

图1-3

从图1-3中可以看到,在抽象类里包含了如下4个属性:

这4个属性分别代表机器ID、生成方式、类型和版本。对于任意一个发号器部署实例,这些属性一旦固定下来将不会改变,因此,我们将这些属性和其处理逻辑放到了抽象的父类中。

现在我们来看看产生发号器的逻辑,主逻辑被封装在抽象父类AbstractIdServiceImpl中,代码如下:

我们清晰地看到,在该段代码中首先构造了一个ID元数据对象,然后调用了模板回调函数populateId,模板回调函数是一个抽象的方法:

这个抽象方法由子类来实现,子类根据不同的场景会有不同的实现,在这里我们只需要在父类中给子类进行处理的一个机会,子类主要负责根据某一算法生成唯一ID的时间和序列号属性,父类则对自己管理的属性机器ID、生成方式、类型和版本进行赋值。

实现类IdServiceImpl通过代理模式代理到某个IdPopulator接口的一个实现来计算时间字段和序列号字段,具体代码如下:

在IdPopulator的实现中需要计算构成唯一ID的格式中的另外两个变量:时间和序列号,它们的产生方式是变化多端和多种多样的,因此,我们把这两个变量和处理它们的逻辑封装在子类中,并且提供了多种实现方式。如在上面的架构设计中提到的,我们使用了传统的Synchronized锁、ReentrantLock及CAS无锁技术来实现,其中,通过ReentrantLock实现是默认的实现方式。可以通过传递 JVM 虚拟机参数来更换其他实现方式:如果 JVM 传递了vesta.sync.lock.impl.key 参数,则使用 Synchronized 锁的实现方式;如果 JVM 传递了vesta.atomic.impl.key参数,则使用CAS无锁的实现方式,否则使用默认的ReentrantLock的实现方式。

其中,IdPopulator是个简单的接口,如下所示:

IdServiceImpl通过IdPopulator来实现时间和序列号字段的计算,其中有3个实现类,包括:AtomicIdPopulator、LockIdPopulator和SyncIdPopulator,如图1-4所示。

图1-4

默认的实现类是LockIdPopulator,定义的时间和序列号属性如下:

在下面的代码中使用了可重入锁来进行同步的修改,可重入锁比Synchronized锁的效率稍高,适合高并发的场景:

完整的实现代码如下:

其中,具体的实现逻辑为:首先查看当前时间是否已经到了下一个时间单位,如果已经到了下一个时间单位,则将序列号清零;如果还在上一个时间单位,就对序列号进行累加,如果累加后越界,就需要等待下一秒再产生唯一ID。

基于 synchornized 锁的 SyncIdPopulator 实现类与 LockIdPopulator 类似,但是使用传统的synchornized锁进行同步,性能稍微逊色一些。

实现代码如下:

最后,我们还通过CAS底层基础设施实现了无锁版本,CAS 实现的无锁版本在高并发的场景下,能够高性能地处理唯一ID的产生,但是,这里需要解决一个技术难题,就是如何安全地并发修改两个变量:时间字段和序列号字段。这里我们通过使用原子变量引用来实现,对时间和序列号两个字段的修改进行CAS保护,使其被高效、安全地修改。

首先,我们需要定义一个联合的数据结构:

然后,定义一个原子变量的引用,这个引用的CAS操作可以保证实现联合的数据结构Variant中的sequence和lastTimestamp中的任意一个被修改了,都可以安全地得到更新:

具体的实现代码如下:

实现的逻辑如下。

(1)取得并保存原来的变量,这个变量包含原来的时间和序列号字段。

(2)基于原来的变量计算新的时间和序列号字段,计算逻辑和 SyncIdPopulator、LockIdPopulator一致。

(3)计算后,使用CAS操作更新原来的变量,在更新的过程中,需要传递保存的原来的变量。

(4)如果保存的原来的变量被其他线程改变了,就需要在这里重新拿到最新的变量,并再次计算和尝试更新。

1.4.4 ID元数据与长整型ID的互相转换

在主流程的ID元数据对象中设置了ID的各个属性后,可通过转换器类将ID的元数据对象转换成长整型的ID。

转换器类的设计如图1-5所示。

图1-5

转换器负责将ID元数据对象转换成长整型的ID,或将长整型的ID转换成ID元数据对象,并且定义了清晰的转换接口,用于将来扩展,能够实现其他类型的转换。

将ID元数据对象转换成长整型的ID的代码实现如下:

如上面的代码实现所示,转换器根据ID元数据的信息对象获取每个属性所在ID的位数,然后通过左移来实现将各个属性拼接到一个长整型数字里。

另外,在前面的接口设计中,有时需要把一个长整型的ID解释成人可读的格式,可从中看到时间、序列号、版本、类型等属性。将长整型的ID转换成ID元数据对象的代码实现如下:

请注意,在上面的代码中使用的是无符号右移操作,因为产生的ID包含的每一位二进制位都代表特殊的含义,所以没有数学上的正负意义,最左边的一位二进制也不是用来表示符号的。

另外,我们看到在做无符号右移操作的时候使用了屏蔽字,这用于从ID数字中取出我们想要的某个属性的值,具体流程如图1-6所示。

图1-6

举例说明,假设唯一ID的数字包含的生成方式属性为11111,则可以参考第2行的第3个方格。图1-6 只是一个示意图,每个属性的位数和设计不是一一对应的。现在我们想取出生成方式属性的数值11111。

首先,程序会把ID数字整体右移,直到生成方式属性位于最右端:

得到的结果可参考图1-6中第3行的数据。

然后,与屏蔽字进行与操作,得到的结果为生成方式属性,参考图1-6中第5行的数据:

屏蔽字参考图1-6中第4行的数据,实现代码如下:

这里-1为64位全为1的二进制数字,首先将其左移属性值所在位置的位移,生成方式属性从右边开始的位置到数字最右边一位全为0,再与-1(也就是64位全为1的二进制数字)进行与操作,结果就形成了屏蔽字,参考图1-6中第4行的数据。

1.4.5 时间操作

在一个 ID 的生成中,最重要的部分就是时间和序列号的生成,其默认的 LockIdPopulator类代码实现如下:

该段代码的主逻辑是,如果当前时间已经到了下一秒(或者毫秒),则重置序列号,如果没有到下一秒(或者毫秒),则对当前秒(或者毫秒)的序列号递增。对于这一段核心逻辑,我们使用了可重入锁进行了保护,因为我们要在并发的场景下维护下面这两个成员变量:

在主逻辑中有一个特殊的场景:假如我们还在同一秒,但是序列号已经用光了,怎么办?在这种情况下,我们只能等待下一秒,这也就是为什么我们设计了最大峰值型和最小粒度型的设计方案。

在这种情况下,我们认为等待的时间不会太长,因为我们不想让线程处于等待状态,所以我们使用自旋锁来实现,这样减少了因线程切换而导致的性能损耗,参考下面的代码:

另外,在实现的过程中需要校验机器时间是否被调慢了,这是至关重要的,如果机器时间被回调了,服务就会产生重复的ID,这需要特别注意:

在产生时间字段时,我们需要通过唯一ID类型来确定产生的时间单位,并对时间进行编码,通过TimeUtils.EPOCH来对时间进行压缩:

1.4.6 机器ID的生成

为了应对互联网大规模、高并发的流量,发号器的设计本身就是分布式的、可伸缩的。在发号器进行分布式部署的时候,由于生成的ID是由所在机器的机器号进行区分的,不至于生成的ID重复,因此生成ID的方式是一个非常重要的因素。

我们设计了不同的生成ID的方式,参考如图1-7所示的类继承图。

图1-7

从图1-7中可以看到,我们默认提供了3种实现方式:PropertyMachineIdProvider、IpConfigura bleMachineIdProvider和DbMachineIdProvider,下面一一介绍它们的实现和使用场景。

1.PropertyMachineIdProvider

这是基于属性配置进行实现的,也是一种用于测试环境的方式,使用这种方式时,需要在部署的每台机器上配置不同的机器号,这在生产环境中是不现实的。这种实现方式非常简单,直接从配置中获取属性即可:

2.IpConfigurableMachineIdProvider

这种方法适合应用于线上的生产环境中,通过所有IP的机器列表为每个机器生成一个唯一的ID号,主要适合服务节点比较少的情况。事实上,生成ID是轻量级的服务,不会需要太大的服务池,因此这也是一种最常用、最简单的方式。

这种方式的实现也很简单:发布前配置所有服务节点IP的映射,每个服务节点必须具有相同的映射,运行时每个服务节点根据本机IP取得在IP映射中的位置,作为自己的机器号。实现代码如下:

3.DbMachineIdProvider

这种方式通过在数据库里面配置机器ID来实现,适用于任何情况,但是使用起来比较麻烦,需要依赖数据库。实现代码如下。

在上面的代码中,在服务初始化时,服务器会从数据库中捞取本IP在数据库里面配置的机器号。

4.ZooKeeperMachineIdProvider

在设计阶段考虑使用ZooKeeper来生成机器的唯一ID,但是考虑到有多种方案可以替代,所以当前还没有在项目中实现。之所以设计了机器ID提供者的类继承体系,就是为了在需要的时候随时可以增加机器ID提供者的实现类。

1.4.7 小结

本节详细介绍了如何根据设计实现发号器,包括项目结构、服务接口的定义、服务接口的实现、ID元数据与长整型ID的互相转换、时间和序列号的生成和机器ID的生成等主题,对每个主题都详细讲述了实现原理、实现思路和核心要点。 mTGtvQW/iUJCsrwUibtrUOaPes1+0b6f3b0n3UClrUHZ3+CW4n5THk8aLe3sMqMQ

点击中间区域
呼出菜单
上一章
目录
下一章
×